diff --git a/.gitignore b/.gitignore index 93edbce..02e8b2c 100644 --- a/.gitignore +++ b/.gitignore @@ -73,4 +73,6 @@ android/ .env -# @end expo-cli \ No newline at end of file +# @end expo-cli + +.eslintcache \ No newline at end of file diff --git a/app.config.js b/app.config.js index 7fc2dff..2fba269 100644 --- a/app.config.js +++ b/app.config.js @@ -27,6 +27,7 @@ module.exports = { plugins: [ 'expo-localization', '@morrowdigital/watermelondb-expo-plugin', + 'expo-document-picker', [ 'expo-build-properties', { diff --git a/app/(home)/index.tsx b/app/(home)/index.tsx index 40f49c3..d32599d 100644 --- a/app/(home)/index.tsx +++ b/app/(home)/index.tsx @@ -29,7 +29,7 @@ const Home: FC = () => { return ( <> - + {!!sites.length && } ); diff --git a/app/settings/export.tsx b/app/settings/export.tsx new file mode 100644 index 0000000..1e053d9 --- /dev/null +++ b/app/settings/export.tsx @@ -0,0 +1,93 @@ +import { Trans } from '@lingui/macro'; +import * as FileSystem from 'expo-file-system'; +import * as Sharing from 'expo-sharing'; +import React, { FC } from 'react'; +import { Dimensions, StyleSheet, View } from 'react-native'; +import { Button, MD3Theme, useTheme } from 'react-native-paper'; +import { ExportIcon } from '../../src/icons/Export'; +import Site from '../../src/models/Site'; +import { useDB } from '../../src/providers/DatabaseProvider'; +import Container from '../../src/ui/Container'; +import Text from '../../src/ui/Text'; +import { format } from 'date-fns'; + +const SettingsExport: FC = () => { + const { listSites } = useDB(); + const theme = useTheme(); + const styles = getStyles(theme); + + const downloadStringAsFile = async () => { + const sites = await listSites(); + const content = JSON.stringify( + sites.map( + site => + ({ + label: site.label, + secret: site.secret, + algorithm: site.algorithm, + digits: site.digits, + issuer: site.issuer, + type: site.type, + period: site.period, + } as Site), + ), + null, + 2, + ); + + const fileUri = + (FileSystem.documentDirectory || '') + + 'shield-authenticator_' + + format(Date.now(), 'yyyy-MM-dd-HH-mm-ss') + + '.json'; + await FileSystem.writeAsStringAsync(fileUri, content); + + const isAvailable = await Sharing.isAvailableAsync(); + if (!isAvailable) { + alert('Sharing is not available on your device.'); + return; + } + + await Sharing.shareAsync(fileUri); + }; + + return ( + + + + Backup your data + + + + To ensure that you don't lose your saved data, you can download a + file of the sites and restore them at a later time + + + + + + + + + + ); +}; + +const getStyles = (theme: MD3Theme) => + StyleSheet.create({ + container: { + flex: 1, + padding: 0, + margin: 0, + }, + buttonContainer: { + marginTop: 24, + }, + }); + +export default SettingsExport; diff --git a/app/settings/import.tsx b/app/settings/import.tsx new file mode 100644 index 0000000..e98d3d9 --- /dev/null +++ b/app/settings/import.tsx @@ -0,0 +1,123 @@ +import { Plural, Trans } from '@lingui/macro'; +import * as FileSystem from 'expo-file-system'; +import * as Sharing from 'expo-sharing'; +import React, { FC, useState } from 'react'; +import { Dimensions, StyleSheet, View } from 'react-native'; +import { Button, MD3Theme, ProgressBar, useTheme } from 'react-native-paper'; +import { ImportIcon } from '../../src/icons/Import'; +import Site from '../../src/models/Site'; +import { useDB } from '../../src/providers/DatabaseProvider'; +import Container from '../../src/ui/Container'; +import Text from '../../src/ui/Text'; +import * as DocumentPicker from 'expo-document-picker'; +import { OtpRecord } from '../../src/types'; + +const SettingsImport: FC = () => { + const { newSite } = useDB(); + const theme = useTheme(); + const styles = getStyles(theme); + const [processing, setProcessing] = useState(false); + const [numberOfSites, setNumberOfSites] = useState(0); + const [sitesProcessed, setSitesProcessed] = useState(0); + + const loadBackupFile = async () => { + const result = await DocumentPicker.getDocumentAsync({}); + if (result.type === 'success') { + const content = await FileSystem.readAsStringAsync(result.uri); + try { + const sites = JSON.parse(content) as Site[]; + if (!sites.length) { + throw new Error(); + } + setNumberOfSites(sites.length); + setProcessing(true); + for (let i = 0; i < sites.length; i++) { + const site = sites[i]; + await newSite( + { + algorithm: site.algorithm, + digits: site.digits, + label: site.label, + period: site.period, + secret: site.secret, + type: site.type, + issuer: site.issuer, + } as OtpRecord, + false, + ); + setSitesProcessed(value => value + 1); + } + } catch (e) { + alert('The format of the file is not valid'); + } + } + }; + + return ( + + + + Restore sites + + + + If you want to restore your saved sites from a backup, you can load + the file stored on your device. It's important to note that the + existing sites will remain unchanged and won't be replaced + + + + + {processing && ( + + + {sitesProcessed !== numberOfSites && Processing} + {sitesProcessed === numberOfSites && Completed} + + + + + + Processed 1 site of {numberOfSites}} + other={Processed # sites of {numberOfSites}} + /> + + + )} + {!processing && ( + + + + )} + + + ); +}; + +const getStyles = (theme: MD3Theme) => + StyleSheet.create({ + container: { + flex: 1, + padding: 0, + margin: 0, + }, + buttonContainer: { + marginTop: 24, + }, + processContainer: { + marginVertical: 16, + }, + }); + +export default SettingsImport; diff --git a/build/latest.apk b/build/latest.apk index d801f52..11b3525 100644 Binary files a/build/latest.apk and b/build/latest.apk differ diff --git a/package-lock.json b/package-lock.json index 259ca03..1fd0ddc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@react-navigation/native": "^6.1.4", "@react-navigation/stack": "^6.3.14", "@shopify/flash-list": "1.4.0", + "date-fns": "^2.29.3", "dotenv": "^16.0.3", "expo": "~48.0.6", "expo-background-fetch": "~11.1.1", @@ -26,12 +27,15 @@ "expo-camera": "~13.2.1", "expo-clipboard": "~4.1.2", "expo-constants": "~14.2.1", + "expo-document-picker": "~11.2.2", + "expo-file-system": "~15.2.2", "expo-font": "~11.1.1", "expo-linking": "~4.0.1", "expo-local-authentication": "~13.2.1", "expo-localization": "~14.1.1", "expo-router": "^1.0.1", "expo-secure-store": "~12.1.1", + "expo-sharing": "~11.2.2", "expo-splash-screen": "~0.18.1", "expo-status-bar": "~1.4.4", "expo-task-manager": "~11.1.1", @@ -10813,7 +10817,6 @@ "version": "2.29.3", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==", - "dev": true, "engines": { "node": ">=0.11" }, @@ -12366,6 +12369,14 @@ "expo": "*" } }, + "node_modules/expo-document-picker": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-11.2.2.tgz", + "integrity": "sha512-EeonRKxkK9E20LEAh93IvlwFjNkUCJKnhBEama9PmIDYWW7RyANZ8eP9C2PupThTDbivzRDNp7Ec7dIeyDAWjw==", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "15.2.2", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-15.2.2.tgz", @@ -12593,6 +12604,14 @@ "expo": "*" } }, + "node_modules/expo-sharing": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-11.2.2.tgz", + "integrity": "sha512-4Lhm1eS/CFIzX+JPuxMUTWBt9rv/WdvJvpQ9y+71bL/9w9dhvsdt9tv0SsNZATz4hk0tbrYD8ZEUsgiHiT1KkQ==", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-splash-screen": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.18.1.tgz", @@ -33284,8 +33303,7 @@ "date-fns": { "version": "2.29.3", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", - "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==", - "dev": true + "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==" }, "dayjs": { "version": "1.11.7", @@ -34639,6 +34657,12 @@ "uuid": "^3.3.2" } }, + "expo-document-picker": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-11.2.2.tgz", + "integrity": "sha512-EeonRKxkK9E20LEAh93IvlwFjNkUCJKnhBEama9PmIDYWW7RyANZ8eP9C2PupThTDbivzRDNp7Ec7dIeyDAWjw==", + "requires": {} + }, "expo-file-system": { "version": "15.2.2", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-15.2.2.tgz", @@ -34800,6 +34824,12 @@ "integrity": "sha512-phD8e8mlJWaESUBCpHq0GjS3OiqZ7g+I6SpTGQpt+xSS0pEE1ZUBXnIF+WR2OvoseKiS9ODzDl85CUlut/eNKw==", "requires": {} }, + "expo-sharing": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-11.2.2.tgz", + "integrity": "sha512-4Lhm1eS/CFIzX+JPuxMUTWBt9rv/WdvJvpQ9y+71bL/9w9dhvsdt9tv0SsNZATz4hk0tbrYD8ZEUsgiHiT1KkQ==", + "requires": {} + }, "expo-splash-screen": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.18.1.tgz", diff --git a/package.json b/package.json index 8b03920..8c872d3 100644 --- a/package.json +++ b/package.json @@ -27,21 +27,28 @@ "@react-native-masked-view/masked-view": "0.2.8", "@react-navigation/native": "^6.1.4", "@react-navigation/stack": "^6.3.14", + "@shopify/flash-list": "1.4.0", + "date-fns": "^2.29.3", "dotenv": "^16.0.3", "expo": "~48.0.6", + "expo-background-fetch": "~11.1.1", "expo-barcode-scanner": "~12.3.2", "expo-build-properties": "~0.5.1", "expo-camera": "~13.2.1", "expo-clipboard": "~4.1.2", "expo-constants": "~14.2.1", + "expo-document-picker": "~11.2.2", + "expo-file-system": "~15.2.2", "expo-font": "~11.1.1", "expo-linking": "~4.0.1", "expo-local-authentication": "~13.2.1", "expo-localization": "~14.1.1", "expo-router": "^1.0.1", "expo-secure-store": "~12.1.1", + "expo-sharing": "~11.2.2", "expo-splash-screen": "~0.18.1", "expo-status-bar": "~1.4.4", + "expo-task-manager": "~11.1.1", "make-plural": "^7.2.0", "react": "18.2.0", "react-dom": "18.2.0", @@ -55,10 +62,7 @@ "react-native-url-polyfill": "^1.3.0", "react-native-vector-icons": "^9.2.0", "react-native-web": "~0.18.11", - "totp-generator": "^0.0.14", - "@shopify/flash-list": "1.4.0", - "expo-task-manager": "~11.1.1", - "expo-background-fetch": "~11.1.1" + "totp-generator": "^0.0.14" }, "jest": { "preset": "jest-expo", diff --git a/src/components/AddSite.tsx b/src/components/AddSite.tsx index fb0cf0d..ec9b2cf 100644 --- a/src/components/AddSite.tsx +++ b/src/components/AddSite.tsx @@ -11,7 +11,7 @@ const AddSite: FC = () => { icon="plus" style={styles.fab} onPress={() => push('/qr')} - label={t`Add site`} + label={t`Add new site`} /> ); }; diff --git a/src/components/SitesList.tsx b/src/components/SitesList.tsx index 7e4ef6c..5abe968 100644 --- a/src/components/SitesList.tsx +++ b/src/components/SitesList.tsx @@ -10,7 +10,7 @@ import React, { import { Trans, t } from '@lingui/macro'; import { FlashList } from '@shopify/flash-list'; -import { StyleSheet, View, ViewToken } from 'react-native'; +import { Dimensions, StyleSheet, View, ViewToken } from 'react-native'; import { TextInput } from 'react-native-paper'; import { DEFAULT_TOTP_PERIOD } from '../constants/app'; import colors from '../constants/colors'; @@ -18,6 +18,7 @@ import { useTimer30 } from '../hooks/useTimer30'; import Site from '../models/Site'; import Text from '../ui/Text'; import SiteInfo from './SiteInfo'; +import { NoSitesIcon } from '../icons/NoSites'; type SitesListProps = { sites: Site[]; @@ -57,15 +58,21 @@ const SitesList: FC = ({ sites, deleteSite }) => { if (!sites.length) { return ( - - No sites have been added yet - + + + Everything is prepared for you to add your sites + + + ); } return ( <> - + = ({ sites, deleteSite }) => { }; const styles = StyleSheet.create({ - inputWrapper: { + wrapper: { marginBottom: 32, }, + wrapperNoResults: { + marginTop: 32, + }, icon: { width: 24, height: 24, diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 743fe09..b636ab4 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -1,15 +1,48 @@ -import { FC } from 'react'; +import { t } from '@lingui/macro'; +import { FC, useState } from 'react'; import { StyleSheet, View } from 'react-native'; +import { Menu } from 'react-native-paper'; import { LogoIcon } from '../icons/LogoIcon'; +import { MenuIcon } from '../icons/MenuIcon'; +import IconButton from '../ui/IconButton'; import Text from '../ui/Text'; +import { useRouter } from 'expo-router'; const TopBar: FC = () => { + const { push } = useRouter(); + const [menuVisible, setMenuVisible] = useState(false); + return ( - - - Shield Authenticator - + + + + Shield Authenticator + + + + setMenuVisible(false)} + anchor={ + setMenuVisible(true)}> + + + } + style={styles.menu} + > + push('/settings/export')} + title={t`Backup your data`} + leadingIcon="export" + /> + push('/settings/import')} + title={t`Restore sites`} + leadingIcon="import" + /> + + ); }; @@ -17,8 +50,16 @@ const TopBar: FC = () => { const styles = StyleSheet.create({ head: { flexDirection: 'row', + justifyContent: 'space-between', alignItems: 'center', }, + logo: { + flexDirection: 'row', + alignItems: 'center', + }, + menu: { + marginTop: 48, + }, }); export default TopBar; diff --git a/src/icons/Export.tsx b/src/icons/Export.tsx new file mode 100644 index 0000000..02cead4 --- /dev/null +++ b/src/icons/Export.tsx @@ -0,0 +1,341 @@ +import { FC } from 'react'; +import Svg, { Circle, G, Path, Text } from 'react-native-svg'; +import { ComponentWithSize } from '../types'; + +export const ExportIcon: FC = ({ + width = 48, + height = 48, +}) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/icons/Import.tsx b/src/icons/Import.tsx new file mode 100644 index 0000000..1f9aa6b --- /dev/null +++ b/src/icons/Import.tsx @@ -0,0 +1,481 @@ +import { FC } from 'react'; +import Svg, { Circle, G, Path, Text } from 'react-native-svg'; +import { ComponentWithSize } from '../types'; + +export const ImportIcon: FC = ({ + width = 48, + height = 48, +}) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/icons/MenuIcon.tsx b/src/icons/MenuIcon.tsx new file mode 100644 index 0000000..4ec5032 --- /dev/null +++ b/src/icons/MenuIcon.tsx @@ -0,0 +1,26 @@ +import { FC } from 'react'; +import Svg, { Path } from 'react-native-svg'; +import colors from '../constants/colors'; +import { ComponentWithColor, ComponentWithSize } from '../types'; + +export const MenuIcon: FC = ({ + width = 24, + height = 24, + color = colors.medium, +}) => { + return ( + + + + ); +}; diff --git a/src/icons/NoSites.tsx b/src/icons/NoSites.tsx new file mode 100644 index 0000000..b45da7e --- /dev/null +++ b/src/icons/NoSites.tsx @@ -0,0 +1,319 @@ +import { FC } from 'react'; +import Svg, { G, Path, Rect } from 'react-native-svg'; +import { ComponentWithSize } from '../types'; + +export const NoSitesIcon: FC = ({ + width = 48, + height = 48, +}) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/icons/ScanQR.tsx b/src/icons/ScanQR.tsx index 32182de..11366a8 100644 --- a/src/icons/ScanQR.tsx +++ b/src/icons/ScanQR.tsx @@ -1,5 +1,5 @@ import { FC } from 'react'; -import Svg, { Circle, G, Path } from 'react-native-svg'; +import Svg, { Circle, G, Path, Text } from 'react-native-svg'; import { ComponentWithSize } from '../types'; export const ScanQRIcon: FC = ({ @@ -7,24 +7,396 @@ export const ScanQRIcon: FC = ({ height = 48, }) => { return ( - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/providers/DatabaseProvider.tsx b/src/providers/DatabaseProvider.tsx index 0036785..61cb298 100644 --- a/src/providers/DatabaseProvider.tsx +++ b/src/providers/DatabaseProvider.tsx @@ -24,7 +24,7 @@ const db = new Database({ export interface DBContextInterface { db: Database; - newSite: (site: OtpRecord) => Promise; + newSite: (site: OtpRecord, update?: boolean) => Promise; listSites: () => Promise; deleteSite: (site: Site) => void; } @@ -39,7 +39,7 @@ const updateSiteData = (site: Site, otpData: OtpRecord) => { site.period = otpData.period; }; -const newSite = (otpData: OtpRecord) => +const newSite = (otpData: OtpRecord, update = true) => db.write(async () => { const existing = await db.collections .get(SITE_TABLE_NAME) @@ -51,6 +51,9 @@ const newSite = (otpData: OtpRecord) => ) .fetch(); if (existing.length) { + if (!update) { + return existing[0]; + } const updateSite = await existing[0].update(site => updateSiteData(site, otpData), ); diff --git a/src/providers/FingerprintAuthProvider.tsx b/src/providers/FingerprintAuthProvider.tsx index 10ad793..91b171a 100644 --- a/src/providers/FingerprintAuthProvider.tsx +++ b/src/providers/FingerprintAuthProvider.tsx @@ -96,7 +96,7 @@ export const FingerprintAuthProvider: FC = ({ Fingerprint authentication - + Secure the access to your stored 2FA using your device fingerprint authentication diff --git a/src/ui/Text.tsx b/src/ui/Text.tsx index 571db2d..31b2fb2 100644 --- a/src/ui/Text.tsx +++ b/src/ui/Text.tsx @@ -48,7 +48,7 @@ const Text: FC = ({ const getStyles = (theme: MD3Theme) => StyleSheet.create({ common: { - paddingBottom: 32, + paddingBottom: 24, }, primary: { color: theme.colors.primary,