improvements

This commit is contained in:
Myzel394 2022-10-30 18:31:15 +01:00
parent 64a8ae3096
commit c6634dc740
14 changed files with 296 additions and 197 deletions

View File

@ -23,6 +23,8 @@
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"formik": "^2.2.9", "formik": "^2.2.9",
"group-array": "^1.0.0", "group-array": "^1.0.0",
"immutability-helper": "^3.1.1",
"in-milliseconds": "^1.2.0",
"in-seconds": "^1.2.0", "in-seconds": "^1.2.0",
"openpgp": "^5.5.0", "openpgp": "^5.5.0",
"react": "^18.2.0", "react": "^18.2.0",

View File

@ -2,11 +2,18 @@ import {createContext} from "react"
import {ServerUser, User} from "~/server-types" import {ServerUser, User} from "~/server-types"
export enum EncryptionStatus {
Unavailable = "Unavailable",
PasswordRequired = "PasswordRequired",
Available = "Available",
}
interface AuthContextTypeBase { interface AuthContextTypeBase {
user: ServerUser | User | null user: ServerUser | User | null
isAuthenticated: boolean isAuthenticated: boolean
login: (user: ServerUser | User) => void login: (user: ServerUser | User) => void
logout: () => void logout: () => void
encryptionStatus: EncryptionStatus
_decryptUsingMasterPassword: (content: string) => string _decryptUsingMasterPassword: (content: string) => string
_encryptUsingMasterPassword: (content: string) => string _encryptUsingMasterPassword: (content: string) => string
_decryptUsingPrivateKey: (message: string) => Promise<string> _decryptUsingPrivateKey: (message: string) => Promise<string>
@ -31,6 +38,7 @@ export type AuthContextType =
const AuthContext = createContext<AuthContextType>({ const AuthContext = createContext<AuthContextType>({
user: null, user: null,
isAuthenticated: false, isAuthenticated: false,
encryptionStatus: EncryptionStatus.Unavailable,
login: () => { login: () => {
throw new Error("login() not implemented") throw new Error("login() not implemented")
}, },

View File

@ -15,7 +15,7 @@ import {
import {client} from "~/constants/axios-client" import {client} from "~/constants/axios-client"
import {decryptString, encryptString} from "~/utils" import {decryptString, encryptString} from "~/utils"
import AuthContext, {AuthContextType} from "./AuthContext" import AuthContext, {AuthContextType, EncryptionStatus} from "./AuthContext"
export interface AuthContextProviderProps { export interface AuthContextProviderProps {
children: ReactNode children: ReactNode
@ -134,6 +134,21 @@ export default function AuthContextProvider({
() => ({ () => ({
user: user ?? null, user: user ?? null,
login: setUser, login: setUser,
encryptionStatus: (() => {
if (!user) {
return EncryptionStatus.Unavailable
}
if (!user.encryptedPassword) {
return EncryptionStatus.Unavailable
}
if (user.isDecrypted) {
return EncryptionStatus.Available
}
return EncryptionStatus.PasswordRequired
})(),
logout, logout,
isAuthenticated: user !== null, isAuthenticated: user !== null,
_encryptUsingMasterPassword: encryptUsingMasterPassword, _encryptUsingMasterPassword: encryptUsingMasterPassword,

View File

@ -1,31 +1,85 @@
import {ReactElement, useContext} from "react" import {useContext} from "react"
import {MdLock} from "react-icons/md" import {MdLock} from "react-icons/md"
import {Link as RouterLink} from "react-router-dom" import {Link as RouterLink} from "react-router-dom"
import {Alert, Button, Grid} from "@mui/material" import {Button, Grid, Typography} from "@mui/material"
import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext"
import LockNavigationContext from "~/LockNavigationContext/LockNavigationContext" import LockNavigationContext from "~/LockNavigationContext/LockNavigationContext"
export default function DecryptionPasswordMissingAlert(): ReactElement { export interface WithEncryptionRequiredProps {
const {handleAnchorClick} = useContext(LockNavigationContext) children?: JSX.Element
}
export default function DecryptionPasswordMissingAlert({
children = <></>,
}: WithEncryptionRequiredProps): JSX.Element {
const {handleAnchorClick} = useContext(LockNavigationContext)
const {encryptionStatus} = useContext(AuthContext)
switch (encryptionStatus) {
case EncryptionStatus.Unavailable: {
return ( return (
<Grid container spacing={2} direction="column" alignItems="center"> <Grid container spacing={4}>
<Grid item> <Grid item>
<Alert severity="warning"> <Typography variant="h6" component="h2">
Your decryption password is required to view this section. Encryption required
</Alert> </Typography>
</Grid>
<Grid item>
<Typography>
You need to set up encryption to use this feature.
</Typography>
</Grid> </Grid>
<Grid item> <Grid item>
<Button <Button
startIcon={<MdLock />} variant="contained"
component={RouterLink} component={RouterLink}
to="/enter-password" to="/complete-account?setup=true"
startIcon={<MdLock />}
onClick={handleAnchorClick} onClick={handleAnchorClick}
> >
Enter password Setup encryption
</Button> </Button>
</Grid> </Grid>
</Grid> </Grid>
) )
} }
case EncryptionStatus.PasswordRequired: {
return (
<Grid
container
spacing={4}
direction="column"
alignItems="center"
>
<Grid item>
<Typography variant="h6" component="h2">
Password required
</Typography>
</Grid>
<Grid item>
<Typography>
Your decryption password is required to view this
section.
</Typography>
</Grid>
<Grid item>
<Button
component={RouterLink}
to={`/enter-password?next=${window.location.pathname}`}
startIcon={<MdLock />}
onClick={handleAnchorClick}
>
Enter Password
</Button>
</Grid>
</Grid>
)
}
default:
return children
}
}

View File

@ -1,3 +1,15 @@
import * as inMilliseconds from "in-milliseconds"
import {QueryClient} from "@tanstack/react-query" import {QueryClient} from "@tanstack/react-query"
export const queryClient = new QueryClient() export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchInterval: inMilliseconds.minutes(10),
refetchOnMount: "always",
staleTime: inMilliseconds.minutes(10),
},
},
})

View File

@ -1,76 +1,15 @@
import {ReactElement} from "react" import {ReactElement} from "react"
import {Link as RouterLink} from "react-router-dom"
import {MdLock} from "react-icons/md"
import {Button, Grid, Typography} from "@mui/material" import {DecryptionPasswordMissingAlert} from "~/components"
import {useUser} from "~/hooks"
export default function WithEncryptionRequired( export default function WithEncryptionRequired(
Component: any, Component: any,
): (props: any) => ReactElement { ): (props: any) => ReactElement {
return (props: any): ReactElement => { return (props: any): ReactElement => {
const user = useUser()
if (!user.encryptedPassword) {
return ( return (
<Grid container spacing={4}> <DecryptionPasswordMissingAlert>
<Grid item> <Component {...props} />
<Typography variant="h6" component="h2"> </DecryptionPasswordMissingAlert>
Encryption required
</Typography>
</Grid>
<Grid item>
<Typography>
To continue, you need to enable encryption.
</Typography>
</Grid>
<Grid item>
<Button
component={RouterLink}
to="/complete-account?setup=true"
startIcon={<MdLock />}
>
Setup encryption
</Button>
</Grid>
</Grid>
) )
} }
if (!user.isDecrypted) {
return (
<Grid
container
spacing={4}
direction="column"
alignItems="center"
>
<Grid item>
<Typography variant="h6" component="h2">
Encryption required
</Typography>
</Grid>
<Grid item>
<Typography>
To continue, please enter your password to decrypt
your data.
</Typography>
</Grid>
<Grid item>
<Button
variant="contained"
component={RouterLink}
to="/enter-password"
startIcon={<MdLock />}
>
Enter Password
</Button>
</Grid>
</Grid>
)
}
return <Component {...props} />
}
} }

View File

@ -6,3 +6,5 @@ export * from "./use-user"
export {default as useUser} from "./use-user" export {default as useUser} from "./use-user"
export * from "./use-system-preferred-theme" export * from "./use-system-preferred-theme"
export {default as useSystemPreferredTheme} from "./use-system-preferred-theme" export {default as useSystemPreferredTheme} from "./use-system-preferred-theme"
export * from "./use-ui-state"
export {default as useUIState} from "./use-ui-state"

14
src/hooks/use-ui-state.ts Normal file
View File

@ -0,0 +1,14 @@
import {Dispatch, SetStateAction, useState} from "react"
import {useUpdateEffect} from "react-use"
export default function useUIState<T>(
outerValue: T,
): [T, Dispatch<SetStateAction<T>>] {
const [value, setValue] = useState<T>(outerValue)
useUpdateEffect(() => {
setValue(outerValue)
}, [outerValue])
return [value, setValue]
}

View File

@ -0,0 +1,97 @@
import {useParams} from "react-router"
import {ReactElement, useContext} from "react"
import {Grid, Typography} from "@mui/material"
import {AliasTypeIndicator, DecryptionPasswordMissingAlert} from "~/components"
import {Alias, DecryptedAlias} from "~/server-types"
import {useUIState} from "~/hooks"
import AliasNotesForm from "~/route-widgets/AliasDetailRoute/AliasNotesForm"
import AliasPreferencesForm from "~/route-widgets/AliasDetailRoute/AliasPreferencesForm"
import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext"
import ChangeAliasActivationStatusSwitch from "~/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch"
export interface AliasDetailsProps {
alias: Alias | DecryptedAlias
}
export default function AliasDetails({
alias: aliasValue,
}: AliasDetailsProps): ReactElement {
const params = useParams()
const {encryptionStatus} = useContext(AuthContext)
const address = atob(params.addressInBase64 as string)
const [aliasUIState, setAliasUIState] = useUIState<Alias | DecryptedAlias>(
aliasValue,
)
return (
<Grid container spacing={4}>
<Grid item>
<Grid container spacing={1} direction="row" alignItems="center">
<Grid item>
<AliasTypeIndicator type={aliasUIState.type} />
</Grid>
<Grid item>
<Typography variant="subtitle1">{address}</Typography>
</Grid>
<Grid item>
<ChangeAliasActivationStatusSwitch
id={aliasUIState.id}
isActive={aliasUIState.isActive}
onChanged={setAliasUIState}
/>
</Grid>
</Grid>
</Grid>
<Grid item width="100%">
<Grid container direction="column" spacing={4}>
<Grid item>
<Typography variant="h6" component="h3">
Notes
</Typography>
</Grid>
<Grid item>
{encryptionStatus === EncryptionStatus.Available ? (
<AliasNotesForm
id={aliasUIState.id}
notes={(aliasUIState as DecryptedAlias).notes}
onChanged={setAliasUIState}
/>
) : (
<DecryptionPasswordMissingAlert />
)}
</Grid>
</Grid>
</Grid>
<Grid item>
<Grid container spacing={4}>
<Grid item>
<Typography variant="h6" component="h3">
Settings
</Typography>
</Grid>
<Grid item>
<Typography variant="body1">
These settings apply to this alias only. You can
either set a value manually or refer to your
defaults settings. Note that this does change in
behavior. When you set a value to refer to your
default setting, the alias will always use the
latest value. So when you change your default
setting, the alias will automatically use the new
value.
</Typography>
</Grid>
<Grid item>
<AliasPreferencesForm
alias={aliasUIState}
onChanged={setAliasUIState}
/>
</Grid>
</Grid>
</Grid>
</Grid>
)
}

View File

@ -4,6 +4,7 @@ import {AxiosError} from "axios"
import {ReactElement, useContext} from "react" import {ReactElement, useContext} from "react"
import {RiLinkM, RiStickyNoteFill} from "react-icons/ri" import {RiLinkM, RiStickyNoteFill} from "react-icons/ri"
import {FieldArray, FormikProvider, useFormik} from "formik" import {FieldArray, FormikProvider, useFormik} from "formik"
import update from "immutability-helper"
import {useMutation} from "@tanstack/react-query" import {useMutation} from "@tanstack/react-query"
import { import {
@ -22,15 +23,17 @@ import {
} from "@mui/material" } from "@mui/material"
import {URL_REGEX} from "~/constants/values" import {URL_REGEX} from "~/constants/values"
import {parseFastAPIError, whenEnterPressed} from "~/utils" import {decryptAliasNotes, parseFastAPIError, whenEnterPressed} from "~/utils"
import {BackupImage, ErrorSnack, SuccessSnack} from "~/components" import {BackupImage, ErrorSnack, SuccessSnack} from "~/components"
import {Alias, AliasNote} from "~/server-types" import {Alias, AliasNote, DecryptedAlias} from "~/server-types"
import {UpdateAliasData, updateAlias} from "~/apis" import {UpdateAliasData, updateAlias} from "~/apis"
import AuthContext from "~/AuthContext/AuthContext" import AuthContext from "~/AuthContext/AuthContext"
export interface AliasNotesFormProps { export interface AliasNotesFormProps {
id: string id: string
notes: AliasNote notes: AliasNote
onChanged: (alias: DecryptedAlias) => void
} }
interface Form { interface Form {
@ -65,13 +68,19 @@ const getDomain = (url: string): string => {
export default function AliasNotesForm({ export default function AliasNotesForm({
id, id,
notes, notes,
onChanged,
}: AliasNotesFormProps): ReactElement { }: AliasNotesFormProps): ReactElement {
const {_encryptUsingMasterPassword} = useContext(AuthContext) const {_encryptUsingMasterPassword, _decryptUsingMasterPassword} =
useContext(AuthContext)
const {mutateAsync, isSuccess} = useMutation< const {mutateAsync, isSuccess} = useMutation<
Alias, Alias,
AxiosError, AxiosError,
UpdateAliasData UpdateAliasData
>(values => updateAlias(id, values)) >(values => updateAlias(id, values), {
onSuccess: newAlias => {
onChanged(decryptAliasNotes(newAlias, _decryptUsingMasterPassword))
},
})
const formik = useFormik<Form>({ const formik = useFormik<Form>({
validationSchema: SCHEMA, validationSchema: SCHEMA,
initialValues: { initialValues: {
@ -80,14 +89,17 @@ export default function AliasNotesForm({
}, },
onSubmit: async (values, {setErrors}) => { onSubmit: async (values, {setErrors}) => {
try { try {
const newNotes = { const newNotes = update(notes, {
...notes,
data: { data: {
...notes.data, personalNotes: {
personalNotes: values.personalNotes, $set: values.personalNotes,
websites: values.websites,
}, },
} websites: {
$set: values.websites,
},
},
})
const data = _encryptUsingMasterPassword( const data = _encryptUsingMasterPassword(
JSON.stringify(newNotes), JSON.stringify(newNotes),
) )

View File

@ -30,6 +30,8 @@ import SelectField from "~/route-widgets/SettingsRoute/SelectField"
export interface AliasPreferencesFormProps { export interface AliasPreferencesFormProps {
alias: Alias | DecryptedAlias alias: Alias | DecryptedAlias
onChanged: (newAlias: Alias | DecryptedAlias) => void
} }
interface Form { interface Form {
@ -56,12 +58,15 @@ const SCHEMA = yup.object().shape({
export default function AliasPreferencesForm({ export default function AliasPreferencesForm({
alias, alias,
onChanged,
}: AliasPreferencesFormProps): ReactElement { }: AliasPreferencesFormProps): ReactElement {
const {mutateAsync, isSuccess} = useMutation< const {mutateAsync, isSuccess} = useMutation<
Alias, Alias,
AxiosError, AxiosError,
UpdateAliasData UpdateAliasData
>(data => updateAlias(alias.id, data)) >(data => updateAlias(alias.id, data), {
onSuccess: onChanged,
})
const formik = useFormik<Form>({ const formik = useFormik<Form>({
enableReinitialize: true, enableReinitialize: true,
initialValues: { initialValues: {

View File

@ -1,19 +1,20 @@
import {ReactElement, useEffect, useState} from "react" import {ReactElement, useContext, useEffect, useState} from "react"
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {Switch} from "@mui/material" import {Switch} from "@mui/material"
import {useMutation} from "@tanstack/react-query" import {useMutation} from "@tanstack/react-query"
import {Alias} from "~/server-types" import {Alias, DecryptedAlias} from "~/server-types"
import {UpdateAliasData, updateAlias} from "~/apis" import {UpdateAliasData, updateAlias} from "~/apis"
import {parseFastAPIError} from "~/utils" import {decryptAliasNotes, parseFastAPIError} from "~/utils"
import {ErrorSnack, SuccessSnack} from "~/components" import {ErrorSnack, SuccessSnack} from "~/components"
import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext"
export interface ChangeAliasActivationStatusSwitchProps { export interface ChangeAliasActivationStatusSwitchProps {
id: string id: string
isActive: boolean isActive: boolean
onChanged: () => void onChanged: (alias: Alias | DecryptedAlias) => void
} }
export default function ChangeAliasActivationStatusSwitch({ export default function ChangeAliasActivationStatusSwitch({
@ -21,6 +22,9 @@ export default function ChangeAliasActivationStatusSwitch({
isActive, isActive,
onChanged, onChanged,
}: ChangeAliasActivationStatusSwitchProps): ReactElement { }: ChangeAliasActivationStatusSwitchProps): ReactElement {
const {_decryptUsingMasterPassword, encryptionStatus} =
useContext(AuthContext)
const [isActiveUIState, setIsActiveUIState] = useState<boolean>(true) const [isActiveUIState, setIsActiveUIState] = useState<boolean>(true)
const [successMessage, setSuccessMessage] = useState<string>("") const [successMessage, setSuccessMessage] = useState<string>("")
@ -31,7 +35,15 @@ export default function ChangeAliasActivationStatusSwitch({
AxiosError, AxiosError,
UpdateAliasData UpdateAliasData
>(values => updateAlias(id, values), { >(values => updateAlias(id, values), {
onSuccess: onChanged, onSuccess: newAlias => {
if (encryptionStatus === EncryptionStatus.Available) {
onChanged(
decryptAliasNotes(newAlias, _decryptUsingMasterPassword),
)
} else {
onChanged(newAlias)
}
},
onError: error => onError: error =>
setErrorMessage(parseFastAPIError(error).detail as string), setErrorMessage(parseFastAPIError(error).detail as string),
}) })

View File

@ -3,27 +3,24 @@ import {useParams} from "react-router-dom"
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {useQuery} from "@tanstack/react-query" import {useQuery} from "@tanstack/react-query"
import {Grid, Typography} from "@mui/material"
import {getAlias} from "~/apis" import {getAlias} from "~/apis"
import {Alias, DecryptedAlias} from "~/server-types" import {Alias, DecryptedAlias} from "~/server-types"
import {AliasTypeIndicator, QueryResult, SimplePage} from "~/components" import {QueryResult, SimplePage} from "~/components"
import {decryptAliasNotes} from "~/utils" import {decryptAliasNotes} from "~/utils"
import AliasNotesForm from "~/route-widgets/AliasDetailRoute/AliasNotesForm" import AliasDetails from "~/route-widgets/AliasDetailRoute/AliasDetails"
import AliasPreferencesForm from "~/route-widgets/AliasDetailRoute/AliasPreferencesForm" import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext"
import AuthContext from "~/AuthContext/AuthContext"
import ChangeAliasActivationStatusSwitch from "~/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch"
import DecryptionPasswordMissingAlert from "~/components/DecryptionPasswordMissingAlert"
export default function AliasDetailRoute(): ReactElement { export default function AliasDetailRoute(): ReactElement {
const params = useParams() const params = useParams()
const {user, _decryptUsingMasterPassword} = useContext(AuthContext)
const address = atob(params.addressInBase64 as string) const address = atob(params.addressInBase64 as string)
const {user, _decryptUsingMasterPassword, encryptionStatus} =
useContext(AuthContext)
const query = useQuery<Alias | DecryptedAlias, AxiosError>( const query = useQuery<Alias | DecryptedAlias, AxiosError>(
["get_alias", params.addressInBase64], ["get_alias", params.addressInBase64],
async () => { async () => {
if (user?.encryptedPassword) { if (encryptionStatus === EncryptionStatus.Available) {
return decryptAliasNotes( return decryptAliasNotes(
await getAlias(address), await getAlias(address),
_decryptUsingMasterPassword, _decryptUsingMasterPassword,
@ -37,81 +34,7 @@ export default function AliasDetailRoute(): ReactElement {
return ( return (
<SimplePage title="Alias Details"> <SimplePage title="Alias Details">
<QueryResult<Alias | DecryptedAlias> query={query}> <QueryResult<Alias | DecryptedAlias> query={query}>
{alias => ( {alias => <AliasDetails alias={alias} />}
<Grid container spacing={4}>
<Grid item>
<Grid
container
spacing={1}
direction="row"
alignItems="center"
>
<Grid item>
<AliasTypeIndicator type={alias.type} />
</Grid>
<Grid item>
<Typography variant="subtitle1">
{address}
</Typography>
</Grid>
<Grid item>
<ChangeAliasActivationStatusSwitch
id={alias.id}
isActive={alias.isActive}
onChanged={query.refetch}
/>
</Grid>
</Grid>
</Grid>
<Grid item width="100%">
<Grid container direction="column" spacing={4}>
<Grid item>
<Typography variant="h6" component="h3">
Notes
</Typography>
</Grid>
<Grid item>
{user?.encryptedPassword &&
(alias as DecryptedAlias).notes ? (
<AliasNotesForm
id={alias.id}
notes={
(alias as DecryptedAlias).notes
}
/>
) : (
<DecryptionPasswordMissingAlert />
)}
</Grid>
</Grid>
</Grid>
<Grid item>
<Grid container spacing={4}>
<Grid item>
<Typography variant="h6" component="h3">
Settings
</Typography>
</Grid>
<Grid item>
<Typography variant="body1">
These settings apply to this alias only.
You can either set a value manually or
refer to your defaults settings. Note
that this does change in behavior. When
you set a value to refer to your default
setting, the alias will always use the
latest value. So when you change your
default setting, the alias will
automatically use the new value.
</Typography>
</Grid>
<Grid item>
<AliasPreferencesForm alias={alias} />
</Grid>
</Grid>
</Grid>
</Grid>
)}
</QueryResult> </QueryResult>
</SimplePage> </SimplePage>
) )

View File

@ -1,13 +1,14 @@
import * as yup from "yup" import * as yup from "yup"
import {ReactElement, useContext} from "react" import {ReactElement, useContext} from "react"
import {useNavigate} from "react-router-dom" import {useLocation, useNavigate} from "react-router-dom"
import {useFormik} from "formik" import {useFormik} from "formik"
import {MdLock} from "react-icons/md"
import {InputAdornment} from "@mui/material"
import {buildEncryptionPassword} from "~/utils" import {buildEncryptionPassword} from "~/utils"
import {useUser} from "~/hooks" import {useUser} from "~/hooks"
import {PasswordField, SimpleForm} from "~/components" import {PasswordField, SimpleForm} from "~/components"
import {InputAdornment} from "@mui/material"
import {MdLock} from "react-icons/md"
import AuthContext from "~/AuthContext/AuthContext" import AuthContext from "~/AuthContext/AuthContext"
interface Form { interface Form {
@ -20,6 +21,7 @@ const schema = yup.object().shape({
export default function EnterDecryptionPassword(): ReactElement { export default function EnterDecryptionPassword(): ReactElement {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const user = useUser() const user = useUser()
const {_setDecryptionPassword} = useContext(AuthContext) const {_setDecryptionPassword} = useContext(AuthContext)
@ -37,7 +39,9 @@ export default function EnterDecryptionPassword(): ReactElement {
if (!_setDecryptionPassword(decryptionPassword)) { if (!_setDecryptionPassword(decryptionPassword)) {
setErrors({password: "Password is invalid."}) setErrors({password: "Password is invalid."})
} else { } else {
navigate("/") const nextUrl =
new URLSearchParams(location.search).get("next") || "/"
navigate(nextUrl)
} }
}, },
}) })