added SettingsRoute.tsx

This commit is contained in:
Myzel394 2022-10-20 21:48:49 +02:00
parent b1e48544dc
commit 53589b9419
11 changed files with 528 additions and 23 deletions

View File

@ -14,6 +14,7 @@ import AuthenticatedRoute from "~/routes/AuthenticatedRoute"
import CompleteAccountRoute from "~/routes/CompleteAccountRoute" import CompleteAccountRoute from "~/routes/CompleteAccountRoute"
import LoginRoute from "~/routes/LoginRoute" import LoginRoute from "~/routes/LoginRoute"
import RootRoute from "~/routes/Root" import RootRoute from "~/routes/Root"
import SettingsRoute from "~/routes/SettingsRoute"
import SignupRoute from "~/routes/SignupRoute" import SignupRoute from "~/routes/SignupRoute"
import VerifyEmailRoute from "~/routes/VerifyEmailRoute" import VerifyEmailRoute from "~/routes/VerifyEmailRoute"
@ -56,6 +57,10 @@ const router = createBrowserRouter([
path: "/aliases", path: "/aliases",
element: <AliasesRoute />, element: <AliasesRoute />,
}, },
{
path: "/settings",
element: <SettingsRoute />,
},
], ],
}, },
], ],

View File

@ -10,6 +10,7 @@ interface AuthContextTypeBase {
_decryptContent: (content: string) => string _decryptContent: (content: string) => string
_encryptContent: (content: string) => string _encryptContent: (content: string) => string
_setDecryptionPassword: (decryptionPassword: string) => void _setDecryptionPassword: (decryptionPassword: string) => void
_updateUser: (user: ServerUser | User) => void
} }
interface AuthContextTypeAuthenticated { interface AuthContextTypeAuthenticated {
@ -44,6 +45,9 @@ const AuthContext = createContext<AuthContextType>({
_setDecryptionPassword: () => { _setDecryptionPassword: () => {
throw new Error("_setMasterDecryptionPassword() not implemented") throw new Error("_setMasterDecryptionPassword() not implemented")
}, },
_updateUser: () => {
throw new Error("_updateUser() not implemented")
},
}) })
export default AuthContext export default AuthContext

View File

@ -89,6 +89,17 @@ export default function AuthContextProvider({
[masterPassword, decryptContent], [masterPassword, decryptContent],
) )
const updateUser = useCallback(
async (newUser: ServerUser | User) => {
if (user === null) {
throw new Error("Can't update user when user is null.")
}
setUser(newUser)
},
[user],
)
const {mutateAsync: refresh} = useMutation< const {mutateAsync: refresh} = useMutation<
RefreshTokenResult, RefreshTokenResult,
AxiosError, AxiosError,
@ -106,6 +117,7 @@ export default function AuthContextProvider({
_encryptContent: encryptContent, _encryptContent: encryptContent,
_decryptContent: decryptContent, _decryptContent: decryptContent,
_setDecryptionPassword: setDecryptionPassword, _setDecryptionPassword: setDecryptionPassword,
_updateUser: updateUser,
}), }),
[refresh, login, logout], [refresh, login, logout],
) )

View File

@ -24,3 +24,5 @@ export * from "./verify-login-with-email"
export {default as verifyLoginWithEmail} from "./verify-login-with-email" export {default as verifyLoginWithEmail} from "./verify-login-with-email"
export * from "./resend-email-login-code" export * from "./resend-email-login-code"
export {default as resendEmailLoginCode} from "./resend-email-login-code" export {default as resendEmailLoginCode} from "./resend-email-login-code"
export * from "./update-preferences"
export {default as updatePreferences} from "./update-preferences"

View File

@ -0,0 +1,28 @@
import {
ImageProxyFormatType,
ProxyUserAgentType,
SimpleDetailResponse,
} from "~/server-types"
import {client} from "~/constants/axios-client"
export interface UpdatePreferencesData {
aliasRemoveTrackers?: boolean
aliasCreateMailReport?: boolean
aliasProxyImages?: boolean
aliasImageProxyFormat?: ImageProxyFormatType
aliasImageProxyUserAgent?: ProxyUserAgentType
}
export default async function updatePreferences(
updateData: UpdatePreferencesData,
): Promise<SimpleDetailResponse> {
const {data} = await client.patch(
`${import.meta.env.VITE_SERVER_BASE_URL}/preferences`,
updateData,
{
withCredentials: true,
},
)
return data
}

