added AliasNotesForm.tsx

This commit is contained in:
Myzel394 2022-10-30 17:27:56 +01:00
parent 12ac8f9a0b
commit c0e48cb62a
11 changed files with 450 additions and 16 deletions

View File

@ -0,0 +1,25 @@
import React, {ReactElement, useState} from "react"
export interface BackupImageProps
extends Omit<
React.DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
>,
"src"
> {
fallbackSrc: string
src: string
}
export default function BackupImage({
fallbackSrc,
src,
...props
}: BackupImageProps): ReactElement {
const [source, setSource] = useState<string>(src)
return (
<img {...props} src={source} onError={() => setSource(fallbackSrc)} />
)
}

View File

@ -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 (
<Grid container spacing={2} direction="column" alignItems="center">
<Grid item>
<Alert severity="warning">
Your decryption password is required to view this section.
</Alert>
</Grid>
<Grid item>
<Button
startIcon={<MdLock />}
component={RouterLink}
to="/enter-password"
onClick={handleAnchorClick}
>
Enter password
</Button>
</Grid>
</Grid>
)
}

View File

@ -22,3 +22,11 @@ export * from "./DecryptReport"
export {default as DecryptReport} from "./DecryptReport" export {default as DecryptReport} from "./DecryptReport"
export * from "./SimplePage" export * from "./SimplePage"
export {default as SimplePage} 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"

View File

@ -1,2 +1,13 @@
import {AliasNote} from "~/server-types"
export const MASTER_PASSWORD_LENGTH = 4096 export const MASTER_PASSWORD_LENGTH = 4096
export const LOCAL_REGEX = /^[a-zA-Z0-9!#$%&*+/=?^_`.{|}~-]{1,64}$/g 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: [],
},
}

View File

@ -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<Form>({
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<WebsiteForm>({
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 (
<>
<form onSubmit={formik.handleSubmit}>
<Grid container spacing={4} direction="column">
<Grid item>
<TextField
label="Personal Notes"
multiline
fullWidth
key="personalNotes"
id="personalNotes"
name="personalNotes"
value={formik.values.personalNotes}
onChange={formik.handleChange}
onBlur={() => 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: (
<InputAdornment position="start">
<RiStickyNoteFill />
</InputAdornment>
),
}}
/>
</Grid>
<Grid item>
<FormGroup row>
<TextField
name="url"
id="url"
label="Website"
variant="outlined"
value={websiteFormik.values.url}
onChange={websiteFormik.handleChange}
onBlur={websiteFormik.handleBlur}
onKeyDown={whenEnterPressed(() =>
websiteFormik.handleSubmit(),
)}
disabled={websiteFormik.isSubmitting}
error={
websiteFormik.touched.url &&
Boolean(websiteFormik.errors.url)
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<RiLinkM />
</InputAdornment>
),
}}
/>
<Button
size="small"
variant="contained"
disableElevation
onClick={() => websiteFormik.handleSubmit()}
>
Add
</Button>
<FormHelperText
error={
websiteFormik.touched.url &&
Boolean(websiteFormik.errors.url)
}
>
{(websiteFormik.touched.url &&
websiteFormik.errors.url) ||
"Add a website to this alias. Used to autofill."}
</FormHelperText>
</FormGroup>
<FormikProvider value={formik}>
<FieldArray
name="websites"
render={arrayHelpers => (
<List>
{formik.values.websites.map(
(website, index) => (
<ListItem key={website.url}>
<ListItemIcon>
<BackupImage
width={20}
fallbackSrc={`https://external-content.duckduckgo.com/ip3/${getDomain(
website.url,
)}.ico`}
src={`${website.url}/favicon.ico`}
/>
</ListItemIcon>
<ListItemText>
{website.url}
</ListItemText>
<ListItemSecondaryAction>
<IconButton
edge="end"
aria-label="delete"
onClick={() =>
arrayHelpers.remove(
index,
)
}
>
<TiDelete />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
),
)}
</List>
)}
/>
</FormikProvider>
</Grid>
</Grid>
</form>
<ErrorSnack message={formik.errors.detail} />
<SuccessSnack message={isSuccess && "Update Alias notes!"} />
</>
)
}

View File

