diff --git a/src/components/BackupImage.tsx b/src/components/BackupImage.tsx new file mode 100644 index 0000000..9e0dfa5 --- /dev/null +++ b/src/components/BackupImage.tsx @@ -0,0 +1,25 @@ +import React, {ReactElement, useState} from "react" + +export interface BackupImageProps + extends Omit< + React.DetailedHTMLProps< + React.ImgHTMLAttributes, + HTMLImageElement + >, + "src" + > { + fallbackSrc: string + src: string +} + +export default function BackupImage({ + fallbackSrc, + src, + ...props +}: BackupImageProps): ReactElement { + const [source, setSource] = useState(src) + + return ( + setSource(fallbackSrc)} /> + ) +} diff --git a/src/components/DecryptionPasswordMissingAlert.tsx b/src/components/DecryptionPasswordMissingAlert.tsx new file mode 100644 index 0000000..5794541 --- /dev/null +++ b/src/components/DecryptionPasswordMissingAlert.tsx @@ -0,0 +1,31 @@ +import {ReactElement, 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 LockNavigationContext from "~/LockNavigationContext/LockNavigationContext" + +export default function DecryptionPasswordMissingAlert(): ReactElement { + const {handleAnchorClick} = useContext(LockNavigationContext) + + return ( + + + + Your decryption password is required to view this section. + + + + + + + ) +} diff --git a/src/components/index.ts b/src/components/index.ts index 8410efa..45b07b3 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -22,3 +22,11 @@ export * from "./DecryptReport" export {default as DecryptReport} from "./DecryptReport" export * from "./SimplePage" export {default as SimplePage} from "./SimplePage" +export * from "./QueryResult" +export {default as QueryResult} from "./QueryResult" +export * from "./AliasTypeIndicator" +export {default as AliasTypeIndicator} from "./AliasTypeIndicator" +export * from "./DecryptionPasswordMissingAlert" +export {default as DecryptionPasswordMissingAlert} from "./DecryptionPasswordMissingAlert" +export * from "./BackupImage" +export {default as BackupImage} from "./BackupImage" diff --git a/src/constants/values.ts b/src/constants/values.ts index c78bd5c..4dbd6b0 100644 --- a/src/constants/values.ts +++ b/src/constants/values.ts @@ -1,2 +1,13 @@ +import {AliasNote} from "~/server-types" + export const MASTER_PASSWORD_LENGTH = 4096 export const LOCAL_REGEX = /^[a-zA-Z0-9!#$%&‘*+–/=?^_`.{|}~-]{1,64}$/g +export const URL_REGEX = + /((http[s]*:\/\/)?[a-z0-9-%\/\&=?\.]+\.[a-z]{2,4}\/?([^\s<>\#%"\,\{\}\\|\\\^\[\]`]+)?)/gi +export const DEFAULT_ALIAS_NOTE: AliasNote = { + version: "1.0", + data: { + personalNotes: "", + websites: [], + }, +} diff --git a/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx b/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx new file mode 100644 index 0000000..1c45668 --- /dev/null +++ b/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx @@ -0,0 +1,266 @@ +import * as yup from "yup" +import {TiDelete} from "react-icons/ti" +import {AxiosError} from "axios" +import {ReactElement, useContext} from "react" +import {RiLinkM, RiStickyNoteFill} from "react-icons/ri" +import {FieldArray, FormikProvider, useFormik} from "formik" + +import {useMutation} from "@tanstack/react-query" +import { + Button, + FormGroup, + FormHelperText, + Grid, + IconButton, + InputAdornment, + List, + ListItem, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + TextField, +} from "@mui/material" + +import {URL_REGEX} from "~/constants/values" +import {parseFastAPIError, whenEnterPressed} from "~/utils" +import {BackupImage, ErrorSnack, SuccessSnack} from "~/components" +import {Alias, AliasNote} from "~/server-types" +import {UpdateAliasData, updateAlias} from "~/apis" +import AuthContext from "~/AuthContext/AuthContext" + +export interface AliasNotesFormProps { + id: string + notes: AliasNote +} + +interface Form { + personalNotes: string + websites: AliasNote["data"]["websites"] + + detail?: string +} + +interface WebsiteForm { + url: string +} + +const SCHEMA = yup.object().shape({ + personalNotes: yup.string(), + websites: yup.array().of( + yup.object().shape({ + url: yup.string().url(), + createdAt: yup.date(), + }), + ), +}) +const WEBSITE_SCHEMA = yup.object().shape({ + url: yup.string().matches(URL_REGEX, "This URL is invalid."), +}) + +const getDomain = (url: string): string => { + const {hostname, port} = new URL(url) + return `${hostname}${port ? `:${port}` : ""}` +} + +export default function AliasNotesForm({ + id, + notes, +}: AliasNotesFormProps): ReactElement { + const {_encryptUsingMasterPassword} = useContext(AuthContext) + const {mutateAsync, isSuccess} = useMutation< + Alias, + AxiosError, + UpdateAliasData + >(values => updateAlias(id, values)) + const formik = useFormik
({ + validationSchema: SCHEMA, + initialValues: { + personalNotes: notes.data.personalNotes, + websites: notes.data.websites, + }, + onSubmit: async (values, {setErrors}) => { + try { + const newNotes = { + ...notes, + data: { + ...notes.data, + personalNotes: values.personalNotes, + websites: values.websites, + }, + } + const data = _encryptUsingMasterPassword( + JSON.stringify(newNotes), + ) + await mutateAsync({ + encryptedNotes: data, + }) + } catch (error) { + setErrors(parseFastAPIError(error as AxiosError)) + } + }, + }) + const websiteFormik = useFormik({ + validationSchema: WEBSITE_SCHEMA, + initialValues: { + url: "", + }, + onSubmit: async values => { + const url = (() => { + // Make sure url starts with `http://` or `https://` + if (values.url.startsWith("http://")) { + return values.url + } + if (values.url.startsWith("https://")) { + return values.url + } + return `https://${values.url}` + })() + + const {hostname, protocol, port} = new URL(url) + const baseUrl = `${protocol}//${hostname}${port ? `:${port}` : ""}` + + websiteFormik.resetForm() + await formik.setFieldValue( + "websites", + [ + ...formik.values.websites, + { + url: baseUrl, + createdAt: new Date(), + }, + ], + true, + ) + await formik.submitForm() + }, + validateOnChange: true, + validateOnBlur: true, + }) + + return ( + <> + + + + formik.submitForm()} + disabled={formik.isSubmitting} + error={ + formik.touched.personalNotes && + Boolean(formik.errors.personalNotes) + } + helperText={ + (formik.touched.personalNotes && + formik.errors.personalNotes) || + "You can enter personal notes for this alias here. Notes are encrypted." + } + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + + websiteFormik.handleSubmit(), + )} + disabled={websiteFormik.isSubmitting} + error={ + websiteFormik.touched.url && + Boolean(websiteFormik.errors.url) + } + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + {(websiteFormik.touched.url && + websiteFormik.errors.url) || + "Add a website to this alias. Used to autofill."} + + + + ( + + {formik.values.websites.map( + (website, index) => ( + + + + + + {website.url} + + + + arrayHelpers.remove( + index, + ) + } + > + + + + + ), + )} + + )} + /> + + + + + + + + ) +} diff --git a/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx b/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx index dee1f1b..3006fb6 100644 --- a/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx +++ b/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx @@ -12,7 +12,12 @@ import {mdiTextBoxMultiple} from "@mdi/js/commonjs/mdi" import {useMutation} from "@tanstack/react-query" import Icon from "@mdi/react" -import {Alias, ImageProxyFormatType, ProxyUserAgentType} from "~/server-types" +import { + Alias, + DecryptedAlias, + ImageProxyFormatType, + ProxyUserAgentType, +} from "~/server-types" import { IMAGE_PROXY_FORMAT_TYPE_NAME_MAP, IMAGE_PROXY_USER_AGENT_TYPE_NAME_MAP, @@ -24,7 +29,7 @@ import FormikAutoLockNavigation from "~/LockNavigationContext/FormikAutoLockNavi import SelectField from "~/route-widgets/SettingsRoute/SelectField" export interface AliasPreferencesFormProps { - alias: Alias + alias: Alias | DecryptedAlias } interface Form { diff --git a/src/routes/AliasDetailRoute.tsx b/src/routes/AliasDetailRoute.tsx index bf766e1..22b474a 100644 --- a/src/routes/AliasDetailRoute.tsx +++ b/src/routes/AliasDetailRoute.tsx @@ -1,4 +1,4 @@ -import {ReactElement, useState} from "react" +import {ReactElement, useContext, useState} from "react" import {useParams} from "react-router-dom" import {AxiosError} from "axios" @@ -6,25 +6,41 @@ import {useMutation, useQuery} from "@tanstack/react-query" import {Grid, Switch, Typography} from "@mui/material" import {UpdateAliasData, getAlias, updateAlias} from "~/apis" -import {Alias} from "~/server-types" -import {ErrorSnack, SuccessSnack} from "~/components" -import {parseFastAPIError} from "~/utils" +import {Alias, DecryptedAlias} from "~/server-types" +import { + AliasTypeIndicator, + ErrorSnack, + QueryResult, + SimplePage, + SuccessSnack, +} from "~/components" +import {decryptAliasNotes, parseFastAPIError} from "~/utils" +import AliasNotesForm from "~/route-widgets/AliasDetailRoute/AliasNotesForm" import AliasPreferencesForm from "~/route-widgets/AliasDetailRoute/AliasPreferencesForm" -import AliasTypeIndicator from "~/components/AliasTypeIndicator" -import QueryResult from "~/components/QueryResult" -import SimplePage from "~/components/SimplePage" +import AuthContext from "~/AuthContext/AuthContext" +import DecryptionPasswordMissingAlert from "~/components/DecryptionPasswordMissingAlert" export default function AliasDetailRoute(): ReactElement { const params = useParams() + const {user, _decryptUsingMasterPassword} = useContext(AuthContext) const address = atob(params.addressInBase64 as string) const [successMessage, setSuccessMessage] = useState("") const [errorMessage, setErrorMessage] = useState("") const [isActive, setIsActive] = useState(true) - const query = useQuery( + const query = useQuery( ["get_alias", params.addressInBase64], - () => getAlias(address), + async () => { + if (user?.encryptedPassword) { + return decryptAliasNotes( + await getAlias(address), + _decryptUsingMasterPassword, + ) + } else { + return getAlias(address) + } + }, { onSuccess: alias => setIsActive(alias.isActive), }, @@ -33,16 +49,15 @@ export default function AliasDetailRoute(): ReactElement { values => updateAlias(query.data!.id, values), { onSuccess: () => query.refetch(), - onError: error => { - setErrorMessage(parseFastAPIError(error).detail as string) - }, + onError: error => + setErrorMessage(parseFastAPIError(error).detail as string), }, ) return ( <> - query={query}> + query={query}> {alias => ( @@ -86,10 +101,33 @@ export default function AliasDetailRoute(): ReactElement { + + + + + Notes + + + + {user?.encryptedPassword && + (alias as DecryptedAlias).notes ? ( + + ) : ( + + )} + + + - + Settings diff --git a/src/server-types.ts b/src/server-types.ts index dd95ca6..35157be 100644 --- a/src/server-types.ts +++ b/src/server-types.ts @@ -92,6 +92,21 @@ export interface Alias { prefImageProxyUserAgent: ProxyUserAgentType | null } +export interface AliasNote { + version: "1.0" + data: { + personalNotes: string + websites: Array<{ + url: string + createdAt: Date + }> + } +} + +export interface DecryptedAlias extends Omit { + notes: AliasNote +} + export interface AliasList { id: string domain: string diff --git a/src/utils/decrypt-alias-notes.ts b/src/utils/decrypt-alias-notes.ts new file mode 100644 index 0000000..ca4ab0f --- /dev/null +++ b/src/utils/decrypt-alias-notes.ts @@ -0,0 +1,20 @@ +import {Alias, DecryptedAlias} from "~/server-types" +import {AuthContextType} from "~/AuthContext/AuthContext" +import {DEFAULT_ALIAS_NOTE} from "~/constants/values" + +export default function decryptAliasNotes( + alias: Alias, + decryptContent: AuthContextType["_decryptUsingMasterPassword"], +): DecryptedAlias { + if (!alias.encryptedNotes) { + return { + ...alias, + notes: DEFAULT_ALIAS_NOTE, + } + } + + return { + ...alias, + notes: JSON.parse(decryptContent(alias.encryptedNotes)), + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index c511b1c..ca7bce8 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -10,3 +10,7 @@ export * from "./build-encryption-password" export {default as buildEncryptionPassword} from "./build-encryption-password" export * from "./decrypt-string" export {default as decryptString} from "./decrypt-string" +export * from "./decrypt-alias-notes" +export {default as decryptAliasNotes} from "./decrypt-alias-notes" +export * from "./when-enter-pressed" +export {default as whenEnterPressed} from "./when-enter-pressed" diff --git a/src/utils/when-enter-pressed.ts b/src/utils/when-enter-pressed.ts new file mode 100644 index 0000000..51ddbed --- /dev/null +++ b/src/utils/when-enter-pressed.ts @@ -0,0 +1,11 @@ +import {KeyboardEventHandler} from "react" + +export default function whenEnterPressed( + callback: KeyboardEventHandler, +) { + return (event: any) => { + if (event.key === "Enter") { + callback(event) + } + } +}