diff --git a/jest.setup.js b/jest.setup.js
index 54f7cfce..5c64ad16 100644
--- a/jest.setup.js
+++ b/jest.setup.js
@@ -17,8 +17,6 @@ jest.mock('react-native-reanimated', () => {
return Reanimated
})
-jest.useFakeTimers()
-
// This is mocked to silence the warning: Animated: `useNativeDriver` is not supported
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper')
@@ -29,6 +27,10 @@ jest.mock('@expo-google-fonts/didact-gothic', () => ({
useFonts: jest.fn().mockReturnValue([true]),
}))
+jest.mock('@expo-google-fonts/roboto-mono', () => ({
+ useFonts: jest.fn().mockReturnValue([true]),
+}))
+
jest.mock('expo-auth-session/providers/google')
jest.mock('expo-constants', () => ({
manifest: { extra: { apiUrl: 'http://dummy.com/api' } },
diff --git a/package.json b/package.json
index 29de7c28..4e50be98 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"dependencies": {
"@expo-google-fonts/didact-gothic": "^0.2.0",
"@expo-google-fonts/poppins": "^0.2.0",
+ "@expo-google-fonts/roboto-mono": "^0.2.0",
"@otplib/core": "^12.0.1",
"@otplib/plugin-base32-enc-dec": "^12.0.1",
"@otplib/plugin-crypto-js": "^12.0.1",
@@ -64,7 +65,7 @@
"@react-native-community/eslint-config": "^3.0.1",
"@testing-library/jest-native": "^4.0.4",
"@testing-library/react-hooks": "^7.0.2",
- "@testing-library/react-native": "^7.2.0",
+ "@testing-library/react-native": "^8.0.0",
"@types/aes-js": "^3.1.1",
"@types/jest": "^27.0.3",
"@types/node": "^16.11.10",
diff --git a/src/Main.tsx b/src/Main.tsx
index 5f2487d3..dc65d09f 100644
--- a/src/Main.tsx
+++ b/src/Main.tsx
@@ -9,7 +9,12 @@ import {
useFonts as useDidactGothic,
DidactGothic_400Regular,
} from '@expo-google-fonts/didact-gothic'
+import {
+ useFonts as useRobotoMono,
+ RobotoMono_400Regular,
+} from '@expo-google-fonts/roboto-mono'
import AppLoading from 'expo-app-loading'
+import { NativeStackScreenProps } from 'react-native-screens/native-stack'
import theme from './lib/theme'
import { useAuth } from './context/AuthContext'
@@ -19,6 +24,10 @@ 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'
+import { OtpRequestScreen } from './screens/OtpRequestScreen'
+import { TokensListScreen } from './screens/TokensListScreen'
+import { CreateTokenScreen } from './screens/CreateTokenScreen'
const MainStack = createStackNavigator()
@@ -26,6 +35,22 @@ export type MainStackParamList = {
Home: undefined
Scan: undefined
Type: undefined
+ CreateToken: {
+ secretId: string
+ }
+ Token: {
+ secretId: string
+ token: string
+ }
+ TokensList: {
+ secretId: string
+ issuer: string
+ }
+ OtpRequest: {
+ secretId: string
+ token: string
+ uniqueId: string
+ }
}
export default function Main() {
@@ -37,9 +62,13 @@ export default function Main() {
DidactGothic_400Regular,
})
+ const [hasRobotoMonoLoaded] = useRobotoMono({
+ RobotoMono_400Regular,
+ })
+
const { user } = useAuth()
- if (!hasPoppinsLoaded || !hasDidactLoaded) {
+ if (!hasPoppinsLoaded || !hasDidactLoaded || !hasRobotoMonoLoaded) {
return
}
@@ -65,6 +94,33 @@ export default function Main() {
component={HomeScreen}
options={{ title: 'Your Tokens', headerRight: HomeHeaderRight }}
/>
+ ) => ({
+ title: issuer,
+ headerLeft: DefaultHeaderLeft,
+ })}
+ />
+
+
+
{
+ return (
+
+
+
+
+
+ )
+}
diff --git a/src/components/SecretCard/ContextMenu.tsx b/src/components/SecretCard/ContextMenu.tsx
index f86349b1..53a63924 100644
--- a/src/components/SecretCard/ContextMenu.tsx
+++ b/src/components/SecretCard/ContextMenu.tsx
@@ -1,19 +1,15 @@
import React from 'react'
-import { Divider, Menu, IconButton } from 'react-native-paper'
+import { Menu, IconButton } from 'react-native-paper'
type ContextMenuProps = {
open: boolean
onToggle: () => 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/CopyableInfo.tsx b/src/components/SecretCard/CopyableInfo.tsx
index d6e95612..61d78db5 100644
--- a/src/components/SecretCard/CopyableInfo.tsx
+++ b/src/components/SecretCard/CopyableInfo.tsx
@@ -1,34 +1,39 @@
import React from 'react'
import { IconButton } from 'react-native-paper'
-import { StyleSheet, View, Text, StyleProp, TextStyle } from 'react-native'
+import { StyleSheet, View } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import Toast from 'react-native-root-toast'
+import { TypographyVariant } from '../../lib/theme'
+import { Typography } from '../Typography'
+
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
},
- iconButton: {
- padding: 0,
- marginTop: -10,
+ textContainer: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
},
})
type CopyableInfoProps = {
children: string
- textStyle: StyleProp
+ typographyVariant: TypographyVariant
}
export const CopyableInfo: React.FC = ({
children,
- textStyle,
+ typographyVariant,
}) => {
return (
- {children}
+
+ {children}
+
= ({ value }) => {
OTP
- {value}
+ {value}
= ({ value }) => {
colors="#EB829C"
>
{({ remainingTime }) => (
- {remainingTime}
+
+ {remainingTime}
+
)}
diff --git a/src/components/SecretCard/SecretCard.tsx b/src/components/SecretCard/SecretCard.tsx
index 0737ff98..d2d61b58 100644
--- a/src/components/SecretCard/SecretCard.tsx
+++ b/src/components/SecretCard/SecretCard.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react'
+import React, { useEffect, useMemo, useState } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { Avatar, Button, Card, Divider } from 'react-native-paper'
import Animated from 'react-native-reanimated'
@@ -11,7 +11,7 @@ import useAnimatedTransition from '../../hooks/use-animated-transition'
import { ContextMenu } from './ContextMenu'
import { OTP } from './OTP'
-import { CopyableInfo } from './CopyableInfo'
+import { TokensInfo } from './TokensInfo'
const styles = StyleSheet.create({
container: {
@@ -43,9 +43,6 @@ const styles = StyleSheet.create({
fontSize: 10,
},
value: {
- fontFamily: 'monospace',
- fontSize: 24,
- color: theme.colors.text,
marginBottom: theme.spacing(2),
},
valueSmall: { fontSize: 16 },
@@ -59,26 +56,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
+ onViewTokens: () => void
onDelete: (_: Secret) => void
- onRevoke: (_: Secret) => void
}
export const SecretCard: React.FC = ({
data,
- onGenerate,
+ onAddToken,
+ onViewTokens,
onDelete,
- onRevoke,
}) => {
const [showMenu, setShowMenu] = useState(false)
const [expanded, setExpanded] = useState(false)
- const [generating, setGenerating] = useState(false)
const [otp, setOtp] = useState('')
+ const tokens = useMemo(() => (data.tokens ? data.tokens : []), [data])
useEffect(() => {
if (!data.secret) return
@@ -94,26 +91,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 +111,6 @@ export const SecretCard: React.FC = ({
right={() => (
@@ -139,23 +119,16 @@ export const SecretCard: React.FC = ({
- {data.token && (
- <>
-
- TOKEN
-
- {data.token || '-'}
-
-
-
- >
- )}
+
SECRET
-
+
{data.secret}
-
+
@@ -171,17 +144,9 @@ export const SecretCard: React.FC = ({
- {!data.token && (
-
- )}
+
diff --git a/src/components/SecretCard/TokensInfo.tsx b/src/components/SecretCard/TokensInfo.tsx
new file mode 100644
index 00000000..87b2ae58
--- /dev/null
+++ b/src/components/SecretCard/TokensInfo.tsx
@@ -0,0 +1,59 @@
+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),
+ paddingBottom: theme.spacing(0.5),
+ fontSize: 10,
+ },
+ 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/hooks/use-secret-selector.ts b/src/hooks/use-secret-selector.ts
new file mode 100644
index 00000000..f24776b5
--- /dev/null
+++ b/src/hooks/use-secret-selector.ts
@@ -0,0 +1,11 @@
+import { useMemo } from 'react'
+
+import { useSecrets } from '../context/SecretsContext'
+
+export function useSecretSelector(secretId: string) {
+ const { secrets } = useSecrets()
+ return useMemo(
+ () => secrets.find(item => item._id === secretId),
+ [secretId, secrets]
+ )
+}
diff --git a/src/hooks/use-token-data-selector.ts b/src/hooks/use-token-data-selector.ts
new file mode 100644
index 00000000..d1fdbfcd
--- /dev/null
+++ b/src/hooks/use-token-data-selector.ts
@@ -0,0 +1,12 @@
+import { useMemo } from 'react'
+
+import { useSecretSelector } from './use-secret-selector'
+
+export function useTokenDataSelector(secretId: string, token: string) {
+ const secret = useSecretSelector(secretId)
+
+ return useMemo(() => {
+ const tokens = secret ? secret.tokens : []
+ return tokens.find(item => item.token === token)
+ }, [secret, token])
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 6009c359..a2ab1bde 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -11,31 +11,47 @@ type APIOptions = {
}
export type API = {
- generateToken: (_: Secret, __: string) => 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,
+ }),
})
+ if (!response.ok) {
+ throw new Error('There was an issue connecting to the server')
+ }
+
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}`,
+ },
+ })
+ },
+ async revokeToken(token) {
+ await fetch(`${apiUrl}/token/${token}`, {
method: 'DELETE',
headers: {
authorization: `Bearer ${opts.idToken}`,
diff --git a/src/lib/theme.ts b/src/lib/theme.ts
index cfc30d33..3768218c 100644
--- a/src/lib/theme.ts
+++ b/src/lib/theme.ts
@@ -16,6 +16,7 @@ export type TypographyVariant =
| 'button'
| 'caption'
| 'overline'
+ | 'code'
const typography: Record = {
h1: {
@@ -85,6 +86,11 @@ const typography: Record = {
fontSize: 10,
fontWeight: '400',
textTransform: 'uppercase',
+ letterSpacing: 1.1,
+ },
+ code: {
+ fontFamily: 'RobotoMono_400Regular',
+ fontSize: 24,
},
}
@@ -97,7 +103,7 @@ const theme = {
...NavigationDefaultTheme.colors,
primary: '#2165E3',
text: '#6D6D68',
- textSecondary: '#CCCCCC',
+ textSecondary: 'rgba(0, 0, 0, 0.6)',
},
typography,
spacing: (mul: number) => mul * 8,
diff --git a/src/screens/CreateTokenScreen.test.tsx b/src/screens/CreateTokenScreen.test.tsx
new file mode 100644
index 00000000..06456c92
--- /dev/null
+++ b/src/screens/CreateTokenScreen.test.tsx
@@ -0,0 +1,105 @@
+import React from 'react'
+import { mocked } from 'ts-jest/utils'
+import * as Notification from 'expo-notifications'
+import { Subscription } from 'expo-modules-core'
+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 { CreateTokenScreen } from './CreateTokenScreen'
+
+const secret: Secret = {
+ _id: 'id',
+ secret: 'secret',
+ uid: 'uid',
+ tokens: [],
+ account: 'account',
+ issuer: '',
+}
+
+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')
+
+jest.mock('../context/SecretsContext', () => {
+ return {
+ useSecrets: () => ({
+ secrets: [secret],
+ add: jest.fn(),
+ update: jest.fn(),
+ remove: jest.fn(),
+ }),
+ useSecretSelector: () => secret,
+ }
+})
+
+const apiFactoryMocked = mocked(apiFactory)
+const addNotificationResponseReceivedListenerMocked = mocked(
+ Notification.addNotificationResponseReceivedListener
+)
+
+describe('CreateTokenScreen', () => {
+ const registerSubscriptionStub = jest.fn()
+ const apiGenerateTokenStub = jest.fn()
+
+ beforeEach(() => {
+ apiFactoryMocked.mockReturnValue({
+ registerSubscription: registerSubscriptionStub,
+ generateToken: apiGenerateTokenStub,
+ } as unknown as API)
+
+ addNotificationResponseReceivedListenerMocked.mockReturnValue(
+ {} as Subscription
+ )
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ const setup = () => {
+ const props = {
+ navigation: getMockedNavigation<'CreateToken'>(),
+ route: { params: { secretId: secret._id } },
+ } as unknown as NativeStackScreenProps
+
+ return renderWithTheme()
+ }
+
+ it('registers subscription on load', () => {
+ setup()
+ expect(registerSubscriptionStub).toHaveBeenCalledTimes(1)
+ expect(registerSubscriptionStub).toHaveBeenCalledWith({
+ token: 'dummy-expo-token',
+ type: 'expo',
+ })
+ })
+
+ it('generates a token when description inputted', () => {
+ const { getByA11yLabel, getByText } = setup()
+
+ 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
new file mode 100644
index 00000000..5cbd5307
--- /dev/null
+++ b/src/screens/CreateTokenScreen.tsx
@@ -0,0 +1,140 @@
+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'
+import { useSecretSelector } from '../hooks/use-secret-selector'
+import { LoadingSpinnerOverlay } from '../components/LoadingSpinnerOverlay'
+
+const styles = StyleSheet.create({
+ container: {
+ paddingHorizontal: theme.spacing(3),
+ 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 { secretId } = route.params
+ const secret = useSecretSelector(secretId)
+ const { user } = useAuth()
+ const [subscriptionId, setSubscriptionId] = useState('')
+ const [description, setDescription] = useState('')
+ const { update } = useSecrets()
+ const expoToken = usePushToken()
+
+ const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user])
+
+ const disabled = description.length < 3
+
+ const isFocused = useIsFocused()
+ const ref = useRef(null)
+
+ const [isGenerating, setIsGenerating] = useState(false)
+
+ useEffect(() => {
+ if (isFocused) {
+ ref.current && ref.current.focus()
+ }
+ }, [isFocused])
+
+ const handleGenerateToken = async () => {
+ setIsGenerating(true)
+ try {
+ const token = await api.generateToken(secret, subscriptionId)
+ const newToken = {
+ token,
+ description,
+ }
+ const existingTokens = secret.tokens ? secret.tokens : []
+ const secretUpdated = {
+ ...secret,
+ tokens: [newToken, ...existingTokens],
+ }
+
+ await update(secretUpdated)
+
+ navigation.replace('Token', {
+ secretId,
+ token,
+ })
+ Toast.show('Token successfully created')
+ } catch (err) {
+ Toast.show('An error occurred generating the token')
+ console.error(err)
+ }
+ setIsGenerating(false)
+ }
+
+ useEffect(() => {
+ if (!user || !expoToken) return
+
+ const register = async () => {
+ const id = await api.registerSubscription({
+ type: 'expo',
+ token: expoToken,
+ })
+ setSubscriptionId(id)
+ }
+
+ register()
+ }, [user, api, expoToken])
+
+ if (!secret) {
+ return null
+ }
+
+ return (
+ <>
+
+
+
+ Insert a description for this token
+
+
+
+
+
+
+
+ {isGenerating && }
+ >
+ )
+}
diff --git a/src/screens/HomeScreen.test.tsx b/src/screens/HomeScreen.test.tsx
index c172b9eb..ca7d5ab9 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', description: 'A description' }],
},
{
_id: '222',
diff --git a/src/screens/HomeScreen.test.tsx.snap.android b/src/screens/HomeScreen.test.tsx.snap.android
index a13a9eb6..b279de97 100644
--- a/src/screens/HomeScreen.test.tsx.snap.android
+++ b/src/screens/HomeScreen.test.tsx.snap.android
@@ -331,7 +331,7 @@ exports[`HomeScreen renders secret cards when available 1`] = `
@@ -355,18 +356,45 @@ exports[`HomeScreen renders secret cards when available 1`] = `
}
}
>
-
- 009988
-
+
+ 009988
+
+
30
@@ -542,96 +589,196 @@ exports[`HomeScreen renders secret cards when available 1`] = `
-
- TOKEN
-
-
+
- some-token
+ TOKENS
-
-
-
+ 1
+
+
+
+
+
+
+
+
+
+
+
+ SEE TOKENS
+
+
+
@@ -644,7 +791,9 @@ exports[`HomeScreen renders secret cards when available 1`] = `
"height": 0.5,
},
undefined,
- undefined,
+ Object {
+ "marginBottom": 8,
+ },
]
}
/>
@@ -663,7 +812,7 @@ exports[`HomeScreen renders secret cards when available 1`] = `
@@ -733,20 +901,144 @@ exports[`HomeScreen renders secret cards when available 1`] = `
style={
Object {
"flexDirection": "row",
- "justifyContent": "flex-start",
+ "justifyContent": "flex-start",
+ }
+ }
+ >
+
+
+
+
+
+
+
+ SECRET
+
+
+
+
+
+
@@ -819,14 +1111,12 @@ exports[`HomeScreen renders secret cards when available 1`] = `
"marginVertical": 9,
"textAlign": "center",
},
- Object {
- "marginHorizontal": 8,
- },
+ undefined,
Object {
"textTransform": "uppercase",
},
Object {
- "color": "#2165E3",
+ "color": "#ffffff",
"fontFamily": "sans-serif-medium",
"fontWeight": "normal",
},
@@ -839,21 +1129,12 @@ exports[`HomeScreen renders secret cards when available 1`] = `
]
}
>
- SECRET
+ Add Token
-
@@ -1162,7 +1443,7 @@ exports[`HomeScreen renders secret cards when available 1`] = `
@@ -1186,18 +1468,45 @@ exports[`HomeScreen renders secret cards when available 1`] = `
}
}
>
-
- 009988
-
+
+ 009988
+
+
30
@@ -1370,6 +1698,217 @@ exports[`HomeScreen renders secret cards when available 1`] = `
]
}
/>
+
+
+
+ TOKENS
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+ SEE TOKENS
+
+
+
+
+
+
+
+
@@ -1385,7 +1924,7 @@ exports[`HomeScreen renders secret cards when available 1`] = `
@@ -1600,7 +2158,7 @@ exports[`HomeScreen renders secret cards when available 1`] = `
accessibilityRole="button"
accessibilityState={
Object {
- "disabled": false,
+ "disabled": undefined,
}
}
accessible={true}
@@ -1683,7 +2241,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 6b764d89..c7214f8e 100644
--- a/src/screens/HomeScreen.test.tsx.snap.ios
+++ b/src/screens/HomeScreen.test.tsx.snap.ios
@@ -331,7 +331,7 @@ exports[`HomeScreen renders secret cards when available 1`] = `
@@ -355,18 +356,45 @@ exports[`HomeScreen renders secret cards when available 1`] = `
}
}
>
-
- 009988
-
+
+ 009988
+
+
30
@@ -542,96 +589,196 @@ exports[`HomeScreen renders secret cards when available 1`] = `
-
- TOKEN
-
-
+
- some-token
+ TOKENS
-
-
-
+ 1
+
+
+
+
+
+
+
+
+
+
+
+ SEE TOKENS
+
+
+
@@ -644,7 +791,9 @@ exports[`HomeScreen renders secret cards when available 1`] = `
"height": 0.5,
},
undefined,
- undefined,
+ Object {
+ "marginBottom": 8,
+ },
]
}
/>
@@ -663,7 +812,7 @@ exports[`HomeScreen renders secret cards when available 1`] = `
@@ -733,20 +901,144 @@ exports[`HomeScreen renders secret cards when available 1`] = `
style={
Object {
"flexDirection": "row",
- "justifyContent": "flex-start",
+ "justifyContent": "flex-start",
+ }
+ }
+ >
+
+
+
+
+
+
+
+ SECRET
+
+
+
+
+
+
@@ -819,14 +1111,12 @@ exports[`HomeScreen renders secret cards when available 1`] = `
"marginVertical": 9,
"textAlign": "center",
},
- Object {
- "marginHorizontal": 8,
- },
+ undefined,
Object {
"textTransform": "uppercase",
},
Object {
- "color": "#2165E3",
+ "color": "#ffffff",
"fontFamily": "System",
"fontWeight": "500",
},
@@ -839,21 +1129,12 @@ exports[`HomeScreen renders secret cards when available 1`] = `
]
}
>
- SECRET
+ Add Token
-
@@ -1162,7 +1443,7 @@ exports[`HomeScreen renders secret cards when available 1`] = `
@@ -1186,18 +1468,45 @@ exports[`HomeScreen renders secret cards when available 1`] = `
}
}
>
-
- 009988
-
+
+ 009988
+
+
30
@@ -1370,6 +1698,217 @@ exports[`HomeScreen renders secret cards when available 1`] = `
]
}
/>
+
+
+
+ TOKENS
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+ SEE TOKENS
+
+
+
+
+
+
+
+
@@ -1385,7 +1924,7 @@ exports[`HomeScreen renders secret cards when available 1`] = `
@@ -1600,7 +2158,7 @@ exports[`HomeScreen renders secret cards when available 1`] = `
accessibilityRole="button"
accessibilityState={
Object {
- "disabled": false,
+ "disabled": undefined,
}
}
accessible={true}
@@ -1683,7 +2241,7 @@ exports[`HomeScreen renders secret cards when available 1`] = `
]
}
>
- Generate Token
+ Add Token
diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx
index 20579eca..ab9044e2 100644
--- a/src/screens/HomeScreen.tsx
+++ b/src/screens/HomeScreen.tsx
@@ -1,9 +1,9 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import * as Notifications from 'expo-notifications'
-import { StyleSheet, ScrollView, Alert, View } from 'react-native'
+import { NotificationResponse } from 'expo-notifications'
+import { ScrollView, StyleSheet, View } from 'react-native'
import { Subscription } from 'expo-modules-core'
import { StackNavigationProp } from '@react-navigation/stack'
-import { NotificationResponse } from 'expo-notifications'
import { useIsFocused } from '@react-navigation/core'
import { useSecrets } from '../context/SecretsContext'
@@ -12,13 +12,13 @@ import apiFactory from '../lib/api'
import { NoSecrets } from '../components/NoSecrets'
import { Actions } from '../components/Actions'
import { SecretCard } from '../components/SecretCard'
-import usePushToken from '../hooks/use-push-token'
import { Secret } from '../types'
import { MainStackParamList } from '../Main'
type NotificationData = {
secretId: string
uniqueId: string
+ token: string
}
const styles = StyleSheet.create({
@@ -40,121 +40,66 @@ 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
}
export const HomeScreen: React.FC = ({ navigation }) => {
const { user } = useAuth()
- const { secrets, update, remove } = useSecrets()
- const expoToken = usePushToken()
+ const { secrets, remove } = useSecrets()
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 handleAddToken = (secret: Secret) => {
+ navigation.navigate('CreateToken', {
+ secretId: secret._id,
+ })
}
- const handleRevokeToken = async (secret: Secret) => {
- try {
- await api.revokeToken(secret)
- await update({ ...secret, token: undefined })
- } catch (err) {
- console.log(err)
- }
- }
+ const handleViewTokens = useCallback(
+ (secret: Secret) => {
+ navigation.navigate('TokensList', {
+ secretId: secret._id,
+ // Included here to make it easier to show in react navigation title
+ issuer: secret.issuer,
+ })
+ },
+ [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
- 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,
+ secretId,
+ uniqueId,
+ })
},
- [secrets, handlePasswordRequest]
+ [navigation, secrets]
)
- 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,9 +119,9 @@ export const HomeScreen: React.FC = ({ navigation }) => {
handleAddToken(secret)}
onDelete={handleDeleteSecret}
+ onViewTokens={() => handleViewTokens(secret)}
/>
))
)}
diff --git a/src/screens/OtpRequestScreen.tsx b/src/screens/OtpRequestScreen.tsx
new file mode 100644
index 00000000..b7cbaf25
--- /dev/null
+++ b/src/screens/OtpRequestScreen.tsx
@@ -0,0 +1,125 @@
+import { StyleSheet, View } from 'react-native'
+import { NativeStackScreenProps } from 'react-native-screens/native-stack'
+import React, { useCallback, useMemo, useState } from 'react'
+import { Avatar, Button } from 'react-native-paper'
+import Toast from 'react-native-root-toast'
+
+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 { useTokenDataSelector } from '../hooks/use-token-data-selector'
+import { useSecretSelector } from '../hooks/use-secret-selector'
+import { LoadingSpinnerOverlay } from '../components/LoadingSpinnerOverlay'
+
+const styles = StyleSheet.create({
+ container: {
+ paddingHorizontal: theme.spacing(3),
+ paddingTop: theme.spacing(4),
+ },
+ provider: {
+ flexDirection: 'row',
+ marginBottom: theme.spacing(4),
+ },
+ providerIcon: {
+ marginRight: theme.spacing(2),
+ },
+ token: {
+ marginBottom: theme.spacing(4),
+ },
+ description: {
+ marginBottom: theme.spacing(3),
+ },
+ descriptionLabel: {
+ marginBottom: theme.spacing(1),
+ },
+ button: {
+ marginTop: theme.spacing(2),
+ },
+})
+
+type Props = NativeStackScreenProps
+
+export const OtpRequestScreen = ({ route, navigation }: Props) => {
+ const { goBack, canGoBack, navigate } = navigation
+ const { user } = useAuth()
+
+ const api = useMemo(() => apiFactory({ idToken: user.idToken }), [user])
+ const { token, secretId, uniqueId } = route.params
+ const secret = useSecretSelector(secretId)
+ 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()) {
+ goBack()
+ } 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()) {
+ goBack()
+ } else {
+ navigate('Home')
+ }
+ setIsLoading(false)
+ }, [api, canGoBack, goBack, navigate, secret.secret, uniqueId])
+
+ return (
+ <>
+
+
+
+
+ {secret.issuer}
+ {secret.account}
+
+
+
+ Token
+ {token}
+
+
+
+ Description
+
+ {description}
+
+
+
+
+
+
+ {isLoading && }
+ >
+ )
+}
diff --git a/src/screens/TokenScreen.test.tsx b/src/screens/TokenScreen.test.tsx
new file mode 100644
index 00000000..4b25bcae
--- /dev/null
+++ b/src/screens/TokenScreen.test.tsx
@@ -0,0 +1,130 @@
+import { NativeStackScreenProps } from 'react-native-screens/native-stack'
+import React from 'react'
+import { fireEvent, waitFor } 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'
+
+const secret: Secret = {
+ _id: 'id',
+ secret: 'secret',
+ uid: 'uid',
+ tokens: [
+ {
+ description: 'A description',
+ token: 'a-token',
+ },
+ ],
+ account: 'account',
+ issuer: '',
+}
+
+jest.mock('../lib/api')
+jest.mock('../hooks/use-push-token', () => () => 'dummy-expo-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()
+ const registerSubscriptionStub = jest.fn().mockResolvedValue('a-sub')
+
+ beforeEach(() => {
+ useSecretsMocked.mockReturnValue({
+ secrets: [secret],
+ add: jest.fn(),
+ update: updateSecretStub,
+ remove: jest.fn(),
+ })
+
+ apiFactoryMocked.mockReturnValue({
+ generateToken: apiGenerateTokenStub,
+ revokeToken: apiRevokeTokenStub,
+ registerSubscription: registerSubscriptionStub,
+ } as unknown as API)
+ })
+
+ const setup = () => {
+ const props = {
+ navigation: getMockedNavigation<'Token'>(),
+ route: {
+ params: { secretId: secret._id, token: secret.tokens[0].token },
+ },
+ } as unknown as NativeStackScreenProps
+
+ return renderWithTheme()
+ }
+
+ it('refreshes token', async () => {
+ const { getByText } = setup()
+ await waitFor(() => {
+ expect(registerSubscriptionStub).toBeCalled()
+ })
+ fireEvent.press(getByText('REFRESH TOKEN'))
+ expect(apiGenerateTokenStub).toBeCalledTimes(1)
+ })
+
+ it('revokes token', async () => {
+ const { getByText } = setup()
+ await waitFor(() => {
+ expect(registerSubscriptionStub).toBeCalled()
+ })
+ fireEvent.press(getByText('REVOKE TOKEN'))
+ expect(apiRevokeTokenStub).toBeCalledTimes(1)
+ })
+
+ it('saves description in the background', async () => {
+ // Using fake timer as description saving is debounced
+ jest.useFakeTimers()
+ updateSecretStub.mockReset()
+ const { getByA11yLabel } = setup()
+
+ await waitFor(() => {
+ expect(registerSubscriptionStub).toBeCalled()
+ })
+
+ const inputtedDescriptionText = 'An updated description'
+
+ const descriptionInput = getByA11yLabel('Description')
+ fireEvent.changeText(descriptionInput, inputtedDescriptionText)
+ jest.runOnlyPendingTimers()
+
+ expect(updateSecretStub).toBeCalledTimes(1)
+ expect(updateSecretStub).toBeCalledWith({
+ ...secret,
+ tokens: [{ ...secret.tokens[0], description: inputtedDescriptionText }],
+ })
+ })
+
+ it("doesn't save description if it's empty", () => {
+ // Using fake timer as description saving is debounced
+ jest.useFakeTimers()
+ registerSubscriptionStub.mockReset()
+ updateSecretStub.mockReset()
+ const { getByA11yLabel } = setup()
+
+ const descriptionInput = getByA11yLabel('Description')
+ fireEvent.changeText(descriptionInput, '')
+
+ jest.runOnlyPendingTimers()
+
+ expect(updateSecretStub).toBeCalledTimes(0)
+ })
+})
diff --git a/src/screens/TokenScreen.tsx b/src/screens/TokenScreen.tsx
new file mode 100644
index 00000000..c6a8252f
--- /dev/null
+++ b/src/screens/TokenScreen.tsx
@@ -0,0 +1,253 @@
+import React, { useEffect, useMemo, useState } from 'react'
+import { Alert, StyleSheet, View } from 'react-native'
+import { Button, ProgressBar, 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'
+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'
+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
+
+const styles = StyleSheet.create({
+ container: {
+ paddingHorizontal: theme.spacing(3),
+ paddingTop: theme.spacing(4),
+ },
+ token: {
+ marginBottom: theme.spacing(4),
+ },
+ description: {
+ marginBottom: theme.spacing(4),
+ },
+ refresh: {
+ marginBottom: theme.spacing(4),
+ },
+ refreshButton: {
+ marginBottom: theme.spacing(1),
+ },
+ refreshingLoader: {
+ position: 'absolute',
+ flex: 1,
+ width: '100%',
+ },
+})
+
+const showRevokeConfirmAlert = (onConfirm: () => void) => {
+ Alert.alert(
+ 'Revoke Token',
+ 'This will permanently remove the token. Are you sure you want to continue?',
+ [
+ {
+ text: 'CANCEL',
+ style: 'cancel',
+ },
+ { text: 'REVOKE', onPress: onConfirm },
+ ],
+ { cancelable: true }
+ )
+}
+
+const showRefreshConfirmAlert = (onConfirm: () => void) => {
+ Alert.alert(
+ 'Refresh Token',
+ 'This will generate a new token. Are you sure you want to continue?',
+ [
+ {
+ text: 'CANCEL',
+ style: 'cancel',
+ },
+ { text: 'REFRESH', onPress: onConfirm },
+ ],
+ { cancelable: true }
+ )
+}
+
+type Props = NativeStackScreenProps
+
+export const TokenScreen = ({ route, navigation }: Props) => {
+ const { secretId, token } = route.params
+ const secret = useSecretSelector(secretId)
+ const tokens = secret?.tokens ? secret.tokens : []
+ const tokenData = useTokenDataSelector(secretId, token)
+ const existingDescription = tokenData?.description || ''
+ const { user } = useAuth()
+ const [subscriptionId, setSubscriptionId] = useState('')
+ 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])
+
+ const handleRevokeToken = async () => {
+ if (!subscriptionId) {
+ Toast.show(`Server connection required to revoke token`)
+ return
+ }
+ setIsRevoking(true)
+ try {
+ await api.revokeToken(token)
+ await update({
+ ...secret,
+ tokens: 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)
+ }
+ setIsRevoking(false)
+ }
+
+ const handleRefreshToken = async () => {
+ if (!subscriptionId) {
+ Toast.show(`Server connection required to refresh token`)
+ return
+ }
+
+ setIsRefreshing(true)
+
+ try {
+ const refreshedToken = await api.generateToken(
+ secret,
+ subscriptionId,
+ token
+ )
+ const newToken = {
+ token: refreshedToken,
+ description,
+ }
+ const tokensCopy = [...tokens]
+ const existingItemIndex = tokens.findIndex(item => item.token === token)
+
+ if (existingItemIndex === -1) {
+ tokensCopy.push(newToken)
+ } else {
+ tokensCopy[existingItemIndex] = newToken
+ }
+
+ const secretUpdated = {
+ ...secret,
+ tokens: tokensCopy,
+ }
+
+ await update(secretUpdated)
+
+ // Navigate to the new token screen
+ navigation.replace('Token', {
+ secretId: secretId,
+ token: refreshedToken,
+ })
+ Toast.show('Token successfully refreshed')
+ } catch (err) {
+ Toast.show(`There was an error refreshing token: ${token}`)
+ console.log(err)
+ }
+
+ setIsRefreshing(false)
+ }
+
+ useEffect(() => {
+ if (!user || !expoToken) return
+
+ const register = async () => {
+ const id = await api.registerSubscription({
+ type: 'expo',
+ token: expoToken,
+ })
+ setSubscriptionId(id)
+ }
+
+ register()
+ }, [user, api, expoToken])
+
+ // Keep the description for the token up to date
+ useEffect(() => {
+ const updateDescription = async () => {
+ const tokens = secret.tokens ? [...secret.tokens] : []
+ const existingItemIndex = tokens.findIndex(item => item.token === token)
+ if (existingItemIndex === -1) {
+ return
+ }
+ const { description: existingDescription } = tokens[existingItemIndex]
+ if (description === existingDescription || description.length < 3) {
+ return
+ }
+ tokens[existingItemIndex] = { token, description: description }
+ await update({
+ ...secret,
+ tokens,
+ })
+ Toast.show('Token description updated')
+ }
+ const timeoutId = setTimeout(
+ updateDescription,
+ SAVE_UPDATED_DESCRIPTION_DELAY
+ )
+ return () => {
+ clearTimeout(timeoutId)
+ }
+ }, [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.
+
+
+
+
+
+
+ {isRefreshing && (
+
+
+
+ )}
+ {(isRevoking || !subscriptionId) && }
+ >
+ )
+}
diff --git a/src/screens/TokensListScreen.tsx b/src/screens/TokensListScreen.tsx
new file mode 100644
index 00000000..1a8b0168
--- /dev/null
+++ b/src/screens/TokensListScreen.tsx
@@ -0,0 +1,177 @@
+import { StyleSheet, View, ScrollView } from 'react-native'
+import React, { Fragment, 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'
+import { useSecretSelector } from '../hooks/use-secret-selector'
+import { Typography } from '../components/Typography'
+
+const styles = StyleSheet.create({
+ container: {
+ flexGrow: 1,
+ justifyContent: 'flex-start',
+ paddingHorizontal: theme.spacing(2),
+ paddingVertical: theme.spacing(3),
+ },
+ noTokens: {
+ marginTop: theme.spacing(8),
+ padding: theme.spacing(4),
+ },
+ noTokensTitle: {
+ textAlign: 'center',
+ marginBottom: theme.spacing(1),
+ },
+ noTokensBody: {
+ textAlign: 'center',
+ },
+ searchArea: {
+ marginBottom: theme.spacing(2),
+ },
+ scrollView: {
+ flexGrow: 1,
+ },
+ tokensCount: {
+ marginBottom: theme.spacing(2),
+ },
+ tokensCountLabel: {
+ marginRight: theme.spacing(2),
+ },
+ tokensCountValue: theme.typography.overline,
+ tokenItem: {
+ 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: {
+ ...theme.typography.code,
+ marginBottom: theme.spacing(1),
+ },
+ tokenDescriptionText: {
+ ...theme.typography.body2,
+ color: theme.colors.text,
+ marginHorizontal: 0,
+ },
+ fab: {
+ backgroundColor: theme.colors.primary,
+ position: 'absolute',
+ margin: theme.spacing(3),
+ bottom: 0,
+ alignSelf: 'center',
+ },
+})
+
+type Props = NativeStackScreenProps
+
+export const TokensListScreen = ({ route, navigation }: Props) => {
+ const { navigate } = navigation
+ const { secretId } = route.params
+ const secret = useSecretSelector(secretId)
+ const tokens = useMemo(() => (secret.tokens ? secret.tokens : []), [secret])
+ const tokensCount = tokens.length
+ const [search, setSearch] = useState('')
+ const [searchFocused, setSearchFocused] = useState(false)
+ const isFocused = useIsFocused()
+ const shouldShowFab = isFocused && !searchFocused
+
+ const filteredTokens = useMemo(() => {
+ if (search.length > 1) {
+ return tokens.filter(({ description }) =>
+ description.toLowerCase().includes(search.toLowerCase())
+ )
+ } else {
+ return tokens
+ }
+ }, [search, tokens])
+
+ const handleCreateToken = useCallback(() => {
+ navigate('CreateToken', { secretId })
+ }, [navigate, secretId])
+
+ return (
+ <>
+
+ {tokensCount > 1 && (
+ <>
+
+
+ {tokensCount}
+ {' '}
+
+ {tokensCount === 1 ? 'TOKEN' : 'TOKENS'}
+
+
+
+ }
+ onFocus={() => setSearchFocused(true)}
+ onBlur={() => setSearchFocused(false)}
+ />
+
+ >
+ )}
+ {tokensCount === 0 ? (
+
+
+ No Tokens
+
+
+ Create a new token and it will appear here.
+
+
+ ) : (
+
+ {filteredTokens.map(({ token, description }) => (
+
+
+ navigation.navigate('Token', { secretId, token })
+ }
+ style={styles.tokenItem}
+ title={token}
+ titleStyle={styles.tokenValueText}
+ description={description}
+ descriptionStyle={styles.tokenDescriptionText}
+ descriptionNumberOfLines={1}
+ right={({ style, ...props }) => (
+
+ )}
+ />
+
+
+ ))}
+
+ )}
+
+
+
+
+ >
+ )
+}
diff --git a/src/types.ts b/src/types.ts
index 67326add..918447b0 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,10 +1,15 @@
+export type Token = {
+ token: string
+ description: string
+}
+
export type Secret = {
_id: string
uid: string
secret: string
account: string
issuer: string
- token?: string
+ tokens?: Token[]
}
export type Subscription = {
diff --git a/test/utils.tsx b/test/utils.tsx
index 3fbec698..9014b646 100644
--- a/test/utils.tsx
+++ b/test/utils.tsx
@@ -14,5 +14,10 @@ export function getMockedNavigation<
P extends keyof MainStackParamList = 'Home',
T = StackNavigationProp
>(fns?: T) {
- return { navigate: jest.fn(), ...fns } as T
+ return {
+ navigate: jest.fn(),
+ goBack: jest.fn(),
+ replace: jest.fn(),
+ ...fns,
+ } as T
}
diff --git a/yarn.lock b/yarn.lock
index 291b2ab3..5b7fb84b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1256,6 +1256,11 @@
resolved "https://registry.yarnpkg.com/@expo-google-fonts/poppins/-/poppins-0.2.0.tgz#f6df892909142740b53d3227cb5eb24990ece151"
integrity sha512-NIwmzadXGHmS7F7nznfpObVAAi4PdSvueLKnPN3ERG0yMYrC8svEP2IY25H4xLoIGBazgkN192ZkStM3PMYajA==
+"@expo-google-fonts/roboto-mono@^0.2.0":
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/@expo-google-fonts/roboto-mono/-/roboto-mono-0.2.0.tgz#c545f2a97aae5d180b4e39e88e95954fac3df497"
+ integrity sha512-pc86Am8mBiF9SB2qOlxNUPg3NvCkAvHn4Td1qB+Jimd3IUZWuwFhcIiGjXivRACrrvNuPpkOua78VIP/3TuXXQ==
+
"@expo/config-plugins@3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-3.1.0.tgz#0752ff33c5eab21cf42034a44e79df97f0f867f8"
@@ -2475,12 +2480,12 @@
"@types/react-test-renderer" ">=16.9.0"
react-error-boundary "^3.1.0"
-"@testing-library/react-native@^7.2.0":
- version "7.2.0"
- resolved "https://registry.yarnpkg.com/@testing-library/react-native/-/react-native-7.2.0.tgz#e5ec5b0974e4e5f525f8057563417d1e9f820d96"
- integrity sha512-rDKzJjAAeGgyoJT0gFQiMsIL09chdWcwZyYx6WZHMgm2c5NDqY52hUuyTkzhqddMYWmSRklFphSg7B2HX+246Q==
+"@testing-library/react-native@^8.0.0":
+ version "8.0.0"
+ resolved "https://registry.yarnpkg.com/@testing-library/react-native/-/react-native-8.0.0.tgz#f1b8f6bcc9f0ef6026b0ec7d072faf4af0e622fa"
+ integrity sha512-XwQIv4Amj8AYsPjASo+1XLFWY7qMm+FyV4+QU5j97CpRd+YasCBNnvfyDAZoudm/5Y0Yx55DYjAX36RugxasPQ==
dependencies:
- pretty-format "^26.0.1"
+ pretty-format "^27.0.0"
"@tootallnate/once@1":
version "1.1.2"
@@ -8727,7 +8732,7 @@ pretty-format@^24.9.0:
ansi-styles "^3.2.0"
react-is "^16.8.4"
-pretty-format@^26.0.1, pretty-format@^26.4.0, pretty-format@^26.5.2, pretty-format@^26.6.2:
+pretty-format@^26.4.0, pretty-format@^26.5.2, pretty-format@^26.6.2:
version "26.6.2"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93"
integrity sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==