@ -12,7 +12,12 @@ import {mdiTextBoxMultiple} from "@mdi/js/commonjs/mdi"
import {useMutation} from "@tanstack/react-query" import {useMutation} from "@tanstack/react-query"
import Icon from "@mdi/react" import Icon from "@mdi/react"
import {Alias, ImageProxyFormatType, ProxyUserAgentType} from "~/server-types" import {
Alias,
DecryptedAlias,
ImageProxyFormatType,
ProxyUserAgentType,
} from "~/server-types"
import { import {
IMAGE_PROXY_FORMAT_TYPE_NAME_MAP, IMAGE_PROXY_FORMAT_TYPE_NAME_MAP,
IMAGE_PROXY_USER_AGENT_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" import SelectField from "~/route-widgets/SettingsRoute/SelectField"
export interface AliasPreferencesFormProps { export interface AliasPreferencesFormProps {
alias: Alias alias: Alias | DecryptedAlias
} }
interface Form { interface Form {

View File

@ -1,4 +1,4 @@
import {ReactElement, useState} from "react" import {ReactElement, useContext, useState} from "react"
import {useParams} from "react-router-dom" import {useParams} from "react-router-dom"
import {AxiosError} from "axios" import {AxiosError} from "axios"
@ -6,25 +6,41 @@ import {useMutation, useQuery} from "@tanstack/react-query"
import {Grid, Switch, Typography} from "@mui/material" import {Grid, Switch, Typography} from "@mui/material"
import {UpdateAliasData, getAlias, updateAlias} from "~/apis" import {UpdateAliasData, getAlias, updateAlias} from "~/apis"
import {Alias} from "~/server-types" import {Alias, DecryptedAlias} from "~/server-types"
import {ErrorSnack, SuccessSnack} from "~/components" import {
import {parseFastAPIError} from "~/utils" 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 AliasPreferencesForm from "~/route-widgets/AliasDetailRoute/AliasPreferencesForm"
import AliasTypeIndicator from "~/components/AliasTypeIndicator" import AuthContext from "~/AuthContext/AuthContext"
import QueryResult from "~/components/QueryResult" import DecryptionPasswordMissingAlert from "~/components/DecryptionPasswordMissingAlert"
import SimplePage from "~/components/SimplePage"
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 [successMessage, setSuccessMessage] = useState<string>("") const [successMessage, setSuccessMessage] = useState<string>("")
const [errorMessage, setErrorMessage] = useState<string>("") const [errorMessage, setErrorMessage] = useState<string>("")
const [isActive, setIsActive] = useState<boolean>(true) const [isActive, setIsActive] = useState<boolean>(true)
const query = useQuery<Alias, AxiosError>( const query = useQuery<Alias | DecryptedAlias, AxiosError>(
["get_alias", params.addressInBase64], ["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), onSuccess: alias => setIsActive(alias.isActive),
}, },
@ -33,16 +49,15 @@ export default function AliasDetailRoute(): ReactElement {
values => updateAlias(query.data!.id, values), values => updateAlias(query.data!.id, values),
{ {
onSuccess: () => query.refetch(), onSuccess: () => query.refetch(),
onError: error => { onError: error =>
setErrorMessage(parseFastAPIError(error).detail as string) setErrorMessage(parseFastAPIError(error).detail as string),
},
}, },
) )
return ( return (
<> <>
<SimplePage title="Alias Details"> <SimplePage title="Alias Details">
<QueryResult<Alias> query={query}> <QueryResult<Alias | DecryptedAlias> query={query}>
{alias => ( {alias => (
<Grid container spacing={4}> <Grid container spacing={4}>
<Grid item> <Grid item>
@ -86,10 +101,33 @@ export default function AliasDetailRoute(): ReactElement {
</Grid> </Grid>
</Grid> </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 item>
<Grid container spacing={4}> <Grid container spacing={4}>
<Grid item> <Grid item>
<Typography variant="h6"> <Typography variant="h6" component="h3">
Settings Settings
</Typography> </Typography>
</Grid> </Grid>

View File

@ -92,6 +92,21 @@ export interface Alias {
prefImageProxyUserAgent: ProxyUserAgentType | null prefImageProxyUserAgent: ProxyUserAgentType | null
} }
export interface AliasNote {
version: "1.0"
data: {
personalNotes: string
websites: Array<{
url: string
createdAt: Date
}>
}
}
export interface DecryptedAlias extends Omit<Alias, "encryptedNotes"> {
notes: AliasNote
}
export interface AliasList { export interface AliasList {
id: string id: string
domain: string domain: string

View File

@ -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)),
}
}

View File

@ -10,3 +10,7 @@ export * from "./build-encryption-password"
export {default as buildEncryptionPassword} from "./build-encryption-password" export {default as buildEncryptionPassword} from "./build-encryption-password"
export * from "./decrypt-string" export * from "./decrypt-string"
export {default as decryptString} 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"

View File

@ -0,0 +1,11 @@
import {KeyboardEventHandler} from "react"
export default function whenEnterPressed<T = HTMLDivElement>(
callback: KeyboardEventHandler<T>,
) {
return (event: any) => {
if (event.key === "Enter") {
callback(event)
}
}
}