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",
"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": {

View File

@ -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"
}

View File

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

View File

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

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 => {
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'
)
}