feat(profile) [closes #219] Import and export user profile (#220)

* feat(profile): implement profile export
* feat(profile): load user-selected file
* feat(profile): import loaded profile
* feat(profile): validate public/private keys
* refactor(settings): remove unnecessary theme functions
* refactor(settings): use theme hook
* feat(settings): show error alert if profile export fails
This commit is contained in:
Jeremy Kahn 2023-12-10 19:43:32 -06:00 committed by GitHub
parent 15142f9328
commit e962c403a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 299 additions and 20 deletions

59
package-lock.json generated
View File

@ -26,6 +26,7 @@
"classnames": "^2.3.1", "classnames": "^2.3.1",
"detectincognitojs": "^1.1.2", "detectincognitojs": "^1.1.2",
"fast-memoize": "^2.5.2", "fast-memoize": "^2.5.2",
"file-saver": "^2.0.5",
"fun-animal-names": "^0.1.1", "fun-animal-names": "^0.1.1",
"idb-chunk-store": "^1.0.1", "idb-chunk-store": "^1.0.1",
"localforage": "^1.10.0", "localforage": "^1.10.0",
@ -33,6 +34,7 @@
"querystring": "^0.2.1", "querystring": "^0.2.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-file-reader-input": "^2.0.0",
"react-git-info": "^2.0.1", "react-git-info": "^2.0.1",
"react-markdown": "^8.0.3", "react-markdown": "^8.0.3",
"react-qrcode-logo": "^2.8.0", "react-qrcode-logo": "^2.8.0",
@ -56,6 +58,8 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@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/react-syntax-highlighter": "^15.5.5",
"@types/streamsaver": "^2.0.1", "@types/streamsaver": "^2.0.1",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
@ -7451,6 +7455,12 @@
"@types/send": "*" "@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": { "node_modules/@types/graceful-fs": {
"version": "4.1.8", "version": "4.1.8",
"license": "MIT", "license": "MIT",
@ -7637,6 +7647,15 @@
"@types/react": "*" "@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": { "node_modules/@types/react-syntax-highlighter": {
"version": "15.5.9", "version": "15.5.9",
"dev": true, "dev": true,
@ -13101,6 +13120,11 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/filelist": {
"version": "1.0.4", "version": "1.0.4",
"license": "Apache-2.0", "license": "Apache-2.0",
@ -22791,6 +22815,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/react-git-info": {
"version": "2.0.1", "version": "2.0.1",
"license": "MIT", "license": "MIT",
@ -31878,6 +31911,12 @@
"@types/send": "*" "@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": { "@types/graceful-fs": {
"version": "4.1.8", "version": "4.1.8",
"requires": { "requires": {
@ -32028,6 +32067,15 @@
"@types/react": "*" "@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": { "@types/react-syntax-highlighter": {
"version": "15.5.9", "version": "15.5.9",
"dev": true, "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": { "filelist": {
"version": "1.0.4", "version": "1.0.4",
"requires": { "requires": {
@ -41065,6 +41118,12 @@
"version": "6.0.9", "version": "6.0.9",
"dev": true "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": { "react-git-info": {
"version": "2.0.1", "version": "2.0.1",
"requires": { "requires": {

View File

@ -22,6 +22,7 @@
"classnames": "^2.3.1", "classnames": "^2.3.1",
"detectincognitojs": "^1.1.2", "detectincognitojs": "^1.1.2",
"fast-memoize": "^2.5.2", "fast-memoize": "^2.5.2",
"file-saver": "^2.0.5",
"fun-animal-names": "^0.1.1", "fun-animal-names": "^0.1.1",
"idb-chunk-store": "^1.0.1", "idb-chunk-store": "^1.0.1",
"localforage": "^1.10.0", "localforage": "^1.10.0",
@ -29,6 +30,7 @@
"querystring": "^0.2.1", "querystring": "^0.2.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-file-reader-input": "^2.0.0",
"react-git-info": "^2.0.1", "react-git-info": "^2.0.1",
"react-markdown": "^8.0.3", "react-markdown": "^8.0.3",
"react-qrcode-logo": "^2.8.0", "react-qrcode-logo": "^2.8.0",
@ -89,6 +91,8 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@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/react-syntax-highlighter": "^15.5.5",
"@types/streamsaver": "^2.0.1", "@types/streamsaver": "^2.0.1",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
@ -123,6 +127,10 @@
"@svgr/plugin-svgo": { "@svgr/plugin-svgo": {
"nth-check": "2.0.1" "nth-check": "2.0.1"
}, },
"react-file-reader-input": {
"react": "$react",
"react-dom": "$react-dom"
},
"resolve-url-loader": { "resolve-url-loader": {
"postcss": "8.4.31" "postcss": "8.4.31"
} }

View File

@ -1,4 +1,5 @@
import { ChangeEvent, useContext, useEffect, useState } from 'react' import { ChangeEvent, useContext, useEffect, useState } from 'react'
import FileReaderInput, { Result } from 'react-file-reader-input'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
@ -7,21 +8,26 @@ import Switch from '@mui/material/Switch'
import FormGroup from '@mui/material/FormGroup' import FormGroup from '@mui/material/FormGroup'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
import Paper from '@mui/material/Paper' import Paper from '@mui/material/Paper'
import useTheme from '@mui/material/styles/useTheme'
import { settingsService } from 'services/Settings'
import { NotificationService } from 'services/Notification' import { NotificationService } from 'services/Notification'
import { ShellContext } from 'contexts/ShellContext' import { ShellContext } from 'contexts/ShellContext'
import { StorageContext } from 'contexts/StorageContext' import { StorageContext } from 'contexts/StorageContext'
import { SettingsContext } from 'contexts/SettingsContext'
import { PeerNameDisplay } from 'components/PeerNameDisplay' import { PeerNameDisplay } from 'components/PeerNameDisplay'
import { ConfirmDialog } from 'components/ConfirmDialog'
import { ConfirmDialog } from '../../components/ConfirmDialog' import { isErrorWithMessage } from '../../utils'
import { SettingsContext } from '../../contexts/SettingsContext'
interface SettingsProps { interface SettingsProps {
userId: string userId: string
} }
export const Settings = ({ userId }: SettingsProps) => { export const Settings = ({ userId }: SettingsProps) => {
const { setTitle } = useContext(ShellContext) const theme = useTheme()
const { setTitle, showAlert } = useContext(ShellContext)
const { updateUserSettings, getUserSettings } = useContext(SettingsContext) const { updateUserSettings, getUserSettings } = useContext(SettingsContext)
const { getPersistedStorage } = useContext(StorageContext) const { getPersistedStorage } = useContext(StorageContext)
const [ const [
@ -85,17 +91,41 @@ export const Settings = ({ userId }: SettingsProps) => {
window.location.reload() 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' const areNotificationsAvailable = NotificationService.permission === 'granted'
return ( return (
<Box className="max-w-3xl mx-auto p-4"> <Box className="max-w-3xl mx-auto p-4">
<Typography <Typography
variant="h2" variant="h2"
sx={theme => ({ sx={{
fontSize: theme.typography.h3.fontSize, fontSize: theme.typography.h3.fontSize,
fontWeight: theme.typography.fontWeightMedium, fontWeight: theme.typography.fontWeightMedium,
mb: 2, mb: 2,
})} }}
> >
Chat Chat
</Typography> </Typography>
@ -137,44 +167,111 @@ export const Settings = ({ userId }: SettingsProps) => {
label="Show active typing indicators" label="Show active typing indicators"
/> />
</FormGroup> </FormGroup>
<Typography variant="subtitle2" sx={_theme => ({})}> <Typography variant="subtitle2">
Disabling this will also hide your active typing status from others. Disabling this will also hide your active typing status from others.
</Typography> </Typography>
</Paper> </Paper>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
<Typography <Typography
variant="h2" variant="h2"
sx={theme => ({ sx={{
fontSize: theme.typography.h3.fontSize, fontSize: theme.typography.h3.fontSize,
fontWeight: theme.typography.fontWeightMedium, fontWeight: theme.typography.fontWeightMedium,
mb: 2, mb: 2,
})} }}
> >
Data Data
</Typography> </Typography>
<Typography <Typography
variant="h2" variant="h2"
sx={theme => ({ sx={{
fontSize: theme.typography.h5.fontSize, fontSize: theme.typography.h5.fontSize,
fontWeight: theme.typography.fontWeightMedium, fontWeight: theme.typography.fontWeightMedium,
mb: 1.5, mb: 1.5,
})} }}
> >
Delete all settings data Export profile data
</Typography> </Typography>
<Typography <Typography
variant="body1" variant="body1"
sx={_theme => ({ sx={{
mb: 2, 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 <strong>Be careful with this</strong>. This will cause your user name to
change from{' '} change from{' '}
<strong> <strong>
<PeerNameDisplay <PeerNameDisplay
sx={theme => ({ sx={{
fontWeight: theme.typography.fontWeightMedium, fontWeight: theme.typography.fontWeightMedium,
})} }}
> >
{userId} {userId}
</PeerNameDisplay> </PeerNameDisplay>
@ -185,9 +282,9 @@ export const Settings = ({ userId }: SettingsProps) => {
<Button <Button
variant="outlined" variant="outlined"
color="error" color="error"
sx={_theme => ({ sx={{
mb: 2, mb: 2,
})} }}
onClick={handleDeleteSettingsClick} onClick={handleDeleteSettingsClick}
> >
Delete all data and restart Delete all data and restart
@ -199,9 +296,9 @@ export const Settings = ({ userId }: SettingsProps) => {
/> />
<Typography <Typography
variant="subtitle2" variant="subtitle2"
sx={_theme => ({ sx={{
mb: 2, mb: 2,
})} }}
> >
Chitchatter only stores user preferences and never message content of Chitchatter only stores user preferences and never message content of
any kind. This preference data is only stored locally on your device and any kind. This preference data is only stored locally on your device and

View File

@ -1,4 +1,4 @@
import { UserSettings } from 'models/settings' import { ColorMode, UserSettings } from 'models/settings'
import { AllowedKeyType, encryptionService } from 'services/Encryption' import { AllowedKeyType, encryptionService } from 'services/Encryption'
export interface SerializedUserSettings export interface SerializedUserSettings
@ -7,6 +7,31 @@ export interface SerializedUserSettings
privateKey: string 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 { export class SerializationService {
serializeUserSettings = async ( serializeUserSettings = async (
userSettings: UserSettings userSettings: UserSettings

View File

@ -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()

View File

@ -0,0 +1 @@
export * from './Settings'

View File

@ -14,3 +14,12 @@ export const isRecord = (variable: any): variable is Record<string, any> => {
export const isError = (e: any): e is Error => { export const isError = (e: any): e is Error => {
return e instanceof 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'
)
}