diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 3973b9dfa3..186432c8ca 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -46,6 +46,7 @@ import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines' import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy' import {usePalette} from 'lib/hooks/usePalette' import {useStores} from './state' +import {AppPasswords} from 'view/screens/AppPasswords' const navigationRef = createNavigationContainerRef() @@ -84,6 +85,7 @@ function commonScreens(Stack: typeof HomeTab) { component={CommunityGuidelinesScreen} /> + ) } diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index f8698f1cc1..eeb97ba7a9 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -19,6 +19,7 @@ export type CommonNavigatorParams = { TermsOfService: undefined CommunityGuidelines: undefined CopyrightPolicy: undefined + AppPasswords: undefined } export type BottomTabNavigatorParams = CommonNavigatorParams & { diff --git a/src/routes.ts b/src/routes.ts index 7ae281424b..811b4ed688 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -13,6 +13,7 @@ export const router = new Router({ PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', Debug: '/sys/debug', Log: '/sys/log', + AppPasswords: '/app-passwords', Support: '/support', PrivacyPolicy: '/support/privacy', TermsOfService: '/support/tos', diff --git a/src/state/models/me.ts b/src/state/models/me.ts index b993637900..fae626d123 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -1,5 +1,8 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {ComAtprotoServerDefs} from '@atproto/api' +import { + ComAtprotoServerDefs, + ComAtprotoServerListAppPasswords, +} from '@atproto/api' import {RootStoreModel} from './root-store' import {PostsFeedModel} from './feeds/posts' import {NotificationsFeedModel} from './feeds/notifications' @@ -21,6 +24,7 @@ export class MeModel { notifications: NotificationsFeedModel follows: MyFollowsCache invites: ComAtprotoServerDefs.InviteCode[] = [] + appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = [] lastProfileStateUpdate = Date.now() lastNotifsUpdate = Date.now() @@ -37,7 +41,7 @@ export class MeModel { this.mainFeed = new PostsFeedModel(this.rootStore, 'home', { algorithm: 'reverse-chronological', }) - this.notifications = new NotificationsFeedModel(this.rootStore, {}) + this.notifications = new NotificationsFeedModel(this.rootStore) this.follows = new MyFollowsCache(this.rootStore) } @@ -51,6 +55,7 @@ export class MeModel { this.description = '' this.avatar = '' this.invites = [] + this.appPasswords = [] } serialize(): unknown { @@ -107,6 +112,7 @@ export class MeModel { }) this.rootStore.emitSessionLoaded() await this.fetchInviteCodes() + await this.fetchAppPasswords() } else { this.clear() } @@ -118,6 +124,7 @@ export class MeModel { this.lastProfileStateUpdate = Date.now() await this.fetchProfile() await this.fetchInviteCodes() + await this.fetchAppPasswords() } if (Date.now() - this.lastNotifsUpdate > NOTIFS_UPDATE_INTERVAL) { this.lastNotifsUpdate = Date.now() @@ -171,6 +178,57 @@ export class MeModel { await this.rootStore.invitedUsers.fetch(this.invites) } } + + async fetchAppPasswords() { + if (this.rootStore.session) { + try { + const res = + await this.rootStore.agent.com.atproto.server.listAppPasswords({}) + runInAction(() => { + this.appPasswords = res.data.passwords + console.log('app passwords', res.data.passwords) + }) + } catch (e) { + this.rootStore.log.error('Failed to fetch user app passwords', e) + } + } + } + + async createAppPassword(name: string) { + if (this.rootStore.session) { + try { + if (this.appPasswords.find(p => p.name === name)) { + // TODO: this should be handled by the backend but it's not + throw new Error('App password with this name already exists') + } + const res = + await this.rootStore.agent.com.atproto.server.createAppPassword({ + name, + }) + runInAction(() => { + this.appPasswords.push(res.data) + }) + return res.data + } catch (e) { + this.rootStore.log.error('Failed to create app password', e) + } + } + } + + async deleteAppPassword(name: string) { + if (this.rootStore.session) { + try { + await this.rootStore.agent.com.atproto.server.revokeAppPassword({ + name: name, + }) + runInAction(() => { + this.appPasswords = this.appPasswords.filter(p => p.name !== name) + }) + } catch (e) { + this.rootStore.log.error('Failed to delete app password', e) + } + } + } } function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean { diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index b717fe05cd..6c58262d8a 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -70,6 +70,10 @@ export interface InviteCodesModal { name: 'invite-codes' } +export interface AddAppPasswordModal { + name: 'add-app-password' +} + export interface ContentFilteringSettingsModal { name: 'content-filtering-settings' } @@ -79,6 +83,7 @@ export type Modal = | ChangeHandleModal | DeleteAccountModal | EditProfileModal + | AddAppPasswordModal // Curation | ContentFilteringSettingsModal diff --git a/src/view/com/modals/AddAppPasswords.tsx b/src/view/com/modals/AddAppPasswords.tsx new file mode 100644 index 0000000000..e0ec0ff267 --- /dev/null +++ b/src/view/com/modals/AddAppPasswords.tsx @@ -0,0 +1,152 @@ +import React, {useState} from 'react' +import {StyleSheet, TextInput, View, TouchableOpacity} from 'react-native' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb} from 'platform/detection' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import Clipboard from '@react-native-clipboard/clipboard' +import * as Toast from '../util/Toast' + +export const snapPoints = ['70%'] + +export function Component({}: {}) { + const pal = usePalette('default') + const store = useStores() + const [name, setName] = useState('') + const [appPassword, setAppPassword] = useState('') + const [wasCopied, setWasCopied] = useState(false) + + const onCopy = React.useCallback(() => { + Clipboard.setString(appPassword) + Toast.show('Copied to clipboard') + setWasCopied(true) + }, [appPassword]) + + const onDone = React.useCallback(() => { + store.shell.closeModal() + }, [store]) + + const createAppPassword = async () => { + const newPassword = await store.me.createAppPassword(name) + if (newPassword) { + setAppPassword(newPassword.password) + } else { + Toast.show('Failed to create app password.') + // TODO: better error handling (?) + } + } + + return ( + + + + {!appPassword + ? 'Please enter a unique name for this App Password:' + : "Please save this secret key somewhere safe and accessible. For security reasons, you won't be able to view it again. If you lose this secret key, you'll need to generate a new one."} + + {!appPassword ? ( + + + + ) : ( + + {appPassword} + {wasCopied ? ( + Copied + ) : ( + + )} + + )} + + +