create and delete app passwords

This commit is contained in:
Ansh Nanda
2023-04-20 23:15:45 -07:00
committed by Paul Frazee
parent aa56f4a5e2
commit d5725b01e9
9 changed files with 460 additions and 2 deletions
+2
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} />
</>
)
}
+1
View File
@@ -19,6 +19,7 @@ export type CommonNavigatorParams = {
TermsOfService: undefined
CommunityGuidelines: undefined
CopyrightPolicy: undefined
AppPasswords: undefined
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {
+1
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: '/app-passwords',
Support: '/support',
PrivacyPolicy: '/support/privacy',
TermsOfService: '/support/tos',
+60 -2
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,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 {
+5
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
+152
View File
@@ -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 (
<View style={[styles.container, pal.view]} testID="addAppPasswordsModal">
<View>
<Text type="lg">
{!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."}
</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}
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>
<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,
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: 8,
},
btnContainer: {
flexDirection: 'row',
justifyContent: 'center',
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
paddingHorizontal: 60,
paddingVertical: 14,
},
btnLabel: {
fontSize: 18,
},
groupContent: {
borderTopWidth: 1,
flexDirection: 'row',
alignItems: 'center',
},
})
+4
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 />
+221
View File
@@ -0,0 +1,221 @@
import React from 'react'
import {Alert, StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
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 {ScrollView} from 'react-native-gesture-handler'
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'
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 () => {
// const createdPassword = await store.me.createAppPassword('test')
// console.log(createdPassword)
store.shell.openModal({name: 'add-app-password'})
}, [store])
// no app passwords (empty) state
if (store.me.appPasswords.length === 0) {
return (
<View style={[styles.container, pal.view]} 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>
<View style={styles.flex1} />
<View style={styles.btnContainer}>
<Button
testID="appPasswordBtn"
type="primary"
label="Add App Password"
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={onAdd}
/>
</View>
</View>
)
}
// has app passwords
return (
<View style={[styles.container, pal.view]} testID="appPasswordsScreen">
<AppPasswordsHeader />
<ScrollView style={[styles.scrollContainer, pal.border]}>
{store.me.appPasswords.map((password, i) => (
<AppPassword
key={password.name}
testID={`appPassword-${i}`}
name={password.name}
createdAt={password.createdAt}
/>
))}
</ScrollView>
<View style={styles.btnContainer}>
<Button
testID="appPasswordBtn"
type="primary"
label="Add App Password"
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={onAdd}
/>
</View>
</View>
)
}),
)
function AppPasswordsHeader() {
const pal = usePalette('default')
return (
<>
<ViewHeader title="App Passwords" />
<Text type="sm" style={[styles.description, pal.text]}>
These app passwords can be used to log into Bluesky on other client 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>
<Text type={'md'} style={pal.text}>
{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,
},
title: {
textAlign: 'center',
marginTop: 12,
marginBottom: 12,
},
description: {
textAlign: 'center',
paddingHorizontal: 20,
marginBottom: 14,
},
scrollContainer: {
flex: 1,
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',
justifyContent: 'space-between',
borderBottomWidth: 1,
paddingHorizontal: 20,
paddingVertical: 14,
},
btnContainer: {
flexDirection: 'row',
justifyContent: 'center',
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
paddingHorizontal: 60,
paddingVertical: 14,
},
btnLabel: {
fontSize: 18,
},
trashIcon: {
color: 'red',
},
})
+14
View File
@@ -281,6 +281,20 @@ export const SettingsScreen = withAuthRequired(
Change my handle
</Text>
</TouchableOpacity>
<Link
testID="appPasswordBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
href="/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>
<View style={styles.spacer20} />