From 53589b941922918a7c3240417b0ae2b3dfe02dbe Mon Sep 17 00:00:00 2001 From: Myzel394 <50424412+Myzel394@users.noreply.github.com> Date: Thu, 20 Oct 2022 21:48:49 +0200 Subject: [PATCH] added SettingsRoute.tsx --- src/App.tsx | 5 + src/AuthContext/AuthContext.ts | 4 + src/AuthContext/AuthContextProvider.tsx | 12 + src/apis/index.ts | 2 + src/apis/update-preferences.ts | 28 ++ src/components/ErrorSnack.tsx | 27 ++ src/components/SuccessSnack.tsx | 29 ++ src/components/index.ts | 4 + .../SettingsRoute/AliasesPreferencesForm.tsx | 359 ++++++++++++++++++ src/routes/AuthenticatedRoute.tsx | 61 +-- src/routes/SettingsRoute.tsx | 20 + 11 files changed, 528 insertions(+), 23 deletions(-) create mode 100644 src/apis/update-preferences.ts create mode 100644 src/components/ErrorSnack.tsx create mode 100644 src/components/SuccessSnack.tsx create mode 100644 src/route-widgets/SettingsRoute/AliasesPreferencesForm.tsx create mode 100644 src/routes/SettingsRoute.tsx diff --git a/src/App.tsx b/src/App.tsx index 8567bc5..1f8f2c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import AuthenticatedRoute from "~/routes/AuthenticatedRoute" import CompleteAccountRoute from "~/routes/CompleteAccountRoute" import LoginRoute from "~/routes/LoginRoute" import RootRoute from "~/routes/Root" +import SettingsRoute from "~/routes/SettingsRoute" import SignupRoute from "~/routes/SignupRoute" import VerifyEmailRoute from "~/routes/VerifyEmailRoute" @@ -56,6 +57,10 @@ const router = createBrowserRouter([ path: "/aliases", element: , }, + { + path: "/settings", + element: , + }, ], }, ], diff --git a/src/AuthContext/AuthContext.ts b/src/AuthContext/AuthContext.ts index 86b7b5c..0c66e78 100644 --- a/src/AuthContext/AuthContext.ts +++ b/src/AuthContext/AuthContext.ts @@ -10,6 +10,7 @@ interface AuthContextTypeBase { _decryptContent: (content: string) => string _encryptContent: (content: string) => string _setDecryptionPassword: (decryptionPassword: string) => void + _updateUser: (user: ServerUser | User) => void } interface AuthContextTypeAuthenticated { @@ -44,6 +45,9 @@ const AuthContext = createContext({ _setDecryptionPassword: () => { throw new Error("_setMasterDecryptionPassword() not implemented") }, + _updateUser: () => { + throw new Error("_updateUser() not implemented") + }, }) export default AuthContext diff --git a/src/AuthContext/AuthContextProvider.tsx b/src/AuthContext/AuthContextProvider.tsx index c5bb6b8..e02f2de 100644 --- a/src/AuthContext/AuthContextProvider.tsx +++ b/src/AuthContext/AuthContextProvider.tsx @@ -89,6 +89,17 @@ export default function AuthContextProvider({ [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< RefreshTokenResult, AxiosError, @@ -106,6 +117,7 @@ export default function AuthContextProvider({ _encryptContent: encryptContent, _decryptContent: decryptContent, _setDecryptionPassword: setDecryptionPassword, + _updateUser: updateUser, }), [refresh, login, logout], ) diff --git a/src/apis/index.ts b/src/apis/index.ts index 2381b03..e77ab4e 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -24,3 +24,5 @@ export * from "./verify-login-with-email" export {default as verifyLoginWithEmail} from "./verify-login-with-email" export * 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" diff --git a/src/apis/update-preferences.ts b/src/apis/update-preferences.ts new file mode 100644 index 0000000..a07197f --- /dev/null +++ b/src/apis/update-preferences.ts @@ -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 { + const {data} = await client.patch( + `${import.meta.env.VITE_SERVER_BASE_URL}/preferences`, + updateData, + { + withCredentials: true, + }, + ) + + return data +} diff --git a/src/components/ErrorSnack.tsx b/src/components/ErrorSnack.tsx new file mode 100644 index 0000000..f4d25d2 --- /dev/null +++ b/src/components/ErrorSnack.tsx @@ -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(true) + + useEffect(() => { + setOpen(Boolean(message)) + }, [message]) + + return ( + setOpen(false)} + > + + {message} + + + ) +} diff --git a/src/components/SuccessSnack.tsx b/src/components/SuccessSnack.tsx new file mode 100644 index 0000000..b277424 --- /dev/null +++ b/src/components/SuccessSnack.tsx @@ -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(true) + + useEffect(() => { + setOpen(Boolean(message)) + }, [message]) + + return ( + setOpen(false)} + > + + {message} + + + ) +} diff --git a/src/components/index.ts b/src/components/index.ts index a27bd0d..1caae5a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -12,3 +12,7 @@ export * from "./MutationStatusSnackbar" export {default as MutationStatusSnackbar} from "./MutationStatusSnackbar" export * 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" diff --git a/src/route-widgets/SettingsRoute/AliasesPreferencesForm.tsx b/src/route-widgets/SettingsRoute/AliasesPreferencesForm.tsx new file mode 100644 index 0000000..2b439cd --- /dev/null +++ b/src/route-widgets/SettingsRoute/AliasesPreferencesForm.tsx @@ -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() + .oneOf(Object.values(ImageProxyFormatType)) + .required(), + imageProxyUserAgent: yup + .mixed() + .oneOf(Object.values(ProxyUserAgentType)) + .required(), +}) + +const IMAGE_PROXY_FORMAT_TYPE_NAME_MAP: Record = { + [ImageProxyFormatType.JPEG]: "JPEG", + [ImageProxyFormatType.PNG]: "PNG", + [ImageProxyFormatType.WEBP]: "WebP", +} + +const IMAGE_PROXY_USER_AGENT_TYPE_NAME_MAP: Record = + { + [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
({ + 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 ( + <> + + + + Aliases Preferences + + + + + Select the default behavior for your aliases. This will + only affect aliases that do not have a custom behavior + set. + + + + + + + + + } + labelPlacement="start" + label="Remove Trackers" + /> + + {(formik.touched.createMailReport && + formik.errors.createMailReport) || + "Remove single-pixel image trackers as well as url trackers."} + + + + + + + } + labelPlacement="start" + label="Create Reports" + /> + + {(formik.touched.createMailReport && + formik.errors.createMailReport) || + "Create reports of emails sent to aliases. Reports are end-to-end encrypted. Only you can access them."} + + + + + + + } + labelPlacement="start" + label="Proxy Images" + /> + + {(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."} + + + + + + + + + ), + }} + 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]) => ( + + { + IMAGE_PROXY_FORMAT_TYPE_NAME_MAP[ + value + ] as string + } + + ))} + + + {formik.touched.imageProxyFormat && + formik.errors.imageProxyFormat} + + + + + + + {Object.entries(ProxyUserAgentType).map( + ([key, value]) => ( + + { + IMAGE_PROXY_USER_AGENT_TYPE_NAME_MAP[ + value + ] as string + } + + ), + )} + + + {(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."} + + + + + } + > + Save Preferences + + + + + +
+ + + + ) +} diff --git a/src/routes/AuthenticatedRoute.tsx b/src/routes/AuthenticatedRoute.tsx index 7b48e79..9f45280 100644 --- a/src/routes/AuthenticatedRoute.tsx +++ b/src/routes/AuthenticatedRoute.tsx @@ -1,7 +1,15 @@ import {ReactElement} from "react" 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 NavigationButton, { @@ -32,32 +40,39 @@ export default function AuthenticatedRoute(): ReactElement { backgroundColor: "transparent", }} > - - - - {sections.map(key => ( - - - - ))} - - - - - + + + + {sections.map(key => ( + + + + ))} + - - + + + + + + + + + diff --git a/src/routes/SettingsRoute.tsx b/src/routes/SettingsRoute.tsx new file mode 100644 index 0000000..1095594 --- /dev/null +++ b/src/routes/SettingsRoute.tsx @@ -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 ( + + + + Settings + + + + + + + ) +}