about summary refs log tree commit diff
path: root/src/view
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 /src/view
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>
Diffstat (limited to 'src/view')
-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
9 files changed, 538 insertions, 6 deletions
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]}