View File

@ -0,0 +1,27 @@
import React, {ReactElement, useEffect, useState} from "react"
import {Alert, Snackbar} from "@mui/material"
export interface ErrorSnackProps {
message?: string | null | false
}
export default function ErrorSnack({message}: ErrorSnackProps): ReactElement {
const [open, setOpen] = useState<boolean>(true)
useEffect(() => {
setOpen(Boolean(message))
}, [message])
return (
<Snackbar
open={open}
autoHideDuration={5000}
onClose={() => setOpen(false)}
>
<Alert severity="error" variant="filled">
{message}
</Alert>
</Snackbar>
)
}

View File

@ -0,0 +1,29 @@
import React, {ReactElement, useEffect, useState} from "react"
import {Alert, Snackbar} from "@mui/material"
export interface SuccessSnackProps {
message?: string | null
}
export default function SuccessSnack({
message,
}: SuccessSnackProps): ReactElement {
const [open, setOpen] = useState<boolean>(true)
useEffect(() => {
setOpen(Boolean(message))
}, [message])
return (
<Snackbar
open={open}
autoHideDuration={5000}
onClose={() => setOpen(false)}
>
<Alert severity="success" variant="filled">
{message}
</Alert>
</Snackbar>
)
}

View File

@ -12,3 +12,7 @@ export * from "./MutationStatusSnackbar"
export {default as MutationStatusSnackbar} from "./MutationStatusSnackbar" export {default as MutationStatusSnackbar} from "./MutationStatusSnackbar"
export * from "./TimedButton" export * from "./TimedButton"
export {default as TimedButton} from "./TimedButton" export {default as TimedButton} from "./TimedButton"
export * from "./ErrorSnack"
export {default as ErrorSnack} from "./ErrorSnack"
export * from "./SuccessSnack"
export {default as SuccessSnack} from "./SuccessSnack"

View File

