From 38eb299011672fc840849ae51e67adefef882bec Mon Sep 17 00:00:00 2001 From: Ansh Date: Fri, 21 Apr 2023 16:55:29 -0700 Subject: [APP-522] Create & revoke App Passwords within settings (#505) * create and delete app passwords * add randomly generated name * Tweak copy and layout of app passwords * Improve app passwords on desktop web * Rearrange settings * Change app-passwords route and add to backend * Fix link * Fix some more desktop web * Remove log --------- Co-authored-by: Paul Frazee --- src/Navigation.tsx | 2 + src/lib/routes/types.ts | 1 + src/routes.ts | 1 + src/state/models/me.ts | 61 ++++++- src/state/models/ui/shell.ts | 5 + src/view/com/modals/AddAppPasswords.tsx | 216 +++++++++++++++++++++++++ src/view/com/modals/Modal.tsx | 4 + src/view/com/util/ViewHeader.tsx | 25 ++- src/view/screens/AppPasswords.tsx | 275 ++++++++++++++++++++++++++++++++ src/view/screens/PostLikedBy.tsx | 2 +- src/view/screens/PostRepostedBy.tsx | 2 +- src/view/screens/ProfileFollowers.tsx | 2 +- src/view/screens/ProfileFollows.tsx | 2 +- src/view/screens/Settings.tsx | 16 +- 14 files changed, 606 insertions(+), 8 deletions(-) create mode 100644 src/view/com/modals/AddAppPasswords.tsx create mode 100644 src/view/screens/AppPasswords.tsx (limited to 'src') diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 3973b9dfa..186432c8c 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 f8698f1cc..eeb97ba7a 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 7ae281424..6762cde9d 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: '/settings/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 b99363790..ba2dc6f32 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,56 @@ 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 + }) + } 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 b717fe05c..6c58262d8 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 000000000..1d2f80ff0 --- /dev/null +++ b/src/view/com/modals/AddAppPasswords.tsx @@ -0,0 +1,216 @@ +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 {s} from 'lib/styles' +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%'] + +const shadesOfBlue: string[] = [ + 'AliceBlue', + 'Aqua', + 'Aquamarine', + 'Azure', + 'BabyBlue', + 'Blue', + 'BlueViolet', + 'CadetBlue', + 'CornflowerBlue', + 'Cyan', + 'DarkBlue', + 'DarkCyan', + 'DarkSlateBlue', + 'DeepSkyBlue', + 'DodgerBlue', + 'ElectricBlue', + 'LightBlue', + 'LightCyan', + 'LightSkyBlue', + 'LightSteelBlue', + 'MediumAquaMarine', + 'MediumBlue', + 'MediumSlateBlue', + 'MidnightBlue', + 'Navy', + 'PowderBlue', + 'RoyalBlue', + 'SkyBlue', + 'SlateBlue', + 'SteelBlue', + 'Teal', + 'Turquoise', +] + +export function Component({}: {}) { + const pal = usePalette('default') + const store = useStores() + const [name, setName] = useState( + shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)], + ) + const [appPassword, setAppPassword] = useState() + const [wasCopied, setWasCopied] = useState(false) + + const onCopy = React.useCallback(() => { + if (appPassword) { + Clipboard.setString(appPassword) + Toast.show('Copied to clipboard') + setWasCopied(true) + } + }, [appPassword]) + + const onDone = React.useCallback(() => { + store.shell.closeModal() + }, [store]) + + const createAppPassword = async () => { + try { + const newPassword = await store.me.createAppPassword(name) + if (newPassword) { + setAppPassword(newPassword.password) + } else { + Toast.show('Failed to create app password.') + // TODO: better error handling (?) + } + } catch (e) { + Toast.show('Failed to create app password.') + store.log.error('Failed to create app password', {e}) + } + } + + return ( + + + {!appPassword ? ( + + Please enter a unique name for this App Password. We have generated + a random name for you. + + ) : ( + + Here is your app password. Use this to + sign into the other app along with your handle. + + )} + {!appPassword ? ( + + + + ) : ( + + {appPassword} + {wasCopied ? ( + Copied + ) : ( + + )} + + )} + + {appPassword ? ( + + For security reasons, you won't be able to view this again. If you + lose this password, you'll need to generate a new one. + + ) : null} + +