From ecd59f20dd4b6fd20c15702c4538447fccc2b47f Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Tue, 16 Nov 2021 10:13:31 +0000 Subject: [PATCH 01/41] feat: setup initial scaffolding for token screen --- src/Main.tsx | 7 ++++ src/screens/HomeScreen.tsx | 34 ++++--------------- src/screens/TokenScreen.tsx | 66 +++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 28 deletions(-) create mode 100644 src/screens/TokenScreen.tsx diff --git a/src/Main.tsx b/src/Main.tsx index 5f2487d3..d795f9cc 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -19,6 +19,7 @@ import { ScanScreen } from './screens/ScanScreen' import { AuthScreen } from './screens/AuthScreen' import HomeHeaderRight from './components/HomeHeaderRight' import DefaultHeaderLeft from './components/DefaultHeaderLeft' +import { TokenScreen } from './screens/TokenScreen' const MainStack = createStackNavigator() @@ -26,6 +27,7 @@ export type MainStackParamList = { Home: undefined Scan: undefined Type: undefined + Token: undefined } export default function Main() { @@ -65,6 +67,11 @@ export default function Main() { component={HomeScreen} options={{ title: 'Your Tokens', headerRight: HomeHeaderRight }} /> + = ({ navigation }) => { const { user } = useAuth() const { secrets, update, remove } = useSecrets() - const expoToken = usePushToken() const isFocused = useIsFocused() const responseListener = useRef() - const [subscriptionId, setSubscriptionId] = useState('') const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user]) - const handleGenerateToken = async (secret: Secret) => { - try { - const token = await api.generateToken(secret, subscriptionId) - await update({ ...secret, token }) - } catch (err) { - console.log(err) - } + const handleCreateToken = () => { + navigation.navigate('Token') } const handleRevokeToken = async (secret: Secret) => { @@ -141,20 +133,6 @@ export const HomeScreen: React.FC = ({ navigation }) => { [secrets, handlePasswordRequest] ) - useEffect(() => { - if (!user || !expoToken) return - - const register = async () => { - const id = await api.registerSubscription({ - type: 'expo', - token: expoToken, - }) - setSubscriptionId(id) - } - - register() - }, [user, api, expoToken]) - useEffect(() => { responseListener.current = Notifications.addNotificationResponseReceivedListener(onNotification) @@ -174,7 +152,7 @@ export const HomeScreen: React.FC = ({ navigation }) => { diff --git a/src/screens/TokenScreen.tsx b/src/screens/TokenScreen.tsx new file mode 100644 index 00000000..5d832dce --- /dev/null +++ b/src/screens/TokenScreen.tsx @@ -0,0 +1,66 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { StyleSheet, View } from 'react-native' +import { Button } from 'react-native-paper' + +import { useAuth } from '../context/AuthContext' +import { Typography } from '../components/Typography' +import { Secret } from '../types' +import apiFactory from '../lib/api' +import { useSecrets } from '../context/SecretsContext' +import usePushToken from '../hooks/use-push-token' + +const styles = StyleSheet.create({ + container: { + flexGrow: 1, + justifyContent: 'flex-start', + width: '100%', + }, + scrollView: { + flexGrow: 1, + }, +}) + +export const TokenScreen = () => { + const { user } = useAuth() + const [subscriptionId, setSubscriptionId] = useState('') + const { update } = useSecrets() + const expoToken = usePushToken() + + const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user]) + + const handleGenerateToken = async (secret: Secret) => { + try { + const token = await api.generateToken(secret, subscriptionId) + await update({ ...secret, token }) + } catch (err) { + console.log(err) + } + } + + useEffect(() => { + if (!user || !expoToken) return + + const register = async () => { + const id = await api.registerSubscription({ + type: 'expo', + token: expoToken, + }) + setSubscriptionId(id) + } + + register() + }, [user, api, expoToken]) + + return ( + + Hello world + + + ) +} From a7fab574e05c41f3b3f11d46fc13dcfa448bbf89 Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Tue, 16 Nov 2021 12:46:21 +0000 Subject: [PATCH 02/41] feat: include extra data in secret --- src/Main.tsx | 7 +- src/components/SecretCard/ContextMenu.tsx | 19 +---- src/components/SecretCard/SecretCard.tsx | 75 ++++++++---------- src/screens/HomeScreen.tsx | 18 ++--- src/screens/TokenScreen.tsx | 93 ++++++++++++++++++----- src/types.ts | 7 +- 6 files changed, 121 insertions(+), 98 deletions(-) diff --git a/src/Main.tsx b/src/Main.tsx index d795f9cc..a29aaa0d 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -20,6 +20,7 @@ import { AuthScreen } from './screens/AuthScreen' import HomeHeaderRight from './components/HomeHeaderRight' import DefaultHeaderLeft from './components/DefaultHeaderLeft' import { TokenScreen } from './screens/TokenScreen' +import { Secret } from './types' const MainStack = createStackNavigator() @@ -27,7 +28,9 @@ export type MainStackParamList = { Home: undefined Scan: undefined Type: undefined - Token: undefined + Token: { + secret: Secret + } } export default function Main() { @@ -70,7 +73,7 @@ export default function Main() { void - onRefresh?: () => void - onRevoke?: () => void onDelete: () => void } export const ContextMenu: React.FC = ({ open, onToggle, - onRefresh, - onRevoke, onDelete, }) => { return ( @@ -29,19 +25,6 @@ export const ContextMenu: React.FC = ({ /> } > - - - ) diff --git a/src/components/SecretCard/SecretCard.tsx b/src/components/SecretCard/SecretCard.tsx index 0737ff98..12fabfbb 100644 --- a/src/components/SecretCard/SecretCard.tsx +++ b/src/components/SecretCard/SecretCard.tsx @@ -59,27 +59,26 @@ const styles = StyleSheet.create({ const BUTTON_LABELS = { showSecret: 'SECRET', - generateToken: 'Generate Token', + addToken: 'Add Token', } type SecretProps = { data: Secret - onGenerate: (_: Secret) => void + onAddToken: () => void onDelete: (_: Secret) => void - onRevoke: (_: Secret) => void } export const SecretCard: React.FC = ({ data, - onGenerate, + onAddToken, onDelete, - onRevoke, }) => { const [showMenu, setShowMenu] = useState(false) const [expanded, setExpanded] = useState(false) - const [generating, setGenerating] = useState(false) const [otp, setOtp] = useState('') + console.log({ data }) + useEffect(() => { if (!data.secret) return // do not fail if secret is missing @@ -94,26 +93,11 @@ export const SecretCard: React.FC = ({ return () => clearTimeout(timeout) }, [data.secret]) - const handleGenerate = () => { - onGenerate(data) - setGenerating(true) - setShowMenu(false) - } - - useEffect(() => { - setGenerating(false) - }, [data.token]) - const handleDelete = () => { onDelete(data) setShowMenu(false) } - const handleRevoke = () => { - onRevoke(data) - setShowMenu(false) - } - const handleToggleExpand = () => setExpanded(!expanded) const handleToggleMenu = () => setShowMenu(!showMenu) @@ -129,8 +113,6 @@ export const SecretCard: React.FC = ({ right={() => ( @@ -139,17 +121,28 @@ export const SecretCard: React.FC = ({ - {data.token && ( - <> - - TOKEN - - {data.token || '-'} - - - - - )} + { + data.tokens && + data.tokens.map(data => ( + + {data.note} + + {data.token} + + + )) + // data.tokens.map((data) => (<>Token + // ) + // (<> + // + // {/*TOKEN*/} + // {/**/} + // {/* {data.token || "-"}*/} + // {/**/} + // + // + // ) + } SECRET @@ -171,17 +164,9 @@ export const SecretCard: React.FC = ({ - {!data.token && ( - - )} + diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 29a52de8..d25840fe 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -72,17 +72,10 @@ export const HomeScreen: React.FC = ({ navigation }) => { const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user]) - const handleCreateToken = () => { - navigation.navigate('Token') - } - - const handleRevokeToken = async (secret: Secret) => { - try { - await api.revokeToken(secret) - await update({ ...secret, token: undefined }) - } catch (err) { - console.log(err) - } + const handleAddToken = (secret: Secret) => { + navigation.navigate('Token', { + secret, + }) } const handleDeleteSecret = async (secret: Secret) => { @@ -152,8 +145,7 @@ export const HomeScreen: React.FC = ({ navigation }) => { handleAddToken(secret)} onDelete={handleDeleteSecret} /> )) diff --git a/src/screens/TokenScreen.tsx b/src/screens/TokenScreen.tsx index 5d832dce..45b9d8df 100644 --- a/src/screens/TokenScreen.tsx +++ b/src/screens/TokenScreen.tsx @@ -1,43 +1,82 @@ import React, { useEffect, useMemo, useState } from 'react' import { StyleSheet, View } from 'react-native' -import { Button } from 'react-native-paper' +import { Button, TextInput } from 'react-native-paper' +import { NativeStackScreenProps } from 'react-native-screens/native-stack' import { useAuth } from '../context/AuthContext' -import { Typography } from '../components/Typography' import { Secret } from '../types' import apiFactory from '../lib/api' import { useSecrets } from '../context/SecretsContext' import usePushToken from '../hooks/use-push-token' +import { MainStackParamList } from '../Main' +import theme from '../lib/theme' const styles = StyleSheet.create({ - container: { - flexGrow: 1, - justifyContent: 'flex-start', + screen: { width: '100%', }, - scrollView: { - flexGrow: 1, + form: { + padding: theme.spacing(2), + }, + inputRow: { + marginBottom: theme.spacing(2), + }, + formButton: { + marginTop: theme.spacing(1), + height: 50, + justifyContent: 'center', }, }) -export const TokenScreen = () => { +type Props = NativeStackScreenProps + +export const TokenScreen = ({ route }: Props) => { + const { secret } = route.params const { user } = useAuth() const [subscriptionId, setSubscriptionId] = useState('') + const [note, setNote] = useState('') const { update } = useSecrets() const expoToken = usePushToken() const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user]) - const handleGenerateToken = async (secret: Secret) => { + const invalidNote = note.length < 3 + const disabled = invalidNote + + const handleGenerateToken = async () => { try { const token = await api.generateToken(secret, subscriptionId) - await update({ ...secret, token }) + const newToken = { + token, + note, + } + const existingTokens = secret.tokens ? secret.tokens : [] + console.log(token, 'newToken') + + // await update({ + // ...secret, + // tokens: [newToken, ...existingTokens], + // }) + } catch (err) { + console.log(err) + } + } + + const handleRevokeToken = async (secret: Secret, token: string) => { + try { + await api.revokeToken(secret) + await update({ + ...secret, + tokens: secret.tokens.filter(data => data.token !== token), + }) } catch (err) { console.log(err) } } useEffect(() => { + console.log(user, 'user') + console.log(expoToken, 'expoToken') if (!user || !expoToken) return const register = async () => { @@ -45,6 +84,7 @@ export const TokenScreen = () => { type: 'expo', token: expoToken, }) + console.log(id, 'id') setSubscriptionId(id) } @@ -52,15 +92,30 @@ export const TokenScreen = () => { }, [user, api, expoToken]) return ( - - Hello world - + + + + + + + ) } diff --git a/src/types.ts b/src/types.ts index 67326add..eeac945c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,15 @@ +export type Token = { + token: string + note: string +} + export type Secret = { _id: string uid: string secret: string account: string issuer: string - token?: string + tokens?: Token[] } export type Subscription = { From ab27166bdeb5e1b42a9107eece71ce55e740b58d Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Tue, 16 Nov 2021 16:01:23 +0000 Subject: [PATCH 03/41] feat: make tokens on homepage clickable --- src/Main.tsx | 1 + src/components/SecretCard/SecretCard.tsx | 38 +++----- src/screens/HomeScreen.tsx | 13 ++- src/screens/TokenScreen.tsx | 116 +++++++++++++++-------- 4 files changed, 102 insertions(+), 66 deletions(-) diff --git a/src/Main.tsx b/src/Main.tsx index a29aaa0d..5be1a6bc 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -30,6 +30,7 @@ export type MainStackParamList = { Type: undefined Token: { secret: Secret + token?: string } } diff --git a/src/components/SecretCard/SecretCard.tsx b/src/components/SecretCard/SecretCard.tsx index 12fabfbb..3fe31e1f 100644 --- a/src/components/SecretCard/SecretCard.tsx +++ b/src/components/SecretCard/SecretCard.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react' -import { StyleSheet, Text, View } from 'react-native' +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native' import { Avatar, Button, Card, Divider } from 'react-native-paper' import Animated from 'react-native-reanimated' @@ -11,7 +11,6 @@ import useAnimatedTransition from '../../hooks/use-animated-transition' import { ContextMenu } from './ContextMenu' import { OTP } from './OTP' -import { CopyableInfo } from './CopyableInfo' const styles = StyleSheet.create({ container: { @@ -65,12 +64,14 @@ const BUTTON_LABELS = { type SecretProps = { data: Secret onAddToken: () => void + onViewToken: (token: string) => void onDelete: (_: Secret) => void } export const SecretCard: React.FC = ({ data, onAddToken, + onViewToken, onDelete, }) => { const [showMenu, setShowMenu] = useState(false) @@ -121,28 +122,17 @@ export const SecretCard: React.FC = ({ - { - data.tokens && - data.tokens.map(data => ( - - {data.note} - - {data.token} - - - )) - // data.tokens.map((data) => (<>Token - // ) - // (<> - // - // {/*TOKEN*/} - // {/**/} - // {/* {data.token || "-"}*/} - // {/**/} - // - // - // ) - } + {data.tokens && + data.tokens.map(data => ( + <> + onViewToken(data.token)}> + + {data.note} + + + + + ))} SECRET diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index d25840fe..8e95b26a 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -66,7 +66,7 @@ type HomeScreenProps = { export const HomeScreen: React.FC = ({ navigation }) => { const { user } = useAuth() - const { secrets, update, remove } = useSecrets() + const { secrets, remove } = useSecrets() const isFocused = useIsFocused() const responseListener = useRef() @@ -78,6 +78,16 @@ export const HomeScreen: React.FC = ({ navigation }) => { }) } + const handleViewToken = useCallback( + (secret: Secret, token: string) => { + navigation.navigate('Token', { + secret, + token, + }) + }, + [navigation] + ) + const handleDeleteSecret = async (secret: Secret) => { try { await api.revokeToken(secret) @@ -147,6 +157,7 @@ export const HomeScreen: React.FC = ({ navigation }) => { data={secret} onAddToken={() => handleAddToken(secret)} onDelete={handleDeleteSecret} + onViewToken={token => handleViewToken(secret, token)} /> )) )} diff --git a/src/screens/TokenScreen.tsx b/src/screens/TokenScreen.tsx index 45b9d8df..5e34e9a9 100644 --- a/src/screens/TokenScreen.tsx +++ b/src/screens/TokenScreen.tsx @@ -1,15 +1,16 @@ import React, { useEffect, useMemo, useState } from 'react' import { StyleSheet, View } from 'react-native' -import { Button, TextInput } from 'react-native-paper' +import { Button, Card, TextInput } from 'react-native-paper' import { NativeStackScreenProps } from 'react-native-screens/native-stack' import { useAuth } from '../context/AuthContext' -import { Secret } from '../types' import apiFactory from '../lib/api' import { useSecrets } from '../context/SecretsContext' import usePushToken from '../hooks/use-push-token' import { MainStackParamList } from '../Main' import theme from '../lib/theme' +import { Typography } from '../components/Typography' +import { CopyableInfo } from '../components/SecretCard/CopyableInfo' const styles = StyleSheet.create({ screen: { @@ -26,22 +27,39 @@ const styles = StyleSheet.create({ height: 50, justifyContent: 'center', }, + row: { + flex: 1, + marginTop: theme.spacing(2), + paddingHorizontal: theme.spacing(1), + }, + label: { + color: theme.colors.textSecondary, + marginRight: theme.spacing(1), + fontSize: 10, + }, + value: { + fontFamily: 'monospace', + fontSize: 24, + color: theme.colors.text, + marginBottom: theme.spacing(2), + }, }) type Props = NativeStackScreenProps -export const TokenScreen = ({ route }: Props) => { - const { secret } = route.params +export const TokenScreen = ({ route, navigation }: Props) => { + const { secret, token } = route.params + const existingNote = + secret.tokens?.find(item => item.token === token)?.note || '' const { user } = useAuth() - const [subscriptionId, setSubscriptionId] = useState('') + const [subscriptionId, setSubscriptionId] = useState(existingNote) const [note, setNote] = useState('') const { update } = useSecrets() const expoToken = usePushToken() const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user]) - const invalidNote = note.length < 3 - const disabled = invalidNote + const disabled = note.length < 3 const handleGenerateToken = async () => { try { @@ -53,27 +71,32 @@ export const TokenScreen = ({ route }: Props) => { const existingTokens = secret.tokens ? secret.tokens : [] console.log(token, 'newToken') - // await update({ - // ...secret, - // tokens: [newToken, ...existingTokens], - // }) - } catch (err) { - console.log(err) - } - } - - const handleRevokeToken = async (secret: Secret, token: string) => { - try { - await api.revokeToken(secret) await update({ ...secret, - tokens: secret.tokens.filter(data => data.token !== token), + tokens: [newToken, ...existingTokens], + }) + + navigation.navigate('Token', { + secret, + token, }) } catch (err) { console.log(err) } } + // const handleRevokeToken = async (secret: Secret, token: string) => { + // try { + // await api.revokeToken(secret) + // await update({ + // ...secret, + // tokens: secret.tokens.filter(data => data.token !== token), + // }) + // } catch (err) { + // console.log(err) + // } + // } + // useEffect(() => { console.log(user, 'user') console.log(expoToken, 'expoToken') @@ -93,29 +116,40 @@ export const TokenScreen = ({ route }: Props) => { return ( - - - + {existingNote}} /> + + {token || '-'} + + + ) : ( + + + + + - - + )} ) } From d13d5c8df43340064a4481cca7f5a30f6b80e244 Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Tue, 16 Nov 2021 17:20:28 +0000 Subject: [PATCH 04/41] test: basic fix up of tests --- src/screens/HomeScreen.test.tsx | 13 +- src/screens/HomeScreen.test.tsx.snap.android | 275 +++++++++++-------- src/screens/HomeScreen.test.tsx.snap.ios | 275 +++++++++++-------- src/screens/TokenScreen.test.tsx | 68 +++++ src/screens/TokenScreen.tsx | 4 +- 5 files changed, 400 insertions(+), 235 deletions(-) create mode 100644 src/screens/TokenScreen.test.tsx diff --git a/src/screens/HomeScreen.test.tsx b/src/screens/HomeScreen.test.tsx index 8b2764b5..03a712ab 100644 --- a/src/screens/HomeScreen.test.tsx +++ b/src/screens/HomeScreen.test.tsx @@ -57,20 +57,9 @@ describe('HomeScreen', () => { it('should match snapshot when there are no secrets', () => { const view = setup() - expect(view).toMatchSnapshot() }) - it('register subscription on load', () => { - setup() - - expect(registerSubscriptionStub).toHaveBeenCalledTimes(1) - expect(registerSubscriptionStub).toHaveBeenCalledWith({ - token: 'dummy-expo-token', - type: 'expo', - }) - }) - it('renders secret cards when available', () => { useSecretsMocked.mockReturnValue({ secrets: [ @@ -80,7 +69,7 @@ describe('HomeScreen', () => { issuer: 'Some issuer', secret: 'mysecret', uid: '222', - token: 'some-token', + tokens: [{ token: 'some-token', note: 'Some note' }], }, { _id: '222', diff --git a/src/screens/HomeScreen.test.tsx.snap.android b/src/screens/HomeScreen.test.tsx.snap.android index 1638d497..1b825276 100644 --- a/src/screens/HomeScreen.test.tsx.snap.android +++ b/src/screens/HomeScreen.test.tsx.snap.android @@ -647,30 +647,27 @@ exports[`HomeScreen renders secret cards when available 1`] = ` } /> - - TOKEN - @@ -684,98 +681,8 @@ exports[`HomeScreen renders secret cards when available 1`] = ` } } > - some-token + Some note - - - - 󰆏 - - - + > + + + + + + 󰐕 + + + + Add Token + + + + + @@ -1919,7 +1974,7 @@ exports[`HomeScreen renders secret cards when available 1`] = ` accessibilityRole="button" accessibilityState={ Object { - "disabled": false, + "disabled": undefined, } } accessible={true} @@ -2037,7 +2092,7 @@ exports[`HomeScreen renders secret cards when available 1`] = ` ] } > - Generate Token + Add Token diff --git a/src/screens/HomeScreen.test.tsx.snap.ios b/src/screens/HomeScreen.test.tsx.snap.ios index c6c5f2aa..50742083 100644 --- a/src/screens/HomeScreen.test.tsx.snap.ios +++ b/src/screens/HomeScreen.test.tsx.snap.ios @@ -647,30 +647,27 @@ exports[`HomeScreen renders secret cards when available 1`] = ` } /> - - TOKEN - @@ -684,98 +681,8 @@ exports[`HomeScreen renders secret cards when available 1`] = ` } } > - some-token + Some note - - - - 󰆏 - - - + > + + + + + + 󰐕 + + + + Add Token + + + + + @@ -1919,7 +1974,7 @@ exports[`HomeScreen renders secret cards when available 1`] = ` accessibilityRole="button" accessibilityState={ Object { - "disabled": false, + "disabled": undefined, } } accessible={true} @@ -2037,7 +2092,7 @@ exports[`HomeScreen renders secret cards when available 1`] = ` ] } > - Generate Token + Add Token diff --git a/src/screens/TokenScreen.test.tsx b/src/screens/TokenScreen.test.tsx new file mode 100644 index 00000000..5939125b --- /dev/null +++ b/src/screens/TokenScreen.test.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { mocked } from 'ts-jest/utils' +import * as Notification from 'expo-notifications' +import { Subscription } from '@unimodules/react-native-adapter' + +import apiFactory, { API } from '../lib/api' +import { getMockedNavigation, renderWithTheme } from '../../test/utils' + +import { TokenScreen } from './TokenScreen' + +jest.mock('@react-navigation/core', () => ({ + useIsFocused: jest.fn().mockReturnValue(true), +})) + +jest.mock('expo-notifications', () => ({ + setNotificationHandler: jest.fn(), + addNotificationResponseReceivedListener: jest.fn(), + removeNotificationSubscription: jest.fn(), +})) + +jest.mock('../lib/api') +jest.mock('../lib/otp', () => ({ + generate: jest.fn().mockReturnValue('009988'), + timeRemaining: jest.fn().mockReturnValue('24'), +})) + +jest.mock('../hooks/use-push-token', () => () => 'dummy-expo-token') + +const apiFactoryMocked = mocked(apiFactory) +const addNotificationResponseReceivedListenerMocked = mocked( + Notification.addNotificationResponseReceivedListener +) + +describe('TokenScreen', () => { + const registerSubscriptionStub = jest.fn() + + beforeEach(() => { + apiFactoryMocked.mockReturnValue({ + registerSubscription: registerSubscriptionStub, + } as unknown as API) + + addNotificationResponseReceivedListenerMocked.mockReturnValue( + {} as Subscription + ) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + const setup = () => { + const navigation: any = getMockedNavigation<'Token'>() + const route: any = { params: { token: 'a-token', secret: {} } } + return renderWithTheme( + + ) + } + + it('register subscription on load', () => { + setup() + + expect(registerSubscriptionStub).toHaveBeenCalledTimes(1) + expect(registerSubscriptionStub).toHaveBeenCalledWith({ + token: 'dummy-expo-token', + type: 'expo', + }) + }) +}) diff --git a/src/screens/TokenScreen.tsx b/src/screens/TokenScreen.tsx index 5e34e9a9..bf82908f 100644 --- a/src/screens/TokenScreen.tsx +++ b/src/screens/TokenScreen.tsx @@ -97,9 +97,8 @@ export const TokenScreen = ({ route, navigation }: Props) => { // } // } // + useEffect(() => { - console.log(user, 'user') - console.log(expoToken, 'expoToken') if (!user || !expoToken) return const register = async () => { @@ -107,7 +106,6 @@ export const TokenScreen = ({ route, navigation }: Props) => { type: 'expo', token: expoToken, }) - console.log(id, 'id') setSubscriptionId(id) } From 736494f9f8f9de2af83357183e4b6ab201a4d2f6 Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Wed, 17 Nov 2021 10:18:48 +0000 Subject: [PATCH 05/41] test: add basic test for token generation --- src/screens/TokenScreen.test.tsx | 38 ++++++++++++++++++++++++++------ src/screens/TokenScreen.tsx | 1 - 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/screens/TokenScreen.test.tsx b/src/screens/TokenScreen.test.tsx index 5939125b..9714be16 100644 --- a/src/screens/TokenScreen.test.tsx +++ b/src/screens/TokenScreen.test.tsx @@ -2,9 +2,13 @@ import React from 'react' import { mocked } from 'ts-jest/utils' import * as Notification from 'expo-notifications' import { Subscription } from '@unimodules/react-native-adapter' +import { fireEvent } from '@testing-library/react-native' +import { NativeStackScreenProps } from 'react-native-screens/native-stack' import apiFactory, { API } from '../lib/api' import { getMockedNavigation, renderWithTheme } from '../../test/utils' +import { Secret } from '../types' +import { MainStackParamList } from '../Main' import { TokenScreen } from './TokenScreen' @@ -32,11 +36,21 @@ const addNotificationResponseReceivedListenerMocked = mocked( ) describe('TokenScreen', () => { + const secret: Secret = { + _id: 'id', + secret: 'secret', + uid: 'uid', + tokens: [{ note: 'My note', token: '' }], + account: 'account', + issuer: '', + } const registerSubscriptionStub = jest.fn() + const generateTokenStub = jest.fn() beforeEach(() => { apiFactoryMocked.mockReturnValue({ registerSubscription: registerSubscriptionStub, + generateToken: generateTokenStub, } as unknown as API) addNotificationResponseReceivedListenerMocked.mockReturnValue( @@ -48,21 +62,31 @@ describe('TokenScreen', () => { jest.clearAllMocks() }) - const setup = () => { - const navigation: any = getMockedNavigation<'Token'>() - const route: any = { params: { token: 'a-token', secret: {} } } - return renderWithTheme( - - ) + const setup = (token?: string) => { + const props = { + navigation: getMockedNavigation<'Token'>(), + route: { params: { token, secret } }, + } as unknown as NativeStackScreenProps + + return renderWithTheme() } it('register subscription on load', () => { setup() - expect(registerSubscriptionStub).toHaveBeenCalledTimes(1) expect(registerSubscriptionStub).toHaveBeenCalledWith({ token: 'dummy-expo-token', type: 'expo', }) }) + + it('generates a token when note inputted', async () => { + const { getByA11yLabel, getByText } = setup() + + const noteInput = getByA11yLabel('Note') + fireEvent.changeText(noteInput, 'My note') + fireEvent.press(getByText('Generate Token')) + expect(generateTokenStub).toBeCalledTimes(1) + expect(generateTokenStub).toBeCalledWith(secret, '') + }) }) diff --git a/src/screens/TokenScreen.tsx b/src/screens/TokenScreen.tsx index bf82908f..99a731fd 100644 --- a/src/screens/TokenScreen.tsx +++ b/src/screens/TokenScreen.tsx @@ -69,7 +69,6 @@ export const TokenScreen = ({ route, navigation }: Props) => { note, } const existingTokens = secret.tokens ? secret.tokens : [] - console.log(token, 'newToken') await update({ ...secret, From 84a5a049e1166f1791d2c60737b85fe00ccec767 Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Fri, 19 Nov 2021 12:30:35 +0000 Subject: [PATCH 06/41] feat: create new otp request screen and move OTP approval there --- src/Main.tsx | 11 ++++ src/lib/api.ts | 27 +++++++--- src/screens/HomeScreen.tsx | 62 +++++---------------- src/screens/OtpRequestScreen.tsx | 93 ++++++++++++++++++++++++++++++++ src/screens/TokenScreen.tsx | 50 +++++++++++++++++ 5 files changed, 187 insertions(+), 56 deletions(-) create mode 100644 src/screens/OtpRequestScreen.tsx diff --git a/src/Main.tsx b/src/Main.tsx index 5be1a6bc..d7235c8d 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -21,6 +21,7 @@ import HomeHeaderRight from './components/HomeHeaderRight' import DefaultHeaderLeft from './components/DefaultHeaderLeft' import { TokenScreen } from './screens/TokenScreen' import { Secret } from './types' +import { OtpRequestScreen } from './screens/OtpRequestScreen' const MainStack = createStackNavigator() @@ -32,6 +33,11 @@ export type MainStackParamList = { secret: Secret token?: string } + OtpRequest: { + secret: Secret + token: string + uniqueId: string + } } export default function Main() { @@ -76,6 +82,11 @@ export default function Main() { component={TokenScreen} options={{ title: 'Token', headerLeft: DefaultHeaderLeft }} /> + Promise - revokeToken: (_: Secret) => Promise + generateToken: (_: Secret, __: string, ___?: string) => Promise + deleteSecret: (_: Secret) => Promise + revokeToken: (_: string) => Promise registerSubscription: (_: Subscription) => Promise respond: (_: string, __: string, ___: boolean) => Promise } export default function apiFactory(opts: APIOptions): API { return { - async generateToken(secret, subscriptionId) { + async generateToken(secret, subscriptionId, existingToken) { const response = await fetch(`${apiUrl}/token`, { method: 'PUT', headers: { 'Content-Type': 'application/json', authorization: `Bearer ${opts.idToken}`, }, - body: JSON.stringify({ secretId: secret._id, subscriptionId }), + body: JSON.stringify({ + secretId: secret._id, + subscriptionId, + existingToken, + }), }) const { token } = await response.json() return token }, - - async revokeToken(secret) { - await fetch(`${apiUrl}/token/${secret._id}`, { + async deleteSecret(secret) { + await fetch(`${apiUrl}/secret/${secret._id}`, { + method: 'DELETE', + headers: { + authorization: `Bearer ${opts.idToken}`, + }, + }) + }, + // TODO needs to revoke by token and not just secret + async revokeToken(token) { + await fetch(`${apiUrl}/token/${token}`, { method: 'DELETE', headers: { authorization: `Bearer ${opts.idToken}`, diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 8e95b26a..7060a446 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react' import * as Notifications from 'expo-notifications' import { NotificationResponse } from 'expo-notifications' -import { Alert, ScrollView, StyleSheet, View } from 'react-native' +import { ScrollView, StyleSheet, View } from 'react-native' import { Subscription } from '@unimodules/react-native-adapter' import { StackNavigationProp } from '@react-navigation/stack' import { useIsFocused } from '@react-navigation/core' @@ -18,6 +18,7 @@ import { MainStackParamList } from '../Main' type NotificationData = { secretId: string uniqueId: string + token: string } const styles = StyleSheet.create({ @@ -39,27 +40,6 @@ Notifications.setNotificationHandler({ }), }) -const showRequestAlert = ( - issuer: string, - account: string, - onApprove: () => void, - onReject: () => void -) => { - Alert.alert( - 'One Time Password requested', - `For secret issued by ${issuer} to ${account}`, - [ - { - text: 'Reject', - style: 'cancel', - onPress: onReject, - }, - { text: 'Approve', onPress: onApprove }, - ], - { cancelable: false } - ) -} - type HomeScreenProps = { navigation: StackNavigationProp } @@ -90,50 +70,34 @@ export const HomeScreen: React.FC = ({ navigation }) => { const handleDeleteSecret = async (secret: Secret) => { try { - await api.revokeToken(secret) + await api.deleteSecret(secret) await remove(secret) } catch (err) { console.log(err) } } - const handlePasswordRequest = useCallback( - async (secret: string, uniqueId: string, approved: boolean) => { - try { - await api.respond(secret, uniqueId, approved) - } catch (err) { - console.log(err) - } - }, - [api] - ) - const onNotification = useCallback( async (res: NotificationResponse) => { const data = res.notification.request.content.data as NotificationData + console.log({ data }, 'notification') - const { secretId, uniqueId } = data + const { secretId, uniqueId, token } = data - const details = secrets.find(({ _id }) => _id === secretId) + const secret = secrets.find(({ _id }) => _id === secretId) - if (!details) { + if (!secret) { console.error(`Failed to find secret with id ${secretId}`) return } - const { secret, issuer, account } = details - - // Delay ensures the prompt is shown, else it's missed some time - setTimeout(() => { - showRequestAlert( - issuer, - account, - () => handlePasswordRequest(secret, uniqueId, true), - () => handlePasswordRequest(secret, uniqueId, false) - ) - }, 100) + navigation.navigate('OtpRequest', { + token, + secret, + uniqueId, + }) }, - [secrets, handlePasswordRequest] + [navigation, secrets] ) useEffect(() => { diff --git a/src/screens/OtpRequestScreen.tsx b/src/screens/OtpRequestScreen.tsx new file mode 100644 index 00000000..065c4173 --- /dev/null +++ b/src/screens/OtpRequestScreen.tsx @@ -0,0 +1,93 @@ +import { StyleSheet, View } from 'react-native' +import { NativeStackScreenProps } from 'react-native-screens/native-stack' +import React, { useCallback, useMemo } from 'react' +import { Button, Card } from 'react-native-paper' + +import theme from '../lib/theme' +import { MainStackParamList } from '../Main' +import { useAuth } from '../context/AuthContext' +import apiFactory from '../lib/api' +import { Typography } from '../components/Typography' +import { CopyableInfo } from '../components/SecretCard/CopyableInfo' + +const styles = StyleSheet.create({ + screen: { + width: '100%', + }, + form: { + padding: theme.spacing(2), + }, + inputRow: { + marginBottom: theme.spacing(2), + }, + formButton: { + marginTop: theme.spacing(1), + height: 50, + justifyContent: 'center', + }, + row: { + flex: 1, + marginTop: theme.spacing(2), + paddingHorizontal: theme.spacing(1), + }, + label: { + color: theme.colors.textSecondary, + marginRight: theme.spacing(1), + fontSize: 10, + }, + value: { + fontFamily: 'monospace', + fontSize: 24, + color: theme.colors.text, + marginBottom: theme.spacing(2), + }, +}) + +type Props = NativeStackScreenProps + +export const OtpRequestScreen = ({ route, navigation }: Props) => { + const { navigate } = navigation + const { user } = useAuth() + + const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user]) + const { token, secret, uniqueId } = route.params + const note = secret.tokens.find(item => item.token === token)?.note + + const handleRejectToken = useCallback(async () => { + console.log('Reject') + api.respond(secret.secret, uniqueId, false) + navigate('Home') + // TODO show notification + }, [api, navigate, secret.secret, uniqueId]) + + const handleApproveToken = useCallback(async () => { + api.respond(secret.secret, uniqueId, true) + navigate('Home') + // TODO show notification + }, [api, navigate, secret.secret, uniqueId]) + + return ( + + + {note}} /> + + {token || '-'} + + + + + + ) +} diff --git a/src/screens/TokenScreen.tsx b/src/screens/TokenScreen.tsx index 99a731fd..729265e9 100644 --- a/src/screens/TokenScreen.tsx +++ b/src/screens/TokenScreen.tsx @@ -97,6 +97,40 @@ export const TokenScreen = ({ route, navigation }: Props) => { // } // + const handleRefreshToken = async () => { + try { + const refreshedToken = await api.generateToken( + secret, + subscriptionId, + token + ) + // const newToken = { + // token: refreshedToken, + // note, + // } + // TODO update item in place + // const existingTokens = secret.tokens ? secret.tokens : [] + // + // await update({ + // ...secret, + // tokens: [newToken, ...existingTokens], + // }) + + navigation.navigate('Token', { + secret, + token: refreshedToken, + }) + } catch (err) { + console.log(err) + } + } + + const handleRevokeToken = async () => { + // try { + // await api.revokeToken() + // } + } + useEffect(() => { if (!user || !expoToken) return @@ -121,6 +155,22 @@ export const TokenScreen = ({ route, navigation }: Props) => { {token || '-'} + + ) : ( From b99b1ad268ea42643e4baaff354c4e8ed4698d99 Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Fri, 19 Nov 2021 16:00:25 +0000 Subject: [PATCH 07/41] feat: scaffold out otp request screen --- src/screens/OtpRequestScreen.tsx | 79 +++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 28 deletions(-) diff --git a/src/screens/OtpRequestScreen.tsx b/src/screens/OtpRequestScreen.tsx index 065c4173..c9255e62 100644 --- a/src/screens/OtpRequestScreen.tsx +++ b/src/screens/OtpRequestScreen.tsx @@ -1,29 +1,31 @@ -import { StyleSheet, View } from 'react-native' +import { StyleSheet, View, Text } from 'react-native' import { NativeStackScreenProps } from 'react-native-screens/native-stack' import React, { useCallback, useMemo } from 'react' -import { Button, Card } from 'react-native-paper' +import { Avatar, Button, Card } from 'react-native-paper' import theme from '../lib/theme' import { MainStackParamList } from '../Main' import { useAuth } from '../context/AuthContext' import apiFactory from '../lib/api' import { Typography } from '../components/Typography' -import { CopyableInfo } from '../components/SecretCard/CopyableInfo' const styles = StyleSheet.create({ screen: { - width: '100%', + margin: theme.spacing(2), + }, + cardContent: { + paddingHorizontal: theme.spacing(1), + }, + cardActions: { + justifyContent: 'space-between', + flexDirection: 'column', + alignItems: 'stretch', }, form: { padding: theme.spacing(2), }, - inputRow: { - marginBottom: theme.spacing(2), - }, - formButton: { - marginTop: theme.spacing(1), - height: 50, - justifyContent: 'center', + button: { + marginTop: theme.spacing(2), }, row: { flex: 1, @@ -69,24 +71,45 @@ export const OtpRequestScreen = ({ route, navigation }: Props) => { return ( - {note}} /> - - {token || '-'} + {secret.issuer}} + subtitle={secret.account} + left={props => } + /> + + + + Token + + + {token} + + + + + Description + + + {note} + + - - + + + + ) From 2596e7f9467f5b9dab6ea118ebea4fc749cd13d8 Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Fri, 19 Nov 2021 17:16:20 +0000 Subject: [PATCH 08/41] feat: add new tokens list screen --- src/Main.tsx | 21 ++++++- src/components/SecretCard/SecretCard.tsx | 21 +++---- src/components/SecretCard/TokensView.tsx | 23 +++++++ src/screens/HomeScreen.tsx | 9 ++- src/screens/TokensListScreen.tsx | 77 ++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 src/components/SecretCard/TokensView.tsx create mode 100644 src/screens/TokensListScreen.tsx diff --git a/src/Main.tsx b/src/Main.tsx index d7235c8d..d7095335 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -10,6 +10,7 @@ import { DidactGothic_400Regular, } from '@expo-google-fonts/didact-gothic' import AppLoading from 'expo-app-loading' +import { NativeStackScreenProps } from 'react-native-screens/native-stack' import theme from './lib/theme' import { useAuth } from './context/AuthContext' @@ -22,6 +23,7 @@ import DefaultHeaderLeft from './components/DefaultHeaderLeft' import { TokenScreen } from './screens/TokenScreen' import { Secret } from './types' import { OtpRequestScreen } from './screens/OtpRequestScreen' +import { TokensListScreen } from './screens/TokensListScreen' const MainStack = createStackNavigator() @@ -33,6 +35,9 @@ export type MainStackParamList = { secret: Secret token?: string } + TokensList: { + secret: Secret + } OtpRequest: { secret: Secret token: string @@ -75,7 +80,21 @@ export default function Main() { + ) => ({ + title: issuer, + headerLeft: DefaultHeaderLeft, + })} /> void - onViewToken: (token: string) => void + onViewTokens: () => void onDelete: (_: Secret) => void } export const SecretCard: React.FC = ({ data, onAddToken, - onViewToken, + onViewTokens, onDelete, }) => { const [showMenu, setShowMenu] = useState(false) @@ -122,17 +123,9 @@ export const SecretCard: React.FC = ({ - {data.tokens && - data.tokens.map(data => ( - <> - onViewToken(data.token)}> - - {data.note} - - - - - ))} + {data.tokens && ( + + )} SECRET diff --git a/src/components/SecretCard/TokensView.tsx b/src/components/SecretCard/TokensView.tsx new file mode 100644 index 00000000..80ed77fb --- /dev/null +++ b/src/components/SecretCard/TokensView.tsx @@ -0,0 +1,23 @@ +import { Text, TouchableOpacity, View } from 'react-native' +import React from 'react' + +type Props = { + count: number + onPress: () => void +} + +export const TokensView = ({ count, onPress }: Props) => { + return ( + + + + TOKENS + {count} + + + SEE TOKENS + + + + ) +} diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 7060a446..e3eff2a1 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -58,11 +58,10 @@ export const HomeScreen: React.FC = ({ navigation }) => { }) } - const handleViewToken = useCallback( - (secret: Secret, token: string) => { - navigation.navigate('Token', { + const handleViewTokens = useCallback( + (secret: Secret) => { + navigation.navigate('TokensList', { secret, - token, }) }, [navigation] @@ -121,7 +120,7 @@ export const HomeScreen: React.FC = ({ navigation }) => { data={secret} onAddToken={() => handleAddToken(secret)} onDelete={handleDeleteSecret} - onViewToken={token => handleViewToken(secret, token)} + onViewTokens={() => handleViewTokens(secret)} /> )) )} diff --git a/src/screens/TokensListScreen.tsx b/src/screens/TokensListScreen.tsx new file mode 100644 index 00000000..d07380d2 --- /dev/null +++ b/src/screens/TokensListScreen.tsx @@ -0,0 +1,77 @@ +import { + StyleSheet, + View, + Text, + ScrollView, + TouchableOpacity, +} from 'react-native' +import React, { useState } from 'react' +import { NativeStackScreenProps } from 'react-native-screens/native-stack' +import { FAB, TextInput } from 'react-native-paper' + +import { MainStackParamList } from '../Main' +import theme from '../lib/theme' + +const styles = StyleSheet.create({ + // container: { + // flexGrow: 1, + // justifyContent: 'flex-start', + // width: '100%', + // }, + scrollView: { + flexGrow: 1, + }, + tokenItem: { + padding: theme.spacing(2), + }, +}) + +type Props = NativeStackScreenProps + +export const TokensListScreen = ({ route, navigation }: Props) => { + const { secret } = route.params + const tokensCount = secret.tokens.length + + const [search, setSearch] = useState('') + + return ( + <> + + + + {tokensCount} {tokensCount === 1 ? 'TOKEN' : 'TOKENS'} + + + + + + + {secret.tokens.map(({ token, note }) => ( + navigation.navigate('Token', { secret, token })} + > + + + {token} + + + {note} + + + + ))} + + + + + ) +} From 90fcecd84e6d3ece407941e060332db9d3da6439 Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Fri, 19 Nov 2021 17:57:19 +0000 Subject: [PATCH 09/41] feat: style tokens info on home screen --- src/Main.tsx | 2 +- src/components/SecretCard/SecretCard.tsx | 6 +-- src/components/SecretCard/TokensInfo.tsx | 61 ++++++++++++++++++++++++ src/components/SecretCard/TokensView.tsx | 23 --------- src/lib/theme.ts | 3 +- 5 files changed, 66 insertions(+), 29 deletions(-) create mode 100644 src/components/SecretCard/TokensInfo.tsx delete mode 100644 src/components/SecretCard/TokensView.tsx diff --git a/src/Main.tsx b/src/Main.tsx index d7095335..a03194ae 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -80,7 +80,7 @@ export default function Main() { = ({ const [expanded, setExpanded] = useState(false) const [otp, setOtp] = useState('') - console.log({ data }) - useEffect(() => { if (!data.secret) return // do not fail if secret is missing @@ -124,7 +122,7 @@ export const SecretCard: React.FC = ({ {data.tokens && ( - + )} diff --git a/src/components/SecretCard/TokensInfo.tsx b/src/components/SecretCard/TokensInfo.tsx new file mode 100644 index 00000000..98a8edcf --- /dev/null +++ b/src/components/SecretCard/TokensInfo.tsx @@ -0,0 +1,61 @@ +import { StyleSheet, Text, View } from 'react-native' +import { Button, Divider } from 'react-native-paper' +import React from 'react' + +import theme from '../../lib/theme' +import { Typography } from '../Typography' + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingLeft: theme.spacing(1), + paddingVertical: theme.spacing(2), + }, + tokensCountLabel: { + color: theme.colors.textSecondary, + marginRight: theme.spacing(1), + fontSize: 10, + }, + tokensCountValue: { + fontFamily: 'monospace', + fontSize: 24, + color: theme.colors.text, + }, + seeTokensText: { + color: theme.colors.primary, + fontWeight: 'bold', + }, + divider: { + marginBottom: theme.spacing(1), + }, +}) + +type Props = { + count: number + onPress: () => void +} + +export const TokensInfo = ({ count, onPress }: Props) => { + return ( + <> + + + TOKENS + {count} + + + + + + + + ) +} diff --git a/src/components/SecretCard/TokensView.tsx b/src/components/SecretCard/TokensView.tsx deleted file mode 100644 index 80ed77fb..00000000 --- a/src/components/SecretCard/TokensView.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Text, TouchableOpacity, View } from 'react-native' -import React from 'react' - -type Props = { - count: number - onPress: () => void -} - -export const TokensView = ({ count, onPress }: Props) => { - return ( - - - - TOKENS - {count} - - - SEE TOKENS - - - - ) -} diff --git a/src/lib/theme.ts b/src/lib/theme.ts index cfc30d33..ceb9b6e6 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -85,6 +85,7 @@ const typography: Record = { fontSize: 10, fontWeight: '400', textTransform: 'uppercase', + letterSpacing: 1.1, }, } @@ -97,7 +98,7 @@ const theme = { ...NavigationDefaultTheme.colors, primary: '#2165E3', text: '#6D6D68', - textSecondary: '#CCCCCC', + textSecondary: 'rgba(0, 0, 0, 0.6)', }, typography, spacing: (mul: number) => mul * 8, From c0d9031d875b16e0da80632799476006653c7af5 Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Mon, 22 Nov 2021 11:44:34 +0000 Subject: [PATCH 10/41] feat: style tokens list screen --- src/screens/TokensListScreen.tsx | 157 +++++++++++++++++++++---------- 1 file changed, 108 insertions(+), 49 deletions(-) diff --git a/src/screens/TokensListScreen.tsx b/src/screens/TokensListScreen.tsx index d07380d2..64d5f7b7 100644 --- a/src/screens/TokensListScreen.tsx +++ b/src/screens/TokensListScreen.tsx @@ -1,28 +1,57 @@ -import { - StyleSheet, - View, - Text, - ScrollView, - TouchableOpacity, -} from 'react-native' -import React, { useState } from 'react' +import { StyleSheet, View, ScrollView } from 'react-native' +import React, { useMemo, useState } from 'react' import { NativeStackScreenProps } from 'react-native-screens/native-stack' -import { FAB, TextInput } from 'react-native-paper' +import { TextInput, Text, Divider, FAB, Portal, List } from 'react-native-paper' import { MainStackParamList } from '../Main' import theme from '../lib/theme' const styles = StyleSheet.create({ - // container: { - // flexGrow: 1, - // justifyContent: 'flex-start', - // width: '100%', - // }, + container: { + flexGrow: 1, + justifyContent: 'flex-start', + paddingHorizontal: theme.spacing(2), + paddingVertical: theme.spacing(3), + }, + searchArea: { + marginBottom: theme.spacing(2), + }, scrollView: { flexGrow: 1, }, + tokensCount: { + marginBottom: theme.spacing(2), + }, + tokensCountLabel: { + fontFamily: 'monospace', + fontSize: 24, + marginRight: theme.spacing(2), + }, + tokensCountValue: theme.typography.overline, tokenItem: { - padding: theme.spacing(2), + paddingHorizontal: 0, + paddingVertical: theme.spacing(1), + // React native paper doesn't allow overriding this https://github.com/callstack/react-native-paper/blob/b545cdcbd8c5f1bd5ad3f0e9f095c294527846a4/src/components/List/ListItem.tsx#L263 + // Needed to override the padding that's hardcoded there + marginLeft: -8, + }, + tokenValueText: { + fontFamily: 'monospace', + fontSize: 24, + color: theme.colors.text, + marginBottom: theme.spacing(1), + }, + tokenNoteText: { + ...theme.typography.body2, + color: theme.colors.text, + marginHorizontal: 0, + }, + fab: { + backgroundColor: theme.colors.primary, + position: 'absolute', + margin: theme.spacing(3), + bottom: 0, + alignSelf: 'center', }, }) @@ -30,48 +59,78 @@ type Props = NativeStackScreenProps export const TokensListScreen = ({ route, navigation }: Props) => { const { secret } = route.params - const tokensCount = secret.tokens.length - + const { tokens } = secret + const tokensCount = tokens.length const [search, setSearch] = useState('') + const showSearchArea = tokensCount > 1 + + const filteredTokens = useMemo(() => { + if (search.length > 1) { + return tokens.filter(({ note }) => + note.toLowerCase().includes(search.toLowerCase()) + ) + } else { + return tokens + } + }, [search, tokens]) + return ( <> - - - - {tokensCount} {tokensCount === 1 ? 'TOKEN' : 'TOKENS'} + + + {tokensCount}{' '} + + {tokensCount === 1 ? 'TOKEN' : 'TOKENS'} - - - - - - {secret.tokens.map(({ token, note }) => ( - navigation.navigate('Token', { secret, token })} - > - - - {token} - - - {note} - - - + + {showSearchArea && ( + + } + /> + + )} + + {filteredTokens.map(({ token, note }) => ( + <> + navigation.navigate('Token', { secret, token })} + style={styles.tokenItem} + title={token} + titleStyle={styles.tokenValueText} + description={note} + descriptionStyle={styles.tokenNoteText} + descriptionNumberOfLines={1} + right={({ style, ...props }) => ( + + )} + /> + + ))} - + + + ) } From 6c7fd35f9cc0146bcf803bc483c1e909bebc3cac Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Mon, 22 Nov 2021 16:10:56 +0000 Subject: [PATCH 11/41] refactor: copyable info to avoid margin hack --- src/components/SecretCard/CopyableInfo.tsx | 12 +++++++----- src/components/SecretCard/OTP.tsx | 6 +----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/components/SecretCard/CopyableInfo.tsx b/src/components/SecretCard/CopyableInfo.tsx index d6e95612..24bc28e2 100644 --- a/src/components/SecretCard/CopyableInfo.tsx +++ b/src/components/SecretCard/CopyableInfo.tsx @@ -9,9 +9,10 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', }, - iconButton: { - padding: 0, - marginTop: -10, + textContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', }, }) @@ -26,9 +27,10 @@ export const CopyableInfo: React.FC = ({ }) => { return ( - {children} + + {children} + Date: Mon, 22 Nov 2021 16:13:52 +0000 Subject: [PATCH 12/41] test: update home snapshots to allow for tweaks to theme and updated copyable text --- src/screens/HomeScreen.test.tsx.snap.android | 278 ++++++++++++++++--- src/screens/HomeScreen.test.tsx.snap.ios | 278 ++++++++++++++++--- 2 files changed, 472 insertions(+), 84 deletions(-) diff --git a/src/screens/HomeScreen.test.tsx.snap.android b/src/screens/HomeScreen.test.tsx.snap.android index 1b825276..56efd958 100644 --- a/src/screens/HomeScreen.test.tsx.snap.android +++ b/src/screens/HomeScreen.test.tsx.snap.android @@ -403,7 +403,7 @@ exports[`HomeScreen renders secret cards when available 1`] = ` @@ -427,18 +428,27 @@ exports[`HomeScreen renders secret cards when available 1`] = ` } } > - - 009988 - + + 009988 + + + + TOKENS + - Some note + 1 + + + + + + + 󰅂 + + + + + SEE TOKENS + + + + + + @@ -712,7 +899,7 @@ exports[`HomeScreen renders secret cards when available 1`] = ` @@ -1490,18 +1678,27 @@ exports[`HomeScreen renders secret cards when available 1`] = ` } } > - - 009988 - + + 009988 + + @@ -427,18 +428,27 @@ exports[`HomeScreen renders secret cards when available 1`] = ` } } > - - 009988 - + + 009988 + + + + TOKENS + - Some note + 1 + + + + + + + 󰅂 + + + + + SEE TOKENS + + + + + + @@ -712,7 +899,7 @@ exports[`HomeScreen renders secret cards when available 1`] = ` @@ -1490,18 +1678,27 @@ exports[`HomeScreen renders secret cards when available 1`] = ` } } > - - 009988 - + + 009988 + + Date: Mon, 22 Nov 2021 16:20:31 +0000 Subject: [PATCH 13/41] test: add create token screen --- src/Main.tsx | 9 + src/components/SecretCard/SecretCard.tsx | 2 +- ...en.test.tsx => CreateTokenScreen.test.tsx} | 18 +- src/screens/CreateTokenScreen.tsx | 126 +++++++++++ src/screens/HomeScreen.tsx | 2 +- src/screens/TokenScreen.tsx | 205 +++++++----------- src/screens/TokensListScreen.tsx | 23 +- 7 files changed, 246 insertions(+), 139 deletions(-) rename src/screens/{TokenScreen.test.tsx => CreateTokenScreen.test.tsx} (86%) create mode 100644 src/screens/CreateTokenScreen.tsx diff --git a/src/Main.tsx b/src/Main.tsx index a03194ae..aecff7e4 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -24,6 +24,7 @@ import { TokenScreen } from './screens/TokenScreen' import { Secret } from './types' import { OtpRequestScreen } from './screens/OtpRequestScreen' import { TokensListScreen } from './screens/TokensListScreen' +import { CreateTokenScreen } from './screens/CreateTokenScreen' const MainStack = createStackNavigator() @@ -31,6 +32,9 @@ export type MainStackParamList = { Home: undefined Scan: undefined Type: undefined + CreateToken: { + secret: Secret + } Token: { secret: Secret token?: string @@ -96,6 +100,11 @@ export default function Main() { headerLeft: DefaultHeaderLeft, })} /> + = ({ - {data.tokens && ( + {data.tokens && data.tokens.length > 0 && ( )} diff --git a/src/screens/TokenScreen.test.tsx b/src/screens/CreateTokenScreen.test.tsx similarity index 86% rename from src/screens/TokenScreen.test.tsx rename to src/screens/CreateTokenScreen.test.tsx index 9714be16..1376054d 100644 --- a/src/screens/TokenScreen.test.tsx +++ b/src/screens/CreateTokenScreen.test.tsx @@ -10,7 +10,7 @@ import { getMockedNavigation, renderWithTheme } from '../../test/utils' import { Secret } from '../types' import { MainStackParamList } from '../Main' -import { TokenScreen } from './TokenScreen' +import { CreateTokenScreen } from './CreateTokenScreen' jest.mock('@react-navigation/core', () => ({ useIsFocused: jest.fn().mockReturnValue(true), @@ -35,7 +35,7 @@ const addNotificationResponseReceivedListenerMocked = mocked( Notification.addNotificationResponseReceivedListener ) -describe('TokenScreen', () => { +describe('CreateTokenScreen', () => { const secret: Secret = { _id: 'id', secret: 'secret', @@ -62,16 +62,16 @@ describe('TokenScreen', () => { jest.clearAllMocks() }) - const setup = (token?: string) => { + const setup = () => { const props = { navigation: getMockedNavigation<'Token'>(), - route: { params: { token, secret } }, - } as unknown as NativeStackScreenProps + route: { params: { secret } }, + } as unknown as NativeStackScreenProps - return renderWithTheme() + return renderWithTheme() } - it('register subscription on load', () => { + it('registers subscription on load', () => { setup() expect(registerSubscriptionStub).toHaveBeenCalledTimes(1) expect(registerSubscriptionStub).toHaveBeenCalledWith({ @@ -83,9 +83,9 @@ describe('TokenScreen', () => { it('generates a token when note inputted', async () => { const { getByA11yLabel, getByText } = setup() - const noteInput = getByA11yLabel('Note') + const noteInput = getByA11yLabel('Description') fireEvent.changeText(noteInput, 'My note') - fireEvent.press(getByText('Generate Token')) + fireEvent.press(getByText('Create Token')) expect(generateTokenStub).toBeCalledTimes(1) expect(generateTokenStub).toBeCalledWith(secret, '') }) diff --git a/src/screens/CreateTokenScreen.tsx b/src/screens/CreateTokenScreen.tsx new file mode 100644 index 00000000..5ff213af --- /dev/null +++ b/src/screens/CreateTokenScreen.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { StyleSheet, View } from 'react-native' +import { Button, TextInput } from 'react-native-paper' +import { NativeStackScreenProps } from 'react-native-screens/native-stack' +import Toast from 'react-native-root-toast' +import { useIsFocused } from '@react-navigation/core' + +import { useAuth } from '../context/AuthContext' +import apiFactory from '../lib/api' +import { useSecrets } from '../context/SecretsContext' +import usePushToken from '../hooks/use-push-token' +import { MainStackParamList } from '../Main' +import theme from '../lib/theme' +import { Typography } from '../components/Typography' + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: theme.spacing(2), + paddingTop: theme.spacing(4), + }, + description: { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(4), + }, + button: { + marginTop: theme.spacing(1), + justifyContent: 'center', + }, +}) + +type Props = NativeStackScreenProps + +export const CreateTokenScreen = ({ route, navigation }: Props) => { + const { secret } = route.params + const { user } = useAuth() + const [subscriptionId, setSubscriptionId] = useState('') + const [note, setNote] = useState('') + const { update } = useSecrets() + const expoToken = usePushToken() + + const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user]) + + const disabled = note.length < 3 + + const isFocused = useIsFocused() + const ref = useRef(null) + + useEffect(() => { + if (isFocused) { + ref.current.focus() + } + }, [isFocused]) + + const handleGenerateToken = async () => { + try { + const token = await api.generateToken(secret, subscriptionId) + const newToken = { + token, + note, + } + const existingTokens = secret.tokens ? secret.tokens : [] + const secretUpdated = { + ...secret, + tokens: [newToken, ...existingTokens], + } + + await update(secretUpdated) + + navigation.navigate('Token', { + secret: secretUpdated, + token, + }) + Toast.show('Token successfully created') + } catch (err) { + Toast.show('An error occurred creating the token') + console.log(err) + } + } + + useEffect(() => { + if (!user || !expoToken) return + + const register = async () => { + const id = await api.registerSubscription({ + type: 'expo', + token: expoToken, + }) + setSubscriptionId(id) + } + + register() + }, [user, api, expoToken]) + + return ( + + + + Insert a description for this token + + + + + + + + ) +} diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index e3eff2a1..e20aeed7 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -53,7 +53,7 @@ export const HomeScreen: React.FC = ({ navigation }) => { const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user]) const handleAddToken = (secret: Secret) => { - navigation.navigate('Token', { + navigation.navigate('CreateToken', { secret, }) } diff --git a/src/screens/TokenScreen.tsx b/src/screens/TokenScreen.tsx index 729265e9..76e4cf13 100644 --- a/src/screens/TokenScreen.tsx +++ b/src/screens/TokenScreen.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react' import { StyleSheet, View } from 'react-native' -import { Button, Card, TextInput } from 'react-native-paper' +import { Button, TextInput } from 'react-native-paper' import { NativeStackScreenProps } from 'react-native-screens/native-stack' +import Toast from 'react-native-root-toast' import { useAuth } from '../context/AuthContext' import apiFactory from '../lib/api' @@ -13,35 +14,19 @@ import { Typography } from '../components/Typography' import { CopyableInfo } from '../components/SecretCard/CopyableInfo' const styles = StyleSheet.create({ - screen: { - width: '100%', - }, - form: { + container: { padding: theme.spacing(2), }, - inputRow: { - marginBottom: theme.spacing(2), - }, - formButton: { - marginTop: theme.spacing(1), - height: 50, - justifyContent: 'center', - }, - row: { - flex: 1, - marginTop: theme.spacing(2), - paddingHorizontal: theme.spacing(1), - }, - label: { - color: theme.colors.textSecondary, - marginRight: theme.spacing(1), - fontSize: 10, + section: { + marginVertical: theme.spacing(2), }, - value: { + tokenText: { fontFamily: 'monospace', fontSize: 24, color: theme.colors.text, - marginBottom: theme.spacing(2), + }, + refreshButton: { + marginBottom: theme.spacing(1), }, }) @@ -52,51 +37,30 @@ export const TokenScreen = ({ route, navigation }: Props) => { const existingNote = secret.tokens?.find(item => item.token === token)?.note || '' const { user } = useAuth() - const [subscriptionId, setSubscriptionId] = useState(existingNote) - const [note, setNote] = useState('') + const [subscriptionId, setSubscriptionId] = useState('') + const [note, setNote] = useState(existingNote) const { update } = useSecrets() const expoToken = usePushToken() const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user]) - const disabled = note.length < 3 + // TODO should update note as user types (and debounce maybe) - const handleGenerateToken = async () => { + const handleRevokeToken = async () => { try { - const token = await api.generateToken(secret, subscriptionId) - const newToken = { - token, - note, - } - const existingTokens = secret.tokens ? secret.tokens : [] - + await api.revokeToken(token) await update({ ...secret, - tokens: [newToken, ...existingTokens], - }) - - navigation.navigate('Token', { - secret, - token, + tokens: secret.tokens.filter(data => data.token !== token), }) + navigation.goBack() + Toast.show('Token successfully revoked') } catch (err) { + Toast.show(`There was an error revoking token: ${token}`) console.log(err) } } - // const handleRevokeToken = async (secret: Secret, token: string) => { - // try { - // await api.revokeToken(secret) - // await update({ - // ...secret, - // tokens: secret.tokens.filter(data => data.token !== token), - // }) - // } catch (err) { - // console.log(err) - // } - // } - // - const handleRefreshToken = async () => { try { const refreshedToken = await api.generateToken( @@ -104,33 +68,38 @@ export const TokenScreen = ({ route, navigation }: Props) => { subscriptionId, token ) - // const newToken = { - // token: refreshedToken, - // note, - // } - // TODO update item in place - // const existingTokens = secret.tokens ? secret.tokens : [] - // - // await update({ - // ...secret, - // tokens: [newToken, ...existingTokens], - // }) - - navigation.navigate('Token', { - secret, + const newToken = { + token: refreshedToken, + note, + } + const tokens = secret.tokens ? [...secret.tokens] : [] + const existingItemIndex = tokens.findIndex(item => item.token === token) + + if (existingItemIndex === -1) { + tokens.push(newToken) + } else { + tokens[existingItemIndex] = newToken + } + + const secretUpdated = { + ...secret, + tokens, + } + + await update(secretUpdated) + + // Navigate to the new token screen + navigation.replace('Token', { + secret: secretUpdated, token: refreshedToken, }) + Toast.show('Token successfully refreshed') } catch (err) { + Toast.show(`There was an error refreshing token: ${token}`) console.log(err) } } - const handleRevokeToken = async () => { - // try { - // await api.revokeToken() - // } - } - useEffect(() => { if (!user || !expoToken) return @@ -146,57 +115,43 @@ export const TokenScreen = ({ route, navigation }: Props) => { }, [user, api, expoToken]) return ( - - {token ? ( - - {existingNote}} - /> - - {token || '-'} - - - - - ) : ( - - - - - - - )} + + + TOKEN + {token || '-'} + + + + + + + + If you renew the token, you’ll need to update it where you’re using it + to request OTP from command-line. + + + + + ) } diff --git a/src/screens/TokensListScreen.tsx b/src/screens/TokensListScreen.tsx index 64d5f7b7..7bc19bbf 100644 --- a/src/screens/TokensListScreen.tsx +++ b/src/screens/TokensListScreen.tsx @@ -1,7 +1,8 @@ import { StyleSheet, View, ScrollView } from 'react-native' -import React, { useMemo, useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { NativeStackScreenProps } from 'react-native-screens/native-stack' import { TextInput, Text, Divider, FAB, Portal, List } from 'react-native-paper' +import { useIsFocused } from '@react-navigation/core' import { MainStackParamList } from '../Main' import theme from '../lib/theme' @@ -58,10 +59,14 @@ const styles = StyleSheet.create({ type Props = NativeStackScreenProps export const TokensListScreen = ({ route, navigation }: Props) => { + const { navigate } = navigation const { secret } = route.params const { tokens } = secret const tokensCount = tokens.length const [search, setSearch] = useState('') + const [searchFocused, setSearchFocused] = useState(false) + const isFocused = useIsFocused() + const shouldShowFab = isFocused && !searchFocused const showSearchArea = tokensCount > 1 @@ -75,6 +80,10 @@ export const TokensListScreen = ({ route, navigation }: Props) => { } }, [search, tokens]) + const handleCreateToken = useCallback(() => { + navigate('CreateToken', { secret }) + }, [navigate, secret]) + return ( <> @@ -95,8 +104,9 @@ export const TokensListScreen = ({ route, navigation }: Props) => { mode="outlined" value={search} onChangeText={setSearch} - autoFocus right={} + onFocus={() => setSearchFocused(true)} + onBlur={() => setSearchFocused(false)} /> )} @@ -129,7 +139,14 @@ export const TokensListScreen = ({ route, navigation }: Props) => { - + ) From 0577a1640abc3d61739197a47535db6200f38a3e Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Mon, 22 Nov 2021 16:29:22 +0000 Subject: [PATCH 14/41] feat: hide tokens count when only 1 available --- src/screens/TokensListScreen.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/screens/TokensListScreen.tsx b/src/screens/TokensListScreen.tsx index 7bc19bbf..33dffb09 100644 --- a/src/screens/TokensListScreen.tsx +++ b/src/screens/TokensListScreen.tsx @@ -68,8 +68,6 @@ export const TokensListScreen = ({ route, navigation }: Props) => { const isFocused = useIsFocused() const shouldShowFab = isFocused && !searchFocused - const showSearchArea = tokensCount > 1 - const filteredTokens = useMemo(() => { if (search.length > 1) { return tokens.filter(({ note }) => @@ -87,13 +85,15 @@ export const TokensListScreen = ({ route, navigation }: Props) => { return ( <> - - {tokensCount}{' '} - - {tokensCount === 1 ? 'TOKEN' : 'TOKENS'} + {tokensCount > 1 && ( + + {tokensCount}{' '} + + {tokensCount === 1 ? 'TOKEN' : 'TOKENS'} + - - {showSearchArea && ( + )} + {tokensCount > 1 && ( Date: Mon, 22 Nov 2021 16:51:49 +0000 Subject: [PATCH 15/41] feat: show toasts on approval and rejection --- src/screens/OtpRequestScreen.tsx | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/screens/OtpRequestScreen.tsx b/src/screens/OtpRequestScreen.tsx index c9255e62..ee7d8f4d 100644 --- a/src/screens/OtpRequestScreen.tsx +++ b/src/screens/OtpRequestScreen.tsx @@ -2,6 +2,7 @@ import { StyleSheet, View, Text } from 'react-native' import { NativeStackScreenProps } from 'react-native-screens/native-stack' import React, { useCallback, useMemo } from 'react' import { Avatar, Button, Card } from 'react-native-paper' +import Toast from 'react-native-root-toast' import theme from '../lib/theme' import { MainStackParamList } from '../Main' @@ -48,7 +49,7 @@ const styles = StyleSheet.create({ type Props = NativeStackScreenProps export const OtpRequestScreen = ({ route, navigation }: Props) => { - const { navigate } = navigation + const { goBack, canGoBack, navigate } = navigation const { user } = useAuth() const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user]) @@ -56,17 +57,24 @@ export const OtpRequestScreen = ({ route, navigation }: Props) => { const note = secret.tokens.find(item => item.token === token)?.note const handleRejectToken = useCallback(async () => { - console.log('Reject') - api.respond(secret.secret, uniqueId, false) - navigate('Home') - // TODO show notification - }, [api, navigate, secret.secret, uniqueId]) + await api.respond(secret.secret, uniqueId, false) + Toast.show('OTP request rejected') + if (canGoBack()) { + goBack() + } else { + navigate('Home') + } + }, [api, canGoBack, goBack, navigate, secret.secret, uniqueId]) const handleApproveToken = useCallback(async () => { - api.respond(secret.secret, uniqueId, true) - navigate('Home') - // TODO show notification - }, [api, navigate, secret.secret, uniqueId]) + await api.respond(secret.secret, uniqueId, true) + Toast.show('OTP request approved') + if (canGoBack()) { + goBack() + } else { + navigate('Home') + } + }, [api, canGoBack, goBack, navigate, secret.secret, uniqueId]) return ( From 3da8b52c170ce899cc48d4ad7aa5346373168faa Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Mon, 22 Nov 2021 17:29:49 +0000 Subject: [PATCH 16/41] fix: make lists refresh on revoke and refresh --- src/Main.tsx | 9 ++++----- src/screens/CreateTokenScreen.tsx | 12 ++++++++++-- src/screens/HomeScreen.tsx | 6 ++++-- src/screens/TokensListScreen.tsx | 19 ++++++++++++++----- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/Main.tsx b/src/Main.tsx index aecff7e4..e72b6f08 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -33,14 +33,15 @@ export type MainStackParamList = { Scan: undefined Type: undefined CreateToken: { - secret: Secret + secretId: string } Token: { secret: Secret token?: string } TokensList: { - secret: Secret + secretId: string + issuer: string } OtpRequest: { secret: Secret @@ -91,9 +92,7 @@ export default function Main() { component={TokensListScreen} options={({ route: { - params: { - secret: { issuer }, - }, + params: { issuer }, }, }: NativeStackScreenProps) => ({ title: issuer, diff --git a/src/screens/CreateTokenScreen.tsx b/src/screens/CreateTokenScreen.tsx index 5ff213af..1e449eef 100644 --- a/src/screens/CreateTokenScreen.tsx +++ b/src/screens/CreateTokenScreen.tsx @@ -31,7 +31,12 @@ const styles = StyleSheet.create({ type Props = NativeStackScreenProps export const CreateTokenScreen = ({ route, navigation }: Props) => { - const { secret } = route.params + const { secretId } = route.params + const { secrets } = useSecrets() + const secret = useMemo( + () => secrets.find(item => item._id === secretId), + [secretId, secrets] + ) const { user } = useAuth() const [subscriptionId, setSubscriptionId] = useState('') const [note, setNote] = useState('') @@ -52,6 +57,9 @@ export const CreateTokenScreen = ({ route, navigation }: Props) => { }, [isFocused]) const handleGenerateToken = async () => { + if (!secret) { + return + } try { const token = await api.generateToken(secret, subscriptionId) const newToken = { @@ -66,7 +74,7 @@ export const CreateTokenScreen = ({ route, navigation }: Props) => { await update(secretUpdated) - navigation.navigate('Token', { + navigation.replace('Token', { secret: secretUpdated, token, }) diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index e20aeed7..7192b402 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -54,14 +54,16 @@ export const HomeScreen: React.FC = ({ navigation }) => { const handleAddToken = (secret: Secret) => { navigation.navigate('CreateToken', { - secret, + secretId: secret._id, }) } const handleViewTokens = useCallback( (secret: Secret) => { navigation.navigate('TokensList', { - secret, + secretId: secret._id, + // Included here to make it easier to show in react navigation title + issuer: secret.issuer, }) }, [navigation] diff --git a/src/screens/TokensListScreen.tsx b/src/screens/TokensListScreen.tsx index 33dffb09..9d38238d 100644 --- a/src/screens/TokensListScreen.tsx +++ b/src/screens/TokensListScreen.tsx @@ -6,6 +6,7 @@ import { useIsFocused } from '@react-navigation/core' import { MainStackParamList } from '../Main' import theme from '../lib/theme' +import { useSecrets } from '../context/SecretsContext' const styles = StyleSheet.create({ container: { @@ -60,8 +61,13 @@ type Props = NativeStackScreenProps export const TokensListScreen = ({ route, navigation }: Props) => { const { navigate } = navigation - const { secret } = route.params - const { tokens } = secret + const { secretId } = route.params + const { secrets } = useSecrets() + const secret = useMemo( + () => secrets.find(item => item._id === secretId), + [secretId, secrets] + ) + const tokens = useMemo(() => (secret ? secret.tokens : []), [secret]) const tokensCount = tokens.length const [search, setSearch] = useState('') const [searchFocused, setSearchFocused] = useState(false) @@ -79,8 +85,8 @@ export const TokensListScreen = ({ route, navigation }: Props) => { }, [search, tokens]) const handleCreateToken = useCallback(() => { - navigate('CreateToken', { secret }) - }, [navigate, secret]) + navigate('CreateToken', { secretId }) + }, [navigate, secretId]) return ( <> @@ -118,7 +124,10 @@ export const TokensListScreen = ({ route, navigation }: Props) => { <> navigation.navigate('Token', { secret, token })} + onPress={() => + secret && + navigation.navigate('Token', { secret: secret, token }) + } style={styles.tokenItem} title={token} titleStyle={styles.tokenValueText} From 0c3179322760b32b5957d6a2c17233a500f71bb4 Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Tue, 23 Nov 2021 10:04:17 +0000 Subject: [PATCH 17/41] feat: style otp request screen --- src/screens/OtpRequestScreen.tsx | 128 +++++++++++++++---------------- 1 file changed, 60 insertions(+), 68 deletions(-) diff --git a/src/screens/OtpRequestScreen.tsx b/src/screens/OtpRequestScreen.tsx index ee7d8f4d..2b51edb2 100644 --- a/src/screens/OtpRequestScreen.tsx +++ b/src/screens/OtpRequestScreen.tsx @@ -1,7 +1,7 @@ -import { StyleSheet, View, Text } from 'react-native' +import { StyleSheet, View } from 'react-native' import { NativeStackScreenProps } from 'react-native-screens/native-stack' import React, { useCallback, useMemo } from 'react' -import { Avatar, Button, Card } from 'react-native-paper' +import { Avatar, Button } from 'react-native-paper' import Toast from 'react-native-root-toast' import theme from '../lib/theme' @@ -11,38 +11,33 @@ import apiFactory from '../lib/api' import { Typography } from '../components/Typography' const styles = StyleSheet.create({ - screen: { - margin: theme.spacing(2), + container: { + paddingHorizontal: theme.spacing(3), + paddingTop: theme.spacing(4), }, - cardContent: { - paddingHorizontal: theme.spacing(1), + provider: { + flexDirection: 'row', + marginBottom: theme.spacing(4), }, - cardActions: { - justifyContent: 'space-between', - flexDirection: 'column', - alignItems: 'stretch', + providerIcon: { + marginRight: theme.spacing(2), }, - form: { - padding: theme.spacing(2), + token: { + marginBottom: theme.spacing(4), }, - button: { - marginTop: theme.spacing(2), - }, - row: { - flex: 1, - marginTop: theme.spacing(2), - paddingHorizontal: theme.spacing(1), - }, - label: { - color: theme.colors.textSecondary, - marginRight: theme.spacing(1), - fontSize: 10, - }, - value: { + tokenValue: { fontFamily: 'monospace', fontSize: 24, color: theme.colors.text, - marginBottom: theme.spacing(2), + }, + description: { + marginBottom: theme.spacing(3), + }, + descriptionLabel: { + marginBottom: theme.spacing(1), + }, + button: { + marginTop: theme.spacing(2), }, }) @@ -77,48 +72,45 @@ export const OtpRequestScreen = ({ route, navigation }: Props) => { }, [api, canGoBack, goBack, navigate, secret.secret, uniqueId]) return ( - - - {secret.issuer}} - subtitle={secret.account} - left={props => } + + + - - - - Token - - - {token} - - - - - Description - - - {note} - - - - - - - - + + {secret.issuer} + {secret.account} + + + + Token + {token} + + + + Description + + {note} + + + + + ) } From ab62ae32bc11deac04dc7e2c61ce261e9984d8d9 Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Tue, 23 Nov 2021 10:08:02 +0000 Subject: [PATCH 18/41] feat: add spacing to tokens label on home screen --- src/components/SecretCard/TokensInfo.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/SecretCard/TokensInfo.tsx b/src/components/SecretCard/TokensInfo.tsx index 98a8edcf..d3564836 100644 --- a/src/components/SecretCard/TokensInfo.tsx +++ b/src/components/SecretCard/TokensInfo.tsx @@ -16,6 +16,7 @@ const styles = StyleSheet.create({ tokensCountLabel: { color: theme.colors.textSecondary, marginRight: theme.spacing(1), + paddingBottom: theme.spacing(0.5), fontSize: 10, }, tokensCountValue: { @@ -41,8 +42,10 @@ export const TokensInfo = ({ count, onPress }: Props) => { return ( <> - - TOKENS + + + TOKENS + {count} From a013c3a729037db4429535837f614ab4452b481e Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Tue, 23 Nov 2021 10:15:02 +0000 Subject: [PATCH 19/41] feat: make spacing more consistent --- src/screens/CreateTokenScreen.tsx | 11 ++++++----- src/screens/TokenScreen.tsx | 21 ++++++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/screens/CreateTokenScreen.tsx b/src/screens/CreateTokenScreen.tsx index 1e449eef..2cb6ea7e 100644 --- a/src/screens/CreateTokenScreen.tsx +++ b/src/screens/CreateTokenScreen.tsx @@ -15,7 +15,7 @@ import { Typography } from '../components/Typography' const styles = StyleSheet.create({ container: { - paddingHorizontal: theme.spacing(2), + paddingHorizontal: theme.spacing(3), paddingTop: theme.spacing(4), }, description: { @@ -52,14 +52,11 @@ export const CreateTokenScreen = ({ route, navigation }: Props) => { useEffect(() => { if (isFocused) { - ref.current.focus() + ref.current && ref.current.focus() } }, [isFocused]) const handleGenerateToken = async () => { - if (!secret) { - return - } try { const token = await api.generateToken(secret, subscriptionId) const newToken = { @@ -99,6 +96,10 @@ export const CreateTokenScreen = ({ route, navigation }: Props) => { register() }, [user, api, expoToken]) + if (!secret) { + return null + } + return ( diff --git a/src/screens/TokenScreen.tsx b/src/screens/TokenScreen.tsx index 76e4cf13..21d448d7 100644 --- a/src/screens/TokenScreen.tsx +++ b/src/screens/TokenScreen.tsx @@ -15,16 +15,23 @@ import { CopyableInfo } from '../components/SecretCard/CopyableInfo' const styles = StyleSheet.create({ container: { - padding: theme.spacing(2), + paddingHorizontal: theme.spacing(3), + paddingTop: theme.spacing(4), }, - section: { - marginVertical: theme.spacing(2), + token: { + marginBottom: theme.spacing(4), }, tokenText: { fontFamily: 'monospace', fontSize: 24, color: theme.colors.text, }, + description: { + marginBottom: theme.spacing(4), + }, + refresh: { + marginBottom: theme.spacing(4), + }, refreshButton: { marginBottom: theme.spacing(1), }, @@ -116,11 +123,11 @@ export const TokenScreen = ({ route, navigation }: Props) => { return ( - + TOKEN {token || '-'} - + { multiline /> - + @@ -154,7 +184,7 @@ export const TokenScreen = ({ route, navigation }: Props) => { From 812dc1eb036081c5e115b62d0925fd15f4ca25f4 Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Tue, 23 Nov 2021 12:33:48 +0000 Subject: [PATCH 22/41] feat: make description save in the background on token page --- src/Main.tsx | 2 +- src/screens/TokenScreen.tsx | 42 ++++++++++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/Main.tsx b/src/Main.tsx index e72b6f08..5c7b9500 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -37,7 +37,7 @@ export type MainStackParamList = { } Token: { secret: Secret - token?: string + token: string } TokensList: { secretId: string diff --git a/src/screens/TokenScreen.tsx b/src/screens/TokenScreen.tsx index 8359999d..d756a1fd 100644 --- a/src/screens/TokenScreen.tsx +++ b/src/screens/TokenScreen.tsx @@ -13,6 +13,8 @@ import theme from '../lib/theme' import { Typography } from '../components/Typography' import { CopyableInfo } from '../components/SecretCard/CopyableInfo' +const SAVE_UPDATED_DESCRIPTION_DELAY = 1000 + const styles = StyleSheet.create({ container: { paddingHorizontal: theme.spacing(3), @@ -43,10 +45,10 @@ const showRevokeConfirmAlert = (onConfirm: () => void) => { 'This will permanently remove the token. Are you sure you want to continue?', [ { - text: 'Cancel', + text: 'CANCEL', style: 'cancel', }, - { text: 'Revoke', onPress: onConfirm }, + { text: 'REVOKE', onPress: onConfirm }, ], { cancelable: true } ) @@ -58,10 +60,10 @@ const showRefreshConfirmAlert = (onConfirm: () => void) => { 'This will generate a new token. Are you sure you want to continue?', [ { - text: 'Cancel', + text: 'CANCEL', style: 'cancel', }, - { text: 'Refresh', onPress: onConfirm }, + { text: 'REFRESH', onPress: onConfirm }, ], { cancelable: true } ) @@ -81,8 +83,6 @@ export const TokenScreen = ({ route, navigation }: Props) => { const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user]) - // TODO should update note as user types (and debounce maybe) - const handleRevokeToken = async () => { try { await api.revokeToken(token) @@ -151,6 +151,31 @@ export const TokenScreen = ({ route, navigation }: Props) => { register() }, [user, api, expoToken]) + // Keep the note for the token up to date + useEffect(() => { + const updateNote = async () => { + const tokens = secret.tokens ? [...secret.tokens] : [] + const existingItemIndex = tokens.findIndex(item => item.token === token) + if (existingItemIndex === -1) { + return + } + const { note: existingNote } = tokens[existingItemIndex] + if (note === existingNote) { + return + } + tokens[existingItemIndex] = { token, note } + await update({ + ...secret, + tokens, + }) + Toast.show('Token description updated') + } + const timeoutId = setTimeout(updateNote, SAVE_UPDATED_DESCRIPTION_DELAY) + return () => { + clearTimeout(timeoutId) + } + }, [note, secret, token, update]) + return ( @@ -160,6 +185,7 @@ export const TokenScreen = ({ route, navigation }: Props) => { { mode="contained" onPress={() => showRefreshConfirmAlert(handleRefreshToken)} > - Refresh Token + REFRESH TOKEN If you renew the token, you’ll need to update it where you’re using it @@ -186,7 +212,7 @@ export const TokenScreen = ({ route, navigation }: Props) => { mode="outlined" onPress={() => showRevokeConfirmAlert(handleRevokeToken)} > - Revoke Token + REVOKE TOKEN From cfa6c5c7e2a4d11a03e4985f6ef2d69f93894af1 Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Tue, 23 Nov 2021 13:21:11 +0000 Subject: [PATCH 23/41] test: add a test for saving the description in the background --- src/screens/TokenScreen.test.tsx | 71 ++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/screens/TokenScreen.test.tsx diff --git a/src/screens/TokenScreen.test.tsx b/src/screens/TokenScreen.test.tsx new file mode 100644 index 00000000..bb209752 --- /dev/null +++ b/src/screens/TokenScreen.test.tsx @@ -0,0 +1,71 @@ +import { NativeStackScreenProps } from 'react-native-screens/native-stack' +import React from 'react' +import { fireEvent } from '@testing-library/react-native' +import { mocked } from 'ts-jest/utils' + +import { getMockedNavigation, renderWithTheme } from '../../test/utils' +import { MainStackParamList } from '../Main' +import { Secret } from '../types' +import { useSecrets } from '../context/SecretsContext' + +import { TokenScreen } from './TokenScreen' + +const secret: Secret = { + _id: 'id', + secret: 'secret', + uid: 'uid', + tokens: [ + { + note: 'My note', + token: 'a-token', + }, + ], + account: 'account', + issuer: '', +} + +jest.mock('../hooks/use-push-token') +jest.mock('../context/SecretsContext') +const useSecretsMocked = mocked(useSecrets) + +describe('TokenScreen', () => { + const updateSecretStub = jest.fn() + + beforeEach(() => { + useSecretsMocked.mockReturnValue({ + secrets: [secret], + add: jest.fn(), + update: updateSecretStub, + remove: jest.fn(), + }) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + const setup = () => { + const props = { + navigation: getMockedNavigation<'Token'>(), + route: { params: { secret: secret, token: secret.tokens[0].token } }, + } as unknown as NativeStackScreenProps + + return renderWithTheme() + } + + it('saves note in the background', () => { + // Using fake timer as description saving is debounced + jest.useFakeTimers() + const { getByA11yLabel } = setup() + + const descriptionInput = getByA11yLabel('Description') + fireEvent.changeText(descriptionInput, 'a new note') + jest.runAllTimers() + + expect(updateSecretStub).toBeCalledTimes(1) + expect(updateSecretStub).toBeCalledWith({ + ...secret, + tokens: [{ ...secret.tokens[0], note: 'a new note' }], + }) + }) +}) From 01c6b017bdb86afd88c29014d8a26401de4ccd3f Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Tue, 23 Nov 2021 14:40:58 +0000 Subject: [PATCH 24/41] feat: don't save description in the bg if empty --- src/screens/TokenScreen.test.tsx | 19 ++++++++++++++++--- src/screens/TokenScreen.tsx | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/screens/TokenScreen.test.tsx b/src/screens/TokenScreen.test.tsx index bb209752..2cd05619 100644 --- a/src/screens/TokenScreen.test.tsx +++ b/src/screens/TokenScreen.test.tsx @@ -53,19 +53,32 @@ describe('TokenScreen', () => { return renderWithTheme() } - it('saves note in the background', () => { + it('saves description in the background', () => { // Using fake timer as description saving is debounced jest.useFakeTimers() const { getByA11yLabel } = setup() + const inputtedDescriptionText = 'a description' const descriptionInput = getByA11yLabel('Description') - fireEvent.changeText(descriptionInput, 'a new note') + fireEvent.changeText(descriptionInput, inputtedDescriptionText) jest.runAllTimers() expect(updateSecretStub).toBeCalledTimes(1) expect(updateSecretStub).toBeCalledWith({ ...secret, - tokens: [{ ...secret.tokens[0], note: 'a new note' }], + tokens: [{ ...secret.tokens[0], note: inputtedDescriptionText }], }) }) + + it("doesn't save description if it's empty", () => { + // Using fake timer as description saving is debounced + jest.useFakeTimers() + const { getByA11yLabel } = setup() + + const descriptionInput = getByA11yLabel('Description') + fireEvent.changeText(descriptionInput, '') + jest.runAllTimers() + + expect(updateSecretStub).toBeCalledTimes(0) + }) }) diff --git a/src/screens/TokenScreen.tsx b/src/screens/TokenScreen.tsx index d756a1fd..a52b510d 100644 --- a/src/screens/TokenScreen.tsx +++ b/src/screens/TokenScreen.tsx @@ -160,7 +160,7 @@ export const TokenScreen = ({ route, navigation }: Props) => { return } const { note: existingNote } = tokens[existingItemIndex] - if (note === existingNote) { + if (note === existingNote || note.length < 3) { return } tokens[existingItemIndex] = { token, note } From d9ad31dea53cc2932913f09100b453ec0706574d Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Tue, 23 Nov 2021 15:47:50 +0000 Subject: [PATCH 25/41] test: add basic refresh and revoke tests for token screen --- src/lib/api.ts | 1 - src/screens/TokenScreen.test.tsx | 46 +++++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 700b3867..8d70d79d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -46,7 +46,6 @@ export default function apiFactory(opts: APIOptions): API { }, }) }, - // TODO needs to revoke by token and not just secret async revokeToken(token) { await fetch(`${apiUrl}/token/${token}`, { method: 'DELETE', diff --git a/src/screens/TokenScreen.test.tsx b/src/screens/TokenScreen.test.tsx index 2cd05619..276dfe2f 100644 --- a/src/screens/TokenScreen.test.tsx +++ b/src/screens/TokenScreen.test.tsx @@ -2,11 +2,13 @@ import { NativeStackScreenProps } from 'react-native-screens/native-stack' import React from 'react' import { fireEvent } from '@testing-library/react-native' import { mocked } from 'ts-jest/utils' +import { Alert } from 'react-native' import { getMockedNavigation, renderWithTheme } from '../../test/utils' import { MainStackParamList } from '../Main' import { Secret } from '../types' import { useSecrets } from '../context/SecretsContext' +import apiFactory, { API } from '../lib/api' import { TokenScreen } from './TokenScreen' @@ -16,7 +18,7 @@ const secret: Secret = { uid: 'uid', tokens: [ { - note: 'My note', + note: 'A description', token: 'a-token', }, ], @@ -24,12 +26,24 @@ const secret: Secret = { issuer: '', } +jest.mock('../lib/api') jest.mock('../hooks/use-push-token') jest.mock('../context/SecretsContext') + const useSecretsMocked = mocked(useSecrets) +const apiFactoryMocked = mocked(apiFactory) + +// Continue after alert by clicking the confirm button +jest + .spyOn(Alert, 'alert') + .mockImplementation((title, message, callbackOrButtons) => + callbackOrButtons[1].onPress() + ) describe('TokenScreen', () => { const updateSecretStub = jest.fn() + const apiGenerateTokenStub = jest.fn() + const apiRevokeTokenStub = jest.fn() beforeEach(() => { useSecretsMocked.mockReturnValue({ @@ -38,10 +52,10 @@ describe('TokenScreen', () => { update: updateSecretStub, remove: jest.fn(), }) - }) - - afterEach(() => { - jest.useRealTimers() + apiFactoryMocked.mockReturnValue({ + generateToken: apiGenerateTokenStub, + revokeToken: apiRevokeTokenStub, + } as unknown as API) }) const setup = () => { @@ -53,15 +67,27 @@ describe('TokenScreen', () => { return renderWithTheme() } + it('refreshes token', () => { + const { getByText } = setup() + fireEvent.press(getByText('REFRESH TOKEN')) + expect(apiGenerateTokenStub).toBeCalledTimes(1) + }) + + it('revokes token', () => { + const { getByText } = setup() + fireEvent.press(getByText('REVOKE TOKEN')) + expect(apiRevokeTokenStub).toBeCalledTimes(1) + }) + it('saves description in the background', () => { + updateSecretStub.mockReset() // Using fake timer as description saving is debounced - jest.useFakeTimers() const { getByA11yLabel } = setup() - const inputtedDescriptionText = 'a description' + const inputtedDescriptionText = 'An updated description' const descriptionInput = getByA11yLabel('Description') fireEvent.changeText(descriptionInput, inputtedDescriptionText) - jest.runAllTimers() + jest.runOnlyPendingTimers() expect(updateSecretStub).toBeCalledTimes(1) expect(updateSecretStub).toBeCalledWith({ @@ -71,13 +97,13 @@ describe('TokenScreen', () => { }) it("doesn't save description if it's empty", () => { + updateSecretStub.mockReset() // Using fake timer as description saving is debounced - jest.useFakeTimers() const { getByA11yLabel } = setup() const descriptionInput = getByA11yLabel('Description') fireEvent.changeText(descriptionInput, '') - jest.runAllTimers() + jest.runOnlyPendingTimers() expect(updateSecretStub).toBeCalledTimes(0) }) From 190c96d254131c0fd174059d787cc55ed869d4e1 Mon Sep 17 00:00:00 2001 From: Kevin Nolan Date: Tue, 23 Nov 2021 16:26:39 +0000 Subject: [PATCH 26/41] refactor: note -> description --- src/screens/CreateTokenScreen.test.tsx | 8 +++---- src/screens/CreateTokenScreen.tsx | 10 ++++----- src/screens/HomeScreen.test.tsx | 2 +- src/screens/OtpRequestScreen.tsx | 6 ++++-- src/screens/TokenScreen.test.tsx | 4 ++-- src/screens/TokenScreen.tsx | 29 ++++++++++++++------------ src/screens/TokensListScreen.tsx | 12 +++++------ src/types.ts | 2 +- 8 files changed, 39 insertions(+), 34 deletions(-) diff --git a/src/screens/CreateTokenScreen.test.tsx b/src/screens/CreateTokenScreen.test.tsx index 9a54445a..be12bf58 100644 --- a/src/screens/CreateTokenScreen.test.tsx +++ b/src/screens/CreateTokenScreen.test.tsx @@ -16,7 +16,7 @@ const secret: Secret = { _id: 'id', secret: 'secret', uid: 'uid', - tokens: [{ note: 'My note', token: '' }], + tokens: [], account: 'account', issuer: '', } @@ -90,11 +90,11 @@ describe('CreateTokenScreen', () => { }) }) - it('generates a token when note inputted', () => { + it('generates a token when description inputted', () => { const { getByA11yLabel, getByText } = setup() - const noteInput = getByA11yLabel('Description') - fireEvent.changeText(noteInput, 'My note') + const descriptionInput = getByA11yLabel('Description') + fireEvent.changeText(descriptionInput, 'A description') fireEvent.press(getByText('Create Token')) expect(apiGenerateTokenStub).toBeCalledTimes(1) expect(apiGenerateTokenStub).toBeCalledWith(secret, '') diff --git a/src/screens/CreateTokenScreen.tsx b/src/screens/CreateTokenScreen.tsx index 2cb6ea7e..861a3b96 100644 --- a/src/screens/CreateTokenScreen.tsx +++ b/src/screens/CreateTokenScreen.tsx @@ -39,13 +39,13 @@ export const CreateTokenScreen = ({ route, navigation }: Props) => { ) const { user } = useAuth() const [subscriptionId, setSubscriptionId] = useState('') - const [note, setNote] = useState('') + const [description, setDescription] = useState('') const { update } = useSecrets() const expoToken = usePushToken() const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user]) - const disabled = note.length < 3 + const disabled = description.length < 3 const isFocused = useIsFocused() const ref = useRef(null) @@ -61,7 +61,7 @@ export const CreateTokenScreen = ({ route, navigation }: Props) => { const token = await api.generateToken(secret, subscriptionId) const newToken = { token, - note, + description, } const existingTokens = secret.tokens ? secret.tokens : [] const secretUpdated = { @@ -116,8 +116,8 @@ export const CreateTokenScreen = ({ route, navigation }: Props) => { placeholder="Description" placeholderTextColor={theme.colors.disabled} mode="outlined" - value={note} - onChangeText={setNote} + value={description} + onChangeText={setDescription} multiline /> diff --git a/src/screens/HomeScreen.test.tsx b/src/screens/HomeScreen.test.tsx index 03a712ab..7757ce72 100644 --- a/src/screens/HomeScreen.test.tsx +++ b/src/screens/HomeScreen.test.tsx @@ -69,7 +69,7 @@ describe('HomeScreen', () => { issuer: 'Some issuer', secret: 'mysecret', uid: '222', - tokens: [{ token: 'some-token', note: 'Some note' }], + tokens: [{ token: 'some-token', description: 'A description' }], }, { _id: '222', diff --git a/src/screens/OtpRequestScreen.tsx b/src/screens/OtpRequestScreen.tsx index 2b51edb2..c19d15cc 100644 --- a/src/screens/OtpRequestScreen.tsx +++ b/src/screens/OtpRequestScreen.tsx @@ -49,7 +49,9 @@ export const OtpRequestScreen = ({ route, navigation }: Props) => { const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user]) const { token, secret, uniqueId } = route.params - const note = secret.tokens.find(item => item.token === token)?.note + const description = secret.tokens.find( + item => item.token === token + )?.description const handleRejectToken = useCallback(async () => { await api.respond(secret.secret, uniqueId, false) @@ -93,7 +95,7 @@ export const OtpRequestScreen = ({ route, navigation }: Props) => { Description - {note} + {description} - - + {isGenerating && } + ) } diff --git a/src/screens/OtpRequestScreen.tsx b/src/screens/OtpRequestScreen.tsx index fea00d70..b7cbaf25 100644 --- a/src/screens/OtpRequestScreen.tsx +++ b/src/screens/OtpRequestScreen.tsx @@ -1,6 +1,6 @@ import { StyleSheet, View } from 'react-native' import { NativeStackScreenProps } from 'react-native-screens/native-stack' -import React, { useCallback, useMemo } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { Avatar, Button } from 'react-native-paper' import Toast from 'react-native-root-toast' @@ -11,6 +11,7 @@ import apiFactory from '../lib/api' import { Typography } from '../components/Typography' import { useTokenDataSelector } from '../hooks/use-token-data-selector' import { useSecretSelector } from '../hooks/use-secret-selector' +import { LoadingSpinnerOverlay } from '../components/LoadingSpinnerOverlay' const styles = StyleSheet.create({ container: { @@ -50,7 +51,10 @@ export const OtpRequestScreen = ({ route, navigation }: Props) => { const tokenData = useTokenDataSelector(secretId, token) const description = tokenData ? tokenData.description : '' + const [isLoading, setIsLoading] = useState(false) + const handleRejectToken = useCallback(async () => { + setIsLoading(true) await api.respond(secret.secret, uniqueId, false) Toast.show('OTP request rejected') if (canGoBack()) { @@ -58,9 +62,11 @@ export const OtpRequestScreen = ({ route, navigation }: Props) => { } else { navigate('Home') } + setIsLoading(false) }, [api, canGoBack, goBack, navigate, secret.secret, uniqueId]) const handleApproveToken = useCallback(async () => { + setIsLoading(true) await api.respond(secret.secret, uniqueId, true) Toast.show('OTP request approved') if (canGoBack()) { @@ -68,48 +74,52 @@ export const OtpRequestScreen = ({ route, navigation }: Props) => { } else { navigate('Home') } + setIsLoading(false) }, [api, canGoBack, goBack, navigate, secret.secret, uniqueId]) return ( - - - + <> + + + + + {secret.issuer} + {secret.account} + + + + Token + {token} + + + + Description + + {description} + - {secret.issuer} - {secret.account} + + - - Token - {token} - - - - Description - - {description} - - - - - - + {isLoading && } + ) } diff --git a/src/screens/TokenScreen.tsx b/src/screens/TokenScreen.tsx index 82b8d77b..c6a8252f 100644 --- a/src/screens/TokenScreen.tsx +++ b/src/screens/TokenScreen.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react' import { Alert, StyleSheet, View } from 'react-native' -import { Button, TextInput } from 'react-native-paper' +import { Button, ProgressBar, TextInput } from 'react-native-paper' import { NativeStackScreenProps } from 'react-native-screens/native-stack' import Toast from 'react-native-root-toast' @@ -14,6 +14,7 @@ import { Typography } from '../components/Typography' import { CopyableInfo } from '../components/SecretCard/CopyableInfo' import { useSecretSelector } from '../hooks/use-secret-selector' import { useTokenDataSelector } from '../hooks/use-token-data-selector' +import { LoadingSpinnerOverlay } from '../components/LoadingSpinnerOverlay' const SAVE_UPDATED_DESCRIPTION_DELAY = 1000 @@ -34,6 +35,11 @@ const styles = StyleSheet.create({ refreshButton: { marginBottom: theme.spacing(1), }, + refreshingLoader: { + position: 'absolute', + flex: 1, + width: '100%', + }, }) const showRevokeConfirmAlert = (onConfirm: () => void) => { @@ -79,6 +85,8 @@ export const TokenScreen = ({ route, navigation }: Props) => { const [description, setDescription] = useState(existingDescription) const { update } = useSecrets() const expoToken = usePushToken() + const [isRevoking, setIsRevoking] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user]) @@ -87,6 +95,7 @@ export const TokenScreen = ({ route, navigation }: Props) => { Toast.show(`Server connection required to revoke token`) return } + setIsRevoking(true) try { await api.revokeToken(token) await update({ @@ -99,6 +108,7 @@ export const TokenScreen = ({ route, navigation }: Props) => { Toast.show(`There was an error revoking token: ${token}`) console.log(err) } + setIsRevoking(false) } const handleRefreshToken = async () => { @@ -107,6 +117,8 @@ export const TokenScreen = ({ route, navigation }: Props) => { return } + setIsRefreshing(true) + try { const refreshedToken = await api.generateToken( secret, @@ -143,6 +155,8 @@ export const TokenScreen = ({ route, navigation }: Props) => { Toast.show(`There was an error refreshing token: ${token}`) console.log(err) } + + setIsRefreshing(false) } useEffect(() => { @@ -188,44 +202,52 @@ export const TokenScreen = ({ route, navigation }: Props) => { }, [description, secret, token, update]) return ( - - - TOKEN - {token || '-'} - - - - - - - - If you renew the token, you’ll need to update it where you’re using it - to request OTP from command-line. - - - - + <> + + + TOKEN + {token || '-'} + + + + + + + + If you renew the token, you’ll need to update it where you’re using + it to request OTP from command-line. + + + + + - + {isRefreshing && ( + + + + )} + {(isRevoking || !subscriptionId) && } + ) }