forked from Shiloh/remnantchat
* 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:
parent
15142f9328
commit
e962c403a5
59
package-lock.json
generated
59
package-lock.json
generated
@ -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": {
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
80
src/services/Settings/Settings.ts
Normal file
80
src/services/Settings/Settings.ts
Normal 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()
|
1
src/services/Settings/index.ts
Normal file
1
src/services/Settings/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Settings'
|
@ -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'
|
||||
)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user