From c6634dc74004614c1bf0e38102fc03539e7da939 Mon Sep 17 00:00:00 2001 From: Myzel394 <50424412+Myzel394@users.noreply.github.com> Date: Sun, 30 Oct 2022 18:31:15 +0100 Subject: [PATCH] improvements --- package.json | 2 + src/AuthContext/AuthContext.ts | 8 ++ src/AuthContext/AuthContextProvider.tsx | 17 ++- .../DecryptionPasswordMissingAlert.tsx | 102 +++++++++++++----- src/constants/react-query.ts | 14 ++- src/hocs/WithEncryptionRequired.tsx | 73 ++----------- src/hooks/index.ts | 2 + src/hooks/use-ui-state.ts | 14 +++ .../AliasDetailRoute/AliasDetails.tsx | 97 +++++++++++++++++ .../AliasDetailRoute/AliasNotesForm.tsx | 32 ++++-- .../AliasDetailRoute/AliasPreferencesForm.tsx | 7 +- .../ChangeAliasActivationStatusSwitch.tsx | 22 +++- src/routes/AliasDetailRoute.tsx | 91 ++-------------- src/routes/EnterDecryptionPassword.tsx | 12 ++- 14 files changed, 296 insertions(+), 197 deletions(-) create mode 100644 src/hooks/use-ui-state.ts create mode 100644 src/route-widgets/AliasDetailRoute/AliasDetails.tsx diff --git a/package.json b/package.json index a56d8ea..0c4b07d 100755 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "date-fns": "^2.29.3", "formik": "^2.2.9", "group-array": "^1.0.0", + "immutability-helper": "^3.1.1", + "in-milliseconds": "^1.2.0", "in-seconds": "^1.2.0", "openpgp": "^5.5.0", "react": "^18.2.0", diff --git a/src/AuthContext/AuthContext.ts b/src/AuthContext/AuthContext.ts index 5355656..7d33820 100644 --- a/src/AuthContext/AuthContext.ts +++ b/src/AuthContext/AuthContext.ts @@ -2,11 +2,18 @@ import {createContext} from "react" import {ServerUser, User} from "~/server-types" +export enum EncryptionStatus { + Unavailable = "Unavailable", + PasswordRequired = "PasswordRequired", + Available = "Available", +} + interface AuthContextTypeBase { user: ServerUser | User | null isAuthenticated: boolean login: (user: ServerUser | User) => void logout: () => void + encryptionStatus: EncryptionStatus _decryptUsingMasterPassword: (content: string) => string _encryptUsingMasterPassword: (content: string) => string _decryptUsingPrivateKey: (message: string) => Promise @@ -31,6 +38,7 @@ export type AuthContextType = const AuthContext = createContext({ user: null, isAuthenticated: false, + encryptionStatus: EncryptionStatus.Unavailable, login: () => { throw new Error("login() not implemented") }, diff --git a/src/AuthContext/AuthContextProvider.tsx b/src/AuthContext/AuthContextProvider.tsx index 2c2a699..d2d421f 100644 --- a/src/AuthContext/AuthContextProvider.tsx +++ b/src/AuthContext/AuthContextProvider.tsx @@ -15,7 +15,7 @@ import { import {client} from "~/constants/axios-client" import {decryptString, encryptString} from "~/utils" -import AuthContext, {AuthContextType} from "./AuthContext" +import AuthContext, {AuthContextType, EncryptionStatus} from "./AuthContext" export interface AuthContextProviderProps { children: ReactNode @@ -134,6 +134,21 @@ export default function AuthContextProvider({ () => ({ user: user ?? null, login: setUser, + encryptionStatus: (() => { + if (!user) { + return EncryptionStatus.Unavailable + } + + if (!user.encryptedPassword) { + return EncryptionStatus.Unavailable + } + + if (user.isDecrypted) { + return EncryptionStatus.Available + } + + return EncryptionStatus.PasswordRequired + })(), logout, isAuthenticated: user !== null, _encryptUsingMasterPassword: encryptUsingMasterPassword, diff --git a/src/components/DecryptionPasswordMissingAlert.tsx b/src/components/DecryptionPasswordMissingAlert.tsx index 5794541..720047f 100644 --- a/src/components/DecryptionPasswordMissingAlert.tsx +++ b/src/components/DecryptionPasswordMissingAlert.tsx @@ -1,31 +1,85 @@ -import {ReactElement, useContext} from "react" +import {useContext} from "react" import {MdLock} from "react-icons/md" 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" -export default function DecryptionPasswordMissingAlert(): ReactElement { - const {handleAnchorClick} = useContext(LockNavigationContext) - - return ( - - - - Your decryption password is required to view this section. - - - - - - - ) +export interface WithEncryptionRequiredProps { + 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 ( + + + + Encryption required + + + + + You need to set up encryption to use this feature. + + + + + + + ) + } + + case EncryptionStatus.PasswordRequired: { + return ( + + + + Password required + + + + + Your decryption password is required to view this + section. + + + + + + + ) + } + + default: + return children + } } diff --git a/src/constants/react-query.ts b/src/constants/react-query.ts index 4a480d6..3354964 100644 --- a/src/constants/react-query.ts +++ b/src/constants/react-query.ts @@ -1,3 +1,15 @@ +import * as inMilliseconds from "in-milliseconds" + 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), + }, + }, +}) diff --git a/src/hocs/WithEncryptionRequired.tsx b/src/hocs/WithEncryptionRequired.tsx index e6f07b0..134ff94 100644 --- a/src/hocs/WithEncryptionRequired.tsx +++ b/src/hocs/WithEncryptionRequired.tsx @@ -1,76 +1,15 @@ 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 {useUser} from "~/hooks" +import {DecryptionPasswordMissingAlert} from "~/components" export default function WithEncryptionRequired( Component: any, ): (props: any) => ReactElement { return (props: any): ReactElement => { - const user = useUser() - - if (!user.encryptedPassword) { - return ( - - - - Encryption required - - - - - To continue, you need to enable encryption. - - - - - - - ) - } - - if (!user.isDecrypted) { - return ( - - - - Encryption required - - - - - To continue, please enter your password to decrypt - your data. - - - - - - - ) - } - - return + return ( + + + + ) } } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f8ca7dd..5ebfb6b 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -6,3 +6,5 @@ export * from "./use-user" export {default as useUser} from "./use-user" export * 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" diff --git a/src/hooks/use-ui-state.ts b/src/hooks/use-ui-state.ts new file mode 100644 index 0000000..a2a3be5 --- /dev/null +++ b/src/hooks/use-ui-state.ts @@ -0,0 +1,14 @@ +import {Dispatch, SetStateAction, useState} from "react" +import {useUpdateEffect} from "react-use" + +export default function useUIState( + outerValue: T, +): [T, Dispatch>] { + const [value, setValue] = useState(outerValue) + + useUpdateEffect(() => { + setValue(outerValue) + }, [outerValue]) + + return [value, setValue] +} diff --git a/src/route-widgets/AliasDetailRoute/AliasDetails.tsx b/src/route-widgets/AliasDetailRoute/AliasDetails.tsx new file mode 100644 index 0000000..e93dae0 --- /dev/null +++ b/src/route-widgets/AliasDetailRoute/AliasDetails.tsx @@ -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( + aliasValue, + ) + + return ( + + + + + + + + {address} + + + + + + + + + + + Notes + + + + {encryptionStatus === EncryptionStatus.Available ? ( + + ) : ( + + )} + + + + + + + + Settings + + + + + 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. + + + + + + + + + ) +} diff --git a/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx b/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx index 13aa9a2..6abf449 100644 --- a/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx +++ b/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx @@ -4,6 +4,7 @@ import {AxiosError} from "axios" import {ReactElement, useContext} from "react" import {RiLinkM, RiStickyNoteFill} from "react-icons/ri" import {FieldArray, FormikProvider, useFormik} from "formik" +import update from "immutability-helper" import {useMutation} from "@tanstack/react-query" import { @@ -22,15 +23,17 @@ import { } from "@mui/material" import {URL_REGEX} from "~/constants/values" -import {parseFastAPIError, whenEnterPressed} from "~/utils" +import {decryptAliasNotes, parseFastAPIError, whenEnterPressed} from "~/utils" 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 AuthContext from "~/AuthContext/AuthContext" export interface AliasNotesFormProps { id: string notes: AliasNote + + onChanged: (alias: DecryptedAlias) => void } interface Form { @@ -65,13 +68,19 @@ const getDomain = (url: string): string => { export default function AliasNotesForm({ id, notes, + onChanged, }: AliasNotesFormProps): ReactElement { - const {_encryptUsingMasterPassword} = useContext(AuthContext) + const {_encryptUsingMasterPassword, _decryptUsingMasterPassword} = + useContext(AuthContext) const {mutateAsync, isSuccess} = useMutation< Alias, AxiosError, UpdateAliasData - >(values => updateAlias(id, values)) + >(values => updateAlias(id, values), { + onSuccess: newAlias => { + onChanged(decryptAliasNotes(newAlias, _decryptUsingMasterPassword)) + }, + }) const formik = useFormik
({ validationSchema: SCHEMA, initialValues: { @@ -80,14 +89,17 @@ export default function AliasNotesForm({ }, onSubmit: async (values, {setErrors}) => { try { - const newNotes = { - ...notes, + const newNotes = update(notes, { data: { - ...notes.data, - personalNotes: values.personalNotes, - websites: values.websites, + personalNotes: { + $set: values.personalNotes, + }, + websites: { + $set: values.websites, + }, }, - } + }) + const data = _encryptUsingMasterPassword( JSON.stringify(newNotes), ) diff --git a/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx b/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx index 3006fb6..6849c56 100644 --- a/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx +++ b/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx @@ -30,6 +30,8 @@ import SelectField from "~/route-widgets/SettingsRoute/SelectField" export interface AliasPreferencesFormProps { alias: Alias | DecryptedAlias + + onChanged: (newAlias: Alias | DecryptedAlias) => void } interface Form { @@ -56,12 +58,15 @@ const SCHEMA = yup.object().shape({ export default function AliasPreferencesForm({ alias, + onChanged, }: AliasPreferencesFormProps): ReactElement { const {mutateAsync, isSuccess} = useMutation< Alias, AxiosError, UpdateAliasData - >(data => updateAlias(alias.id, data)) + >(data => updateAlias(alias.id, data), { + onSuccess: onChanged, + }) const formik = useFormik({ enableReinitialize: true, initialValues: { diff --git a/src/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch.tsx b/src/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch.tsx index 8283a34..d60b8ed 100644 --- a/src/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch.tsx +++ b/src/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch.tsx @@ -1,19 +1,20 @@ -import {ReactElement, useEffect, useState} from "react" +import {ReactElement, useContext, useEffect, useState} from "react" import {AxiosError} from "axios" import {Switch} from "@mui/material" import {useMutation} from "@tanstack/react-query" -import {Alias} from "~/server-types" +import {Alias, DecryptedAlias} from "~/server-types" import {UpdateAliasData, updateAlias} from "~/apis" -import {parseFastAPIError} from "~/utils" +import {decryptAliasNotes, parseFastAPIError} from "~/utils" import {ErrorSnack, SuccessSnack} from "~/components" +import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext" export interface ChangeAliasActivationStatusSwitchProps { id: string isActive: boolean - onChanged: () => void + onChanged: (alias: Alias | DecryptedAlias) => void } export default function ChangeAliasActivationStatusSwitch({ @@ -21,6 +22,9 @@ export default function ChangeAliasActivationStatusSwitch({ isActive, onChanged, }: ChangeAliasActivationStatusSwitchProps): ReactElement { + const {_decryptUsingMasterPassword, encryptionStatus} = + useContext(AuthContext) + const [isActiveUIState, setIsActiveUIState] = useState(true) const [successMessage, setSuccessMessage] = useState("") @@ -31,7 +35,15 @@ export default function ChangeAliasActivationStatusSwitch({ AxiosError, UpdateAliasData >(values => updateAlias(id, values), { - onSuccess: onChanged, + onSuccess: newAlias => { + if (encryptionStatus === EncryptionStatus.Available) { + onChanged( + decryptAliasNotes(newAlias, _decryptUsingMasterPassword), + ) + } else { + onChanged(newAlias) + } + }, onError: error => setErrorMessage(parseFastAPIError(error).detail as string), }) diff --git a/src/routes/AliasDetailRoute.tsx b/src/routes/AliasDetailRoute.tsx index 853e0a9..f335cde 100644 --- a/src/routes/AliasDetailRoute.tsx +++ b/src/routes/AliasDetailRoute.tsx @@ -3,27 +3,24 @@ import {useParams} from "react-router-dom" import {AxiosError} from "axios" import {useQuery} from "@tanstack/react-query" -import {Grid, Typography} from "@mui/material" import {getAlias} from "~/apis" import {Alias, DecryptedAlias} from "~/server-types" -import {AliasTypeIndicator, QueryResult, SimplePage} from "~/components" +import {QueryResult, SimplePage} from "~/components" import {decryptAliasNotes} from "~/utils" -import AliasNotesForm from "~/route-widgets/AliasDetailRoute/AliasNotesForm" -import AliasPreferencesForm from "~/route-widgets/AliasDetailRoute/AliasPreferencesForm" -import AuthContext from "~/AuthContext/AuthContext" -import ChangeAliasActivationStatusSwitch from "~/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch" -import DecryptionPasswordMissingAlert from "~/components/DecryptionPasswordMissingAlert" +import AliasDetails from "~/route-widgets/AliasDetailRoute/AliasDetails" +import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext" export default function AliasDetailRoute(): ReactElement { const params = useParams() - const {user, _decryptUsingMasterPassword} = useContext(AuthContext) const address = atob(params.addressInBase64 as string) + const {user, _decryptUsingMasterPassword, encryptionStatus} = + useContext(AuthContext) const query = useQuery( ["get_alias", params.addressInBase64], async () => { - if (user?.encryptedPassword) { + if (encryptionStatus === EncryptionStatus.Available) { return decryptAliasNotes( await getAlias(address), _decryptUsingMasterPassword, @@ -37,81 +34,7 @@ export default function AliasDetailRoute(): ReactElement { return ( query={query}> - {alias => ( - - - - - - - - - {address} - - - - - - - - - - - - Notes - - - - {user?.encryptedPassword && - (alias as DecryptedAlias).notes ? ( - - ) : ( - - )} - - - - - - - - Settings - - - - - 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. - - - - - - - - - )} + {alias => } ) diff --git a/src/routes/EnterDecryptionPassword.tsx b/src/routes/EnterDecryptionPassword.tsx index 7e377a2..0746209 100644 --- a/src/routes/EnterDecryptionPassword.tsx +++ b/src/routes/EnterDecryptionPassword.tsx @@ -1,13 +1,14 @@ import * as yup from "yup" import {ReactElement, useContext} from "react" -import {useNavigate} from "react-router-dom" +import {useLocation, useNavigate} from "react-router-dom" import {useFormik} from "formik" +import {MdLock} from "react-icons/md" + +import {InputAdornment} from "@mui/material" import {buildEncryptionPassword} from "~/utils" import {useUser} from "~/hooks" import {PasswordField, SimpleForm} from "~/components" -import {InputAdornment} from "@mui/material" -import {MdLock} from "react-icons/md" import AuthContext from "~/AuthContext/AuthContext" interface Form { @@ -20,6 +21,7 @@ const schema = yup.object().shape({ export default function EnterDecryptionPassword(): ReactElement { const navigate = useNavigate() + const location = useLocation() const user = useUser() const {_setDecryptionPassword} = useContext(AuthContext) @@ -37,7 +39,9 @@ export default function EnterDecryptionPassword(): ReactElement { if (!_setDecryptionPassword(decryptionPassword)) { setErrors({password: "Password is invalid."}) } else { - navigate("/") + const nextUrl = + new URLSearchParams(location.search).get("next") || "/" + navigate(nextUrl) } }, })