about summary refs log tree commit diff
diff options
context:
space:
mode:
authorAnsh <anshnanda10@gmail.com>2023-04-21 16:55:29 -0700
committerGitHub <noreply@github.com>2023-04-21 18:55:29 -0500
commit38eb299011672fc840849ae51e67adefef882bec (patch)
tree71c23c9818e48ecb898b99a3413e09aa3ba7c008
parentaa56f4a5e2c4236b7ae74ab61e75e419a86ed83d (diff)
downloadvoidsky-38eb299011672fc840849ae51e67adefef882bec.tar.zst
[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>
-rw-r--r--bskyweb/cmd/bskyweb/server.go1
-rw-r--r--src/Navigation.tsx2
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/routes.ts1
-rw-r--r--src/state/models/me.ts61
-rw-r--r--src/state/models/ui/shell.ts5
-rw-r--r--src/view/com/modals/AddAppPasswords.tsx216
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/util/ViewHeader.tsx25
-rw-r--r--src/view/screens/AppPasswords.tsx275
-rw-r--r--src/view/screens/PostLikedBy.tsx2
-rw-r--r--src/view/screens/PostRepostedBy.tsx2
-rw-r--r--src/view/screens/ProfileFollowers.tsx2
-rw-r--r--src/view/screens/ProfileFollows.tsx2
-rw-r--r--src/view/screens/Settings.tsx16
15 files changed, 607 insertions, 8 deletions
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 0c8e9f8d2..3339cccc1 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -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)
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<AllNavigatorParams>()
 
@@ -84,6 +85,7 @@ function commonScreens(Stack: typeof HomeTab) {
         component={CommunityGuidelinesScreen}
       />
       <Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
+      <Stack.Screen name="AppPasswords" component={AppPasswords} />
     </>
   )
 }
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<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',
+  },
+})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index a83cdfdae..5d034a19d 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -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 />
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index ad0a5a1d2..816c835cc 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -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',
diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx
new file mode 100644
index 000000000..c3e837f84
--- /dev/null
+++ b/src/view/screens/AppPasswords.tsx
@@ -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',
+  },
+})
diff --git a/src/view/screens/PostLikedBy.tsx b/src/view/screens/PostLikedBy.tsx
index fb44f1f9b..2e162ef0f 100644
--- a/src/view/screens/PostLikedBy.tsx
+++ b/src/view/screens/PostLikedBy.tsx
@@ -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>
   )
diff --git a/src/view/screens/PostRepostedBy.tsx b/src/view/screens/PostRepostedBy.tsx
index 19f0af18b..bfd827f67 100644
--- a/src/view/screens/PostRepostedBy.tsx
+++ b/src/view/screens/PostRepostedBy.tsx
@@ -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>
   )
diff --git a/src/view/screens/ProfileFollowers.tsx b/src/view/screens/ProfileFollowers.tsx
index e2f95fbe4..d782cb696 100644
--- a/src/view/screens/ProfileFollowers.tsx
+++ b/src/view/screens/ProfileFollowers.tsx
@@ -20,7 +20,7 @@ export const ProfileFollowersScreen = withAuthRequired(({route}: Props) => {
 
   return (
     <View>
-      <ViewHeader title="Followers" />
+      <ViewHeader title="Followers" showOnDesktop />
       <ProfileFollowersComponent name={name} />
     </View>
   )
diff --git a/src/view/screens/ProfileFollows.tsx b/src/view/screens/ProfileFollows.tsx
index f70944f55..9c15d1d92 100644
--- a/src/view/screens/ProfileFollows.tsx
+++ b/src/view/screens/ProfileFollows.tsx
@@ -20,7 +20,7 @@ export const ProfileFollowsScreen = withAuthRequired(({route}: Props) => {
 
   return (
     <View>
-      <ViewHeader title="Following" />
+      <ViewHeader title="Following" showOnDesktop />
       <ProfileFollowsComponent name={name} />
     </View>
   )
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 76a3efa60..6cf83c391 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -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]}