diff --git a/package-lock.json b/package-lock.json index 1aed84d..76c7e2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "classnames": "^2.3.1", "detectincognitojs": "^1.1.2", "fast-memoize": "^2.5.2", + "file-saver": "^2.0.5", "fun-animal-names": "^0.1.1", "idb-chunk-store": "^1.0.1", "localforage": "^1.10.0", @@ -33,6 +34,7 @@ "querystring": "^0.2.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-file-reader-input": "^2.0.0", "react-git-info": "^2.0.1", "react-markdown": "^8.0.3", "react-qrcode-logo": "^2.8.0", @@ -56,6 +58,8 @@ }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@types/file-saver": "^2.0.7", + "@types/react-file-reader-input": "^2.0.4", "@types/react-syntax-highlighter": "^15.5.5", "@types/streamsaver": "^2.0.1", "@types/uuid": "^8.3.4", @@ -7451,6 +7455,12 @@ "@types/send": "*" } }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.8", "license": "MIT", @@ -7637,6 +7647,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-file-reader-input": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/react-file-reader-input/-/react-file-reader-input-2.0.4.tgz", + "integrity": "sha512-Jqrfn+w42j8t8Q3npMXXKPdk+reIM0UHLKVc3ykrA7q7bN3Z62SGhsClZX0+Edlqm66lcKwmDQl+WMm+Xor7Xg==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-syntax-highlighter": { "version": "15.5.9", "dev": true, @@ -13101,6 +13120,11 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "node_modules/filelist": { "version": "1.0.4", "license": "Apache-2.0", @@ -22791,6 +22815,15 @@ "dev": true, "license": "MIT" }, + "node_modules/react-file-reader-input": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-file-reader-input/-/react-file-reader-input-2.0.0.tgz", + "integrity": "sha512-1XgkCpwMnNQsuOIy938UCntz8Xzwt9ECwHaH3cCfIQK1SPpH+y7gCYtqEcb6Rm0hAUq7Lp9+Ljoti9zGMswYrQ==", + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0", + "react-dom": "^15.0.0 || ^16.0.0" + } + }, "node_modules/react-git-info": { "version": "2.0.1", "license": "MIT", @@ -31878,6 +31911,12 @@ "@types/send": "*" } }, + "@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true + }, "@types/graceful-fs": { "version": "4.1.8", "requires": { @@ -32028,6 +32067,15 @@ "@types/react": "*" } }, + "@types/react-file-reader-input": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/react-file-reader-input/-/react-file-reader-input-2.0.4.tgz", + "integrity": "sha512-Jqrfn+w42j8t8Q3npMXXKPdk+reIM0UHLKVc3ykrA7q7bN3Z62SGhsClZX0+Edlqm66lcKwmDQl+WMm+Xor7Xg==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-syntax-highlighter": { "version": "15.5.9", "dev": true, @@ -35366,6 +35414,11 @@ } } }, + "file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "filelist": { "version": "1.0.4", "requires": { @@ -41065,6 +41118,12 @@ "version": "6.0.9", "dev": true }, + "react-file-reader-input": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-file-reader-input/-/react-file-reader-input-2.0.0.tgz", + "integrity": "sha512-1XgkCpwMnNQsuOIy938UCntz8Xzwt9ECwHaH3cCfIQK1SPpH+y7gCYtqEcb6Rm0hAUq7Lp9+Ljoti9zGMswYrQ==", + "requires": {} + }, "react-git-info": { "version": "2.0.1", "requires": { diff --git a/package.json b/package.json index 5c46657..0e82bf4 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "classnames": "^2.3.1", "detectincognitojs": "^1.1.2", "fast-memoize": "^2.5.2", + "file-saver": "^2.0.5", "fun-animal-names": "^0.1.1", "idb-chunk-store": "^1.0.1", "localforage": "^1.10.0", @@ -29,6 +30,7 @@ "querystring": "^0.2.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-file-reader-input": "^2.0.0", "react-git-info": "^2.0.1", "react-markdown": "^8.0.3", "react-qrcode-logo": "^2.8.0", @@ -89,6 +91,8 @@ }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@types/file-saver": "^2.0.7", + "@types/react-file-reader-input": "^2.0.4", "@types/react-syntax-highlighter": "^15.5.5", "@types/streamsaver": "^2.0.1", "@types/uuid": "^8.3.4", @@ -123,6 +127,10 @@ "@svgr/plugin-svgo": { "nth-check": "2.0.1" }, + "react-file-reader-input": { + "react": "$react", + "react-dom": "$react-dom" + }, "resolve-url-loader": { "postcss": "8.4.31" } diff --git a/src/pages/Settings/Settings.tsx b/src/pages/Settings/Settings.tsx index a6ff906..cfee265 100644 --- a/src/pages/Settings/Settings.tsx +++ b/src/pages/Settings/Settings.tsx @@ -1,4 +1,5 @@ import { ChangeEvent, useContext, useEffect, useState } from 'react' +import FileReaderInput, { Result } from 'react-file-reader-input' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import Typography from '@mui/material/Typography' @@ -7,21 +8,26 @@ import Switch from '@mui/material/Switch' import FormGroup from '@mui/material/FormGroup' import FormControlLabel from '@mui/material/FormControlLabel' import Paper from '@mui/material/Paper' +import useTheme from '@mui/material/styles/useTheme' +import { settingsService } from 'services/Settings' import { NotificationService } from 'services/Notification' import { ShellContext } from 'contexts/ShellContext' import { StorageContext } from 'contexts/StorageContext' +import { SettingsContext } from 'contexts/SettingsContext' import { PeerNameDisplay } from 'components/PeerNameDisplay' +import { ConfirmDialog } from 'components/ConfirmDialog' -import { ConfirmDialog } from '../../components/ConfirmDialog' -import { SettingsContext } from '../../contexts/SettingsContext' +import { isErrorWithMessage } from '../../utils' interface SettingsProps { userId: string } export const Settings = ({ userId }: SettingsProps) => { - const { setTitle } = useContext(ShellContext) + const theme = useTheme() + + const { setTitle, showAlert } = useContext(ShellContext) const { updateUserSettings, getUserSettings } = useContext(SettingsContext) const { getPersistedStorage } = useContext(StorageContext) const [ @@ -85,17 +91,41 @@ export const Settings = ({ userId }: SettingsProps) => { window.location.reload() } + const handleExportSettingsClick = async () => { + try { + await settingsService.exportSettings(getUserSettings()) + } catch (e) { + if (isErrorWithMessage(e)) { + showAlert(e.message, { severity: 'error' }) + } + } + } + + const handleImportSettingsClick = async ([[, file]]: Result[]) => { + try { + const userSettings = await settingsService.importSettings(file) + + updateUserSettings(userSettings) + + showAlert('Profile successfully imported', { severity: 'success' }) + } catch (e) { + if (isErrorWithMessage(e)) { + showAlert(e.message, { severity: 'error' }) + } + } + } + const areNotificationsAvailable = NotificationService.permission === 'granted' return ( <Box className="max-w-3xl mx-auto p-4"> <Typography variant="h2" - sx={theme => ({ + sx={{ fontSize: theme.typography.h3.fontSize, fontWeight: theme.typography.fontWeightMedium, mb: 2, - })} + }} > Chat </Typography> @@ -137,44 +167,111 @@ export const Settings = ({ userId }: SettingsProps) => { label="Show active typing indicators" /> </FormGroup> - <Typography variant="subtitle2" sx={_theme => ({})}> + <Typography variant="subtitle2"> Disabling this will also hide your active typing status from others. </Typography> </Paper> <Divider sx={{ my: 2 }} /> <Typography variant="h2" - sx={theme => ({ + sx={{ fontSize: theme.typography.h3.fontSize, fontWeight: theme.typography.fontWeightMedium, mb: 2, - })} + }} > Data </Typography> <Typography variant="h2" - sx={theme => ({ + sx={{ fontSize: theme.typography.h5.fontSize, fontWeight: theme.typography.fontWeightMedium, mb: 1.5, - })} + }} > - Delete all settings data + Export profile data </Typography> <Typography variant="body1" - sx={_theme => ({ + sx={{ mb: 2, - })} + }} + > + Export your Chitchatter profile data so that it can be moved to another + browser or device.{' '} + <strong>Be careful not to share the exported data with anyone</strong>. + It contains your unique verification keys. + </Typography> + <Button + variant="outlined" + sx={{ + mb: 2, + }} + onClick={handleExportSettingsClick} + > + Export profile data + </Button> + <Typography + variant="h2" + sx={{ + fontSize: theme.typography.h5.fontSize, + fontWeight: theme.typography.fontWeightMedium, + mb: 1.5, + }} + > + Import profile data + </Typography> + <Typography + variant="body1" + sx={{ + mb: 2, + }} + > + Import your Chitchatter profile that was previously exported from + another browser or device. + </Typography> + <FileReaderInput + {...{ + as: 'text', + onChange: (_e, results) => { + handleImportSettingsClick(results) + }, + }} + > + <Button + color="warning" + variant="outlined" + sx={{ + mb: 2, + }} + > + Import profile data + </Button> + </FileReaderInput> + <Typography + variant="h2" + sx={{ + fontSize: theme.typography.h5.fontSize, + fontWeight: theme.typography.fontWeightMedium, + mb: 1.5, + }} + > + Delete all profile data + </Typography> + <Typography + variant="body1" + sx={{ + mb: 2, + }} > <strong>Be careful with this</strong>. This will cause your user name to change from{' '} <strong> <PeerNameDisplay - sx={theme => ({ + sx={{ fontWeight: theme.typography.fontWeightMedium, - })} + }} > {userId} </PeerNameDisplay> @@ -185,9 +282,9 @@ export const Settings = ({ userId }: SettingsProps) => { <Button variant="outlined" color="error" - sx={_theme => ({ + sx={{ mb: 2, - })} + }} onClick={handleDeleteSettingsClick} > Delete all data and restart @@ -199,9 +296,9 @@ export const Settings = ({ userId }: SettingsProps) => { /> <Typography variant="subtitle2" - sx={_theme => ({ + sx={{ mb: 2, - })} + }} > Chitchatter only stores user preferences and never message content of any kind. This preference data is only stored locally on your device and diff --git a/src/services/Serialization/Serialization.ts b/src/services/Serialization/Serialization.ts index 4d1016a..8d3a25b 100644 --- a/src/services/Serialization/Serialization.ts +++ b/src/services/Serialization/Serialization.ts @@ -1,4 +1,4 @@ -import { UserSettings } from 'models/settings' +import { ColorMode, UserSettings } from 'models/settings' import { AllowedKeyType, encryptionService } from 'services/Encryption' export interface SerializedUserSettings @@ -7,6 +7,31 @@ export interface SerializedUserSettings privateKey: string } +export const isSerializedUserSettings = ( + data: any +): data is SerializedUserSettings => { + return ( + typeof data === 'object' && + data !== null && + 'colorMode' in data && + Object.values(ColorMode).includes(data.colorMode) && + 'userId' in data && + typeof data.userId === 'string' && + 'customUsername' in data && + typeof data.customUsername === 'string' && + 'playSoundOnNewMessage' in data && + typeof data.playSoundOnNewMessage === 'boolean' && + 'showNotificationOnNewMessage' in data && + typeof data.showNotificationOnNewMessage === 'boolean' && + 'showActiveTypingStatus' in data && + typeof data.showActiveTypingStatus === 'boolean' && + 'publicKey' in data && + typeof data.publicKey === 'string' && + 'privateKey' in data && + typeof data.privateKey === 'string' + ) +} + export class SerializationService { serializeUserSettings = async ( userSettings: UserSettings diff --git a/src/services/Settings/Settings.ts b/src/services/Settings/Settings.ts new file mode 100644 index 0000000..62da5dc --- /dev/null +++ b/src/services/Settings/Settings.ts @@ -0,0 +1,80 @@ +import { saveAs } from 'file-saver' + +import { UserSettings } from 'models/settings' +import { encryptionService } from 'services/Encryption' +import { + isSerializedUserSettings, + serializationService, +} from 'services/Serialization/Serialization' + +class InvalidFileError extends Error { + message = 'InvalidFileError: File could not be imported' +} + +const encryptionTestTarget = 'chitchatter' + +export class SettingsService { + exportSettings = async (userSettings: UserSettings) => { + const serializedUserSettings = + await serializationService.serializeUserSettings(userSettings) + + const blob = new Blob([JSON.stringify(serializedUserSettings)], { + type: 'application/json;charset=utf-8', + }) + + saveAs(blob, `chitchatter-profile-${userSettings.userId}.json`) + } + + importSettings = async (file: File) => { + const fileReader = new FileReader() + + const promise = new Promise<UserSettings>((resolve, reject) => { + fileReader.addEventListener('loadend', async evt => { + try { + const fileReaderResult = evt.target?.result + + if (typeof fileReaderResult !== 'string') { + throw new Error() + } + + const parsedFileResult = JSON.parse(fileReaderResult) + + if (!isSerializedUserSettings(parsedFileResult)) { + throw new Error() + } + + const deserializedUserSettings = + await serializationService.deserializeUserSettings(parsedFileResult) + + const encryptedString = await encryptionService.encryptString( + deserializedUserSettings.publicKey, + encryptionTestTarget + ) + + const decryptedString = await encryptionService.decryptString( + deserializedUserSettings.privateKey, + encryptedString + ) + + // NOTE: This determines whether the public and private keys match + // and are compatible with Chitchatter. + if (decryptedString !== encryptionTestTarget) { + throw new Error() + } + + resolve(deserializedUserSettings) + } catch (e) { + const err = new InvalidFileError() + console.error(err) + reject(err) + } + }) + + fileReader.readAsText(file.slice()) + }) + + return promise + } +} + +export const settingsService = new SettingsService() diff --git a/src/services/Settings/index.ts b/src/services/Settings/index.ts new file mode 100644 index 0000000..6db39fa --- /dev/null +++ b/src/services/Settings/index.ts @@ -0,0 +1 @@ +export * from './Settings' diff --git a/src/utils.ts b/src/utils.ts index 9c92d2f..57114dc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -14,3 +14,12 @@ export const isRecord = (variable: any): variable is Record<string, any> => { export const isError = (e: any): e is Error => { return e instanceof Error } + +export const isErrorWithMessage = (e: unknown): e is { message: string } => { + return ( + typeof e === 'object' && + e !== null && + 'message' in e && + typeof e.message === 'string' + ) +}