[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 <pfrazee@gmail.com>
zio/stable
Ansh 2023-04-21 16:55:29 -07:00 committed by GitHub
parent aa56f4a5e2
commit 38eb299011
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 607 additions and 8 deletions

View File

@ -92,6 +92,7 @@ func serve(cctx *cli.Context) error {
e.GET("/search", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)
e.GET("/settings", server.WebGeneric)
e.GET("/settings/app-passwords", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
e.GET("/support", server.WebGeneric)

View File

@ -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<AllNavigatorParams>()
@ -84,6 +85,7 @@ function commonScreens(Stack: typeof HomeTab) {
component={CommunityGuidelinesScreen}
/>
<Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
<Stack.Screen name="AppPasswords" component={AppPasswords} />
</>
)
}

View File

@ -19,6 +19,7 @@ export type CommonNavigatorParams = {
TermsOfService: undefined
CommunityGuidelines: undefined
CopyrightPolicy: undefined
AppPasswords: undefined
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {

View File

@ -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',

View File

@ -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 {

View File

@ -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

View File

@ -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<string>()
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 (
<View style={[styles.container, pal.view]} testID="addAppPasswordsModal">
<View>
{!appPassword ? (
<Text type="lg">
Please enter a unique name for this App Password. We have generated
a random name for you.
</Text>
) : (
<Text type="lg">
<Text type="lg-bold">Here is your app password.</Text> Use this to
sign into the other app along with your handle.
</Text>
)}
{!appPassword ? (
<View style={[pal.btn, styles.textInputWrapper]}>
<TextInput
style={[styles.input, pal.text]}
onChangeText={setName}
value={name}
placeholder="Enter a name for this App Password"
placeholderTextColor={pal.colors.textLight}
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
autoFocus={true}
selectTextOnFocus={true}
multiline={true} // need this to be true otherwise selectTextOnFocus doesn't work
numberOfLines={1} // hack for multiline so only one line shows (android)
scrollEnabled={false} // hack for multiline so only one line shows (ios)
blurOnSubmit={true} // hack for multiline so it submits
editable={!appPassword}
returnKeyType="done"
onEndEditing={createAppPassword}
/>
</View>
) : (
<TouchableOpacity
style={[pal.border, styles.passwordContainer, pal.btn]}
onPress={onCopy}>
<Text type="2xl-bold">{appPassword}</Text>
{wasCopied ? (
<Text style={[pal.textLight]}>Copied</Text>
) : (
<FontAwesomeIcon
icon={['far', 'clone']}
style={pal.text as FontAwesomeIconStyle}
size={18}
/>
)}
</TouchableOpacity>
)}
</View>
{appPassword ? (
<Text type="lg" style={[pal.textLight, s.mb10]}>
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.
</Text>
) : null}
<View style={styles.btnContainer}>
<Button
type="primary"
label={!appPassword ? 'Create App Password' : 'Done'}
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={!appPassword ? createAppPassword : onDone}
/>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: isDesktopWeb ? 0 : 50,
marginHorizontal: 16,
},
textInputWrapper: {
borderRadius: 8,
flexDirection: 'row',
alignItems: 'center',
marginTop: 16,
marginBottom: 8,
},
input: {
flex: 1,
width: '100%',
paddingVertical: 10,
paddingHorizontal: 8,
marginTop: 6,
fontSize: 17,
letterSpacing: 0.25,
fontWeight: '400',
borderRadius: 10,
},
passwordContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 8,
paddingHorizontal: 16,
alignItems: 'center',
borderRadius: 10,
marginTop: 16,
marginBottom: 12,
},
btnContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: 12,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
paddingHorizontal: 60,
paddingVertical: 14,
},
btnLabel: {
fontSize: 18,
},
groupContent: {
borderTopWidth: 1,
flexDirection: 'row',
alignItems: 'center',
},
})

View File

@ -17,6 +17,7 @@ import * as DeleteAccountModal from './DeleteAccount'
import * as ChangeHandleModal from './ChangeHandle'
import * as WaitlistModal from './Waitlist'
import * as InviteCodesModal from './InviteCodes'
import * as AddAppPassword from './AddAppPasswords'
import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
const DEFAULT_SNAPPOINTS = ['90%']
@ -81,6 +82,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'invite-codes') {
snapPoints = InviteCodesModal.snapPoints
element = <InviteCodesModal.Component />
} else if (activeModal?.name === 'add-app-password') {
snapPoints = AddAppPassword.snapPoints
element = <AddAppPassword.Component />
} else if (activeModal?.name === 'content-filtering-settings') {
snapPoints = ContentFilteringSettingsModal.snapPoints
element = <ContentFilteringSettingsModal.Component />

View File

@ -3,6 +3,7 @@ import {observer} from 'mobx-react-lite'
import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {CenteredView} from './Views'
import {UserAvatar} from './UserAvatar'
import {Text} from './text/Text'
import {useStores} from 'state/index'
@ -18,10 +19,12 @@ export const ViewHeader = observer(function ({
title,
canGoBack,
hideOnScroll,
showOnDesktop,
}: {
title: string
canGoBack?: boolean
hideOnScroll?: boolean
showOnDesktop?: boolean
}) {
const pal = usePalette('default')
const store = useStores()
@ -42,7 +45,10 @@ export const ViewHeader = observer(function ({
}, [track, store])
if (isDesktopWeb) {
return <></>
if (showOnDesktop) {
return <DesktopWebHeader title={title} />
}
return null
} else {
if (typeof canGoBack === 'undefined') {
canGoBack = navigation.canGoBack()
@ -76,6 +82,19 @@ export const ViewHeader = observer(function ({
}
})
function DesktopWebHeader({title}: {title: string}) {
const pal = usePalette('default')
return (
<CenteredView style={[styles.header, styles.desktopHeader, pal.border]}>
<View style={styles.titleContainer} pointerEvents="none">
<Text type="title-lg" style={[pal.text, styles.title]}>
{title}
</Text>
</View>
</CenteredView>
)
}
const Container = observer(
({
children,
@ -133,6 +152,10 @@ const styles = StyleSheet.create({
top: 0,
width: '100%',
},
desktopHeader: {
borderBottomWidth: 1,
paddingVertical: 12,
},
titleContainer: {
marginLeft: 'auto',

View File

@ -0,0 +1,275 @@
import React from 'react'
import {Alert, StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ScrollView} from 'react-native-gesture-handler'
import {Text} from '../com/util/text/Text'
import {Button} from '../com/util/forms/Button'
import * as Toast from '../com/util/Toast'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {isDesktopWeb} from 'platform/detection'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {observer} from 'mobx-react-lite'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from 'lib/routes/types'
import {useAnalytics} from 'lib/analytics'
import {useFocusEffect} from '@react-navigation/native'
import {ViewHeader} from '../com/util/ViewHeader'
import {CenteredView} from 'view/com/util/Views'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'>
export const AppPasswords = withAuthRequired(
observer(({}: Props) => {
const pal = usePalette('default')
const store = useStores()
const {screen} = useAnalytics()
useFocusEffect(
React.useCallback(() => {
screen('Settings')
store.shell.setMinimalShellMode(false)
}, [screen, store]),
)
const onAdd = React.useCallback(async () => {
store.shell.openModal({name: 'add-app-password'})
}, [store])
// no app passwords (empty) state
if (store.me.appPasswords.length === 0) {
return (
<CenteredView
style={[
styles.container,
isDesktopWeb && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="appPasswordsScreen">
<AppPasswordsHeader />
<View style={[styles.empty, pal.viewLight]}>
<Text type="lg" style={[pal.text, styles.emptyText]}>
You have not created any app passwords yet. You can create one by
pressing the button below.
</Text>
</View>
{!isDesktopWeb && <View style={styles.flex1} />}
<View
style={[
styles.btnContainer,
isDesktopWeb && styles.btnContainerDesktop,
]}>
<Button
testID="appPasswordBtn"
type="primary"
label="Add App Password"
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={onAdd}
/>
</View>
</CenteredView>
)
}
// has app passwords
return (
<CenteredView
style={[
styles.container,
isDesktopWeb && styles.containerDesktop,
pal.view,
pal.border,
]}
testID="appPasswordsScreen">
<AppPasswordsHeader />
<ScrollView
style={[
styles.scrollContainer,
pal.border,
!isDesktopWeb && styles.flex1,
]}>
{store.me.appPasswords.map((password, i) => (
<AppPassword
key={password.name}
testID={`appPassword-${i}`}
name={password.name}
createdAt={password.createdAt}
/>
))}
{isDesktopWeb && (
<View style={[styles.btnContainer, styles.btnContainerDesktop]}>
<Button
testID="appPasswordBtn"
type="primary"
label="Add App Password"
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={onAdd}
/>
</View>
)}
</ScrollView>
{!isDesktopWeb && (
<View style={styles.btnContainer}>
<Button
testID="appPasswordBtn"
type="primary"
label="Add App Password"
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={onAdd}
/>
</View>
)}
</CenteredView>
)
}),
)
function AppPasswordsHeader() {
const pal = usePalette('default')
return (
<>
<ViewHeader title="App Passwords" showOnDesktop />
<Text
type="sm"
style={[
styles.description,
pal.text,
isDesktopWeb && styles.descriptionDesktop,
]}>
These passwords can be used to log onto Bluesky in other apps without
giving them full access to your account or your password.
</Text>
</>
)
}
function AppPassword({
testID,
name,
createdAt,
}: {
testID: string
name: string
createdAt: string
}) {
const pal = usePalette('default')
const store = useStores()
const onDelete = React.useCallback(async () => {
Alert.alert(
'Delete App Password',
`Are you sure you want to delete the app password "${name}"?`,
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
await store.me.deleteAppPassword(name)
Toast.show('App password deleted')
},
},
],
)
}, [store, name])
return (
<TouchableOpacity
testID={testID}
style={[styles.item, pal.border]}
onPress={onDelete}>
<Text type="md-bold" style={pal.text}>
{name}
</Text>
<View style={styles.flex1} />
<Text type="md" style={[pal.text, styles.pr10]}>
{new Date(createdAt).toDateString()}
</Text>
<FontAwesomeIcon icon={['far', 'trash-can']} style={styles.trashIcon} />
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: isDesktopWeb ? 0 : 100,
},
containerDesktop: {
borderLeftWidth: 1,
borderRightWidth: 1,
},
title: {
textAlign: 'center',
marginTop: 12,
marginBottom: 12,
},
description: {
textAlign: 'center',
paddingHorizontal: 20,
marginBottom: 14,
},
descriptionDesktop: {
marginTop: 14,
},
scrollContainer: {
borderTopWidth: 1,
marginTop: 4,
marginBottom: 16,
},
flex1: {
flex: 1,
},
empty: {
paddingHorizontal: 20,
paddingVertical: 20,
borderRadius: 16,
marginHorizontal: 24,
marginTop: 10,
},
emptyText: {
textAlign: 'center',
},
item: {
flexDirection: 'row',
alignItems: 'center',
borderBottomWidth: 1,
paddingHorizontal: 20,
paddingVertical: 14,
},
pr10: {
marginRight: 10,
},
btnContainer: {
flexDirection: 'row',
justifyContent: 'center',
},
btnContainerDesktop: {
marginTop: 14,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
paddingHorizontal: 60,
paddingVertical: 14,
},
btnLabel: {
fontSize: 18,
},
trashIcon: {
color: 'red',
},
})

View File

@ -22,7 +22,7 @@ export const PostLikedByScreen = withAuthRequired(({route}: Props) => {
return (
<View>
<ViewHeader title="Liked by" />
<ViewHeader title="Liked by" showOnDesktop />
<PostLikedByComponent uri={uri} />
</View>
)

View File

@ -22,7 +22,7 @@ export const PostRepostedByScreen = withAuthRequired(({route}: Props) => {
return (
<View>
<ViewHeader title="Reposted by" />
<ViewHeader title="Reposted by" showOnDesktop />
<PostRepostedByComponent uri={uri} />
</View>
)

View File

@ -20,7 +20,7 @@ export const ProfileFollowersScreen = withAuthRequired(({route}: Props) => {
return (
<View>
<ViewHeader title="Followers" />
<ViewHeader title="Followers" showOnDesktop />
<ProfileFollowersComponent name={name} />
</View>
)

View File

@ -20,7 +20,7 @@ export const ProfileFollowsScreen = withAuthRequired(({route}: Props) => {
return (
<View>
<ViewHeader title="Following" />
<ViewHeader title="Following" showOnDesktop />
<ProfileFollowsComponent name={name} />
</View>
)

View File

@ -140,7 +140,7 @@ export const SettingsScreen = withAuthRequired(
return (
<View style={[s.hContentRegion]} testID="settingsScreen">
<ViewHeader title="Settings" />
<ViewHeader title="Settings" showOnDesktop />
<ScrollView style={s.hContentRegion} scrollIndicatorInsets={{right: 1}}>
<View style={styles.spacer20} />
<View style={[s.flexRow, styles.heading]}>
@ -267,6 +267,20 @@ export const SettingsScreen = withAuthRequired(
Content moderation
</Text>
</TouchableOpacity>
<Link
testID="appPasswordBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
href="/settings/app-passwords">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="lock"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
App Passwords
</Text>
</Link>
<TouchableOpacity
testID="changeHandleBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}