added translation

This commit is contained in:
Myzel394 2022-11-01 15:57:53 +01:00
parent fdcdd1af67
commit e4acfb2a5d
55 changed files with 1305 additions and 1003 deletions

View File

@ -1,6 +1,7 @@
{ {
"extends": [ "extends": [
"plugin:react/recommended", "plugin:react/recommended",
"plugin:react-i18n/recommended",
"plugin:compat/recommended", "plugin:compat/recommended",
"prettier" "prettier"
], ],
@ -17,7 +18,7 @@
"ecmaVersion": 13, "ecmaVersion": 13,
"sourceType": "module" "sourceType": "module"
}, },
"plugins": ["ordered-imports", "react", "@typescript-eslint"], "plugins": ["react-i18n", "ordered-imports", "react", "@typescript-eslint"],
"rules": { "rules": {
"react/react-in-jsx-scope": "off", "react/react-in-jsx-scope": "off",
"compat/compat": "error", "compat/compat": "error",

View File

@ -1,6 +1,6 @@
{ {
"endOfLine": "lf", "endOfLine": "lf",
"printWidth": 80, "printWidth": 100,
"tabWidth": 4, "tabWidth": 4,
"trailingComma": "all", "trailingComma": "all",
"singleQuote": false, "singleQuote": false,

View File

@ -24,12 +24,17 @@
"deep-equal": "^2.0.5", "deep-equal": "^2.0.5",
"formik": "^2.2.9", "formik": "^2.2.9",
"group-array": "^1.0.0", "group-array": "^1.0.0",
"i18next": "^22.0.4",
"i18next-browser-languagedetector": "^7.0.0",
"i18next-http-backend": "^2.0.0",
"i18next-localstorage-cache": "^1.1.1",
"immutability-helper": "^3.1.1", "immutability-helper": "^3.1.1",
"in-milliseconds": "^1.2.0", "in-milliseconds": "^1.2.0",
"in-seconds": "^1.2.0", "in-seconds": "^1.2.0",
"openpgp": "^5.5.0", "openpgp": "^5.5.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^12.0.0",
"react-icons": "^4.4.0", "react-icons": "^4.4.0",
"react-router-dom": "^6.4.2", "react-router-dom": "^6.4.2",
"react-use": "^17.4.0", "react-use": "^17.4.0",
@ -37,7 +42,8 @@
"sort-array": "^4.1.5", "sort-array": "^4.1.5",
"ua-parser-js": "^1.0.2", "ua-parser-js": "^1.0.2",
"use-system-theme": "^0.1.1", "use-system-theme": "^0.1.1",
"yup": "^0.32.11" "yup": "^0.32.11",
"yup-locales": "^1.2.10"
}, },
"devDependencies": { "devDependencies": {
"@types/crypto-js": "^4.1.1", "@types/crypto-js": "^4.1.1",
@ -62,6 +68,7 @@
"eslint-plugin-compat": "^4.0.2", "eslint-plugin-compat": "^4.0.2",
"eslint-plugin-ordered-imports": "^0.6.0", "eslint-plugin-ordered-imports": "^0.6.0",
"eslint-plugin-react": "^7.31.10", "eslint-plugin-react": "^7.31.10",
"eslint-plugin-react-i18n": "^1.0.3",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"typescript": "^4.6.4", "typescript": "^4.6.4",
"vite": "^3.1.0" "vite": "^3.1.0"

View File

@ -0,0 +1,334 @@
{
"general": {
"cancelLabel": "Cancel",
"emptyValue": "-",
"emptyUnavailableValue": "Unavailable",
"defaultValueSelection": "Default <{{value}}>",
"defaultValueSelectionRaw": "<{{value}}>",
"booleanSelection": {
"true": "Yes",
"false": "No"
},
"defaultError": "An error occurred.",
"defaultSuccess": "Success!",
"loading": "Loading..."
},
"routes": {
"LoginRoute": {
"forms": {
"email": {
"title": "Sign in",
"description": "We'll send you a verification code to your email.",
"continueAction": "Send Code",
"form": {
"email": {
"label": "Email",
"placeholder": "johndoe@example.com"
}
}
},
"confirmCode": {
"title": "You got mail!",
"description": "We sent you a code to your email. Enter it below to login",
"continueAction": "Log in",
"form": {
"code": {
"label": "Verification Code",
"errors": {
"invalidChars": "Invalid verification code"
}
}
}
}
}
},
"SignupRoute": {
"forms": {
"email": {
"title": "Sign up",
"description": "We only need your email and you are ready to go!",
"continueAction": "Continue",
"form": {
"email": {
"label": "Email",
"placeholder": "johndoe@example.com"
}
}
},
"mailVerification": {
"title": "You got mail!",
"description": "We sent you an email with a link to confirm your email address. Please check your inbox and click on the link to continue.",
"editEmail": {
"title": "Edit email address?",
"description": "Would you like to return to the previous step and edit your email address?",
"continueAction": "Yes, edit email"
}
}
}
},
"VerifyEmailRoute": {
"title": "Verify your email",
"isLoading": "Verifying your email...",
"isCodeInvalid": "Sorry, but this verification code is invalid.",
"errors": {
"code": {
"invalid": "The verification code is invalid."
}
}
},
"CompleteAccountRoute": {
"forms": {
"generateReports": {
"title": "Generate Email Reports?",
"description": "Would you like to create fully encrypted email reports for your mails? Only you will be able to access it. Not even we can decrypt it.",
"continueAction": "Yes",
"cancelAction": "No"
},
"password": {
"title": "Set up your password",
"description": "Please enter a safe password so that we can encrypt your data.",
"continueAction": "Continue",
"form": {
"password": {
"label": "Password",
"placeholder": "********"
},
"passwordConfirm": {
"label": "Confirm Password",
"placeholder": "Re-enter your password",
"mustMatchHelperText": "Passwords do not match."
}
}
},
"available": {
"title": "Encryption already enabled",
"description": "You already have encryption enabled. Changing passwords is currently not supported."
}
}
},
"AliasesRoute": {
"title": "Aliases",
"pageActions": {
"search": {
"label": "Search",
"placeholder": "Search for names"
}
},
"actions": {
"createRandomAlias": {
"label": "Create Random Alias"
},
"createCustomAlias": {
"label": "Create Custom Alias",
"description": "You can define your own custom alias. Note that a random suffix will be added at the end to avoid duplicates.",
"continueAction": "Create Alias",
"form": {
"address": {
"label": "Address",
"placeholder": "awesome-fish"
}
}
}
}
},
"AliasDetailRoute": {
"title": "Alias Details",
"sections": {
"settings": {
"title": "Settings",
"description": "These settings apply to this alias only. You can either set a value manually or refer to your default 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."
},
"notes": {
"title": "Notes",
"form": {
"createdAt": {
"label": "Created at",
"empty": "Unavailable"
},
"personalNotes": {
"label": "Personal Notes",
"empty": "-",
"helperText": "You can enter personal notes for this alias here. Notes are encrypted."
},
"websites": {
"label": "Websites",
"emptyText": "You haven't used this alias on any site yet.",
"placeholder": "https://example.com",
"helperText": "Add a website to this alias. Used to autofill."
}
}
}
}
},
"ReportsRoute": {
"title": "Reports",
"pageActions": {
"sort": {
"List": "List reports by their date",
"GroupByAlias": "Group reports by their alias"
}
}
},
"ReportDetailRoute": {
"title": "Report Details",
"sections": {
"information": {
"title": "Email Information",
"form": {
"from": {
"label": "From"
},
"to": {
"label": "To"
},
"subject": {
"label": "Subject"
}
}
},
"trackers": {
"title": "Trackers",
"results": {
"imageTrackers": {
"text_zero": "No image trackers found",
"text_one": "Removed 1 image tracker",
"text_other": "Removed {{count}} image trackers"
},
"proxiedImages": {
"text_zero": "No images found",
"text_one": "Proxying 1 image",
"text_other": "Proxying {{count}} images",
"status": {
"isStored": "Stored on Server",
"isProxying": "Proxying image"
}
}
}
}
}
},
"SettingsRoute": {
"title": "Settings",
"forms": {
"aliasPreferences": {
"title": "Alias Preferences",
"description": "Select the default behavior for your aliases. This will only affect aliases that do not have a custom behavior set."
}
}
}
},
"components": {
"NavigationButton": {
"overview": "Overview",
"aliases": "Aliases",
"reports": "Reports",
"settings": "Settings"
},
"AuthenticateRoute": {
"signup": "Sign up",
"login": "Log in"
},
"EnterDecryptionPassword": {
"title": "Decrypt Reports",
"description": "Please enter your password so that your reports can de decrypted.",
"cancelAction": "Decrypt later",
"continueAction": "Continue",
"form": {
"password": {
"label": "Password",
"placeholder": "********",
"errors": {
"invalidPassword": "Password is invalid"
}
}
}
},
"ResendMailButton": {
"label": "Resend Mail"
},
"OpenMailButton": {
"label": "Open Mail"
},
"DecryptionPasswordMissingAlert": {
"unavailable": {
"title": "Encryption required",
"description": "You need to set up encryption to use this feature.",
"continueAction": "Set up encryption"
},
"passwordRequired": {
"title": "Password required",
"description": "Your decryption password is required to view this section.",
"continueAction": "Enter password"
}
},
"TimedButton": {
"remainingTime_one": "({{count}})",
"remainingTime_other": "({{count}})"
},
"ErrorLoadingDataMessage": {
"tryAgain": "Try Again"
},
"AliasTypeIndicator": {
"random": "This is a randomly generated alias",
"custom": "This is a custom-made alias"
}
},
"relations": {
"alias": {
"mutations": {
"success": {
"aliasCreation": "Created Alias successfully!",
"aliasUpdated": "Updated Alias successfully!",
"notesUpdated": "Updated & encrypted notes successfully!",
"aliasChangedToEnabled": "Alias has been enabled",
"aliasChangedToDisabled": "Alias has been disabled"
}
},
"settings": {
"removeTrackers": {
"label": "Remove Trackers",
"helperText": "Remove single-pixel image trackers as well as url trackers."
},
"createMailReports": {
"label": "Create Mail Reports",
"helperText": "Create reports of emails sent to aliases. Reports are end-to-end encrypted. Only you can access them."
},
"proxyImages": {
"label": "Proxy Images",
"helperText": "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."
},
"imageProxyFormat": {
"label": "Image File Type",
"enumTexts": {
"jpeg": "JPEG",
"png": "PNG",
"webp": "WEBP"
}
},
"imageProxyUserAgent": {
"label": "Image Proxy User Agent",
"helperText": "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.",
"enumTexts": {
"apple-mail": "Apple Mail",
"google-mail": "Google Mail",
"outlook-windows": "Outlook / Windows",
"outlook-macos": "Outlook / MacOS",
"firefox": "Firefox Browser",
"chrome": "Chrome Browser"
}
},
"saveAction": "Save Settings"
}
},
"report": {
"emailMeta": {
"flow": "{{from}} -> {{to}}",
"emptySubject": "<No Subject>"
}
}
}
}

View File

@ -22,6 +22,8 @@ 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"
import "./init-i18n"
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
path: "/", path: "/",

View File

@ -1,9 +1,11 @@
import {ReactElement} from "react" import {ReactElement} from "react"
import {FaHashtag, FaRandom} from "react-icons/fa" import {FaHashtag, FaRandom} from "react-icons/fa"
import {useTranslation} from "react-i18next"
import {Box, Tooltip} from "@mui/material" import {Box, Tooltip} from "@mui/material"
import {AliasType} from "~/server-types" import {AliasType} from "~/server-types"
import {createEnumMapFromTranslation} from "~/utils"
export interface AliasTypeIndicatorProps { export interface AliasTypeIndicatorProps {
type: AliasType type: AliasType
@ -14,16 +16,16 @@ const ALIAS_TYPE_ICON_MAP: Record<AliasType, ReactElement> = {
[AliasType.CUSTOM]: <FaHashtag />, [AliasType.CUSTOM]: <FaHashtag />,
} }
const ALIAS_TYPE_TOOLTIP_MAP: Record<AliasType, string> = { const ALIAS_TYPE_TOOLTIP_MAP = createEnumMapFromTranslation(
[AliasType.RANDOM]: "This is a randomly generated alias", "components.AliasTypeIndicator",
[AliasType.CUSTOM]: "This is a custom-made alias", AliasType,
} )
export default function AliasTypeIndicator({type}: AliasTypeIndicatorProps): ReactElement {
const {t} = useTranslation()
export default function AliasTypeIndicator({
type,
}: AliasTypeIndicatorProps): ReactElement {
return ( return (
<Tooltip title={ALIAS_TYPE_TOOLTIP_MAP[type]} arrow> <Tooltip title={t(ALIAS_TYPE_TOOLTIP_MAP[type] as string)} arrow>
<Box display="flex" justifyContent="center" alignItems="center"> <Box display="flex" justifyContent="center" alignItems="center">
{ALIAS_TYPE_ICON_MAP[type]} {ALIAS_TYPE_ICON_MAP[type]}
</Box> </Box>

View File

@ -1,6 +1,7 @@
import {useContext} from "react" import {useContext} from "react"
import {MdLock} from "react-icons/md" import {MdLock} from "react-icons/md"
import {Link as RouterLink} from "react-router-dom" import {Link as RouterLink} from "react-router-dom"
import {useTranslation} from "react-i18next"
import {Button, Grid, Typography, useTheme} from "@mui/material" import {Button, Grid, Typography, useTheme} from "@mui/material"
@ -14,6 +15,7 @@ export interface WithEncryptionRequiredProps {
export default function DecryptionPasswordMissingAlert({ export default function DecryptionPasswordMissingAlert({
children = <></>, children = <></>,
}: WithEncryptionRequiredProps): JSX.Element { }: WithEncryptionRequiredProps): JSX.Element {
const {t} = useTranslation()
const {handleAnchorClick} = useContext(LockNavigationContext) const {handleAnchorClick} = useContext(LockNavigationContext)
const {encryptionStatus} = useContext(AuthContext) const {encryptionStatus} = useContext(AuthContext)
const theme = useTheme() const theme = useTheme()
@ -22,7 +24,7 @@ export default function DecryptionPasswordMissingAlert({
case EncryptionStatus.Unavailable: { case EncryptionStatus.Unavailable: {
return ( return (
<Grid <Grid
paddingY={2} padding={4}
bgcolor={theme.palette.background.default} bgcolor={theme.palette.background.default}
container container
gap={2} gap={2}
@ -31,12 +33,12 @@ export default function DecryptionPasswordMissingAlert({
> >
<Grid item> <Grid item>
<Typography variant="h6" component="h2"> <Typography variant="h6" component="h2">
Encryption required {t("components.DecryptionPasswordMissingAlert.unavailable.title")}
</Typography> </Typography>
</Grid> </Grid>
<Grid item> <Grid item>
<Typography> <Typography>
You need to set up encryption to use this feature. {t("components.DecryptionPasswordMissingAlert.unavailable.description")}
</Typography> </Typography>
</Grid> </Grid>
<Grid item> <Grid item>
@ -47,7 +49,9 @@ export default function DecryptionPasswordMissingAlert({
startIcon={<MdLock />} startIcon={<MdLock />}
onClick={handleAnchorClick} onClick={handleAnchorClick}
> >
Setup encryption {t(
"components.DecryptionPasswordMissingAlert.unavailable.continueAction",
)}
</Button> </Button>
</Grid> </Grid>
</Grid> </Grid>
@ -57,7 +61,7 @@ export default function DecryptionPasswordMissingAlert({
case EncryptionStatus.PasswordRequired: { case EncryptionStatus.PasswordRequired: {
return ( return (
<Grid <Grid
paddingY={2} padding={4}
bgcolor={theme.palette.background.default} bgcolor={theme.palette.background.default}
container container
gap={2} gap={2}
@ -66,13 +70,14 @@ export default function DecryptionPasswordMissingAlert({
> >
<Grid item> <Grid item>
<Typography variant="h6" component="h2"> <Typography variant="h6" component="h2">
Password required {t("components.DecryptionPasswordMissingAlert.passwordRequired.title")}
</Typography> </Typography>
</Grid> </Grid>
<Grid item> <Grid item>
<Typography> <Typography>
Your decryption password is required to view this {t(
section. "components.DecryptionPasswordMissingAlert.passwordRequired.description",
)}
</Typography> </Typography>
</Grid> </Grid>
<Grid item> <Grid item>
@ -82,7 +87,9 @@ export default function DecryptionPasswordMissingAlert({
startIcon={<MdLock />} startIcon={<MdLock />}
onClick={handleAnchorClick} onClick={handleAnchorClick}
> >
Enter Password {t(
"components.DecryptionPasswordMissingAlert.passwordRequired.continueAction",
)}
</Button> </Button>
</Grid> </Grid>
</Grid> </Grid>

View File

@ -1,3 +1,4 @@
import {useTranslation} from "react-i18next"
import React, {ReactElement} from "react" import React, {ReactElement} from "react"
import {Alert, Button, Grid} from "@mui/material" import {Alert, Button, Grid} from "@mui/material"
@ -11,13 +12,17 @@ export default function ErrorLoadingDataMessage({
message, message,
onRetry, onRetry,
}: ErrorLoadingDataMessageProps): ReactElement { }: ErrorLoadingDataMessageProps): ReactElement {
const {t} = useTranslation()
return ( return (
<Grid container spacing={2} flexDirection="column" alignItems="center"> <Grid container spacing={2} flexDirection="column" alignItems="center">
<Grid item> <Grid item>
<Alert severity="error">{message}</Alert> <Alert severity="error">{message}</Alert>
</Grid> </Grid>
<Grid item> <Grid item>
<Button onClick={onRetry}>Try Again</Button> <Button onClick={onRetry}>
{t("components.ErrorLoadingDataMessage.tryAgain")}
</Button>
</Grid> </Grid>
</Grid> </Grid>
) )

View File

@ -1,4 +1,5 @@
import {ReactElement, useEffect, useState} from "react" import {ReactElement} from "react"
import {useTranslation} from "react-i18next"
import {CircularProgress, Grid, Typography} from "@mui/material" import {CircularProgress, Grid, Typography} from "@mui/material"
@ -6,24 +7,8 @@ export interface LoadingDataProps {
message?: string message?: string
} }
export default function LoadingData({ export default function LoadingData({message = "Loading"}: LoadingDataProps): ReactElement {
message = "Loading", const {t} = useTranslation()
}: LoadingDataProps): ReactElement {
const [ellipsis, setEllipsis] = useState<string>("")
useEffect(() => {
const interval = setInterval(() => {
setEllipsis((value: string) => {
if (value.length === 3) {
return ""
}
return value + "."
})
}, 300)
return () => clearInterval(interval)
}, [])
return ( return (
<Grid container spacing={2} direction="column" alignItems="center"> <Grid container spacing={2} direction="column" alignItems="center">
@ -31,7 +16,7 @@ export default function LoadingData({
<CircularProgress /> <CircularProgress />
</Grid> </Grid>
<Grid item> <Grid item>
<Typography variant="caption">Loading{ellipsis}</Typography> <Typography variant="caption">{t("general.loading")}</Typography>
</Grid> </Grid>
</Grid> </Grid>
) )

View File

@ -1,12 +1,12 @@
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {ReactElement, useEffect, useLayoutEffect, useRef, useState} from "react" import {ReactElement, useEffect, useLayoutEffect, useRef, useState} from "react"
import {useTranslation} from "react-i18next"
import {UseMutationResult} from "@tanstack/react-query" import {UseMutationResult} from "@tanstack/react-query"
import {Alert, AlertProps, Snackbar} from "@mui/material" import {Alert, AlertProps, Snackbar} from "@mui/material"
import {FastAPIError} from "~/utils" import {FastAPIError} from "~/utils"
import {SimpleDetailResponse} from "~/server-types" import {SimpleDetailResponse} from "~/server-types"
import getErrorMessage from "~/utils/get-error-message"
export interface MutationStatusSnackbarProps< export interface MutationStatusSnackbarProps<
TData = unknown, TData = unknown,
@ -29,12 +29,9 @@ export default function MutationStatusSnackbar<
mutation, mutation,
successMessage, successMessage,
errorMessage, errorMessage,
}: MutationStatusSnackbarProps< }: MutationStatusSnackbarProps<TData, TError, TVariables, TContext>): ReactElement {
TData, const {t} = useTranslation()
TError,
TVariables,
TContext
>): ReactElement {
const $severity = useRef<AlertProps["severity"]>() const $severity = useRef<AlertProps["severity"]>()
const $message = useRef<string>() const $message = useRef<string>()
@ -60,22 +57,22 @@ export default function MutationStatusSnackbar<
$message.current = (() => { $message.current = (() => {
if (mutation.isError) { if (mutation.isError) {
// @ts-ignore // @ts-ignore
return errorMessage ?? getErrorMessage(mutation.error) return (
errorMessage ||
(mutation.error.response?.data as any).detail ||
t("general.defaultError")
)
} }
if (mutation.isSuccess) { if (mutation.isSuccess) {
return successMessage ?? mutation.data?.detail ?? "Success!" return successMessage ?? mutation.data?.detail ?? t("general.defaultSuccess")
} }
})() })()
} }
}, [mutation.isSuccess, mutation.isError]) }, [mutation.isSuccess, mutation.isError])
return ( return (
<Snackbar <Snackbar open={open} onClose={() => setOpen(false)} autoHideDuration={5000}>
open={open}
onClose={() => setOpen(false)}
autoHideDuration={5000}
>
<Alert severity={$severity.current} variant="filled"> <Alert severity={$severity.current} variant="filled">
{$message.current} {$message.current}
</Alert> </Alert>

View File

@ -5,24 +5,21 @@ import UAParser from "ua-parser-js"
import {Button} from "@mui/material" import {Button} from "@mui/material"
import {APP_LINK_MAP} from "~/utils" import {APP_LINK_MAP} from "~/utils"
import {useTranslation} from "react-i18next"
export interface OpenMailButtonProps { export interface OpenMailButtonProps {
domain: string domain: string
} }
export default function OpenMailButton({ export default function OpenMailButton({domain}: OpenMailButtonProps): ReactElement {
domain, const {t} = useTranslation()
}: OpenMailButtonProps): ReactElement {
const userAgent = new UAParser() const userAgent = new UAParser()
if (userAgent.getOS().name === "Android" && APP_LINK_MAP[domain]) { if (userAgent.getOS().name === "Android" && APP_LINK_MAP[domain]) {
return ( return (
<Button <Button startIcon={<IoMdMailOpen />} variant="text" href={APP_LINK_MAP[domain].android}>
startIcon={<IoMdMailOpen />} {t("components.OpenMailButton.label")}
variant="text"
href={APP_LINK_MAP[domain].android}
>
Open Mail
</Button> </Button>
) )
} }

View File

@ -0,0 +1,21 @@
import {ReactElement} from "react"
import {Grid} from "@mui/material"
export interface SimpleInformationContainerProps {
children: ReactElement[]
}
export default function SimpleInformationContainer({
children,
}: SimpleInformationContainerProps): ReactElement {
return (
<Grid container spacing={2} direction="column">
{children.map(child => (
<Grid item key={child.key}>
{child}
</Grid>
))}
</Grid>
)
}

View File

@ -0,0 +1,21 @@
import {ReactElement} from "react"
import {Grid} from "@mui/material"
export interface SimpleMultipleSectionsProps {
children: ReactElement[]
}
export default function SimpleMultipleSections({
children,
}: SimpleMultipleSectionsProps): ReactElement {
return (
<Grid container spacing={6} direction="column">
{children.map(child => (
<Grid item key={child.key}>
{child}
</Grid>
))}
</Grid>
)
}

View File

@ -0,0 +1,37 @@
import {ReactElement} from "react"
import {useTranslation} from "react-i18next"
import {Grid, Typography} from "@mui/material"
export interface SimpleOverlayInformationProps {
label: string
emptyText?: string
icon?: ReactElement
children?: ReactElement | string | boolean | null
}
export default function SimpleOverlayInformation({
label,
emptyText,
icon,
children,
}: SimpleOverlayInformationProps): ReactElement {
const {t} = useTranslation()
const emptyTextValue = emptyText ?? t("general.emptyValue")
return (
<Grid container spacing={1} direction="column">
<Grid item>
<Typography variant="overline">{label}</Typography>
</Grid>
<Grid item>
<Grid container spacing={1} flexDirection="row" alignItems="center">
{icon && <Grid item>{icon}</Grid>}
<Grid item>
{children || <Typography variant="body2">{emptyTextValue}</Typography>}
</Grid>
</Grid>
</Grid>
</Grid>
)
}

View File

@ -0,0 +1,21 @@
import {ReactElement, ReactNode} from "react"
import {Grid, Typography} from "@mui/material"
export interface SimpleSectionProps {
label: string
children: ReactNode
}
export default function SimpleSection({label, children}: SimpleSectionProps): ReactElement {
return (
<Grid container direction="column" spacing={1}>
<Grid item>
<Typography variant="h6" component="h2">
{label}
</Typography>
</Grid>
<Grid item>{children}</Grid>
</Grid>
)
}

View File

@ -5,6 +5,7 @@ import {LoadingButton, LoadingButtonProps} from "@mui/lab"
import {useIntervalUpdate} from "~/hooks" import {useIntervalUpdate} from "~/hooks"
import {isDev} from "~/constants/development" import {isDev} from "~/constants/development"
import {useTranslation} from "react-i18next"
export interface TimedButtonProps extends LoadingButtonProps { export interface TimedButtonProps extends LoadingButtonProps {
interval: number interval: number
@ -17,7 +18,10 @@ export default function TimedButton({
disabled: parentDisabled = false, disabled: parentDisabled = false,
...props ...props
}: TimedButtonProps): ReactElement { }: TimedButtonProps): ReactElement {
const {t} = useTranslation()
const [startDate, resetInterval] = useIntervalUpdate(1000) const [startDate, resetInterval] = useIntervalUpdate(1000)
const secondsPassed = differenceInSeconds(new Date(), startDate) const secondsPassed = differenceInSeconds(new Date(), startDate)
const secondsLeft = (isDev ? 3 : interval) - secondsPassed const secondsLeft = (isDev ? 3 : interval) - secondsPassed
@ -31,7 +35,9 @@ export default function TimedButton({
}} }}
> >
<span>{children} </span> <span>{children} </span>
{secondsLeft > 0 && <span> ({secondsLeft})</span>} {secondsLeft > 0 && (
<span>{t("components.TimedButton.remainingTime", {count: secondsLeft})}</span>
)}
</LoadingButton> </LoadingButton>
) )
} }

View File

@ -30,3 +30,9 @@ export * from "./DecryptionPasswordMissingAlert"
export {default as DecryptionPasswordMissingAlert} from "./DecryptionPasswordMissingAlert" export {default as DecryptionPasswordMissingAlert} from "./DecryptionPasswordMissingAlert"
export * from "./FaviconImage" export * from "./FaviconImage"
export {default as FaviconImage} from "./FaviconImage" export {default as FaviconImage} from "./FaviconImage"
export * from "./SimpleOverlayInformation"
export {default as SimpleOverlayInformation} from "./SimpleOverlayInformation"
export * from "./SimpleInformationContainer"
export {default as SimpleInformationContainer} from "./SimpleInformationContainer"
export * as SimplePageBuilder from "./simple-page-builder"

View File

@ -0,0 +1,5 @@
export {default as Page} from "./SimplePage"
export {default as Section} from "./SimpleSection"
export {default as MultipleSections} from "./SimpleMultipleSections"
export {default as OverlayInformation} from "./SimpleOverlayInformation"
export {default as InformationContainer} from "./SimpleInformationContainer"

View File

@ -0,0 +1,11 @@
import {ImageProxyFormatType, ProxyUserAgentType} from "~/server-types"
import {createEnumMapFromTranslation} from "~/utils"
export const IMAGE_PROXY_FORMAT_TYPE_NAME_MAP = createEnumMapFromTranslation(
"relations.alias.settings.imageProxyFormat.enumTexts",
ImageProxyFormatType,
)
export const IMAGE_PROXY_USER_AGENT_TYPE_NAME_MAP = createEnumMapFromTranslation(
"relations.alias.settings.imageProxyUserAgent.enumTexts",
ProxyUserAgentType,
)

View File

@ -1,22 +0,0 @@
import {ImageProxyFormatType, ProxyUserAgentType} from "~/server-types"
export const IMAGE_PROXY_FORMAT_TYPE_NAME_MAP: Record<
ImageProxyFormatType,
string
> = {
[ImageProxyFormatType.JPEG]: "JPEG",
[ImageProxyFormatType.PNG]: "PNG",
[ImageProxyFormatType.WEBP]: "WebP",
}
export 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",
}

30
src/init-i18n.ts Normal file
View File

@ -0,0 +1,30 @@
import {initReactI18next} from "react-i18next"
import {de} from "yup-locales"
import {setLocale} from "yup"
// @ts-ignore
// eslint-disable-next-line ordered-imports/ordered-imports
import Cache from "i18next-localstorage-cache"
import HttpApi from "i18next-http-backend"
import LanguageDetector from "i18next-browser-languagedetector"
import i18n from "i18next"
import {isDev} from "~/constants/development"
i18n.use(HttpApi)
.use(LanguageDetector)
.use(Cache)
.use(initReactI18next)
.init({
debug: isDev,
fallbackLng: "en",
load: "languageOnly",
backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
interpolation: {
escapeValue: false, // react already safes from xss => https://www.i18next.com/translation-function/interpolation#unescape
},
})
setLocale(de)

View File

@ -14,6 +14,7 @@ import {
import {URL_REGEX} from "~/constants/values" import {URL_REGEX} from "~/constants/values"
import {whenEnterPressed} from "~/utils" import {whenEnterPressed} from "~/utils"
import {useTranslation} from "react-i18next"
export interface AddWebsiteFieldProps { export interface AddWebsiteFieldProps {
onAdd: (website: string) => Promise<void> onAdd: (website: string) => Promise<void>
@ -32,6 +33,7 @@ export default function AddWebsiteField({
onAdd, onAdd,
isLoading, isLoading,
}: AddWebsiteFieldProps): ReactElement { }: AddWebsiteFieldProps): ReactElement {
const {t} = useTranslation()
const websiteFormik = useFormik<WebsiteForm>({ const websiteFormik = useFormik<WebsiteForm>({
validationSchema: WEBSITE_SCHEMA, validationSchema: WEBSITE_SCHEMA,
initialValues: { initialValues: {
@ -60,13 +62,18 @@ export default function AddWebsiteField({
}) })
return ( return (
<Grid container spacing={2} direction="column"> <Grid container direction="column">
<Grid item> <Grid item>
<FormGroup row> <FormGroup row>
<TextField <TextField
name="url" name="url"
id="url" id="url"
label="Website" label={t(
"routes.AliasDetailRoute.sections.notes.form.websites.label",
)}
placeholder={t(
"routes.AliasDetailRoute.sections.notes.form.websites.placeholder",
)}
variant="outlined" variant="outlined"
value={websiteFormik.values.url} value={websiteFormik.values.url}
onChange={websiteFormik.handleChange} onChange={websiteFormik.handleChange}
@ -106,7 +113,9 @@ export default function AddWebsiteField({
} }
> >
{(websiteFormik.touched.url && websiteFormik.errors.url) || {(websiteFormik.touched.url && websiteFormik.errors.url) ||
"Add a website to this alias. Used to autofill."} t(
"routes.AliasDetailRoute.sections.notes.form.websites.helperText",
)}
</FormHelperText> </FormHelperText>
</Grid> </Grid>
</Grid> </Grid>

View File

@ -1,9 +1,10 @@
import {useParams} from "react-router" import {useParams} from "react-router"
import {ReactElement, useContext} from "react" import {ReactElement, useContext} from "react"
import {useTranslation} from "react-i18next"
import {Grid, Typography} from "@mui/material" import {Grid, Typography} from "@mui/material"
import {AliasTypeIndicator, DecryptionPasswordMissingAlert} from "~/components" import {AliasTypeIndicator, DecryptionPasswordMissingAlert, SimplePageBuilder} from "~/components"
import {Alias, DecryptedAlias} from "~/server-types" import {Alias, DecryptedAlias} from "~/server-types"
import {useUIState} from "~/hooks" import {useUIState} from "~/hooks"
import AliasNotesForm from "~/route-widgets/AliasDetailRoute/AliasNotesForm" import AliasNotesForm from "~/route-widgets/AliasDetailRoute/AliasNotesForm"
@ -15,25 +16,17 @@ export interface AliasDetailsProps {
alias: Alias | DecryptedAlias alias: Alias | DecryptedAlias
} }
const getDomain = (url: string): string => { export default function AliasDetails({alias: aliasValue}: AliasDetailsProps): ReactElement {
const {hostname, port} = new URL(url) const {t} = useTranslation()
return `${hostname}${port ? `:${port}` : ""}`
}
export default function AliasDetails({
alias: aliasValue,
}: AliasDetailsProps): ReactElement {
const params = useParams() const params = useParams()
const {encryptionStatus} = useContext(AuthContext) const {encryptionStatus} = useContext(AuthContext)
const address = atob(params.addressInBase64 as string) const address = atob(params.addressInBase64 as string)
const [aliasUIState, setAliasUIState] = useUIState<Alias | DecryptedAlias>( const [aliasUIState, setAliasUIState] = useUIState<Alias | DecryptedAlias>(aliasValue)
aliasValue,
)
return ( return (
<Grid container spacing={4}> <SimplePageBuilder.MultipleSections>
<Grid item> {[
<Grid container spacing={1} direction="row" alignItems="center"> <Grid container spacing={1} direction="row" alignItems="center">
<Grid item> <Grid item>
<AliasTypeIndicator type={aliasUIState.type} /> <AliasTypeIndicator type={aliasUIState.type} />
@ -48,9 +41,8 @@ export default function AliasDetails({
onChanged={setAliasUIState} onChanged={setAliasUIState}
/> />
</Grid> </Grid>
</Grid> </Grid>,
</Grid> <div key="notes">
<Grid item width="100%">
{encryptionStatus === EncryptionStatus.Available ? ( {encryptionStatus === EncryptionStatus.Available ? (
<AliasNotesForm <AliasNotesForm
id={aliasUIState.id} id={aliasUIState.id}
@ -60,33 +52,14 @@ export default function AliasDetails({
) : ( ) : (
<DecryptionPasswordMissingAlert /> <DecryptionPasswordMissingAlert />
)} )}
</Grid> </div>,
<Grid item> <SimplePageBuilder.Section
<Grid container spacing={4}> label={t("routes.AliasDetailRoute.sections.settings.title")}
<Grid item> key="settings"
<Typography variant="h6" component="h3"> >
Settings <AliasPreferencesForm alias={aliasUIState} onChanged={setAliasUIState} />
</Typography> </SimplePageBuilder.Section>,
</Grid> ]}
<Grid item> </SimplePageBuilder.MultipleSections>
<AliasPreferencesForm
alias={aliasUIState}
onChanged={setAliasUIState}
/>
</Grid>
<Grid item>
<Typography variant="body2">
These settings apply to this alias only. You can
either set a value manually or refer to your default
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.
</Typography>
</Grid>
</Grid>
</Grid>
</Grid>
) )
} }

View File

@ -26,9 +26,10 @@ import {
} from "@mui/material" } from "@mui/material"
import {parseFastAPIError} from "~/utils" import {parseFastAPIError} from "~/utils"
import {ErrorSnack, FaviconImage, SuccessSnack} from "~/components" import {ErrorSnack, FaviconImage, SimpleOverlayInformation, SuccessSnack} from "~/components"
import {Alias, AliasNote, DecryptedAlias} from "~/server-types" import {Alias, AliasNote, DecryptedAlias} from "~/server-types"
import {UpdateAliasData, updateAlias} from "~/apis" import {UpdateAliasData, updateAlias} from "~/apis"
import {useTranslation} from "react-i18next"
import AddWebsiteField from "~/route-widgets/AliasDetailRoute/AddWebsiteField" import AddWebsiteField from "~/route-widgets/AliasDetailRoute/AddWebsiteField"
import AuthContext from "~/AuthContext/AuthContext" import AuthContext from "~/AuthContext/AuthContext"
import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes" import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes"
@ -57,18 +58,12 @@ const SCHEMA = yup.object().shape({
), ),
}) })
export default function AliasNotesForm({ export default function AliasNotesForm({id, notes, onChanged}: AliasNotesFormProps): ReactElement {
id, const {t} = useTranslation()
notes, const {_encryptUsingMasterPassword, _decryptUsingMasterPassword} = useContext(AuthContext)
onChanged, const {mutateAsync, isSuccess} = useMutation<Alias, AxiosError, UpdateAliasData>(
}: AliasNotesFormProps): ReactElement { values => updateAlias(id, values),
const {_encryptUsingMasterPassword, _decryptUsingMasterPassword} = {
useContext(AuthContext)
const {mutateAsync, isSuccess} = useMutation<
Alias,
AxiosError,
UpdateAliasData
>(values => updateAlias(id, values), {
onSuccess: newAlias => { onSuccess: newAlias => {
;(newAlias as any as DecryptedAlias).notes = decryptAliasNotes( ;(newAlias as any as DecryptedAlias).notes = decryptAliasNotes(
newAlias.encryptedNotes, newAlias.encryptedNotes,
@ -77,7 +72,8 @@ export default function AliasNotesForm({
onChanged(newAlias as any as DecryptedAlias) onChanged(newAlias as any as DecryptedAlias)
}, },
}) },
)
const initialValues = useMemo( const initialValues = useMemo(
() => ({ () => ({
personalNotes: notes.data.personalNotes, personalNotes: notes.data.personalNotes,
@ -101,9 +97,7 @@ export default function AliasNotesForm({
}, },
}) })
const data = _encryptUsingMasterPassword( const data = _encryptUsingMasterPassword(JSON.stringify(newNotes))
JSON.stringify(newNotes),
)
await mutateAsync({ await mutateAsync({
encryptedNotes: data, encryptedNotes: data,
}) })
@ -118,12 +112,12 @@ export default function AliasNotesForm({
return ( return (
<> <>
<form onSubmit={formik.handleSubmit}> <form onSubmit={formik.handleSubmit}>
<Grid container direction="column" spacing={4}> <Grid container direction="column" spacing={1}>
<Grid item> <Grid item>
<Grid container spacing={1} direction="row"> <Grid container spacing={1} direction="row">
<Grid item> <Grid item>
<Typography variant="h6" component="h3"> <Typography variant="h6" component="h3">
Notes {t("routes.AliasDetailRoute.sections.notes.title")}
</Typography> </Typography>
</Grid> </Grid>
<Grid item> <Grid item>
@ -133,13 +127,9 @@ export default function AliasNotesForm({
onClick={async () => { onClick={async () => {
if ( if (
isInEditMode && isInEditMode &&
!deepEqual( !deepEqual(initialValues, formik.values, {
initialValues,
formik.values,
{
strict: true, strict: true,
}, })
)
) { ) {
await formik.submitForm() await formik.submitForm()
} }
@ -147,51 +137,38 @@ export default function AliasNotesForm({
setIsInEditMode(!isInEditMode) setIsInEditMode(!isInEditMode)
}} }}
> >
{isInEditMode ? ( {isInEditMode ? <MdCheckCircle /> : <FaPen />}
<MdCheckCircle />
) : (
<FaPen />
)}
</IconButton> </IconButton>
</Grid> </Grid>
</Grid> </Grid>
</Grid> </Grid>
<Grid item> <Grid item>
<Grid container spacing={4} direction="column"> <Grid container spacing={2} direction="column">
{notes.data.createdAt && ( {notes.data.createdAt && (
<Grid item> <Grid item>
<Grid <SimpleOverlayInformation
container emptyText={t("general.emptyUnavailableValue")}
spacing={1} icon={<MdEditCalendar />}
flexDirection="row" label={t(
alignItems="center" "routes.AliasDetailRoute.sections.notes.form.createdAt.label",
>
<Grid item>
<MdEditCalendar />
</Grid>
<Grid item>
<Tooltip
title={notes.data.createdAt.toISOString()}
>
<Typography variant="body1">
{format(
notes.data.createdAt,
"Pp",
)} )}
>
{notes.data.createdAt && (
<Tooltip title={notes.data.createdAt.toISOString()}>
<Typography variant="body1">
{format(notes.data.createdAt, "Pp")}
</Typography> </Typography>
</Tooltip> </Tooltip>
</Grid> )}
</Grid> </SimpleOverlayInformation>
</Grid> </Grid>
)} )}
<Grid item> <Grid item>
<Grid container spacing={1} direction="column"> <SimpleOverlayInformation
<Grid item> label={t(
<Typography variant="overline"> "routes.AliasDetailRoute.sections.notes.form.personalNotes.label",
Personal Notes )}
</Typography> >
</Grid>
<Grid item>
{isInEditMode ? ( {isInEditMode ? (
<TextField <TextField
label="Personal Notes" label="Personal Notes"
@ -200,26 +177,20 @@ export default function AliasNotesForm({
key="personalNotes" key="personalNotes"
id="personalNotes" id="personalNotes"
name="personalNotes" name="personalNotes"
value={ value={formik.values.personalNotes}
formik.values.personalNotes
}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
disabled={formik.isSubmitting} disabled={formik.isSubmitting}
error={ error={
formik.touched formik.touched.personalNotes &&
.personalNotes && Boolean(formik.errors.personalNotes)
Boolean(
formik.errors
.personalNotes,
)
} }
helperText={ helperText={
(formik.touched (formik.touched.personalNotes &&
.personalNotes && formik.errors.personalNotes) ||
formik.errors t(
.personalNotes) || "routes.AliasDetailRoute.sections.notes.form.personalNotes.helperText",
"You can enter personal notes for this alias here. Notes are encrypted." )
} }
InputProps={{ InputProps={{
startAdornment: ( startAdornment: (
@ -230,34 +201,29 @@ export default function AliasNotesForm({
}} }}
/> />
) : ( ) : (
<Typography> notes.data.personalNotes
{notes.data.personalNotes}
</Typography>
)} )}
</Grid> </SimpleOverlayInformation>
</Grid>
</Grid> </Grid>
<Grid item> <Grid item>
<Grid container spacing={1} direction="column"> <SimpleOverlayInformation
<Grid item> label={t(
<Typography variant="overline"> "routes.AliasDetailRoute.sections.notes.form.websites.label",
Websites )}
</Typography> emptyText={t(
</Grid> "routes.AliasDetailRoute.sections.notes.form.websites.emptyText",
)}
>
{isInEditMode ? ( {isInEditMode ? (
<Grid item> <Grid item>
<AddWebsiteField <AddWebsiteField
onAdd={async website => { onAdd={async website => {
await formik.setFieldValue( await formik.setFieldValue("websites", [
"websites", ...formik.values.websites,
[
...formik.values
.websites,
{ {
url: website, url: website,
}, },
], ])
)
}} }}
isLoading={formik.isSubmitting} isLoading={formik.isSubmitting}
/> />
@ -267,26 +233,15 @@ export default function AliasNotesForm({
render={arrayHelpers => ( render={arrayHelpers => (
<List> <List>
{formik.values.websites.map( {formik.values.websites.map(
( (website, index) => (
website, <ListItem key={website.url}>
index,
) => (
<ListItem
key={
website.url
}
>
<ListItemIcon> <ListItemIcon>
<FaviconImage <FaviconImage
url={ url={website.url}
website.url
}
/> />
</ListItemIcon> </ListItemIcon>
<ListItemText> <ListItemText>
{ {website.url}
website.url
}
</ListItemText> </ListItemText>
<ListItemSecondaryAction> <ListItemSecondaryAction>
<IconButton <IconButton
@ -309,45 +264,24 @@ export default function AliasNotesForm({
/> />
</FormikProvider> </FormikProvider>
</Grid> </Grid>
) : ( ) : notes.data.websites.length ? (
<Grid item> <Grid item>
{notes.data.websites.length ? (
<List> <List>
{notes.data.websites.map( {notes.data.websites.map(website => (
website => ( <ListItem key={website.url}>
<ListItem
key={
website.url
}
>
<ListItemIcon> <ListItemIcon>
<FaviconImage <FaviconImage
width={ width={20}
20 url={website.url}
}
url={
website.url
}
/> />
</ListItemIcon> </ListItemIcon>
<ListItemText> <ListItemText>{website.url}</ListItemText>
{
website.url
}
</ListItemText>
</ListItem> </ListItem>
), ))}
)}
</List> </List>
) : (
<Typography variant="body2">
You haven&apos;t used this
alias on any site yet.
</Typography>
)}
</Grid>
)}
</Grid> </Grid>
) : null}
</SimpleOverlayInformation>
</Grid> </Grid>
</Grid> </Grid>
</Grid> </Grid>
@ -355,7 +289,7 @@ export default function AliasNotesForm({
</form> </form>
<ErrorSnack message={formik.errors.detail} /> <ErrorSnack message={formik.errors.detail} />
<SuccessSnack <SuccessSnack
message={isSuccess && "Updated notes successfully!"} message={isSuccess && t("relations.alias.mutations.success.notesUpdated")}
/> />
</> </>
) )

View File

@ -5,26 +5,22 @@ import {useFormik} from "formik"
import {FaFile} from "react-icons/fa" import {FaFile} from "react-icons/fa"
import {MdCheckCircle} from "react-icons/md" import {MdCheckCircle} from "react-icons/md"
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import {LoadingButton} from "@mui/lab" import {LoadingButton} from "@mui/lab"
import {Collapse, Grid} from "@mui/material" import {Collapse, Grid, Typography} from "@mui/material"
import {mdiTextBoxMultiple} from "@mdi/js/commonjs/mdi" 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 { import {Alias, DecryptedAlias, ImageProxyFormatType, ProxyUserAgentType} from "~/server-types"
Alias,
DecryptedAlias,
ImageProxyFormatType,
ProxyUserAgentType,
} from "~/server-types"
import {
IMAGE_PROXY_FORMAT_TYPE_NAME_MAP,
IMAGE_PROXY_USER_AGENT_TYPE_NAME_MAP,
} from "~/constants/enum_mappings"
import {UpdateAliasData, updateAlias} from "~/apis" import {UpdateAliasData, updateAlias} from "~/apis"
import {ErrorSnack, SuccessSnack} from "~/components" import {ErrorSnack, SuccessSnack} from "~/components"
import {parseFastAPIError} from "~/utils" import {parseFastAPIError} from "~/utils"
import {
IMAGE_PROXY_FORMAT_TYPE_NAME_MAP,
IMAGE_PROXY_USER_AGENT_TYPE_NAME_MAP,
} from "~/constants/enum-mappings"
import FormikAutoLockNavigation from "~/LockNavigationContext/FormikAutoLockNavigation" import FormikAutoLockNavigation from "~/LockNavigationContext/FormikAutoLockNavigation"
import SelectField from "~/route-widgets/SettingsRoute/SelectField" import SelectField from "~/route-widgets/SettingsRoute/SelectField"
@ -44,29 +40,36 @@ interface Form {
detail?: string detail?: string
} }
const SCHEMA = yup.object().shape({
removeTrackers: yup.mixed<boolean | null>().oneOf([true, false, null]),
createMailReport: yup.mixed<boolean | null>().oneOf([true, false, null]),
proxyImages: yup.mixed<boolean | null>().oneOf([true, false, null]),
imageProxyFormat: yup
.mixed<ImageProxyFormatType>()
.oneOf([null, ...Object.values(ImageProxyFormatType)]),
imageProxyUserAgent: yup
.mixed<ProxyUserAgentType>()
.oneOf([null, ...Object.values(ProxyUserAgentType)]),
})
export default function AliasPreferencesForm({ export default function AliasPreferencesForm({
alias, alias,
onChanged, onChanged,
}: AliasPreferencesFormProps): ReactElement { }: AliasPreferencesFormProps): ReactElement {
const {mutateAsync, isSuccess} = useMutation< const {t} = useTranslation()
Alias, const SCHEMA = yup.object().shape({
AxiosError, removeTrackers: yup
UpdateAliasData .mixed<boolean | null>()
>(data => updateAlias(alias.id, data), { .oneOf([true, false, null])
onSuccess: onChanged, .label(t("relations.alias.settings.removeTrackers.label")),
createMailReport: yup
.mixed<boolean | null>()
.oneOf([true, false, null])
.label(t("relations.alias.settings.createMailReport.label")),
proxyImages: yup.mixed<boolean | null>().oneOf([true, false, null]),
imageProxyFormat: yup
.mixed<ImageProxyFormatType>()
.oneOf([null, ...Object.values(ImageProxyFormatType)])
.label(t("relations.alias.settings.imageProxyFormat.label")),
imageProxyUserAgent: yup
.mixed<ProxyUserAgentType>()
.oneOf([null, ...Object.values(ProxyUserAgentType)])
.label(t("relations.alias.settings.imageProxyUserAgent.label")),
}) })
const {mutateAsync, isSuccess} = useMutation<Alias, AxiosError, UpdateAliasData>(
data => updateAlias(alias.id, data),
{
onSuccess: onChanged,
},
)
const formik = useFormik<Form>({ const formik = useFormik<Form>({
enableReinitialize: true, enableReinitialize: true,
initialValues: { initialValues: {
@ -97,6 +100,7 @@ export default function AliasPreferencesForm({
<form onSubmit={formik.handleSubmit}> <form onSubmit={formik.handleSubmit}>
<Grid <Grid
container container
marginTop={1}
spacing={4} spacing={4}
flexDirection="column" flexDirection="column"
alignItems="center" alignItems="center"
@ -105,7 +109,7 @@ export default function AliasPreferencesForm({
<Grid container spacing={4}> <Grid container spacing={4}>
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<SelectField <SelectField
label="Remove Trackers" label={t("relations.alias.settings.removeTrackers.label")}
formik={formik} formik={formik}
icon={<BsShieldShaded />} icon={<BsShieldShaded />}
name="removeTrackers" name="removeTrackers"
@ -113,14 +117,9 @@ export default function AliasPreferencesForm({
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<SelectField <SelectField
label="Create Reports" label={t("relations.alias.settings.createMailReports.label")}
formik={formik} formik={formik}
icon={ icon={<Icon path={mdiTextBoxMultiple} size={0.8} />}
<Icon
path={mdiTextBoxMultiple}
size={0.8}
/>
}
name="createMailReport" name="createMailReport"
/> />
</Grid> </Grid>
@ -128,23 +127,20 @@ export default function AliasPreferencesForm({
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12}> <Grid item xs={12}>
<SelectField <SelectField
label="Proxy Images" label={t("relations.alias.settings.proxyImages.label")}
formik={formik} formik={formik}
icon={<BsImage />} icon={<BsImage />}
name="proxyImages" name="proxyImages"
/> />
</Grid> </Grid>
<Grid item xs={12}> <Grid item xs={12}>
<Collapse <Collapse in={formik.values.proxyImages !== false}>
in={
formik.values.proxyImages !==
false
}
>
<Grid container spacing={4}> <Grid container spacing={4}>
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<SelectField <SelectField
label="Image File Type" label={t(
"relations.alias.settings.imageProxyFormat.label",
)}
formik={formik} formik={formik}
icon={<FaFile />} icon={<FaFile />}
name="imageProxyFormat" name="imageProxyFormat"
@ -155,7 +151,9 @@ export default function AliasPreferencesForm({
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<SelectField <SelectField
label="Image Proxy User Agent" label={t(
"relations.alias.settings.imageProxyUserAgent.label",
)}
formik={formik} formik={formik}
name="imageProxyUserAgent" name="imageProxyUserAgent"
valueTextMap={ valueTextMap={
@ -177,15 +175,20 @@ export default function AliasPreferencesForm({
type="submit" type="submit"
startIcon={<MdCheckCircle />} startIcon={<MdCheckCircle />}
> >
Save Settings {t("relations.alias.settings.saveAction")}
</LoadingButton> </LoadingButton>
</Grid> </Grid>
<Grid item>
<Typography variant="body2">
{t("routes.AliasDetailRoute.sections.settings.description")}
</Typography>
</Grid>
</Grid> </Grid>
</form> </form>
<FormikAutoLockNavigation formik={formik} /> <FormikAutoLockNavigation formik={formik} />
<ErrorSnack message={formik.errors.detail} /> <ErrorSnack message={formik.errors.detail} />
<SuccessSnack <SuccessSnack
message={isSuccess && "Updated Alias successfully!"} message={isSuccess && t("relations.alias.mutations.success.aliasUpdated")}
/> />
</> </>
) )

View File

@ -1,5 +1,6 @@
import {ReactElement, useContext, useEffect, useState} from "react" import {ReactElement, useContext, useState} from "react"
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import {Switch} from "@mui/material" import {Switch} from "@mui/material"
import {useMutation} from "@tanstack/react-query" import {useMutation} from "@tanstack/react-query"
@ -8,6 +9,7 @@ import {Alias, DecryptedAlias} from "~/server-types"
import {UpdateAliasData, updateAlias} from "~/apis" import {UpdateAliasData, updateAlias} from "~/apis"
import {parseFastAPIError} from "~/utils" import {parseFastAPIError} from "~/utils"
import {ErrorSnack, SuccessSnack} from "~/components" import {ErrorSnack, SuccessSnack} from "~/components"
import {useUIState} from "~/hooks"
import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext" import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext"
import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes" import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes"
@ -23,10 +25,11 @@ export default function ChangeAliasActivationStatusSwitch({
isActive, isActive,
onChanged, onChanged,
}: ChangeAliasActivationStatusSwitchProps): ReactElement { }: ChangeAliasActivationStatusSwitchProps): ReactElement {
const {t} = useTranslation()
const {_decryptUsingMasterPassword, encryptionStatus} = const {_decryptUsingMasterPassword, encryptionStatus} =
useContext(AuthContext) useContext(AuthContext)
const [isActiveUIState, setIsActiveUIState] = useState<boolean>(true) const [isActiveUIState, setIsActiveUIState] = useUIState<boolean>(isActive)
const [successMessage, setSuccessMessage] = useState<string>("") const [successMessage, setSuccessMessage] = useState<string>("")
const [errorMessage, setErrorMessage] = useState<string>("") const [errorMessage, setErrorMessage] = useState<string>("")
@ -50,10 +53,6 @@ export default function ChangeAliasActivationStatusSwitch({
setErrorMessage(parseFastAPIError(error).detail as string), setErrorMessage(parseFastAPIError(error).detail as string),
}) })
useEffect(() => {
setIsActiveUIState(isActive)
}, [isActive])
return ( return (
<> <>
<Switch <Switch
@ -68,9 +67,17 @@ export default function ChangeAliasActivationStatusSwitch({
}) })
if (!isActiveUIState) { if (!isActiveUIState) {
setSuccessMessage("Alias activated successfully!") setSuccessMessage(
t(
"relations.alias.mutations.success.aliasChangedToEnabled",
) as string,
)
} else { } else {
setSuccessMessage("Alias deactivated successfully!") setSuccessMessage(
t(
"relations.alias.mutations.success.aliasChangedToDisabled",
) as string,
)
} }
} catch {} } catch {}
}} }}

View File

@ -21,6 +21,7 @@ import {Alias, AliasType} from "~/server-types"
import {parseFastAPIError} from "~/utils" import {parseFastAPIError} from "~/utils"
import {ErrorSnack, SuccessSnack} from "~/components" import {ErrorSnack, SuccessSnack} from "~/components"
import {DEFAULT_ALIAS_NOTE} from "~/constants/values" import {DEFAULT_ALIAS_NOTE} from "~/constants/values"
import {useTranslation} from "react-i18next"
import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext" import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext"
import CustomAliasDialog from "~/route-widgets/AliasesRoute/CustomAliasDialog" import CustomAliasDialog from "~/route-widgets/AliasesRoute/CustomAliasDialog"
@ -31,6 +32,7 @@ export interface CreateAliasButtonProps {
export default function CreateAliasButton({ export default function CreateAliasButton({
onCreated, onCreated,
}: CreateAliasButtonProps): ReactElement { }: CreateAliasButtonProps): ReactElement {
const {t} = useTranslation()
const {_encryptUsingMasterPassword, encryptionStatus} = const {_encryptUsingMasterPassword, encryptionStatus} =
useContext(AuthContext) useContext(AuthContext)
@ -83,7 +85,7 @@ export default function CreateAliasButton({
}) })
} }
> >
Create random alias {t("routes.AliasesRoute.actions.createRandomAlias.label")}
</Button> </Button>
<Button <Button
size="small" size="small"
@ -108,7 +110,11 @@ export default function CreateAliasButton({
<ListItemIcon> <ListItemIcon>
<FaPen /> <FaPen />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Create Custom Alias" /> <ListItemText
primary={t(
"routes.AliasesRoute.actions.createCustomAlias.label",
)}
/>
</MenuItem> </MenuItem>
</MenuList> </MenuList>
</Menu> </Menu>
@ -123,7 +129,10 @@ export default function CreateAliasButton({
/> />
<ErrorSnack message={errorMessage} /> <ErrorSnack message={errorMessage} />
<SuccessSnack <SuccessSnack
message={isSuccess && "Created Alias successfully!"} message={
isSuccess &&
t("relations.alias.mutations.success.aliasCreation")
}
/> />
</> </>
) )

View File

@ -5,6 +5,7 @@ import {useLoaderData} from "react-router-dom"
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {TiCancel} from "react-icons/ti" import {TiCancel} from "react-icons/ti"
import {FaPen} from "react-icons/fa" import {FaPen} from "react-icons/fa"
import {useTranslation} from "react-i18next"
import { import {
Box, Box,
@ -44,6 +45,7 @@ export default function CustomAliasDialog({
onClose, onClose,
}: CustomAliasDialogProps): ReactElement { }: CustomAliasDialogProps): ReactElement {
const serverSettings = useLoaderData() as ServerSettings const serverSettings = useLoaderData() as ServerSettings
const {t} = useTranslation()
const schema = yup.object().shape({ const schema = yup.object().shape({
local: yup local: yup
@ -75,11 +77,14 @@ export default function CustomAliasDialog({
return ( return (
<Dialog onClose={onClose} open={visible} keepMounted={false}> <Dialog onClose={onClose} open={visible} keepMounted={false}>
<form onSubmit={formik.handleSubmit}> <form onSubmit={formik.handleSubmit}>
<DialogTitle>Create Custom Alias</DialogTitle> <DialogTitle>
{t("routes.AliasesRoute.actions.createCustomAlias.label")}
</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
You can define your own custom alias. Note that a random {t(
suffix will be added at the end to avoid duplicates. "routes.AliasesRoute.actions.createCustomAlias.description",
)}
</DialogContentText> </DialogContentText>
<Box paddingY={4}> <Box paddingY={4}>
<TextField <TextField
@ -88,7 +93,12 @@ export default function CustomAliasDialog({
autoFocus autoFocus
name="local" name="local"
id="local" id="local"
label="Address" label={t(
"routes.AliasesRoute.actions.createCustomAlias.form.address.label",
)}
placeholder={t(
"routes.AliasesRoute.actions.createCustomAlias.form.address.placeholder",
)}
value={formik.values.local} value={formik.values.local}
onChange={formik.handleChange} onChange={formik.handleChange}
disabled={formik.isSubmitting} disabled={formik.isSubmitting}
@ -122,7 +132,7 @@ export default function CustomAliasDialog({
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose} startIcon={<TiCancel />}> <Button onClick={onClose} startIcon={<TiCancel />}>
Cancel {t("general.cancelLabel")}
</Button> </Button>
<Button <Button
onClick={() => {}} onClick={() => {}}
@ -131,7 +141,9 @@ export default function CustomAliasDialog({
variant="contained" variant="contained"
type="submit" type="submit"
> >
Create Alias {t(
"routes.AliasesRoute.actions.createCustomAlias.continueAction",
)}
</Button> </Button>
</DialogActions> </DialogActions>
</form> </form>

View File

@ -6,6 +6,7 @@ import {Link as RouterLink, useLocation} from "react-router-dom"
import {Button} from "@mui/material" import {Button} from "@mui/material"
import {mdiTextBoxMultiple} from "@mdi/js/commonjs/mdi" import {mdiTextBoxMultiple} from "@mdi/js/commonjs/mdi"
import {useTranslation} from "react-i18next"
import Icon from "@mdi/react" import Icon from "@mdi/react"
import LockNavigationContext from "~/LockNavigationContext/LockNavigationContext" import LockNavigationContext from "~/LockNavigationContext/LockNavigationContext"
@ -28,10 +29,10 @@ const SECTION_ICON_MAP: Record<NavigationSection, ReactElement> = {
} }
const SECTION_TEXT_MAP: Record<NavigationSection, string> = { const SECTION_TEXT_MAP: Record<NavigationSection, string> = {
[NavigationSection.Overview]: "Overview", [NavigationSection.Overview]: "components.NavigationButton.overview",
[NavigationSection.Aliases]: "Aliases", [NavigationSection.Aliases]: "components.NavigationButton.aliases",
[NavigationSection.Reports]: "Reports", [NavigationSection.Reports]: "components.NavigationButton.reports",
[NavigationSection.Settings]: "Settings", [NavigationSection.Settings]: "components.NavigationButton.settings",
} }
const PATH_SECTION_MAP: Record<string, NavigationSection> = { const PATH_SECTION_MAP: Record<string, NavigationSection> = {
@ -44,12 +45,13 @@ const PATH_SECTION_MAP: Record<string, NavigationSection> = {
export default function NavigationButton({ export default function NavigationButton({
section, section,
}: NavigationButtonProps): ReactElement { }: NavigationButtonProps): ReactElement {
const {t} = useTranslation()
const {handleAnchorClick} = useContext(LockNavigationContext) const {handleAnchorClick} = useContext(LockNavigationContext)
const location = useLocation() const location = useLocation()
const currentSection = PATH_SECTION_MAP[location.pathname.split("/")[1]] const currentSection = PATH_SECTION_MAP[location.pathname.split("/")[1]]
const Icon = SECTION_ICON_MAP[section] const Icon = SECTION_ICON_MAP[section]
const text = SECTION_TEXT_MAP[section] const text = t(SECTION_TEXT_MAP[section])
return ( return (
<Button <Button

View File

@ -5,6 +5,7 @@ import React, {ReactElement} from "react"
import {Box, Button, Grid, Typography} from "@mui/material" import {Box, Button, Grid, Typography} from "@mui/material"
import {MultiStepFormElement} from "~/components" import {MultiStepFormElement} from "~/components"
import {mdiTextBoxMultiple} from "@mdi/js/commonjs/mdi" import {mdiTextBoxMultiple} from "@mdi/js/commonjs/mdi"
import {useTranslation} from "react-i18next"
import Icon from "@mdi/react" import Icon from "@mdi/react"
export interface GenerateEmailReportsFormProps { export interface GenerateEmailReportsFormProps {
@ -16,6 +17,8 @@ export default function GenerateEmailReportsForm({
onNo, onNo,
onYes, onYes,
}: GenerateEmailReportsFormProps): ReactElement { }: GenerateEmailReportsFormProps): ReactElement {
const {t} = useTranslation()
return ( return (
<MultiStepFormElement> <MultiStepFormElement>
<Grid <Grid
@ -29,40 +32,26 @@ export default function GenerateEmailReportsForm({
justifyContent="center" justifyContent="center"
> >
<Grid item> <Grid item>
<Grid <Grid container direction="column" spacing={4} alignItems="center">
container
direction="column"
spacing={4}
alignItems="center"
>
<Grid item> <Grid item>
<Grid container spacing={4} direction="column"> <Grid container spacing={4} direction="column">
<Grid item> <Grid item>
<Typography <Typography variant="h6" component="h2" align="center">
variant="h6" {t(
component="h2" "routes.CompleteAccountRoute.forms.generateReports.title",
align="center" )}
>
Generate Email Reports?
</Typography> </Typography>
</Grid> </Grid>
<Grid item> <Grid item>
<Box display="flex" justifyContent="center"> <Box display="flex" justifyContent="center">
<Icon <Icon path={mdiTextBoxMultiple} size={2} />
path={mdiTextBoxMultiple}
size={2}
/>
</Box> </Box>
</Grid> </Grid>
<Grid item> <Grid item>
<Typography <Typography variant="subtitle1" component="p">
variant="subtitle1" {t(
component="p" "routes.CompleteAccountRoute.forms.generateReports.description",
> )}
Would you like to create fully encrypted
email reports for your mails? Only you
will be able to access it. Not even we
can decrypt it.
</Typography> </Typography>
</Grid> </Grid>
</Grid> </Grid>
@ -72,12 +61,10 @@ export default function GenerateEmailReportsForm({
<Grid item> <Grid item>
<Grid container spacing={2} direction="row"> <Grid container spacing={2} direction="row">
<Grid item> <Grid item>
<Button <Button startIcon={<TiCancel />} color="secondary" onClick={onNo}>
startIcon={<TiCancel />} {t(
color="secondary" "routes.CompleteAccountRoute.forms.generateReports.cancelAction",
onClick={onNo} )}
>
No
</Button> </Button>
</Grid> </Grid>
<Grid item> <Grid item>
@ -86,7 +73,9 @@ export default function GenerateEmailReportsForm({
color="primary" color="primary"
onClick={onYes} onClick={onYes}
> >
Yes {t(
"routes.CompleteAccountRoute.forms.generateReports.continueAction",
)}
</Button> </Button>
</Grid> </Grid>
</Grid> </Grid>

View File

@ -2,6 +2,8 @@ import * as yup from "yup"
import {useFormik} from "formik" import {useFormik} from "formik"
import {MdCheckCircle, MdChevronRight, MdLock} from "react-icons/md" import {MdCheckCircle, MdChevronRight, MdLock} from "react-icons/md"
import {generateKey, readKey} from "openpgp" import {generateKey, readKey} from "openpgp"
import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import React, {ReactElement, useContext, useMemo} from "react" import React, {ReactElement, useContext, useMemo} from "react"
import passwordGenerator from "secure-random-password" import passwordGenerator from "secure-random-password"
@ -17,7 +19,6 @@ import {MASTER_PASSWORD_LENGTH} from "~/constants/values"
import {AuthenticationDetails, UserNote} from "~/server-types" import {AuthenticationDetails, UserNote} from "~/server-types"
import {UpdateAccountData, updateAccount} from "~/apis" import {UpdateAccountData, updateAccount} from "~/apis"
import {encryptUserNote} from "~/utils/encrypt-user-note" import {encryptUserNote} from "~/utils/encrypt-user-note"
import {AxiosError} from "axios"
import AuthContext from "~/AuthContext/AuthContext" import AuthContext from "~/AuthContext/AuthContext"
export interface PasswordFormProps { export interface PasswordFormProps {
@ -30,20 +31,25 @@ interface Form {
detail?: string detail?: string
} }
export default function PasswordForm({onDone}: PasswordFormProps): ReactElement {
const {t} = useTranslation()
const user = useUser()
const theme = useSystemPreferredTheme()
const schema = yup.object().shape({ const schema = yup.object().shape({
password: yup.string().required(), password: yup.string().required(),
passwordConfirmation: yup passwordConfirmation: yup
.string() .string()
.required() .required()
.oneOf([yup.ref("password"), null], "Passwords must match"), .oneOf(
[yup.ref("password"), null],
t(
"routes.CompleteAccountRoute.forms.password.form.passwordConfirm.mustMatchHelperText",
) as string,
)
.label(t("routes.CompleteAccountRoute.forms.password.form.passwordConfirm.label")),
}) })
export default function PasswordForm({
onDone,
}: PasswordFormProps): ReactElement {
const user = useUser()
const theme = useSystemPreferredTheme()
const {_setDecryptionPassword, login} = useContext(AuthContext) const {_setDecryptionPassword, login} = useContext(AuthContext)
const awaitGenerateKey = useMemo( const awaitGenerateKey = useMemo(
@ -57,16 +63,15 @@ export default function PasswordForm({
}), }),
[], [],
) )
const {mutateAsync} = useMutation< const {mutateAsync} = useMutation<AuthenticationDetails, AxiosError, UpdateAccountData>(
AuthenticationDetails, updateAccount,
AxiosError, {
UpdateAccountData
>(updateAccount, {
onSuccess: ({user}) => { onSuccess: ({user}) => {
login(user) login(user)
onDone() onDone()
}, },
}) },
)
const formik = useFormik<Form>({ const formik = useFormik<Form>({
validationSchema: schema, validationSchema: schema,
initialValues: { initialValues: {
@ -85,10 +90,7 @@ export default function PasswordForm({
values.password, values.password,
user.email.address, user.email.address,
) )
const encryptedMasterPassword = encryptString( const encryptedMasterPassword = encryptString(masterPassword, encryptionPassword)
masterPassword,
encryptionPassword,
)
const note: UserNote = { const note: UserNote = {
theme, theme,
privateKey: keyPair.privateKey, privateKey: keyPair.privateKey,
@ -109,7 +111,7 @@ export default function PasswordForm({
encryptedNotes, encryptedNotes,
}) })
} catch (error) { } catch (error) {
setErrors({detail: "An error occurred"}) setErrors({detail: t("general.defaultError")})
} }
}, },
}) })
@ -128,18 +130,13 @@ export default function PasswordForm({
<Grid item> <Grid item>
<Grid container spacing={2} direction="column"> <Grid container spacing={2} direction="column">
<Grid item> <Grid item>
<Typography <Typography variant="h6" component="h2" align="center">
variant="h6" {t("routes.CompleteAccountRoute.forms.password.title")}
component="h2"
align="center"
>
Set up your password
</Typography> </Typography>
</Grid> </Grid>
<Grid item> <Grid item>
<Typography variant="subtitle1" component="p"> <Typography variant="subtitle1" component="p">
Please enter a safe password so that we can {t("routes.CompleteAccountRoute.forms.password.description")}
encrypt your data.
</Typography> </Typography>
</Grid> </Grid>
</Grid> </Grid>
@ -151,19 +148,20 @@ export default function PasswordForm({
fullWidth fullWidth
id="password" id="password"
name="password" name="password"
label="Password" label={t(
"routes.CompleteAccountRoute.forms.password.form.password.label",
)}
placeholder={t(
"routes.CompleteAccountRoute.forms.password.form.password.placeholder",
)}
autoComplete="new-password" autoComplete="new-password"
value={formik.values.password} value={formik.values.password}
onChange={formik.handleChange} onChange={formik.handleChange}
disabled={formik.isSubmitting} disabled={formik.isSubmitting}
error={ error={
formik.touched.password && formik.touched.password && Boolean(formik.errors.password)
Boolean(formik.errors.password)
}
helperText={
formik.touched.password &&
formik.errors.password
} }
helperText={formik.touched.password && formik.errors.password}
InputProps={{ InputProps={{
startAdornment: ( startAdornment: (
<InputAdornment position="start"> <InputAdornment position="start">
@ -178,15 +176,18 @@ export default function PasswordForm({
fullWidth fullWidth
id="passwordConfirmation" id="passwordConfirmation"
name="passwordConfirmation" name="passwordConfirmation"
label="Confirm Password" label={t(
"routes.CompleteAccountRoute.forms.password.form.passwordConfirm.label",
)}
placeholder={t(
"routes.CompleteAccountRoute.forms.password.form.passwordConfirm.placeholder",
)}
value={formik.values.passwordConfirmation} value={formik.values.passwordConfirmation}
onChange={formik.handleChange} onChange={formik.handleChange}
disabled={formik.isSubmitting} disabled={formik.isSubmitting}
error={ error={
formik.touched.passwordConfirmation && formik.touched.passwordConfirmation &&
Boolean( Boolean(formik.errors.passwordConfirmation)
formik.errors.passwordConfirmation,
)
} }
helperText={ helperText={
formik.touched.passwordConfirmation && formik.touched.passwordConfirmation &&
@ -210,7 +211,7 @@ export default function PasswordForm({
loading={formik.isSubmitting} loading={formik.isSubmitting}
startIcon={<MdChevronRight />} startIcon={<MdChevronRight />}
> >
Continue {t("routes.CompleteAccountRoute.forms.password.continueAction")}
</LoadingButton> </LoadingButton>
</Grid> </Grid>
</Grid> </Grid>

View File

@ -1,21 +1,23 @@
import * as yup from "yup"
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {ReactElement} from "react" import {ReactElement} from "react"
import {useFormik} from "formik" import {useFormik} from "formik"
import {FaHashtag} from "react-icons/fa" import {FaHashtag} from "react-icons/fa"
import {MdChevronRight, MdMail} from "react-icons/md" import {MdChevronRight, MdMail} from "react-icons/md"
import {useLoaderData} from "react-router-dom"
import {useTranslation} from "react-i18next"
import ResendMailButton from "./ResendMailButton"
import {useMutation} from "@tanstack/react-query" import {useMutation} from "@tanstack/react-query"
import {Box, Grid, InputAdornment, TextField, Typography} from "@mui/material" import {Box, Grid, InputAdornment, TextField, Typography} from "@mui/material"
import {LoadingButton} from "@mui/lab" import {LoadingButton} from "@mui/lab"
import {AuthenticationDetails, ServerUser} from "~/server-types" import {AuthenticationDetails, ServerSettings, ServerUser} from "~/server-types"
import {VerifyLoginWithEmailData, verifyLoginWithEmail} from "~/apis" import {VerifyLoginWithEmailData, verifyLoginWithEmail} from "~/apis"
import {MultiStepFormElement} from "~/components" import {MultiStepFormElement} from "~/components"
import {parseFastAPIError} from "~/utils" import {parseFastAPIError} from "~/utils"
import ResendMailButton from "./ResendMailButton"
import useSchema from "./use-schema"
export interface ConfirmCodeFormProps { export interface ConfirmCodeFormProps {
onConfirm: (user: ServerUser) => void onConfirm: (user: ServerUser) => void
email: string email: string
@ -32,16 +34,36 @@ export default function ConfirmCodeForm({
email, email,
sameRequestToken, sameRequestToken,
}: ConfirmCodeFormProps): ReactElement { }: ConfirmCodeFormProps): ReactElement {
const schema = useSchema() const settings = useLoaderData() as ServerSettings
const {mutateAsync} = useMutation< const {t} = useTranslation()
AuthenticationDetails, const SCHEMA = yup.object().shape({
AxiosError, code: yup
VerifyLoginWithEmailData .string()
>(verifyLoginWithEmail, { .required()
onSuccess: ({user}) => onConfirm(user), .min(settings.emailLoginTokenLength)
.max(settings.emailLoginTokenLength)
.test(
"chars",
t("routes.LoginRoute.forms.confirmCode.form.code.errors.invalidChars") as string,
code => {
if (!code) {
return false
}
const chars = settings.emailLoginTokenChars.split("")
return code.split("").every(char => chars.includes(char))
},
),
}) })
const {mutateAsync} = useMutation<AuthenticationDetails, AxiosError, VerifyLoginWithEmailData>(
verifyLoginWithEmail,
{
onSuccess: ({user}) => onConfirm(user),
},
)
const formik = useFormik<Form>({ const formik = useFormik<Form>({
validationSchema: schema, validationSchema: SCHEMA,
initialValues: { initialValues: {
code: "", code: "",
detail: "", detail: "",
@ -54,7 +76,8 @@ export default function ConfirmCodeForm({
token: values.code, token: values.code,
}) })
} catch (error) { } catch (error) {
setErrors(parseFastAPIError(error as AxiosError)) const errors = parseFastAPIError(error as AxiosError)
setErrors({code: errors.detail})
} }
}, },
}) })
@ -71,7 +94,7 @@ export default function ConfirmCodeForm({
> >
<Grid item> <Grid item>
<Typography variant="h6" component="h1" align="center"> <Typography variant="h6" component="h1" align="center">
You got mail! {t("routes.LoginRoute.forms.confirmCode.title")}
</Typography> </Typography>
</Grid> </Grid>
<Grid item> <Grid item>
@ -80,13 +103,8 @@ export default function ConfirmCodeForm({
</Box> </Box>
</Grid> </Grid>
<Grid item> <Grid item>
<Typography <Typography variant="subtitle1" component="p" align="center">
variant="subtitle1" {t("routes.LoginRoute.forms.confirmCode.description")}
component="p"
align="center"
>
We sent you a code to your email. Enter it below to
login.
</Typography> </Typography>
</Grid> </Grid>
<Grid item> <Grid item>
@ -95,17 +113,12 @@ export default function ConfirmCodeForm({
fullWidth fullWidth
name="code" name="code"
id="code" id="code"
label="Code" label={t("routes.LoginRoute.forms.confirmCode.form.code.label")}
value={formik.values.code} value={formik.values.code}
onChange={formik.handleChange} onChange={formik.handleChange}
disabled={formik.isSubmitting} disabled={formik.isSubmitting}
error={ error={formik.touched.code && Boolean(formik.errors.code)}
formik.touched.code && helperText={formik.touched.code && formik.errors.code}
Boolean(formik.errors.code)
}
helperText={
formik.touched.code && formik.errors.code
}
InputProps={{ InputProps={{
startAdornment: ( startAdornment: (
<InputAdornment position="start"> <InputAdornment position="start">
@ -116,12 +129,7 @@ export default function ConfirmCodeForm({
/> />
</Grid> </Grid>
<Grid item> <Grid item>
<Grid <Grid width="100%" container display="flex" justifyContent="space-between">
width="100%"
container
display="flex"
justifyContent="space-between"
>
<Grid item> <Grid item>
<ResendMailButton <ResendMailButton
email={email} email={email}
@ -135,7 +143,7 @@ export default function ConfirmCodeForm({
type="submit" type="submit"
startIcon={<MdChevronRight />} startIcon={<MdChevronRight />}
> >
Login {t("routes.LoginRoute.forms.confirmCode.continueAction")}
</LoadingButton> </LoadingButton>
</Grid> </Grid>
</Grid> </Grid>

View File

@ -8,6 +8,7 @@ import {resendEmailLoginCode} from "~/apis"
import {MutationStatusSnackbar, TimedButton} from "~/components" import {MutationStatusSnackbar, TimedButton} from "~/components"
import {ServerSettings, SimpleDetailResponse} from "~/server-types" import {ServerSettings, SimpleDetailResponse} from "~/server-types"
import {MdMail} from "react-icons/md" import {MdMail} from "react-icons/md"
import {useTranslation} from "react-i18next"
export interface ResendMailButtonProps { export interface ResendMailButtonProps {
email: string email: string
@ -19,6 +20,7 @@ export default function ResendMailButton({
sameRequestToken, sameRequestToken,
}: ResendMailButtonProps): ReactElement { }: ResendMailButtonProps): ReactElement {
const settings = useLoaderData() as ServerSettings const settings = useLoaderData() as ServerSettings
const {t} = useTranslation()
const mutation = useMutation<SimpleDetailResponse, AxiosError, void>(() => const mutation = useMutation<SimpleDetailResponse, AxiosError, void>(() =>
resendEmailLoginCode({ resendEmailLoginCode({
@ -35,7 +37,7 @@ export default function ResendMailButton({
startIcon={<MdMail />} startIcon={<MdMail />}
onClick={() => mutate()} onClick={() => mutate()}
> >
Resend Mail {t("components.ResendMailButton.label")}
</TimedButton> </TimedButton>
<MutationStatusSnackbar mutation={mutation} /> <MutationStatusSnackbar mutation={mutation} />
</> </>

View File

@ -1,25 +0,0 @@
import * as yup from "yup"
import {useLoaderData} from "react-router-dom"
import {ServerSettings} from "~/server-types"
export default function useSchema(): yup.ObjectSchema<any> {
const settings = useLoaderData() as ServerSettings
return yup.object().shape({
code: yup
.string()
.required()
.min(settings.emailLoginTokenLength)
.max(settings.emailLoginTokenLength)
.test("chars", "This code is not valid.", code => {
if (!code) {
return false
}
const chars = settings.emailLoginTokenChars.split("")
return code.split("").every(char => chars.includes(char))
}),
})
}

View File

@ -3,6 +3,7 @@ import {ReactElement} from "react"
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {useFormik} from "formik" import {useFormik} from "formik"
import {MdEmail} from "react-icons/md" import {MdEmail} from "react-icons/md"
import {useTranslation} from "react-i18next"
import {useMutation} from "@tanstack/react-query" import {useMutation} from "@tanstack/react-query"
import {InputAdornment, TextField} from "@mui/material" import {InputAdornment, TextField} from "@mui/material"
@ -20,18 +21,18 @@ interface Form {
detail: string detail: string
} }
const SCHEMA = yup.object().shape({
email: yup.string().email().required(),
})
export default function EmailForm({onLogin}: EmailFormProps): ReactElement { export default function EmailForm({onLogin}: EmailFormProps): ReactElement {
const {mutateAsync} = useMutation<LoginWithEmailResult, AxiosError, string>( const {t} = useTranslation()
loginWithEmail, const SCHEMA = yup.object().shape({
{ email: yup
onSuccess: ({sameRequestToken}) => .string()
onLogin(formik.values.email, sameRequestToken), .email()
}, .required()
) .label(t("routes.LoginRoute.forms.email.form.email.label")),
})
const {mutateAsync} = useMutation<LoginWithEmailResult, AxiosError, string>(loginWithEmail, {
onSuccess: ({sameRequestToken}) => onLogin(formik.values.email, sameRequestToken),
})
const formik = useFormik<Form>({ const formik = useFormik<Form>({
validationSchema: SCHEMA, validationSchema: SCHEMA,
initialValues: { initialValues: {
@ -51,9 +52,9 @@ export default function EmailForm({onLogin}: EmailFormProps): ReactElement {
<MultiStepFormElement> <MultiStepFormElement>
<form onSubmit={formik.handleSubmit}> <form onSubmit={formik.handleSubmit}>
<SimpleForm <SimpleForm
title="Sign in" title={t("routes.LoginRoute.forms.email.title")}
description="We'll send you a verification code to your email." description={t("routes.LoginRoute.forms.email.description")}
continueActionLabel="Send code" continueActionLabel={t("routes.LoginRoute.forms.email.continueAction")}
nonFieldError={formik.errors.detail} nonFieldError={formik.errors.detail}
isSubmitting={formik.isSubmitting} isSubmitting={formik.isSubmitting}
> >
@ -64,17 +65,13 @@ export default function EmailForm({onLogin}: EmailFormProps): ReactElement {
name="email" name="email"
id="email" id="email"
label="Email" label="Email"
placeholder={t("routes.LoginRoute.forms.email.form.email.placeholder")}
inputMode="email" inputMode="email"
value={formik.values.email} value={formik.values.email}
onChange={formik.handleChange} onChange={formik.handleChange}
disabled={formik.isSubmitting} disabled={formik.isSubmitting}
error={ error={formik.touched.email && Boolean(formik.errors.email)}
formik.touched.email && helperText={formik.touched.email && formik.errors.email}
Boolean(formik.errors.email)
}
helperText={
formik.touched.email && formik.errors.email
}
InputProps={{ InputProps={{
startAdornment: ( startAdornment: (
<InputAdornment position="start"> <InputAdornment position="start">

View File

@ -2,6 +2,7 @@ import {BsImage} from "react-icons/bs"
import {ReactElement, useState} from "react" import {ReactElement, useState} from "react"
import {MdLocationOn} from "react-icons/md" import {MdLocationOn} from "react-icons/md"
import {useLoaderData} from "react-router-dom" import {useLoaderData} from "react-router-dom"
import {useTranslation} from "react-i18next"
import addHours from "date-fns/addHours" import addHours from "date-fns/addHours"
import isBefore from "date-fns/isBefore" import isBefore from "date-fns/isBefore"
@ -17,14 +18,14 @@ import {
} from "@mui/material" } from "@mui/material"
import {DecryptedReportContent, ServerSettings} from "~/server-types" import {DecryptedReportContent, ServerSettings} from "~/server-types"
import {isDev} from "~/constants/development"
export interface ProxiedImagesListItemProps { export interface ProxiedImagesListItemProps {
images: DecryptedReportContent["messageDetails"]["content"]["proxiedImages"] images: DecryptedReportContent["messageDetails"]["content"]["proxiedImages"]
} }
export default function ProxiedImagesListItem({ export default function ProxiedImagesListItem({images}: ProxiedImagesListItemProps): ReactElement {
images, const {t} = useTranslation()
}: ProxiedImagesListItemProps): ReactElement {
const serverSettings = useLoaderData() as ServerSettings const serverSettings = useLoaderData() as ServerSettings
const theme = useTheme() const theme = useTheme()
@ -42,14 +43,18 @@ export default function ProxiedImagesListItem({
<ListItemIcon> <ListItemIcon>
<BsImage /> <BsImage />
</ListItemIcon> </ListItemIcon>
<ListItemText>Proxying {images.length} images</ListItemText> <ListItemText>
{t("routes.ReportDetailRoute.sections.trackers.results.proxiedImages.text", {
count: images.length,
})}
</ListItemText>
</ListItemButton> </ListItemButton>
<Collapse in={showProxiedImages}> <Collapse in={showProxiedImages}>
<Box bgcolor={theme.palette.background.default}> <Box bgcolor={theme.palette.background.default}>
<List> <List>
{images.map(image => ( {images.map(image => (
<ListItemButton <ListItemButton
href={image.serverUrl} href={isDev ? image.url : image.serverUrl}
target="_blank" target="_blank"
key={image.imageProxyId} key={image.imageProxyId}
> >
@ -79,9 +84,13 @@ export default function ProxiedImagesListItem({
), ),
) )
) { ) {
return "Stored on Server." return t(
"routes.ReportDetailRoute.sections.trackers.results.proxiedImages.status.isStored",
)
} else { } else {
return "Proxying through Server." return t(
"routes.ReportDetailRoute.sections.trackers.results.proxiedImages.status.isProxying",
)
} }
})()} })()}
</Grid> </Grid>

View File

@ -14,6 +14,7 @@ import {
import {DecryptedReportContent} from "~/server-types" import {DecryptedReportContent} from "~/server-types"
import {BsShieldShaded} from "react-icons/bs" import {BsShieldShaded} from "react-icons/bs"
import {useTranslation} from "react-i18next"
export interface SinglePixelImageTrackersListItemProps { export interface SinglePixelImageTrackersListItemProps {
images: DecryptedReportContent["messageDetails"]["content"]["singlePixelImages"] images: DecryptedReportContent["messageDetails"]["content"]["singlePixelImages"]
@ -22,6 +23,7 @@ export interface SinglePixelImageTrackersListItemProps {
export default function SinglePixelImageTrackersListItem({ export default function SinglePixelImageTrackersListItem({
images, images,
}: SinglePixelImageTrackersListItemProps): ReactElement { }: SinglePixelImageTrackersListItemProps): ReactElement {
const {t} = useTranslation()
const theme = useTheme() const theme = useTheme()
const [showImageTrackers, setShowImageTrackers] = useState<boolean>(false) const [showImageTrackers, setShowImageTrackers] = useState<boolean>(false)
@ -45,30 +47,24 @@ export default function SinglePixelImageTrackersListItem({
<BsShieldShaded /> <BsShieldShaded />
</ListItemIcon> </ListItemIcon>
<ListItemText> <ListItemText>
Removed {images.length} image trackers {t("routes.ReportDetailRoute.sections.trackers.results.imageTrackers.text", {
count: images.length,
})}
</ListItemText> </ListItemText>
</ListItemButton> </ListItemButton>
<Collapse in={showImageTrackers}> <Collapse in={showImageTrackers}>
<Box bgcolor={theme.palette.background.default}> <Box bgcolor={theme.palette.background.default}>
<List> <List>
{Object.entries(imagesPerTracker).map( {Object.entries(imagesPerTracker).map(([trackerName, images]) => (
([trackerName, images]) => (
<> <>
<Typography <Typography variant="caption" component="h3" ml={1}>
variant="caption"
component="h3"
ml={1}
>
{trackerName} {trackerName}
</Typography> </Typography>
{images.map(image => ( {images.map(image => (
<ListItem key={image.source}> <ListItem key={image.source}>{image.source}</ListItem>
{image.source}
</ListItem>
))} ))}
</> </>
), ))}
)}
</List> </List>
</Box> </Box>
</Collapse> </Collapse>

View File

@ -4,6 +4,7 @@ import {useNavigate} from "react-router-dom"
import {ListItemButton, ListItemText} from "@mui/material" import {ListItemButton, ListItemText} from "@mui/material"
import {DecryptedReportContent} from "~/server-types" import {DecryptedReportContent} from "~/server-types"
import {useTranslation} from "react-i18next"
export interface ReportInformationItemProps { export interface ReportInformationItemProps {
report: DecryptedReportContent report: DecryptedReportContent
@ -13,16 +14,20 @@ export default function ReportInformationItem({
report, report,
}: ReportInformationItemProps): ReactElement { }: ReportInformationItemProps): ReactElement {
const navigate = useNavigate() const navigate = useNavigate()
const {t} = useTranslation()
return ( return (
<ListItemButton onClick={() => navigate(`/reports/${report.id}`)}> <ListItemButton onClick={() => navigate(`/reports/${report.id}`)}>
<ListItemText <ListItemText
primary={ primary={
report.messageDetails.content.subject ?? ( report.messageDetails.content.subject ?? (
<i>{"<No Subject>"}</i> <i>{t("relations.report.emailMeta.emptySubject")}</i>
) )
} }
secondary={`${report.messageDetails.meta.from} -> ${report.messageDetails.meta.to}`} secondary={t("relations.report.emailMeta.flow", {
from: report.messageDetails.meta.from,
to: report.messageDetails.meta.to,
})}
/> />
</ListItemButton> </ListItemButton>
) )

View File

@ -2,6 +2,7 @@ import * as yup from "yup"
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {useFormik} from "formik" import {useFormik} from "formik"
import {MdCheckCircle, MdImage} from "react-icons/md" import {MdCheckCircle, MdImage} from "react-icons/md"
import {useTranslation} from "react-i18next"
import React, {ReactElement, useContext} from "react" import React, {ReactElement, useContext} from "react"
import {useMutation} from "@tanstack/react-query" import {useMutation} from "@tanstack/react-query"
@ -21,11 +22,7 @@ import {
} from "@mui/material" } from "@mui/material"
import {LoadingButton} from "@mui/lab" import {LoadingButton} from "@mui/lab"
import { import {ImageProxyFormatType, ProxyUserAgentType, SimpleDetailResponse} from "~/server-types"
ImageProxyFormatType,
ProxyUserAgentType,
SimpleDetailResponse,
} from "~/server-types"
import {UpdatePreferencesData, updatePreferences} from "~/apis" import {UpdatePreferencesData, updatePreferences} from "~/apis"
import {useUser} from "~/hooks" import {useUser} from "~/hooks"
import {parseFastAPIError} from "~/utils" import {parseFastAPIError} from "~/utils"
@ -33,7 +30,7 @@ import {SuccessSnack} from "~/components"
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,
} from "~/constants/enum_mappings" } from "~/constants/enum-mappings"
import AuthContext from "~/AuthContext/AuthContext" import AuthContext from "~/AuthContext/AuthContext"
import ErrorSnack from "~/components/ErrorSnack" import ErrorSnack from "~/components/ErrorSnack"
@ -47,23 +44,26 @@ interface Form {
detail?: string 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(),
})
export default function AliasesPreferencesForm(): ReactElement { export default function AliasesPreferencesForm(): ReactElement {
const {_updateUser} = useContext(AuthContext) const {_updateUser} = useContext(AuthContext)
const user = useUser() const user = useUser()
const {t} = useTranslation()
const SCHEMA = yup.object().shape({
removeTrackers: yup.boolean().label(t("relations.alias.settings.removeTrackers.label")),
createMailReport: yup.boolean().label(t("relations.alias.settings.createMailReport.label")),
proxyImages: yup.boolean().label(t("relations.alias.settings.proxyImages.label")),
imageProxyFormat: yup
.mixed<ImageProxyFormatType>()
.oneOf(Object.values(ImageProxyFormatType))
.required()
.label(t("relations.alias.settings.imageProxyFormat.label")),
imageProxyUserAgent: yup
.mixed<ProxyUserAgentType>()
.oneOf(Object.values(ProxyUserAgentType))
.required()
.label(t("relations.alias.settings.imageProxyUserAgent.label")),
})
const {mutateAsync, data} = useMutation< const {mutateAsync, data} = useMutation<
SimpleDetailResponse, SimpleDetailResponse,
AxiosError, AxiosError,
@ -110,22 +110,15 @@ export default function AliasesPreferencesForm(): ReactElement {
return ( return (
<> <>
<form onSubmit={formik.handleSubmit}> <form onSubmit={formik.handleSubmit}>
<Grid <Grid container spacing={4} flexDirection="column" alignItems="center">
container
spacing={4}
flexDirection="column"
alignItems="center"
>
<Grid item> <Grid item>
<Typography variant="h6" component="h3"> <Typography variant="h6" component="h3">
Aliases Preferences {t("routes.SettingsRoute.forms.aliasPreferences.title")}
</Typography> </Typography>
</Grid> </Grid>
<Grid item> <Grid item>
<Typography variant="body1" component="p"> <Typography variant="body1" component="p">
Select the default behavior for your aliases. This {t("routes.SettingsRoute.forms.aliasPreferences.description")}
will only affect aliases that do not have a custom
behavior set.
</Typography> </Typography>
</Grid> </Grid>
<Grid item> <Grid item>
@ -136,7 +129,7 @@ export default function AliasesPreferencesForm(): ReactElement {
spacing={4} spacing={4}
alignItems="flex-end" alignItems="flex-end"
> >
<Grid item md={6}> <Grid item md={6} xs={12}>
<FormGroup> <FormGroup>
<FormControlLabel <FormControlLabel
disabled={formik.isSubmitting} disabled={formik.isSubmitting}
@ -144,9 +137,7 @@ export default function AliasesPreferencesForm(): ReactElement {
<Checkbox <Checkbox
name="removeTrackers" name="removeTrackers"
id="removeTrackers" id="removeTrackers"
checked={ checked={formik.values.removeTrackers}
formik.values.removeTrackers
}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
/> />
@ -162,11 +153,11 @@ export default function AliasesPreferencesForm(): ReactElement {
> >
{(formik.touched.createMailReport && {(formik.touched.createMailReport &&
formik.errors.createMailReport) || formik.errors.createMailReport) ||
"Remove single-pixel image trackers as well as url trackers."} t("relations.alias.settings.removeTrackers.helperText")}
</FormHelperText> </FormHelperText>
</FormGroup> </FormGroup>
</Grid> </Grid>
<Grid item md={6}> <Grid item md={6} xs={12}>
<FormGroup> <FormGroup>
<FormControlLabel <FormControlLabel
disabled={formik.isSubmitting} disabled={formik.isSubmitting}
@ -174,10 +165,7 @@ export default function AliasesPreferencesForm(): ReactElement {
<Checkbox <Checkbox
name="createMailReport" name="createMailReport"
id="createMailReport" id="createMailReport"
checked={ checked={formik.values.createMailReport}
formik.values
.createMailReport
}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
/> />
@ -193,7 +181,9 @@ export default function AliasesPreferencesForm(): ReactElement {
> >
{(formik.touched.createMailReport && {(formik.touched.createMailReport &&
formik.errors.createMailReport) || formik.errors.createMailReport) ||
"Create reports of emails sent to aliases. Reports are end-to-end encrypted. Only you can access them."} t(
"relations.alias.settings.createMailReports.helperText",
)}
</FormHelperText> </FormHelperText>
</FormGroup> </FormGroup>
</Grid> </Grid>
@ -205,9 +195,7 @@ export default function AliasesPreferencesForm(): ReactElement {
<Checkbox <Checkbox
name="proxyImages" name="proxyImages"
id="proxyImages" id="proxyImages"
checked={ checked={formik.values.proxyImages}
formik.values.proxyImages
}
onChange={formik.handleChange} onChange={formik.handleChange}
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
/> />
@ -217,29 +205,24 @@ export default function AliasesPreferencesForm(): ReactElement {
/> />
<FormHelperText <FormHelperText
error={Boolean( error={Boolean(
formik.touched.proxyImages && formik.touched.proxyImages && formik.errors.proxyImages,
formik.errors.proxyImages,
)} )}
> >
{(formik.touched.proxyImages && {(formik.touched.proxyImages &&
formik.errors.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."} t("relations.alias.settings.proxyImages.helperText")}
</FormHelperText> </FormHelperText>
</FormGroup> </FormGroup>
<Collapse in={formik.values.proxyImages}> <Collapse in={formik.values.proxyImages}>
<Grid <Grid
display="flex" display="flex"
flexDirection={ flexDirection={isLarge ? "row" : "column"}
isLarge ? "row" : "column"
}
container container
marginY={2} marginY={2}
spacing={4} spacing={4}
alignItems={ alignItems={isLarge ? "flex-start" : "flex-end"}
isLarge ? "flex-start" : "flex-end"
}
> >
<Grid item md={6}> <Grid item md={6} xs={12}>
<FormGroup> <FormGroup>
<TextField <TextField
fullWidth fullWidth
@ -254,62 +237,38 @@ export default function AliasesPreferencesForm(): ReactElement {
name="imageProxyFormat" name="imageProxyFormat"
id="imageProxyFormat" id="imageProxyFormat"
label="Image File Type" label="Image File Type"
value={ value={formik.values.imageProxyFormat}
formik.values onChange={formik.handleChange}
.imageProxyFormat disabled={formik.isSubmitting}
}
onChange={
formik.handleChange
}
disabled={
formik.isSubmitting
}
error={ error={
formik.touched formik.touched.imageProxyFormat &&
.imageProxyFormat && Boolean(formik.errors.imageProxyFormat)
Boolean(
formik.errors
.imageProxyFormat,
)
} }
helperText={ helperText={
formik.touched formik.touched.imageProxyFormat &&
.imageProxyFormat && formik.errors.imageProxyFormat
formik.errors
.imageProxyFormat
} }
> >
{Object.entries( {Object.entries(
ImageProxyFormatType, IMAGE_PROXY_FORMAT_TYPE_NAME_MAP,
).map(([key, value]) => ( ).map(([value, translationString]) => (
<MenuItem <MenuItem key={value} value={value}>
key={key} {t(translationString)}
value={value}
>
{
IMAGE_PROXY_FORMAT_TYPE_NAME_MAP[
value
] as string
}
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
<FormHelperText <FormHelperText
error={Boolean( error={Boolean(
formik.touched formik.touched.imageProxyFormat &&
.imageProxyFormat && formik.errors.imageProxyFormat,
formik.errors
.imageProxyFormat,
)} )}
> >
{formik.touched {formik.touched.imageProxyFormat &&
.imageProxyFormat && formik.errors.imageProxyFormat}
formik.errors
.imageProxyFormat}
</FormHelperText> </FormHelperText>
</FormGroup> </FormGroup>
</Grid> </Grid>
<Grid item md={6}> <Grid item md={6} xs={12}>
<FormGroup> <FormGroup>
<TextField <TextField
fullWidth fullWidth
@ -317,59 +276,37 @@ export default function AliasesPreferencesForm(): ReactElement {
name="imageProxyUserAgent" name="imageProxyUserAgent"
id="imageProxyUserAgent" id="imageProxyUserAgent"
label="Image Proxy User Agent" label="Image Proxy User Agent"
value={ value={formik.values.imageProxyUserAgent}
formik.values onChange={formik.handleChange}
.imageProxyUserAgent disabled={formik.isSubmitting}
}
onChange={
formik.handleChange
}
disabled={
formik.isSubmitting
}
error={ error={
formik.touched formik.touched.imageProxyUserAgent &&
.imageProxyUserAgent && Boolean(formik.errors.imageProxyUserAgent)
Boolean(
formik.errors
.imageProxyUserAgent,
)
} }
helperText={ helperText={
formik.touched formik.touched.imageProxyUserAgent &&
.imageProxyUserAgent && formik.errors.imageProxyUserAgent
formik.errors
.imageProxyUserAgent
} }
> >
{Object.entries( {Object.entries(
ProxyUserAgentType, IMAGE_PROXY_USER_AGENT_TYPE_NAME_MAP,
).map(([key, value]) => ( ).map(([value, translationString]) => (
<MenuItem <MenuItem key={value} value={value}>
key={key} {t(translationString)}
value={value}
>
{
IMAGE_PROXY_USER_AGENT_TYPE_NAME_MAP[
value
] as string
}
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
<FormHelperText <FormHelperText
error={Boolean( error={Boolean(
formik.touched formik.touched.imageProxyUserAgent &&
.imageProxyUserAgent && formik.errors.imageProxyUserAgent,
formik.errors
.imageProxyUserAgent,
)} )}
> >
{(formik.touched {(formik.touched.imageProxyUserAgent &&
.imageProxyUserAgent && formik.errors.imageProxyUserAgent) ||
formik.errors t(
.imageProxyUserAgent) || "relations.alias.settings.imageProxyUserAgent.helperText",
"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> </FormHelperText>
</FormGroup> </FormGroup>
</Grid> </Grid>

View File

@ -1,4 +1,5 @@
import {ReactElement} from "react" import {ReactElement} from "react"
import {useTranslation} from "react-i18next"
import { import {
FormControl, FormControl,
@ -21,26 +22,25 @@ export interface SelectFieldProps {
icon?: ReactElement icon?: ReactElement
} }
const BOOLEAN_SELECT_TEXT_MAP: Record<string, string> = {
true: "Yes",
false: "No",
}
export default function SelectField({ export default function SelectField({
label, label,
formik, formik,
icon, icon,
name, name,
valueTextMap = BOOLEAN_SELECT_TEXT_MAP, valueTextMap: parentValueTextMap,
}: SelectFieldProps): ReactElement { }: SelectFieldProps): ReactElement {
const user = useUser() const user = useUser()
const {t} = useTranslation()
const BOOLEAN_SELECT_TEXT_MAP: Record<string, string> = {
true: "general.booleanSelection.true",
false: "general.booleanSelection.false",
}
const valueTextMap = parentValueTextMap ?? BOOLEAN_SELECT_TEXT_MAP
const labelId = `${name}-label` const labelId = `${name}-label`
const preferenceName = `alias${ const preferenceName = `alias${name.charAt(0).toUpperCase() + name.slice(1)}`
name.charAt(0).toUpperCase() + name.slice(1)
}`
const value = user.preferences[preferenceName as keyof User["preferences"]] const value = user.preferences[preferenceName as keyof User["preferences"]]
const defaultValueText = valueTextMap[value.toString()] const defaultValueText = t(valueTextMap[value.toString()])
return ( return (
<FormControl fullWidth> <FormControl fullWidth>
@ -52,9 +52,7 @@ export default function SelectField({
label={label} label={label}
labelId={labelId} labelId={labelId}
startAdornment={ startAdornment={
icon ? ( icon ? <InputAdornment position="start">{icon}</InputAdornment> : undefined
<InputAdornment position="start">{icon}</InputAdornment>
) : undefined
} }
value={(formik.values[name] ?? "null").toString()} value={(formik.values[name] ?? "null").toString()}
onChange={event => { onChange={event => {
@ -77,25 +75,31 @@ export default function SelectField({
error={Boolean(formik.touched[name] && formik.errors[name])} error={Boolean(formik.touched[name] && formik.errors[name])}
renderValue={value => renderValue={value =>
value === "null" ? ( value === "null" ? (
<i>{`<${defaultValueText}>`}</i> <i>
{t("general.defaultValueSelectionRaw", {
value: defaultValueText,
})}
</i>
) : ( ) : (
valueTextMap[value.toString()] t(valueTextMap[value.toString()])
) )
} }
> >
<MenuItem value="null"> <MenuItem value="null">
<i>{`Default <${defaultValueText}>`}</i> <i>
{t("general.defaultValueSelection", {
value: defaultValueText,
})}
</i>
</MenuItem> </MenuItem>
{valueTextMap && {valueTextMap &&
Object.entries(valueTextMap).map(([value, text]) => ( Object.entries(valueTextMap).map(([value, translationString]) => (
<MenuItem key={value} value={value}> <MenuItem key={value} value={value}>
{text} {t(translationString)}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
<FormHelperText <FormHelperText error={Boolean(formik.touched[name] && formik.errors[name])}>
error={Boolean(formik.touched[name] && formik.errors[name])}
>
{formik.touched[name] && formik.errors[name]} {formik.touched[name] && formik.errors[name]}
</FormHelperText> </FormHelperText>
</FormControl> </FormControl>

View File

@ -13,6 +13,7 @@ import {MultiStepFormElement, SimpleForm} from "~/components"
import {SignupResult, checkIsDomainDisposable, signup} from "~/apis" import {SignupResult, checkIsDomainDisposable, signup} from "~/apis"
import {parseFastAPIError} from "~/utils" import {parseFastAPIError} from "~/utils"
import {ServerSettings} from "~/server-types" import {ServerSettings} from "~/server-types"
import {useTranslation} from "react-i18next"
export interface EmailFormProps { export interface EmailFormProps {
serverSettings: ServerSettings serverSettings: ServerSettings
@ -24,20 +25,19 @@ interface Form {
detail?: string detail?: string
} }
export default function EmailForm({onSignUp, serverSettings}: EmailFormProps): ReactElement {
const {t} = useTranslation()
const SCHEMA = yup.object().shape({ const SCHEMA = yup.object().shape({
email: yup.string().email().required(), email: yup
.string()
.email()
.required()
.label(t("routes.SignupRoute.forms.email.form.email.label")),
}) })
export default function EmailForm({ const {mutateAsync} = useMutation<SignupResult, AxiosError, string>(signup, {
onSignUp,
serverSettings,
}: EmailFormProps): ReactElement {
const {mutateAsync} = useMutation<SignupResult, AxiosError, string>(
signup,
{
onSuccess: ({normalizedEmail}) => onSignUp(normalizedEmail), onSuccess: ({normalizedEmail}) => onSignUp(normalizedEmail),
}, })
)
const formik = useFormik<Form>({ const formik = useFormik<Form>({
validationSchema: SCHEMA, validationSchema: SCHEMA,
initialValues: { initialValues: {
@ -46,9 +46,7 @@ export default function EmailForm({
onSubmit: async (values, {setErrors}) => { onSubmit: async (values, {setErrors}) => {
// Check is email disposable // Check is email disposable
try { try {
const isDisposable = await checkIsDomainDisposable( const isDisposable = await checkIsDomainDisposable(values.email.split("@")[1])
values.email.split("@")[1],
)
if (isDisposable) { if (isDisposable) {
setErrors({ setErrors({
@ -76,9 +74,9 @@ export default function EmailForm({
<MultiStepFormElement> <MultiStepFormElement>
<form onSubmit={formik.handleSubmit}> <form onSubmit={formik.handleSubmit}>
<SimpleForm <SimpleForm
title="Sign up" title={t("routes.SignupRoute.forms.email.title")}
description="We only need your email and you are ready to go!" description={t("routes.SignupRoute.forms.email.description")}
continueActionLabel="Sign up" continueActionLabel={t("routes.SignupRoute.forms.email.continueAction")}
nonFieldError={formik.errors.detail} nonFieldError={formik.errors.detail}
isSubmitting={formik.isSubmitting} isSubmitting={formik.isSubmitting}
> >
@ -88,18 +86,16 @@ export default function EmailForm({
fullWidth fullWidth
name="email" name="email"
id="email" id="email"
label="Email" label={t("routes.SignupRoute.forms.email.form.email.label")}
placeholder={t(
"routes.SignupRoute.forms.email.form.email.placeholder",
)}
inputMode="email" inputMode="email"
value={formik.values.email} value={formik.values.email}
onChange={formik.handleChange} onChange={formik.handleChange}
disabled={formik.isSubmitting} disabled={formik.isSubmitting}
error={ error={formik.touched.email && Boolean(formik.errors.email)}
formik.touched.email && helperText={formik.touched.email && formik.errors.email}
Boolean(formik.errors.email)
}
helperText={
formik.touched.email && formik.errors.email
}
InputProps={{ InputProps={{
startAdornment: ( startAdornment: (
<InputAdornment position="start"> <InputAdornment position="start">
@ -113,9 +109,7 @@ export default function EmailForm({
</form> </form>
</MultiStepFormElement> </MultiStepFormElement>
{!serverSettings.otherRelaysEnabled && ( {!serverSettings.otherRelaysEnabled && (
<DetectEmailAutofillService <DetectEmailAutofillService domains={serverSettings.otherRelayDomains} />
domains={serverSettings.otherRelayDomains}
/>
)} )}
</> </>
) )

View File

@ -1,6 +1,7 @@
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {useLoaderData} from "react-router-dom" import {useLoaderData} from "react-router-dom"
import {MdMail} from "react-icons/md" import {MdMail} from "react-icons/md"
import {useTranslation} from "react-i18next"
import React, {ReactElement} from "react" import React, {ReactElement} from "react"
import {useMutation} from "@tanstack/react-query" import {useMutation} from "@tanstack/react-query"
@ -13,9 +14,8 @@ export interface ResendMailButtonProps {
email: string email: string
} }
export default function ResendMailButton({ export default function ResendMailButton({email}: ResendMailButtonProps): ReactElement {
email, const {t} = useTranslation()
}: ResendMailButtonProps): ReactElement {
const settings = useLoaderData() as ServerSettings const settings = useLoaderData() as ServerSettings
const mutation = useMutation<SimpleDetailResponse, AxiosError, void>(() => const mutation = useMutation<SimpleDetailResponse, AxiosError, void>(() =>
@ -30,7 +30,7 @@ export default function ResendMailButton({
startIcon={<MdMail />} startIcon={<MdMail />}
onClick={() => mutate()} onClick={() => mutate()}
> >
Resend Mail {t("components.ResendMailButton.label")}
</TimedButton> </TimedButton>
<MutationStatusSnackbar mutation={mutation} /> <MutationStatusSnackbar mutation={mutation} />
</> </>

View File

@ -14,6 +14,7 @@ import {
} from "@mui/material" } from "@mui/material"
import {MultiStepFormElement, OpenMailButton} from "~/components" import {MultiStepFormElement, OpenMailButton} from "~/components"
import {useTranslation} from "react-i18next"
import ResendMailButton from "~/route-widgets/SignupRoute/YouGotMail/ResendMailButton" import ResendMailButton from "~/route-widgets/SignupRoute/YouGotMail/ResendMailButton"
export interface YouGotMailProps { export interface YouGotMailProps {
@ -21,10 +22,9 @@ export interface YouGotMailProps {
onGoBack: () => void onGoBack: () => void
} }
export default function YouGotMail({ export default function YouGotMail({email, onGoBack}: YouGotMailProps): ReactElement {
email, const {t} = useTranslation()
onGoBack,
}: YouGotMailProps): ReactElement {
const [askToEditEmail, setAskToEditEmail] = useState<boolean>(false) const [askToEditEmail, setAskToEditEmail] = useState<boolean>(false)
const domain = email.split("@")[1] const domain = email.split("@")[1]
@ -43,30 +43,21 @@ export default function YouGotMail({
> >
<Grid item> <Grid item>
<Typography variant="h6" component="h2" align="center"> <Typography variant="h6" component="h2" align="center">
You got mail! {t("routes.SignupRoute.forms.mailVerification.title")}
</Typography> </Typography>
</Grid> </Grid>
<Grid item> <Grid item>
<Typography variant="subtitle1" component="p"> <Typography variant="subtitle1" component="p">
We sent you an email with a link to confirm your {t("routes.SignupRoute.forms.mailVerification.description")}
email address. Please check your inbox and click on
the link to continue.
</Typography> </Typography>
</Grid> </Grid>
<Grid item> <Grid item>
<Grid <Grid container alignItems="center" direction="row" spacing={2}>
container
alignItems="center"
direction="row"
spacing={2}
>
<Grid item> <Grid item>
<code>{email}</code> <code>{email}</code>
</Grid> </Grid>
<Grid item> <Grid item>
<IconButton <IconButton onClick={() => setAskToEditEmail(true)}>
onClick={() => setAskToEditEmail(true)}
>
<MdEdit /> <MdEdit />
</IconButton> </IconButton>
</Grid> </Grid>
@ -81,21 +72,21 @@ export default function YouGotMail({
</Grid> </Grid>
</MultiStepFormElement> </MultiStepFormElement>
<Dialog open={askToEditEmail}> <Dialog open={askToEditEmail}>
<DialogTitle>Edit email address?</DialogTitle> <DialogTitle>
{t("routes.SignupRoute.forms.mailVerification.editEmail.title")}
</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
Would you like to return to the previous step and edit {t("routes.SignupRoute.forms.mailVerification.editEmail.description")}
your email address?
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button <Button startIcon={<MdCancel />} onClick={() => setAskToEditEmail(false)}>
startIcon={<MdCancel />} {t("general.cancelLabel")}
onClick={() => setAskToEditEmail(false)} </Button>
> <Button onClick={onGoBack}>
Cancel {t("routes.SignupRoute.forms.mailVerification.editEmail.continueAction")}
</Button> </Button>
<Button onClick={onGoBack}>Yes, edit email</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</> </>

View File

@ -1,6 +1,7 @@
import {ReactElement, useContext} from "react" import {ReactElement, useContext} from "react"
import {useParams} from "react-router-dom" import {useParams} from "react-router-dom"
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import {useQuery} from "@tanstack/react-query" import {useQuery} from "@tanstack/react-query"
@ -12,6 +13,7 @@ import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext"
import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes" import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes"
export default function AliasDetailRoute(): ReactElement { export default function AliasDetailRoute(): ReactElement {
const {t} = useTranslation()
const params = useParams() const params = useParams()
const address = atob(params.addressInBase64 as string) const address = atob(params.addressInBase64 as string)
const {_decryptUsingMasterPassword, encryptionStatus} = const {_decryptUsingMasterPassword, encryptionStatus} =
@ -34,7 +36,7 @@ export default function AliasDetailRoute(): ReactElement {
) )
return ( return (
<SimplePage title="Alias Details"> <SimplePage title={t("routes.AliasDetailRoute.title")}>
<QueryResult<Alias | DecryptedAlias> query={query}> <QueryResult<Alias | DecryptedAlias> query={query}>
{alias => <AliasDetails alias={alias} />} {alias => <AliasDetails alias={alias} />}
</QueryResult> </QueryResult>

View File

@ -1,6 +1,7 @@
import {ReactElement, useState, useTransition} from "react" import {ReactElement, useState, useTransition} from "react"
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {MdSearch} from "react-icons/md" import {MdSearch} from "react-icons/md"
import {useTranslation} from "react-i18next"
import {useQuery} from "@tanstack/react-query" import {useQuery} from "@tanstack/react-query"
import {InputAdornment, TextField} from "@mui/material" import {InputAdornment, TextField} from "@mui/material"
@ -11,6 +12,8 @@ import AliasesDetails from "~/route-widgets/AliasesRoute/AliasesDetails"
import getAliases from "~/apis/get-aliases" import getAliases from "~/apis/get-aliases"
export default function AliasesRoute(): ReactElement { export default function AliasesRoute(): ReactElement {
const {t} = useTranslation()
const [searchValue, setSearchValue] = useState<string>("") const [searchValue, setSearchValue] = useState<string>("")
const [queryValue, setQueryValue] = useState<string>("") const [queryValue, setQueryValue] = useState<string>("")
const [, startTransition] = useTransition() const [, startTransition] = useTransition()
@ -25,7 +28,7 @@ export default function AliasesRoute(): ReactElement {
return ( return (
<SimplePage <SimplePage
title="Aliases" title={t("routes.AliasesRoute.title")}
pageOptionsActions={ pageOptionsActions={
<TextField <TextField
value={searchValue} value={searchValue}
@ -35,7 +38,10 @@ export default function AliasesRoute(): ReactElement {
setQueryValue(event.target.value) setQueryValue(event.target.value)
}) })
}} }}
label="Search" label={t("routes.AliasesRoute.pageActions.search.label")}
placeholder={t(
"routes.AliasesRoute.pageActions.search.placeholder",
)}
id="search" id="search"
InputProps={{ InputProps={{
startAdornment: ( startAdornment: (

View File

@ -1,10 +1,13 @@
import {ReactElement} from "react" import {ReactElement} from "react"
import {Link as RouterLink, Outlet} from "react-router-dom" import {Link as RouterLink, Outlet} from "react-router-dom"
import {MdAdd, MdLogin} from "react-icons/md" import {MdAdd, MdLogin} from "react-icons/md"
import {useTranslation} from "react-i18next"
import {Box, Button, Grid} from "@mui/material" import {Box, Button, Grid} from "@mui/material"
export default function AuthenticateRoute(): ReactElement { export default function AuthenticateRoute(): ReactElement {
const {t} = useTranslation()
return ( return (
<Box <Box
display="flex" display="flex"
@ -15,12 +18,7 @@ export default function AuthenticateRoute(): ReactElement {
> >
<div /> <div />
<Outlet /> <Outlet />
<Grid <Grid container spacing={2} justifyContent="center" marginBottom={2}>
container
spacing={2}
justifyContent="center"
marginBottom={2}
>
<Grid item> <Grid item>
<Button <Button
component={RouterLink} component={RouterLink}
@ -29,7 +27,7 @@ export default function AuthenticateRoute(): ReactElement {
size="small" size="small"
startIcon={<MdAdd />} startIcon={<MdAdd />}
> >
Sign Up {t("components.AuthenticateRoute.signup")}
</Button> </Button>
</Grid> </Grid>
<Grid item> <Grid item>
@ -40,7 +38,7 @@ export default function AuthenticateRoute(): ReactElement {
size="small" size="small"
startIcon={<MdLogin />} startIcon={<MdLogin />}
> >
Login {t("components.AuthenticateRoute.login")}
</Button> </Button>
</Grid> </Grid>
</Grid> </Grid>

View File

@ -1,4 +1,5 @@
import {ReactElement, useContext, useState} from "react" import {ReactElement, useContext, useState} from "react"
import {useTranslation} from "react-i18next"
import {Grid, Paper, Typography} from "@mui/material" import {Grid, Paper, Typography} from "@mui/material"
@ -9,16 +10,15 @@ import GenerateEmailReportsForm from "~/route-widgets/CompleteAccountRoute/Gener
import PasswordForm from "~/route-widgets/CompleteAccountRoute/PasswordForm" import PasswordForm from "~/route-widgets/CompleteAccountRoute/PasswordForm"
export default function CompleteAccountRoute(): ReactElement { export default function CompleteAccountRoute(): ReactElement {
const {t} = useTranslation()
const {encryptionStatus} = useContext(AuthContext) const {encryptionStatus} = useContext(AuthContext)
const navigateToNext = useNavigateToNext() const navigateToNext = useNavigateToNext()
// If query `setup` is `true`, skip directly to the setup // If query `setup` is `true`, skip directly to the setup
const [showGenerationReportForm, setShowGenerationReportForm] = useState( const [showGenerationReportForm, setShowGenerationReportForm] = useState(() => {
() => {
const searchParams = new URLSearchParams(location.search) const searchParams = new URLSearchParams(location.search)
return searchParams.get("setup") === "true" return searchParams.get("setup") === "true"
}, })
)
if (encryptionStatus === EncryptionStatus.Unavailable) { if (encryptionStatus === EncryptionStatus.Unavailable) {
return ( return (
@ -29,10 +29,7 @@ export default function CompleteAccountRoute(): ReactElement {
onYes={() => setShowGenerationReportForm(true)} onYes={() => setShowGenerationReportForm(true)}
onNo={navigateToNext} onNo={navigateToNext}
/>, />,
<PasswordForm <PasswordForm onDone={navigateToNext} key="password_form" />,
onDone={navigateToNext}
key="password_form"
/>,
]} ]}
index={showGenerationReportForm ? 1 : 0} index={showGenerationReportForm ? 1 : 0}
/> />
@ -51,13 +48,12 @@ export default function CompleteAccountRoute(): ReactElement {
> >
<Grid item> <Grid item>
<Typography variant="h6" component="h1" align="center"> <Typography variant="h6" component="h1" align="center">
Encryption already enabled {t("routes.CompleteAccountRoute.forms.available.title")}
</Typography> </Typography>
</Grid> </Grid>
<Grid item> <Grid item>
<Typography variant="body1"> <Typography variant="body1">
You already have encryption enabled. Changing passwords {t("routes.CompleteAccountRoute.forms.available.description")}
is currently not supported.
</Typography> </Typography>
</Grid> </Grid>
</Grid> </Grid>

View File

@ -1,13 +1,13 @@
import * as yup from "yup" import * as yup from "yup"
import {ReactElement, useContext} from "react" import {ReactElement, useContext} from "react"
import {useLocation, useNavigate} from "react-router-dom"
import {useFormik} from "formik" import {useFormik} from "formik"
import {MdLock} from "react-icons/md" import {MdLock} from "react-icons/md"
import {useTranslation} from "react-i18next"
import {InputAdornment} from "@mui/material" import {InputAdornment} from "@mui/material"
import {buildEncryptionPassword} from "~/utils" import {buildEncryptionPassword} from "~/utils"
import {useUser} from "~/hooks" import {useNavigateToNext, useUser} from "~/hooks"
import {PasswordField, SimpleForm} from "~/components" import {PasswordField, SimpleForm} from "~/components"
import AuthContext from "~/AuthContext/AuthContext" import AuthContext from "~/AuthContext/AuthContext"
@ -20,8 +20,8 @@ const schema = yup.object().shape({
}) })
export default function EnterDecryptionPassword(): ReactElement { export default function EnterDecryptionPassword(): ReactElement {
const navigate = useNavigate() const {t} = useTranslation()
const location = useLocation() const navigateToNext = useNavigateToNext()
const user = useUser() const user = useUser()
const {_setDecryptionPassword} = useContext(AuthContext) const {_setDecryptionPassword} = useContext(AuthContext)
@ -37,11 +37,13 @@ export default function EnterDecryptionPassword(): ReactElement {
) )
if (!_setDecryptionPassword(decryptionPassword)) { if (!_setDecryptionPassword(decryptionPassword)) {
setErrors({password: "Password is invalid."}) setErrors({
password: t(
"components.EnterDecryptionPassword.form.password.errors.invalidPassword",
),
})
} else { } else {
const nextUrl = navigateToNext()
new URLSearchParams(location.search).get("next") || "/"
setTimeout(() => navigate(nextUrl), 0)
} }
}, },
}) })
@ -49,10 +51,16 @@ export default function EnterDecryptionPassword(): ReactElement {
return ( return (
<form onSubmit={formik.handleSubmit}> <form onSubmit={formik.handleSubmit}>
<SimpleForm <SimpleForm
title="Decrypt reports" title={t("components.EnterDecryptionPassword.title")}
description="Please enter your password so that your reports can de decrypted." description={t(
cancelActionLabel="Don't decrypt" "components.EnterDecryptionPassword.description",
continueActionLabel="Continue" )}
cancelActionLabel={t(
"components.EnterDecryptionPassword.cancelAction",
)}
continueActionLabel={t(
"components.EnterDecryptionPassword.continueAction",
)}
isSubmitting={formik.isSubmitting} isSubmitting={formik.isSubmitting}
> >
{[ {[
@ -61,7 +69,12 @@ export default function EnterDecryptionPassword(): ReactElement {
fullWidth fullWidth
name="password" name="password"
id="password" id="password"
label="Password" label={t(
"components.EnterDecryptionPassword.form.password.label",
)}
placeholder={t(
"components.EnterDecryptionPassword.form.password.placeholder",
)}
value={formik.values.password} value={formik.values.password}
onChange={formik.handleChange} onChange={formik.handleChange}
disabled={formik.isSubmitting} disabled={formik.isSubmitting}

View File

@ -1,19 +1,21 @@
import {useParams} from "react-router-dom" import {useParams} from "react-router-dom"
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import React, {ReactElement} from "react" import React, {ReactElement} from "react"
import {useQuery} from "@tanstack/react-query" import {useQuery} from "@tanstack/react-query"
import {Box, Grid, List, Typography} from "@mui/material" import {List} from "@mui/material"
import {Report} from "~/server-types" import {DecryptedReportContent, Report} from "~/server-types"
import {getReport} from "~/apis" import {getReport} from "~/apis"
import {DecryptReport} from "~/components" import {DecryptReport, SimpleOverlayInformation, SimplePageBuilder} from "~/components"
import {WithEncryptionRequired} from "~/hocs"
import ProxiedImagesListItem from "~/route-widgets/ReportDetailRoute/ProxiedImagesListItem" import ProxiedImagesListItem from "~/route-widgets/ReportDetailRoute/ProxiedImagesListItem"
import QueryResult from "~/components/QueryResult" import QueryResult from "~/components/QueryResult"
import SimplePage from "~/components/SimplePage"
import SinglePixelImageTrackersListItem from "~/route-widgets/ReportDetailRoute/SinglePixelImageTrackersListItem" import SinglePixelImageTrackersListItem from "~/route-widgets/ReportDetailRoute/SinglePixelImageTrackersListItem"
export default function ReportDetailRoute(): ReactElement { function ReportDetailRoute(): ReactElement {
const {t} = useTranslation()
const params = useParams() const params = useParams()
const query = useQuery<Report, AxiosError>(["get_report", params.id], () => const query = useQuery<Report, AxiosError>(["get_report", params.id], () =>
@ -21,102 +23,89 @@ export default function ReportDetailRoute(): ReactElement {
) )
return ( return (
<SimplePage title="Report Details"> <SimplePageBuilder.Page title="Report Details">
<QueryResult<Report> query={query}> <QueryResult<Report> query={query}>
{encryptedReport => ( {encryptedReport => (
<DecryptReport <DecryptReport encryptedContent={encryptedReport.encryptedContent}>
encryptedContent={encryptedReport.encryptedContent}
>
{report => ( {report => (
<Grid container spacing={4}> <SimplePageBuilder.MultipleSections>
<Grid item xs={12}> {[
<Typography variant="h6" component="h2"> <SimplePageBuilder.Section
Email information key="information"
</Typography> label={t(
<Grid container columnSpacing={4}> "routes.ReportDetailRoute.sections.information.title",
<Grid item xs={12} md={6} lg={4}> )}
<Box component="dl">
<Typography
variant="overline"
component="dt"
> >
From <SimplePageBuilder.InformationContainer>
</Typography> {[
<Typography <SimpleOverlayInformation
variant="body1" key="from"
component="dd" label={t(
"routes.ReportDetailRoute.sections.information.form.from.label",
)}
> >
{ {
report.messageDetails (report as DecryptedReportContent)
.meta.from .messageDetails.meta.from
} }
</Typography> </SimpleOverlayInformation>,
</Box>
</Grid> <SimpleOverlayInformation
<Grid item xs={12} md={6} lg={4}> key="to"
<Box component="dl"> label={t(
<Typography "routes.ReportDetailRoute.sections.information.form.to.label",
variant="overline" )}
component="dt"
>
To
</Typography>
<Typography
variant="body1"
component="dd"
> >
{ {
report.messageDetails (report as DecryptedReportContent)
.meta.to .messageDetails.meta.to
} }
</Typography> </SimpleOverlayInformation>,
</Box>
</Grid> <SimpleOverlayInformation
<Grid item xs={12} lg={4}> key="subject"
<Box component="dl"> label={t(
<Typography "routes.ReportDetailRoute.sections.information.form.subject.label",
variant="overline" )}
component="dt"
>
Subject
</Typography>
<Typography
variant="body1"
component="dd"
> >
{ {
report.messageDetails (report as DecryptedReportContent)
.content.subject .messageDetails.content.subject
} }
</Typography> </SimpleOverlayInformation>,
</Box> ]}
</Grid> </SimplePageBuilder.InformationContainer>
</Grid> </SimplePageBuilder.Section>,
</Grid>
<Grid item> <SimplePageBuilder.Section
<Typography variant="h6" component="h2"> key="trackers"
Trackers label={t(
</Typography> "routes.ReportDetailRoute.sections.trackers.title",
)}
>
<List> <List>
<SinglePixelImageTrackersListItem <SinglePixelImageTrackersListItem
images={ images={
report.messageDetails.content (report as DecryptedReportContent)
.singlePixelImages .messageDetails.content.singlePixelImages
} }
/> />
<ProxiedImagesListItem <ProxiedImagesListItem
images={ images={
report.messageDetails.content (report as DecryptedReportContent)
.proxiedImages .messageDetails.content.proxiedImages
} }
/> />
</List> </List>
</Grid> </SimplePageBuilder.Section>,
</Grid> ]}
</SimplePageBuilder.MultipleSections>
)} )}
</DecryptReport> </DecryptReport>
)} )}
</QueryResult> </QueryResult>
</SimplePage> </SimplePageBuilder.Page>
) )
} }
export default WithEncryptionRequired(ReportDetailRoute)

View File

@ -2,22 +2,18 @@ import {ReactElement, useState} from "react"
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {MdList} from "react-icons/md" import {MdList} from "react-icons/md"
import {FaMask} from "react-icons/fa" import {FaMask} from "react-icons/fa"
import {useTranslation} from "react-i18next"
import groupArray from "group-array" import groupArray from "group-array"
import sortArray from "sort-array" import sortArray from "sort-array"
import {useQuery} from "@tanstack/react-query" import {useQuery} from "@tanstack/react-query"
import { import {InputAdornment, List, MenuItem, TextField, Typography} from "@mui/material"
InputAdornment,
List,
MenuItem,
TextField,
Typography,
} from "@mui/material"
import {DecryptedReportContent, PaginationResult, Report} from "~/server-types" import {DecryptedReportContent, PaginationResult, Report} from "~/server-types"
import {getReports} from "~/apis" import {getReports} from "~/apis"
import {WithEncryptionRequired} from "~/hocs" import {WithEncryptionRequired} from "~/hocs"
import {DecryptReport} from "~/components" import {DecryptReport} from "~/components"
import {createEnumMapFromTranslation} from "~/utils"
import QueryResult from "~/components/QueryResult" import QueryResult from "~/components/QueryResult"
import ReportInformationItem from "~/route-widgets/ReportsRoute/ReportInformationItem" import ReportInformationItem from "~/route-widgets/ReportsRoute/ReportInformationItem"
import SimplePage from "~/components/SimplePage" import SimplePage from "~/components/SimplePage"
@ -27,25 +23,21 @@ enum SortingView {
GroupByAlias = "GroupByAlias", GroupByAlias = "GroupByAlias",
} }
const SORTING_VIEW_NAME_MAP: Record<SortingView, string> = {
[SortingView.List]: "List reports by their date",
[SortingView.GroupByAlias]: "Group reports by their aliases",
}
const SORTING_VIEW_ICON_MAP: Record<SortingView, ReactElement> = { const SORTING_VIEW_ICON_MAP: Record<SortingView, ReactElement> = {
[SortingView.List]: <MdList />, [SortingView.List]: <MdList />,
[SortingView.GroupByAlias]: <FaMask />, [SortingView.GroupByAlias]: <FaMask />,
} }
const SORTING_VIEW_NAME_MAP: Record<SortingView, string> = createEnumMapFromTranslation(
"routes.ReportsRoute.pageActions.sort",
SortingView,
)
function ReportsRoute(): ReactElement { function ReportsRoute(): ReactElement {
const query = useQuery<PaginationResult<Report>, AxiosError>( const {t} = useTranslation()
["get_reports"],
getReports,
)
const [sortingView, setSortingView] = useState<SortingView>( const query = useQuery<PaginationResult<Report>, AxiosError>(["get_reports"], getReports)
SortingView.List,
) const [sortingView, setSortingView] = useState<SortingView>(SortingView.List)
return ( return (
<SimplePage <SimplePage
@ -53,9 +45,7 @@ function ReportsRoute(): ReactElement {
pageOptionsActions={ pageOptionsActions={
<TextField <TextField
value={sortingView} value={sortingView}
onChange={event => onChange={event => setSortingView(event.target.value as SortingView)}
setSortingView(event.target.value as SortingView)
}
label="Sorting" label="Sorting"
id="sorting" id="sorting"
InputProps={{ InputProps={{
@ -69,7 +59,7 @@ function ReportsRoute(): ReactElement {
> >
{Object.keys(SORTING_VIEW_NAME_MAP).map(name => ( {Object.keys(SORTING_VIEW_NAME_MAP).map(name => (
<MenuItem key={name} value={name}> <MenuItem key={name} value={name}>
{SORTING_VIEW_NAME_MAP[name as SortingView]} {t(SORTING_VIEW_NAME_MAP[name as SortingView])}
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
@ -115,18 +105,12 @@ function ReportsRoute(): ReactElement {
> >
{alias} {alias}
</Typography> </Typography>
{reports.map( {reports.map(report => (
report => (
<ReportInformationItem <ReportInformationItem
report={ report={report}
report key={report.id}
}
key={
report.id
}
/> />
), ))}
)}
</> </>
), ),
) )

View File

@ -1,20 +1,15 @@
import {useTranslation} from "react-i18next"
import React, {ReactElement} from "react" import React, {ReactElement} from "react"
import {Grid, Typography} from "@mui/material" import {SimplePageBuilder} from "~/components"
import AliasesPreferencesForm from "~/route-widgets/SettingsRoute/AliasesPreferencesForm" import AliasesPreferencesForm from "~/route-widgets/SettingsRoute/AliasesPreferencesForm"
export default function SettingsRoute(): ReactElement { export default function SettingsRoute(): ReactElement {
const {t} = useTranslation()
return ( return (
<Grid container spacing={4}> <SimplePageBuilder.Page title={t("routes.SettingsRoute.title")}>
<Grid item>
<Typography variant="h5" component="h2">
Settings
</Typography>
</Grid>
<Grid item>
<AliasesPreferencesForm /> <AliasesPreferencesForm />
</Grid> </SimplePageBuilder.Page>
</Grid>
) )
} }

View File

@ -3,6 +3,7 @@ import {useLoaderData, useNavigate} from "react-router-dom"
import {useAsync, useLocalStorage} from "react-use" import {useAsync, useLocalStorage} from "react-use"
import {MdCancel} from "react-icons/md" import {MdCancel} from "react-icons/md"
import {AxiosError} from "axios" import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import React, {ReactElement, useContext} from "react" import React, {ReactElement, useContext} from "react"
import {Grid, Paper, Typography, useTheme} from "@mui/material" import {Grid, Paper, Typography, useTheme} from "@mui/material"
@ -18,6 +19,8 @@ const emailSchema = yup.string().email()
export default function VerifyEmailRoute(): ReactElement { export default function VerifyEmailRoute(): ReactElement {
const theme = useTheme() const theme = useTheme()
const navigate = useNavigate() const navigate = useNavigate()
const {t} = useTranslation()
const {login} = useContext(AuthContext) const {login} = useContext(AuthContext)
const [_, setEmail] = useLocalStorage<string>("signup-form-state-email", "") const [_, setEmail] = useLocalStorage<string>("signup-form-state-email", "")
const {email, token} = useQueryParams<{ const {email, token} = useQueryParams<{
@ -29,7 +32,7 @@ export default function VerifyEmailRoute(): ReactElement {
const tokenSchema = yup const tokenSchema = yup
.string() .string()
.length(serverSettings.emailVerificationLength) .length(serverSettings.emailVerificationLength)
.test("token", "Invalid token", token => { .test("token", t("routes.VerifyEmailRoute.errors.code.invalid") as string, token => {
if (!token) { if (!token) {
return false return false
} }
@ -38,17 +41,16 @@ export default function VerifyEmailRoute(): ReactElement {
return token.split("").every(char => chars.includes(char)) return token.split("").every(char => chars.includes(char))
}) })
const {mutateAsync} = useMutation< const {mutateAsync} = useMutation<AuthenticationDetails, AxiosError, VerifyEmailData>(
AuthenticationDetails, verifyEmail,
AxiosError, {
VerifyEmailData
>(verifyEmail, {
onSuccess: ({user}) => { onSuccess: ({user}) => {
setEmail("") setEmail("")
login(user) login(user)
navigate("/auth/complete-account") navigate("/auth/complete-account")
}, },
}) },
)
const {loading} = useAsync(async () => { const {loading} = useAsync(async () => {
await emailSchema.validate(email) await emailSchema.validate(email)
await tokenSchema.validate(token) await tokenSchema.validate(token)
@ -71,34 +73,23 @@ export default function VerifyEmailRoute(): ReactElement {
> >
<Grid item> <Grid item>
<Typography variant="h5" component="h1" align="center"> <Typography variant="h5" component="h1" align="center">
Verify your email {t("routes.VerifyEmailRoute.title")}
</Typography> </Typography>
</Grid> </Grid>
{loading ? ( {loading ? (
<Grid item> <Grid item>
<Typography <Typography variant="subtitle1" component="p" align="center">
variant="subtitle1" {t("routes.VerifyEmailRoute.isLoading")}
component="p"
align="center"
>
Verifying your email...
</Typography> </Typography>
</Grid> </Grid>
) : ( ) : (
<> <>
<Grid item> <Grid item>
<MdCancel <MdCancel size={100} color={theme.palette.error.main} />
size={100}
color={theme.palette.error.main}
/>
</Grid> </Grid>
<Grid item> <Grid item>
<Typography <Typography variant="subtitle1" component="p" align="center">
variant="subtitle1" {t("routes.VerifyEmailRoute.isCodeInvalid")}
component="p"
align="center"
>
Sorry, but this verification code is invalid.
</Typography> </Typography>
</Grid> </Grid>
</> </>

View File

@ -0,0 +1,9 @@
export default function createEnumMapFromTranslation<T extends Record<string, string>>(
prefix: string,
TEnum: T,
): Record<keyof T, string> {
return Object.fromEntries(Object.values(TEnum).map(key => [key, `${prefix}.${key}`])) as Record<
keyof T,
string
>
}

View File

@ -1,13 +0,0 @@
import {AxiosError} from "axios"
import {FastAPIError} from "~/utils/parse-fastapi-error"
export default function getErrorMessage(
error: AxiosError<FastAPIError>,
): string {
if (typeof error.response?.data?.detail === "string") {
return error.response.data.detail
}
return "There was an error."
}

View File

@ -12,3 +12,5 @@ export * from "./decrypt-string"
export {default as decryptString} from "./decrypt-string" export {default as decryptString} from "./decrypt-string"
export * from "./when-enter-pressed" export * from "./when-enter-pressed"
export {default as whenEnterPressed} from "./when-enter-pressed" export {default as whenEnterPressed} from "./when-enter-pressed"
export * from "./create-enum-map-from-translation"
export {default as createEnumMapFromTranslation} from "./create-enum-map-from-translation"