@ -0,0 +1,359 @@
import * as yup from "yup"
import {AxiosError} from "axios"
import {useFormik} from "formik"
import {MdCheckCircle, MdImage} from "react-icons/md"
import React, {ReactElement, useContext} from "react"
import {useMutation} from "@tanstack/react-query"
import {
Checkbox,
FormControlLabel,
FormGroup,
FormHelperText,
Grid,
InputAdornment,
MenuItem,
TextField,
Typography,
} from "@mui/material"
import {LoadingButton} from "@mui/lab"
import {
ImageProxyFormatType,
ProxyUserAgentType,
SimpleDetailResponse,
} from "~/server-types"
import {UpdatePreferencesData, updatePreferences} from "~/apis"
import {useUser} from "~/hooks"
import {parseFastapiError} from "~/utils"
import {SuccessSnack} from "~/components"
import AuthContext from "~/AuthContext/AuthContext"
import ErrorSnack from "~/components/ErrorSnack"
interface Form {
removeTrackers: boolean
createMailReport: boolean
proxyImages: boolean
imageProxyFormat: ImageProxyFormatType
imageProxyUserAgent: ProxyUserAgentType
detail?: string
}
const SCHEMA = yup.object().shape({
removeTrackers: yup.boolean(),
createMailReport: yup.boolean(),
proxyImages: yup.boolean(),
imageProxyFormat: yup
.mixed<ImageProxyFormatType>()
.oneOf(Object.values(ImageProxyFormatType))
.required(),
imageProxyUserAgent: yup
.mixed<ProxyUserAgentType>()
.oneOf(Object.values(ProxyUserAgentType))
.required(),
})
const IMAGE_PROXY_FORMAT_TYPE_NAME_MAP: Record<ImageProxyFormatType, string> = {
[ImageProxyFormatType.JPEG]: "JPEG",
[ImageProxyFormatType.PNG]: "PNG",
[ImageProxyFormatType.WEBP]: "WebP",
}
const IMAGE_PROXY_USER_AGENT_TYPE_NAME_MAP: Record<ProxyUserAgentType, string> =
{
[ProxyUserAgentType.APPLE_MAIL]: "Apple Mail",
[ProxyUserAgentType.GOOGLE_MAIL]: "Google Mail",
[ProxyUserAgentType.CHROME]: "Chrome Browser",
[ProxyUserAgentType.FIREFOX]: "Firefox Browser",
[ProxyUserAgentType.OUTLOOK_MACOS]: "Outlook / MacOS",
[ProxyUserAgentType.OUTLOOK_WINDOWS]: "Outlook / Windows",
}
export default function AliasesPreferencesForm(): ReactElement {
const {_updateUser} = useContext(AuthContext)
const user = useUser()
const {mutateAsync, data} = useMutation<
SimpleDetailResponse,
AxiosError,
UpdatePreferencesData
>(updatePreferences, {
onSuccess: (_, values) => {
const newUser = {
...user,
preferences: {
...user.preferences,
...values,
},
}
_updateUser(newUser)
},
})
const formik = useFormik<Form>({
validationSchema: SCHEMA,
initialValues: {
removeTrackers: user.preferences.aliasRemoveTrackers,
createMailReport: user.preferences.aliasCreateMailReport,
proxyImages: user.preferences.aliasProxyImages,
imageProxyFormat: user.preferences.aliasImageProxyFormat,
imageProxyUserAgent: user.preferences.aliasImageProxyUserAgent,
},
onSubmit: async (values, {setErrors}) => {
try {
await mutateAsync({
aliasRemoveTrackers: values.removeTrackers,
aliasCreateMailReport: values.createMailReport,
aliasProxyImages: values.proxyImages,
aliasImageProxyFormat: values.imageProxyFormat,
aliasImageProxyUserAgent: values.imageProxyUserAgent,
})
} catch (error) {
setErrors(parseFastapiError(error as AxiosError))
}
},
})
return (
<>
<Grid container spacing={4}>
<Grid item>
<Typography variant="h6" component="h3">
Aliases Preferences
</Typography>
</Grid>
<Grid item>
<Typography variant="body1" component="p">
Select the default behavior for your aliases. This will
only affect aliases that do not have a custom behavior
set.
</Typography>
</Grid>
<Grid item>
<form onSubmit={formik.handleSubmit}>
<Grid
display="flex"
flexDirection="column"
container
spacing={4}
alignItems="flex-end"
>
<Grid item>
<FormGroup>
<FormControlLabel
disabled={formik.isSubmitting}
control={
<Checkbox
name="removeTrackers"
id="removeTrackers"
checked={
formik.values.removeTrackers
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
}
labelPlacement="start"
label="Remove Trackers"
/>
<FormHelperText
error={Boolean(
formik.touched.createMailReport &&
formik.errors.createMailReport,
)}
>
{(formik.touched.createMailReport &&
formik.errors.createMailReport) ||
"Remove single-pixel image trackers as well as url trackers."}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item>
<FormGroup>
<FormControlLabel
disabled={formik.isSubmitting}
control={
<Checkbox
name="createMailReport"
id="createMailReport"
checked={
formik.values
.createMailReport
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
}
labelPlacement="start"
label="Create Reports"
/>
<FormHelperText
error={Boolean(
formik.touched.createMailReport &&
formik.errors.createMailReport,
)}
>
{(formik.touched.createMailReport &&
formik.errors.createMailReport) ||
"Create reports of emails sent to aliases. Reports are end-to-end encrypted. Only you can access them."}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item>
<FormGroup>
<FormControlLabel
disabled={formik.isSubmitting}
control={
<Checkbox
name="proxyImages"
id="proxyImages"
checked={
formik.values.proxyImages
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
}
labelPlacement="start"
label="Proxy Images"
/>
<FormHelperText
error={Boolean(
formik.touched.proxyImages &&
formik.errors.proxyImages,
)}
>
{(formik.touched.proxyImages &&
formik.errors.proxyImages) ||
"Proxies images in your emails through this KleckRelay instance. This adds an extra layer of privacy. Images are loaded immediately after we receive the email. They then will be stored for some time (cache time). During that time, the image will be served from us. This means the original server has no idea you have opened the mail. After the cache time, the image is loaded from the original server, but it gets proxied by us. This means the original server will not be able to access neither your IP address nor your user agent."}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item>
<FormGroup>
<TextField
fullWidth
select
InputProps={{
startAdornment: (
<InputAdornment position="start">
<MdImage />
</InputAdornment>
),
}}
name="imageProxyFormat"
id="imageProxyFormat"
label="Image File Type"
value={formik.values.imageProxyFormat}
onChange={formik.handleChange}
disabled={formik.isSubmitting}
error={
formik.touched.imageProxyFormat &&
Boolean(
formik.errors.imageProxyFormat,
)
}
helperText={
formik.touched.imageProxyFormat &&
formik.errors.imageProxyFormat
}
>
{Object.entries(
ImageProxyFormatType,
).map(([key, value]) => (
<MenuItem key={key} value={value}>
{
IMAGE_PROXY_FORMAT_TYPE_NAME_MAP[
value
] as string
}
</MenuItem>
))}
</TextField>
<FormHelperText
error={Boolean(
formik.touched.imageProxyFormat &&
formik.errors.imageProxyFormat,
)}
>
{formik.touched.imageProxyFormat &&
formik.errors.imageProxyFormat}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item>
<FormGroup>
<TextField
fullWidth
select
name="imageProxyUserAgent"
id="imageProxyUserAgent"
label="Image Proxy User Agent"
value={
formik.values.imageProxyUserAgent
}
onChange={formik.handleChange}
disabled={formik.isSubmitting}
error={
formik.touched
.imageProxyUserAgent &&
Boolean(
formik.errors
.imageProxyUserAgent,
)
}
helperText={
formik.touched
.imageProxyUserAgent &&
formik.errors.imageProxyUserAgent
}
>
{Object.entries(ProxyUserAgentType).map(
([key, value]) => (
<MenuItem
key={key}
value={value}
>
{
IMAGE_PROXY_USER_AGENT_TYPE_NAME_MAP[
value
] as string
}
</MenuItem>
),
)}
</TextField>
<FormHelperText
error={Boolean(
formik.touched
.imageProxyUserAgent &&
formik.errors
.imageProxyUserAgent,
)}
>
{(formik.touched.imageProxyUserAgent &&
formik.errors
.imageProxyUserAgent) ||
"An User Agent is a identifier each browser and email client sends when retrieving files, such as images. You can specify here what user agent you would like to be used by the proxy. User Agents are kept up-to-date."}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item>
<LoadingButton
loading={formik.isSubmitting}
variant="contained"
type="submit"
startIcon={<MdCheckCircle />}
>
Save Preferences
</LoadingButton>
</Grid>
</Grid>
</form>
</Grid>
</Grid>
<ErrorSnack message={formik.errors.detail} />
<SuccessSnack message={data?.detail} />
</>
)
}

View File

@ -1,7 +1,15 @@
import {ReactElement} from "react" import {ReactElement} from "react"
import {Outlet} from "react-router-dom" import {Outlet} from "react-router-dom"
import {Box, Container, List, ListItem, Paper, useTheme} from "@mui/material" import {
Box,
Container,
Grid,
List,
ListItem,
Paper,
useTheme,
} from "@mui/material"
import {useUser} from "~/hooks" import {useUser} from "~/hooks"
import NavigationButton, { import NavigationButton, {
@ -32,32 +40,39 @@ export default function AuthenticatedRoute(): ReactElement {
backgroundColor: "transparent", backgroundColor: "transparent",
}} }}
> >
<Box <Grid
display="flex" container
flexDirection="row"
justifyContent="center" justifyContent="center"
alignItems="flex-start" alignItems="flex-start"
> >
<Box <Grid item xs={12} sm={4}>
bgcolor={theme.palette.background.paper} <Box
component="nav" bgcolor={theme.palette.background.paper}
> component="nav"
<List> >
{sections.map(key => ( <List>
<ListItem key={key}> {sections.map(key => (
<NavigationButton <ListItem key={key}>
section={NavigationSection[key]} <NavigationButton
/> section={NavigationSection[key]}
</ListItem> />
))} </ListItem>
</List> ))}
</Box> </List>
<Paper>
<Box padding={4} maxHeight="80vh" overflow="scroll">
<Outlet />
</Box> </Box>
</Paper> </Grid>
</Box> <Grid item xs={12} sm={8}>
<Paper>
<Box
padding={4}
maxHeight="80vh"
overflow="scroll"
>
<Outlet />
</Box>
</Paper>
</Grid>
</Grid>
</Container> </Container>
</Box> </Box>
</Box> </Box>

View File

@ -0,0 +1,20 @@
import React, {ReactElement} from "react"
import {Grid, Typography} from "@mui/material"
import AliasesPreferencesForm from "~/route-widgets/SettingsRoute/AliasesPreferencesForm"
export default function SettingsRoute(): ReactElement {
return (
<Grid container spacing={4}>
<Grid item>
<Typography variant="h5" component="h2">
Settings
</Typography>
</Grid>
<Grid item>
<AliasesPreferencesForm />
</Grid>
</Grid>
)
}