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==