diff --git a/.eslintrc.json b/.eslintrc.json index 1b59e82..0ee1615 100755 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,7 @@ { "extends": [ "plugin:react/recommended", + "plugin:react-i18n/recommended", "plugin:compat/recommended", "prettier" ], @@ -17,7 +18,7 @@ "ecmaVersion": 13, "sourceType": "module" }, - "plugins": ["ordered-imports", "react", "@typescript-eslint"], + "plugins": ["react-i18n", "ordered-imports", "react", "@typescript-eslint"], "rules": { "react/react-in-jsx-scope": "off", "compat/compat": "error", diff --git a/.prettierrc b/.prettierrc index 8eaaabc..02af758 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,6 @@ { "endOfLine": "lf", - "printWidth": 80, + "printWidth": 100, "tabWidth": 4, "trailingComma": "all", "singleQuote": false, diff --git a/package.json b/package.json index d485a33..b21a7af 100755 --- a/package.json +++ b/package.json @@ -24,12 +24,17 @@ "deep-equal": "^2.0.5", "formik": "^2.2.9", "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", "in-milliseconds": "^1.2.0", "in-seconds": "^1.2.0", "openpgp": "^5.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-i18next": "^12.0.0", "react-icons": "^4.4.0", "react-router-dom": "^6.4.2", "react-use": "^17.4.0", @@ -37,7 +42,8 @@ "sort-array": "^4.1.5", "ua-parser-js": "^1.0.2", "use-system-theme": "^0.1.1", - "yup": "^0.32.11" + "yup": "^0.32.11", + "yup-locales": "^1.2.10" }, "devDependencies": { "@types/crypto-js": "^4.1.1", @@ -62,6 +68,7 @@ "eslint-plugin-compat": "^4.0.2", "eslint-plugin-ordered-imports": "^0.6.0", "eslint-plugin-react": "^7.31.10", + "eslint-plugin-react-i18n": "^1.0.3", "prettier": "^2.7.1", "typescript": "^4.6.4", "vite": "^3.1.0" diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json new file mode 100644 index 0000000..2c45f8b --- /dev/null +++ b/public/locales/en/translation.json @@ -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": "" + } + } + } +} diff --git a/src/App.tsx b/src/App.tsx index 52b8cd1..9536418 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,8 @@ import SettingsRoute from "~/routes/SettingsRoute" import SignupRoute from "~/routes/SignupRoute" import VerifyEmailRoute from "~/routes/VerifyEmailRoute" +import "./init-i18n" + const router = createBrowserRouter([ { path: "/", diff --git a/src/components/AliasTypeIndicator.tsx b/src/components/AliasTypeIndicator.tsx index 19d868f..3b7505e 100644 --- a/src/components/AliasTypeIndicator.tsx +++ b/src/components/AliasTypeIndicator.tsx @@ -1,9 +1,11 @@ import {ReactElement} from "react" import {FaHashtag, FaRandom} from "react-icons/fa" +import {useTranslation} from "react-i18next" import {Box, Tooltip} from "@mui/material" import {AliasType} from "~/server-types" +import {createEnumMapFromTranslation} from "~/utils" export interface AliasTypeIndicatorProps { type: AliasType @@ -14,16 +16,16 @@ const ALIAS_TYPE_ICON_MAP: Record = { [AliasType.CUSTOM]: , } -const ALIAS_TYPE_TOOLTIP_MAP: Record = { - [AliasType.RANDOM]: "This is a randomly generated alias", - [AliasType.CUSTOM]: "This is a custom-made alias", -} +const ALIAS_TYPE_TOOLTIP_MAP = createEnumMapFromTranslation( + "components.AliasTypeIndicator", + AliasType, +) + +export default function AliasTypeIndicator({type}: AliasTypeIndicatorProps): ReactElement { + const {t} = useTranslation() -export default function AliasTypeIndicator({ - type, -}: AliasTypeIndicatorProps): ReactElement { return ( - + {ALIAS_TYPE_ICON_MAP[type]} diff --git a/src/components/DecryptionPasswordMissingAlert.tsx b/src/components/DecryptionPasswordMissingAlert.tsx index 3c798b0..5d85f0f 100644 --- a/src/components/DecryptionPasswordMissingAlert.tsx +++ b/src/components/DecryptionPasswordMissingAlert.tsx @@ -1,6 +1,7 @@ import {useContext} from "react" import {MdLock} from "react-icons/md" import {Link as RouterLink} from "react-router-dom" +import {useTranslation} from "react-i18next" import {Button, Grid, Typography, useTheme} from "@mui/material" @@ -14,6 +15,7 @@ export interface WithEncryptionRequiredProps { export default function DecryptionPasswordMissingAlert({ children = <>, }: WithEncryptionRequiredProps): JSX.Element { + const {t} = useTranslation() const {handleAnchorClick} = useContext(LockNavigationContext) const {encryptionStatus} = useContext(AuthContext) const theme = useTheme() @@ -22,7 +24,7 @@ export default function DecryptionPasswordMissingAlert({ case EncryptionStatus.Unavailable: { return ( - Encryption required + {t("components.DecryptionPasswordMissingAlert.unavailable.title")} - You need to set up encryption to use this feature. + {t("components.DecryptionPasswordMissingAlert.unavailable.description")} @@ -47,7 +49,9 @@ export default function DecryptionPasswordMissingAlert({ startIcon={} onClick={handleAnchorClick} > - Setup encryption + {t( + "components.DecryptionPasswordMissingAlert.unavailable.continueAction", + )} @@ -57,7 +61,7 @@ export default function DecryptionPasswordMissingAlert({ case EncryptionStatus.PasswordRequired: { return ( - Password required + {t("components.DecryptionPasswordMissingAlert.passwordRequired.title")} - Your decryption password is required to view this - section. + {t( + "components.DecryptionPasswordMissingAlert.passwordRequired.description", + )} @@ -82,7 +87,9 @@ export default function DecryptionPasswordMissingAlert({ startIcon={} onClick={handleAnchorClick} > - Enter Password + {t( + "components.DecryptionPasswordMissingAlert.passwordRequired.continueAction", + )} diff --git a/src/components/ErrorLoadingDataMessage.tsx b/src/components/ErrorLoadingDataMessage.tsx index 26596b8..1072813 100644 --- a/src/components/ErrorLoadingDataMessage.tsx +++ b/src/components/ErrorLoadingDataMessage.tsx @@ -1,3 +1,4 @@ +import {useTranslation} from "react-i18next" import React, {ReactElement} from "react" import {Alert, Button, Grid} from "@mui/material" @@ -11,13 +12,17 @@ export default function ErrorLoadingDataMessage({ message, onRetry, }: ErrorLoadingDataMessageProps): ReactElement { + const {t} = useTranslation() + return ( {message} - + ) diff --git a/src/components/LoadingData.tsx b/src/components/LoadingData.tsx index d572734..8e43ddb 100644 --- a/src/components/LoadingData.tsx +++ b/src/components/LoadingData.tsx @@ -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" @@ -6,24 +7,8 @@ export interface LoadingDataProps { message?: string } -export default function LoadingData({ - message = "Loading", -}: LoadingDataProps): ReactElement { - const [ellipsis, setEllipsis] = useState("") - - useEffect(() => { - const interval = setInterval(() => { - setEllipsis((value: string) => { - if (value.length === 3) { - return "" - } - - return value + "." - }) - }, 300) - - return () => clearInterval(interval) - }, []) +export default function LoadingData({message = "Loading"}: LoadingDataProps): ReactElement { + const {t} = useTranslation() return ( @@ -31,7 +16,7 @@ export default function LoadingData({ - Loading{ellipsis} + {t("general.loading")} ) diff --git a/src/components/MutationStatusSnackbar.tsx b/src/components/MutationStatusSnackbar.tsx index 85e85a8..30540d8 100644 --- a/src/components/MutationStatusSnackbar.tsx +++ b/src/components/MutationStatusSnackbar.tsx @@ -1,12 +1,12 @@ import {AxiosError} from "axios" import {ReactElement, useEffect, useLayoutEffect, useRef, useState} from "react" +import {useTranslation} from "react-i18next" import {UseMutationResult} from "@tanstack/react-query" import {Alert, AlertProps, Snackbar} from "@mui/material" import {FastAPIError} from "~/utils" import {SimpleDetailResponse} from "~/server-types" -import getErrorMessage from "~/utils/get-error-message" export interface MutationStatusSnackbarProps< TData = unknown, @@ -29,12 +29,9 @@ export default function MutationStatusSnackbar< mutation, successMessage, errorMessage, -}: MutationStatusSnackbarProps< - TData, - TError, - TVariables, - TContext ->): ReactElement { +}: MutationStatusSnackbarProps): ReactElement { + const {t} = useTranslation() + const $severity = useRef() const $message = useRef() @@ -60,22 +57,22 @@ export default function MutationStatusSnackbar< $message.current = (() => { if (mutation.isError) { // @ts-ignore - return errorMessage ?? getErrorMessage(mutation.error) + return ( + errorMessage || + (mutation.error.response?.data as any).detail || + t("general.defaultError") + ) } if (mutation.isSuccess) { - return successMessage ?? mutation.data?.detail ?? "Success!" + return successMessage ?? mutation.data?.detail ?? t("general.defaultSuccess") } })() } }, [mutation.isSuccess, mutation.isError]) return ( - setOpen(false)} - autoHideDuration={5000} - > + setOpen(false)} autoHideDuration={5000}> {$message.current} diff --git a/src/components/OpenMailButton.tsx b/src/components/OpenMailButton.tsx index 6d37bf2..edf1851 100644 --- a/src/components/OpenMailButton.tsx +++ b/src/components/OpenMailButton.tsx @@ -5,24 +5,21 @@ import UAParser from "ua-parser-js" import {Button} from "@mui/material" import {APP_LINK_MAP} from "~/utils" +import {useTranslation} from "react-i18next" export interface OpenMailButtonProps { domain: string } -export default function OpenMailButton({ - domain, -}: OpenMailButtonProps): ReactElement { +export default function OpenMailButton({domain}: OpenMailButtonProps): ReactElement { + const {t} = useTranslation() + const userAgent = new UAParser() if (userAgent.getOS().name === "Android" && APP_LINK_MAP[domain]) { return ( - ) } diff --git a/src/components/SimpleInformationContainer.tsx b/src/components/SimpleInformationContainer.tsx new file mode 100644 index 0000000..dace417 --- /dev/null +++ b/src/components/SimpleInformationContainer.tsx @@ -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 ( + + {children.map(child => ( + + {child} + + ))} + + ) +} diff --git a/src/components/SimpleMultipleSections.tsx b/src/components/SimpleMultipleSections.tsx new file mode 100644 index 0000000..443a0fe --- /dev/null +++ b/src/components/SimpleMultipleSections.tsx @@ -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 ( + + {children.map(child => ( + + {child} + + ))} + + ) +} diff --git a/src/components/SimpleOverlayInformation.tsx b/src/components/SimpleOverlayInformation.tsx new file mode 100644 index 0000000..42d233d --- /dev/null +++ b/src/components/SimpleOverlayInformation.tsx @@ -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 ( + + + {label} + + + + {icon && {icon}} + + {children || {emptyTextValue}} + + + + + ) +} diff --git a/src/components/SimpleSection.tsx b/src/components/SimpleSection.tsx new file mode 100644 index 0000000..1af230b --- /dev/null +++ b/src/components/SimpleSection.tsx @@ -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 ( + + + + {label} + + + {children} + + ) +} diff --git a/src/components/TimedButton.tsx b/src/components/TimedButton.tsx index dcc56f2..6f1adad 100644 --- a/src/components/TimedButton.tsx +++ b/src/components/TimedButton.tsx @@ -5,6 +5,7 @@ import {LoadingButton, LoadingButtonProps} from "@mui/lab" import {useIntervalUpdate} from "~/hooks" import {isDev} from "~/constants/development" +import {useTranslation} from "react-i18next" export interface TimedButtonProps extends LoadingButtonProps { interval: number @@ -17,7 +18,10 @@ export default function TimedButton({ disabled: parentDisabled = false, ...props }: TimedButtonProps): ReactElement { + const {t} = useTranslation() + const [startDate, resetInterval] = useIntervalUpdate(1000) + const secondsPassed = differenceInSeconds(new Date(), startDate) const secondsLeft = (isDev ? 3 : interval) - secondsPassed @@ -30,8 +34,10 @@ export default function TimedButton({ onClick?.(event) }} > - {children} - {secondsLeft > 0 && ({secondsLeft})} + {children} + {secondsLeft > 0 && ( + {t("components.TimedButton.remainingTime", {count: secondsLeft})} + )} ) } diff --git a/src/components/index.ts b/src/components/index.ts index 2dfae21..2eb9270 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -30,3 +30,9 @@ export * from "./DecryptionPasswordMissingAlert" export {default as DecryptionPasswordMissingAlert} from "./DecryptionPasswordMissingAlert" export * 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" diff --git a/src/components/simple-page-builder.ts b/src/components/simple-page-builder.ts new file mode 100644 index 0000000..27344ec --- /dev/null +++ b/src/components/simple-page-builder.ts @@ -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" diff --git a/src/constants/enum-mappings.ts b/src/constants/enum-mappings.ts new file mode 100644 index 0000000..e85097b --- /dev/null +++ b/src/constants/enum-mappings.ts @@ -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, +) diff --git a/src/constants/enum_mappings.ts b/src/constants/enum_mappings.ts deleted file mode 100644 index e49681f..0000000 --- a/src/constants/enum_mappings.ts +++ /dev/null @@ -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", -} diff --git a/src/init-i18n.ts b/src/init-i18n.ts new file mode 100644 index 0000000..5bf053a --- /dev/null +++ b/src/init-i18n.ts @@ -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) diff --git a/src/route-widgets/AliasDetailRoute/AddWebsiteField.tsx b/src/route-widgets/AliasDetailRoute/AddWebsiteField.tsx index 498b664..10510d0 100644 --- a/src/route-widgets/AliasDetailRoute/AddWebsiteField.tsx +++ b/src/route-widgets/AliasDetailRoute/AddWebsiteField.tsx @@ -14,6 +14,7 @@ import { import {URL_REGEX} from "~/constants/values" import {whenEnterPressed} from "~/utils" +import {useTranslation} from "react-i18next" export interface AddWebsiteFieldProps { onAdd: (website: string) => Promise @@ -32,6 +33,7 @@ export default function AddWebsiteField({ onAdd, isLoading, }: AddWebsiteFieldProps): ReactElement { + const {t} = useTranslation() const websiteFormik = useFormik({ validationSchema: WEBSITE_SCHEMA, initialValues: { @@ -60,13 +62,18 @@ export default function AddWebsiteField({ }) return ( - + {(websiteFormik.touched.url && websiteFormik.errors.url) || - "Add a website to this alias. Used to autofill."} + t( + "routes.AliasDetailRoute.sections.notes.form.websites.helperText", + )} diff --git a/src/route-widgets/AliasDetailRoute/AliasDetails.tsx b/src/route-widgets/AliasDetailRoute/AliasDetails.tsx index c6c46fe..02ffaa1 100644 --- a/src/route-widgets/AliasDetailRoute/AliasDetails.tsx +++ b/src/route-widgets/AliasDetailRoute/AliasDetails.tsx @@ -1,9 +1,10 @@ import {useParams} from "react-router" import {ReactElement, useContext} from "react" +import {useTranslation} from "react-i18next" import {Grid, Typography} from "@mui/material" -import {AliasTypeIndicator, DecryptionPasswordMissingAlert} from "~/components" +import {AliasTypeIndicator, DecryptionPasswordMissingAlert, SimplePageBuilder} from "~/components" import {Alias, DecryptedAlias} from "~/server-types" import {useUIState} from "~/hooks" import AliasNotesForm from "~/route-widgets/AliasDetailRoute/AliasNotesForm" @@ -15,25 +16,17 @@ export interface AliasDetailsProps { alias: Alias | DecryptedAlias } -const getDomain = (url: string): string => { - const {hostname, port} = new URL(url) - return `${hostname}${port ? `:${port}` : ""}` -} - -export default function AliasDetails({ - alias: aliasValue, -}: AliasDetailsProps): ReactElement { +export default function AliasDetails({alias: aliasValue}: AliasDetailsProps): ReactElement { + const {t} = useTranslation() const params = useParams() const {encryptionStatus} = useContext(AuthContext) const address = atob(params.addressInBase64 as string) - const [aliasUIState, setAliasUIState] = useUIState( - aliasValue, - ) + const [aliasUIState, setAliasUIState] = useUIState(aliasValue) return ( - - + + {[ @@ -48,45 +41,25 @@ export default function AliasDetails({ onChanged={setAliasUIState} /> - - - - {encryptionStatus === EncryptionStatus.Available ? ( - - ) : ( - - )} - - - - - - Settings - - - - , +
+ {encryptionStatus === EncryptionStatus.Available ? ( + - - - - 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. - - - - - + ) : ( + + )} +
, + + + , + ]} + ) } diff --git a/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx b/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx index 075742d..7b53d04 100644 --- a/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx +++ b/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx @@ -26,9 +26,10 @@ import { } from "@mui/material" 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 {UpdateAliasData, updateAlias} from "~/apis" +import {useTranslation} from "react-i18next" import AddWebsiteField from "~/route-widgets/AliasDetailRoute/AddWebsiteField" import AuthContext from "~/AuthContext/AuthContext" import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes" @@ -57,27 +58,22 @@ const SCHEMA = yup.object().shape({ ), }) -export default function AliasNotesForm({ - id, - notes, - onChanged, -}: AliasNotesFormProps): ReactElement { - const {_encryptUsingMasterPassword, _decryptUsingMasterPassword} = - useContext(AuthContext) - const {mutateAsync, isSuccess} = useMutation< - Alias, - AxiosError, - UpdateAliasData - >(values => updateAlias(id, values), { - onSuccess: newAlias => { - ;(newAlias as any as DecryptedAlias).notes = decryptAliasNotes( - newAlias.encryptedNotes, - _decryptUsingMasterPassword, - ) +export default function AliasNotesForm({id, notes, onChanged}: AliasNotesFormProps): ReactElement { + const {t} = useTranslation() + const {_encryptUsingMasterPassword, _decryptUsingMasterPassword} = useContext(AuthContext) + const {mutateAsync, isSuccess} = useMutation( + values => updateAlias(id, values), + { + onSuccess: newAlias => { + ;(newAlias as any as DecryptedAlias).notes = decryptAliasNotes( + newAlias.encryptedNotes, + _decryptUsingMasterPassword, + ) - onChanged(newAlias as any as DecryptedAlias) + onChanged(newAlias as any as DecryptedAlias) + }, }, - }) + ) const initialValues = useMemo( () => ({ personalNotes: notes.data.personalNotes, @@ -101,9 +97,7 @@ export default function AliasNotesForm({ }, }) - const data = _encryptUsingMasterPassword( - JSON.stringify(newNotes), - ) + const data = _encryptUsingMasterPassword(JSON.stringify(newNotes)) await mutateAsync({ encryptedNotes: data, }) @@ -118,12 +112,12 @@ export default function AliasNotesForm({ return ( <>
- + - Notes + {t("routes.AliasDetailRoute.sections.notes.title")} @@ -133,13 +127,9 @@ export default function AliasNotesForm({ onClick={async () => { if ( isInEditMode && - !deepEqual( - initialValues, - formik.values, - { - strict: true, - }, - ) + !deepEqual(initialValues, formik.values, { + strict: true, + }) ) { await formik.submitForm() } @@ -147,117 +137,93 @@ export default function AliasNotesForm({ setIsInEditMode(!isInEditMode) }} > - {isInEditMode ? ( - - ) : ( - - )} + {isInEditMode ? : } - + {notes.data.createdAt && ( - } + label={t( + "routes.AliasDetailRoute.sections.notes.form.createdAt.label", + )} > - - - - - + {notes.data.createdAt && ( + - {format( - notes.data.createdAt, - "Pp", - )} + {format(notes.data.createdAt, "Pp")} - - + )} + )} - - - - Personal Notes - - - - {isInEditMode ? ( - - - - ), - }} - /> - ) : ( - - {notes.data.personalNotes} - - )} - - + + {isInEditMode ? ( + + + + ), + }} + /> + ) : ( + notes.data.personalNotes + )} + - - - - Websites - - + {isInEditMode ? ( { - await formik.setFieldValue( - "websites", - [ - ...formik.values - .websites, - { - url: website, - }, - ], - ) + await formik.setFieldValue("websites", [ + ...formik.values.websites, + { + url: website, + }, + ]) }} isLoading={formik.isSubmitting} /> @@ -267,26 +233,15 @@ export default function AliasNotesForm({ render={arrayHelpers => ( {formik.values.websites.map( - ( - website, - index, - ) => ( - + (website, index) => ( + - { - website.url - } + {website.url} - ) : ( + ) : notes.data.websites.length ? ( - {notes.data.websites.length ? ( - - {notes.data.websites.map( - website => ( - - - - - - { - website.url - } - - - ), - )} - - ) : ( - - You haven't used this - alias on any site yet. - - )} + + {notes.data.websites.map(website => ( + + + + + {website.url} + + ))} + - )} - + ) : null} + @@ -355,7 +289,7 @@ export default function AliasNotesForm({ ) diff --git a/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx b/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx index 6849c56..6e7d9be 100644 --- a/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx +++ b/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx @@ -5,26 +5,22 @@ import {useFormik} from "formik" import {FaFile} from "react-icons/fa" import {MdCheckCircle} from "react-icons/md" import {AxiosError} from "axios" +import {useTranslation} from "react-i18next" 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 {useMutation} from "@tanstack/react-query" import Icon from "@mdi/react" -import { - 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 {Alias, DecryptedAlias, ImageProxyFormatType, ProxyUserAgentType} from "~/server-types" import {UpdateAliasData, updateAlias} from "~/apis" import {ErrorSnack, SuccessSnack} from "~/components" 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 SelectField from "~/route-widgets/SettingsRoute/SelectField" @@ -44,29 +40,36 @@ interface Form { detail?: string } -const SCHEMA = yup.object().shape({ - removeTrackers: yup.mixed().oneOf([true, false, null]), - createMailReport: yup.mixed().oneOf([true, false, null]), - proxyImages: yup.mixed().oneOf([true, false, null]), - imageProxyFormat: yup - .mixed() - .oneOf([null, ...Object.values(ImageProxyFormatType)]), - imageProxyUserAgent: yup - .mixed() - .oneOf([null, ...Object.values(ProxyUserAgentType)]), -}) - export default function AliasPreferencesForm({ alias, onChanged, }: AliasPreferencesFormProps): ReactElement { - const {mutateAsync, isSuccess} = useMutation< - Alias, - AxiosError, - UpdateAliasData - >(data => updateAlias(alias.id, data), { - onSuccess: onChanged, + const {t} = useTranslation() + const SCHEMA = yup.object().shape({ + removeTrackers: yup + .mixed() + .oneOf([true, false, null]) + .label(t("relations.alias.settings.removeTrackers.label")), + createMailReport: yup + .mixed() + .oneOf([true, false, null]) + .label(t("relations.alias.settings.createMailReport.label")), + proxyImages: yup.mixed().oneOf([true, false, null]), + imageProxyFormat: yup + .mixed() + .oneOf([null, ...Object.values(ImageProxyFormatType)]) + .label(t("relations.alias.settings.imageProxyFormat.label")), + imageProxyUserAgent: yup + .mixed() + .oneOf([null, ...Object.values(ProxyUserAgentType)]) + .label(t("relations.alias.settings.imageProxyUserAgent.label")), }) + const {mutateAsync, isSuccess} = useMutation( + data => updateAlias(alias.id, data), + { + onSuccess: onChanged, + }, + ) const formik = useFormik
({ enableReinitialize: true, initialValues: { @@ -97,6 +100,7 @@ export default function AliasPreferencesForm({ } name="removeTrackers" @@ -113,14 +117,9 @@ export default function AliasPreferencesForm({ - } + icon={} name="createMailReport" /> @@ -128,23 +127,20 @@ export default function AliasPreferencesForm({ } name="proxyImages" /> - + } name="imageProxyFormat" @@ -155,7 +151,9 @@ export default function AliasPreferencesForm({ } > - Save Settings + {t("relations.alias.settings.saveAction")} + + + {t("routes.AliasDetailRoute.sections.settings.description")} + + ) diff --git a/src/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch.tsx b/src/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch.tsx index 7f02033..5a44d08 100644 --- a/src/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch.tsx +++ b/src/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch.tsx @@ -1,5 +1,6 @@ -import {ReactElement, useContext, useEffect, useState} from "react" +import {ReactElement, useContext, useState} from "react" import {AxiosError} from "axios" +import {useTranslation} from "react-i18next" import {Switch} from "@mui/material" import {useMutation} from "@tanstack/react-query" @@ -8,6 +9,7 @@ import {Alias, DecryptedAlias} from "~/server-types" import {UpdateAliasData, updateAlias} from "~/apis" import {parseFastAPIError} from "~/utils" import {ErrorSnack, SuccessSnack} from "~/components" +import {useUIState} from "~/hooks" import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext" import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes" @@ -23,10 +25,11 @@ export default function ChangeAliasActivationStatusSwitch({ isActive, onChanged, }: ChangeAliasActivationStatusSwitchProps): ReactElement { + const {t} = useTranslation() const {_decryptUsingMasterPassword, encryptionStatus} = useContext(AuthContext) - const [isActiveUIState, setIsActiveUIState] = useState(true) + const [isActiveUIState, setIsActiveUIState] = useUIState(isActive) const [successMessage, setSuccessMessage] = useState("") const [errorMessage, setErrorMessage] = useState("") @@ -50,10 +53,6 @@ export default function ChangeAliasActivationStatusSwitch({ setErrorMessage(parseFastAPIError(error).detail as string), }) - useEffect(() => { - setIsActiveUIState(isActive) - }, [isActive]) - return ( <> - Create random alias + {t("routes.AliasesRoute.actions.createRandomAlias.label")} diff --git a/src/route-widgets/AuthenticateRoute/NavigationButton.tsx b/src/route-widgets/AuthenticateRoute/NavigationButton.tsx index 9e6144a..e5a9a90 100644 --- a/src/route-widgets/AuthenticateRoute/NavigationButton.tsx +++ b/src/route-widgets/AuthenticateRoute/NavigationButton.tsx @@ -6,6 +6,7 @@ import {Link as RouterLink, useLocation} from "react-router-dom" import {Button} from "@mui/material" import {mdiTextBoxMultiple} from "@mdi/js/commonjs/mdi" +import {useTranslation} from "react-i18next" import Icon from "@mdi/react" import LockNavigationContext from "~/LockNavigationContext/LockNavigationContext" @@ -28,10 +29,10 @@ const SECTION_ICON_MAP: Record = { } const SECTION_TEXT_MAP: Record = { - [NavigationSection.Overview]: "Overview", - [NavigationSection.Aliases]: "Aliases", - [NavigationSection.Reports]: "Reports", - [NavigationSection.Settings]: "Settings", + [NavigationSection.Overview]: "components.NavigationButton.overview", + [NavigationSection.Aliases]: "components.NavigationButton.aliases", + [NavigationSection.Reports]: "components.NavigationButton.reports", + [NavigationSection.Settings]: "components.NavigationButton.settings", } const PATH_SECTION_MAP: Record = { @@ -44,12 +45,13 @@ const PATH_SECTION_MAP: Record = { export default function NavigationButton({ section, }: NavigationButtonProps): ReactElement { + const {t} = useTranslation() const {handleAnchorClick} = useContext(LockNavigationContext) const location = useLocation() const currentSection = PATH_SECTION_MAP[location.pathname.split("/")[1]] const Icon = SECTION_ICON_MAP[section] - const text = SECTION_TEXT_MAP[section] + const text = t(SECTION_TEXT_MAP[section]) return ( @@ -86,7 +73,9 @@ export default function GenerateEmailReportsForm({ color="primary" onClick={onYes} > - Yes + {t( + "routes.CompleteAccountRoute.forms.generateReports.continueAction", + )} diff --git a/src/route-widgets/CompleteAccountRoute/PasswordForm.tsx b/src/route-widgets/CompleteAccountRoute/PasswordForm.tsx index 67cff9b..cdb54fa 100644 --- a/src/route-widgets/CompleteAccountRoute/PasswordForm.tsx +++ b/src/route-widgets/CompleteAccountRoute/PasswordForm.tsx @@ -2,6 +2,8 @@ import * as yup from "yup" import {useFormik} from "formik" import {MdCheckCircle, MdChevronRight, MdLock} from "react-icons/md" import {generateKey, readKey} from "openpgp" +import {AxiosError} from "axios" +import {useTranslation} from "react-i18next" import React, {ReactElement, useContext, useMemo} from "react" import passwordGenerator from "secure-random-password" @@ -17,7 +19,6 @@ import {MASTER_PASSWORD_LENGTH} from "~/constants/values" import {AuthenticationDetails, UserNote} from "~/server-types" import {UpdateAccountData, updateAccount} from "~/apis" import {encryptUserNote} from "~/utils/encrypt-user-note" -import {AxiosError} from "axios" import AuthContext from "~/AuthContext/AuthContext" export interface PasswordFormProps { @@ -30,20 +31,25 @@ interface Form { detail?: string } -const schema = yup.object().shape({ - password: yup.string().required(), - passwordConfirmation: yup - .string() - .required() - .oneOf([yup.ref("password"), null], "Passwords must match"), -}) - -export default function PasswordForm({ - onDone, -}: PasswordFormProps): ReactElement { +export default function PasswordForm({onDone}: PasswordFormProps): ReactElement { + const {t} = useTranslation() const user = useUser() const theme = useSystemPreferredTheme() + const schema = yup.object().shape({ + password: yup.string().required(), + passwordConfirmation: yup + .string() + .required() + .oneOf( + [yup.ref("password"), null], + t( + "routes.CompleteAccountRoute.forms.password.form.passwordConfirm.mustMatchHelperText", + ) as string, + ) + .label(t("routes.CompleteAccountRoute.forms.password.form.passwordConfirm.label")), + }) + const {_setDecryptionPassword, login} = useContext(AuthContext) const awaitGenerateKey = useMemo( @@ -57,16 +63,15 @@ export default function PasswordForm({ }), [], ) - const {mutateAsync} = useMutation< - AuthenticationDetails, - AxiosError, - UpdateAccountData - >(updateAccount, { - onSuccess: ({user}) => { - login(user) - onDone() + const {mutateAsync} = useMutation( + updateAccount, + { + onSuccess: ({user}) => { + login(user) + onDone() + }, }, - }) + ) const formik = useFormik
({ validationSchema: schema, initialValues: { @@ -85,10 +90,7 @@ export default function PasswordForm({ values.password, user.email.address, ) - const encryptedMasterPassword = encryptString( - masterPassword, - encryptionPassword, - ) + const encryptedMasterPassword = encryptString(masterPassword, encryptionPassword) const note: UserNote = { theme, privateKey: keyPair.privateKey, @@ -109,7 +111,7 @@ export default function PasswordForm({ encryptedNotes, }) } catch (error) { - setErrors({detail: "An error occurred"}) + setErrors({detail: t("general.defaultError")}) } }, }) @@ -128,18 +130,13 @@ export default function PasswordForm({ - - Set up your password + + {t("routes.CompleteAccountRoute.forms.password.title")} - Please enter a safe password so that we can - encrypt your data. + {t("routes.CompleteAccountRoute.forms.password.description")} @@ -151,19 +148,20 @@ export default function PasswordForm({ fullWidth id="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" value={formik.values.password} onChange={formik.handleChange} disabled={formik.isSubmitting} error={ - formik.touched.password && - Boolean(formik.errors.password) - } - helperText={ - formik.touched.password && - formik.errors.password + formik.touched.password && Boolean(formik.errors.password) } + helperText={formik.touched.password && formik.errors.password} InputProps={{ startAdornment: ( @@ -178,15 +176,18 @@ export default function PasswordForm({ fullWidth id="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} onChange={formik.handleChange} disabled={formik.isSubmitting} error={ formik.touched.passwordConfirmation && - Boolean( - formik.errors.passwordConfirmation, - ) + Boolean(formik.errors.passwordConfirmation) } helperText={ formik.touched.passwordConfirmation && @@ -210,7 +211,7 @@ export default function PasswordForm({ loading={formik.isSubmitting} startIcon={} > - Continue + {t("routes.CompleteAccountRoute.forms.password.continueAction")} diff --git a/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx b/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx index 45e2e84..f1073c6 100644 --- a/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx +++ b/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx @@ -1,21 +1,23 @@ +import * as yup from "yup" import {AxiosError} from "axios" import {ReactElement} from "react" import {useFormik} from "formik" import {FaHashtag} from "react-icons/fa" 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 {Box, Grid, InputAdornment, TextField, Typography} from "@mui/material" import {LoadingButton} from "@mui/lab" -import {AuthenticationDetails, ServerUser} from "~/server-types" +import {AuthenticationDetails, ServerSettings, ServerUser} from "~/server-types" import {VerifyLoginWithEmailData, verifyLoginWithEmail} from "~/apis" import {MultiStepFormElement} from "~/components" import {parseFastAPIError} from "~/utils" -import ResendMailButton from "./ResendMailButton" -import useSchema from "./use-schema" - export interface ConfirmCodeFormProps { onConfirm: (user: ServerUser) => void email: string @@ -32,16 +34,36 @@ export default function ConfirmCodeForm({ email, sameRequestToken, }: ConfirmCodeFormProps): ReactElement { - const schema = useSchema() - const {mutateAsync} = useMutation< - AuthenticationDetails, - AxiosError, - VerifyLoginWithEmailData - >(verifyLoginWithEmail, { - onSuccess: ({user}) => onConfirm(user), + const settings = useLoaderData() as ServerSettings + const {t} = useTranslation() + const SCHEMA = yup.object().shape({ + code: yup + .string() + .required() + .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( + verifyLoginWithEmail, + { + onSuccess: ({user}) => onConfirm(user), + }, + ) const formik = useFormik({ - validationSchema: schema, + validationSchema: SCHEMA, initialValues: { code: "", detail: "", @@ -54,7 +76,8 @@ export default function ConfirmCodeForm({ token: values.code, }) } 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({ > - You got mail! + {t("routes.LoginRoute.forms.confirmCode.title")} @@ -80,13 +103,8 @@ export default function ConfirmCodeForm({ - - We sent you a code to your email. Enter it below to - login. + + {t("routes.LoginRoute.forms.confirmCode.description")} @@ -95,17 +113,12 @@ export default function ConfirmCodeForm({ fullWidth name="code" id="code" - label="Code" + label={t("routes.LoginRoute.forms.confirmCode.form.code.label")} value={formik.values.code} onChange={formik.handleChange} disabled={formik.isSubmitting} - error={ - formik.touched.code && - Boolean(formik.errors.code) - } - helperText={ - formik.touched.code && formik.errors.code - } + error={formik.touched.code && Boolean(formik.errors.code)} + helperText={formik.touched.code && formik.errors.code} InputProps={{ startAdornment: ( @@ -116,12 +129,7 @@ export default function ConfirmCodeForm({ /> - + } > - Login + {t("routes.LoginRoute.forms.confirmCode.continueAction")} diff --git a/src/route-widgets/LoginRoute/ConfirmCodeForm/ResendMailButton.tsx b/src/route-widgets/LoginRoute/ConfirmCodeForm/ResendMailButton.tsx index f8a606c..a0d4e40 100644 --- a/src/route-widgets/LoginRoute/ConfirmCodeForm/ResendMailButton.tsx +++ b/src/route-widgets/LoginRoute/ConfirmCodeForm/ResendMailButton.tsx @@ -8,6 +8,7 @@ import {resendEmailLoginCode} from "~/apis" import {MutationStatusSnackbar, TimedButton} from "~/components" import {ServerSettings, SimpleDetailResponse} from "~/server-types" import {MdMail} from "react-icons/md" +import {useTranslation} from "react-i18next" export interface ResendMailButtonProps { email: string @@ -19,6 +20,7 @@ export default function ResendMailButton({ sameRequestToken, }: ResendMailButtonProps): ReactElement { const settings = useLoaderData() as ServerSettings + const {t} = useTranslation() const mutation = useMutation(() => resendEmailLoginCode({ @@ -35,7 +37,7 @@ export default function ResendMailButton({ startIcon={} onClick={() => mutate()} > - Resend Mail + {t("components.ResendMailButton.label")} diff --git a/src/route-widgets/LoginRoute/ConfirmCodeForm/use-schema.ts b/src/route-widgets/LoginRoute/ConfirmCodeForm/use-schema.ts deleted file mode 100644 index 64f4b67..0000000 --- a/src/route-widgets/LoginRoute/ConfirmCodeForm/use-schema.ts +++ /dev/null @@ -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 { - 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)) - }), - }) -} diff --git a/src/route-widgets/LoginRoute/EmailForm.tsx b/src/route-widgets/LoginRoute/EmailForm.tsx index bfe63ca..39245f5 100644 --- a/src/route-widgets/LoginRoute/EmailForm.tsx +++ b/src/route-widgets/LoginRoute/EmailForm.tsx @@ -3,6 +3,7 @@ import {ReactElement} from "react" import {AxiosError} from "axios" import {useFormik} from "formik" import {MdEmail} from "react-icons/md" +import {useTranslation} from "react-i18next" import {useMutation} from "@tanstack/react-query" import {InputAdornment, TextField} from "@mui/material" @@ -20,18 +21,18 @@ interface Form { detail: string } -const SCHEMA = yup.object().shape({ - email: yup.string().email().required(), -}) - export default function EmailForm({onLogin}: EmailFormProps): ReactElement { - const {mutateAsync} = useMutation( - loginWithEmail, - { - onSuccess: ({sameRequestToken}) => - onLogin(formik.values.email, sameRequestToken), - }, - ) + const {t} = useTranslation() + const SCHEMA = yup.object().shape({ + email: yup + .string() + .email() + .required() + .label(t("routes.LoginRoute.forms.email.form.email.label")), + }) + const {mutateAsync} = useMutation(loginWithEmail, { + onSuccess: ({sameRequestToken}) => onLogin(formik.values.email, sameRequestToken), + }) const formik = useFormik({ validationSchema: SCHEMA, initialValues: { @@ -51,9 +52,9 @@ export default function EmailForm({onLogin}: EmailFormProps): ReactElement { @@ -64,17 +65,13 @@ export default function EmailForm({onLogin}: EmailFormProps): ReactElement { name="email" id="email" label="Email" + placeholder={t("routes.LoginRoute.forms.email.form.email.placeholder")} inputMode="email" value={formik.values.email} onChange={formik.handleChange} disabled={formik.isSubmitting} - error={ - formik.touched.email && - Boolean(formik.errors.email) - } - helperText={ - formik.touched.email && formik.errors.email - } + error={formik.touched.email && Boolean(formik.errors.email)} + helperText={formik.touched.email && formik.errors.email} InputProps={{ startAdornment: ( diff --git a/src/route-widgets/ReportDetailRoute/ProxiedImagesListItem.tsx b/src/route-widgets/ReportDetailRoute/ProxiedImagesListItem.tsx index 399e6b1..4c8d43d 100644 --- a/src/route-widgets/ReportDetailRoute/ProxiedImagesListItem.tsx +++ b/src/route-widgets/ReportDetailRoute/ProxiedImagesListItem.tsx @@ -2,6 +2,7 @@ import {BsImage} from "react-icons/bs" import {ReactElement, useState} from "react" import {MdLocationOn} from "react-icons/md" import {useLoaderData} from "react-router-dom" +import {useTranslation} from "react-i18next" import addHours from "date-fns/addHours" import isBefore from "date-fns/isBefore" @@ -17,14 +18,14 @@ import { } from "@mui/material" import {DecryptedReportContent, ServerSettings} from "~/server-types" +import {isDev} from "~/constants/development" export interface ProxiedImagesListItemProps { images: DecryptedReportContent["messageDetails"]["content"]["proxiedImages"] } -export default function ProxiedImagesListItem({ - images, -}: ProxiedImagesListItemProps): ReactElement { +export default function ProxiedImagesListItem({images}: ProxiedImagesListItemProps): ReactElement { + const {t} = useTranslation() const serverSettings = useLoaderData() as ServerSettings const theme = useTheme() @@ -42,14 +43,18 @@ export default function ProxiedImagesListItem({ - Proxying {images.length} images + + {t("routes.ReportDetailRoute.sections.trackers.results.proxiedImages.text", { + count: images.length, + })} + {images.map(image => ( @@ -79,9 +84,13 @@ export default function ProxiedImagesListItem({ ), ) ) { - return "Stored on Server." + return t( + "routes.ReportDetailRoute.sections.trackers.results.proxiedImages.status.isStored", + ) } else { - return "Proxying through Server." + return t( + "routes.ReportDetailRoute.sections.trackers.results.proxiedImages.status.isProxying", + ) } })()} diff --git a/src/route-widgets/ReportDetailRoute/SinglePixelImageTrackersListItem.tsx b/src/route-widgets/ReportDetailRoute/SinglePixelImageTrackersListItem.tsx index c94b04d..6ab249e 100644 --- a/src/route-widgets/ReportDetailRoute/SinglePixelImageTrackersListItem.tsx +++ b/src/route-widgets/ReportDetailRoute/SinglePixelImageTrackersListItem.tsx @@ -14,6 +14,7 @@ import { import {DecryptedReportContent} from "~/server-types" import {BsShieldShaded} from "react-icons/bs" +import {useTranslation} from "react-i18next" export interface SinglePixelImageTrackersListItemProps { images: DecryptedReportContent["messageDetails"]["content"]["singlePixelImages"] @@ -22,6 +23,7 @@ export interface SinglePixelImageTrackersListItemProps { export default function SinglePixelImageTrackersListItem({ images, }: SinglePixelImageTrackersListItemProps): ReactElement { + const {t} = useTranslation() const theme = useTheme() const [showImageTrackers, setShowImageTrackers] = useState(false) @@ -45,30 +47,24 @@ export default function SinglePixelImageTrackersListItem({ - Removed {images.length} image trackers + {t("routes.ReportDetailRoute.sections.trackers.results.imageTrackers.text", { + count: images.length, + })} - {Object.entries(imagesPerTracker).map( - ([trackerName, images]) => ( - <> - - {trackerName} - - {images.map(image => ( - - {image.source} - - ))} - - ), - )} + {Object.entries(imagesPerTracker).map(([trackerName, images]) => ( + <> + + {trackerName} + + {images.map(image => ( + {image.source} + ))} + + ))} diff --git a/src/route-widgets/ReportsRoute/ReportInformationItem.tsx b/src/route-widgets/ReportsRoute/ReportInformationItem.tsx index ca563f5..38b7a54 100644 --- a/src/route-widgets/ReportsRoute/ReportInformationItem.tsx +++ b/src/route-widgets/ReportsRoute/ReportInformationItem.tsx @@ -4,6 +4,7 @@ import {useNavigate} from "react-router-dom" import {ListItemButton, ListItemText} from "@mui/material" import {DecryptedReportContent} from "~/server-types" +import {useTranslation} from "react-i18next" export interface ReportInformationItemProps { report: DecryptedReportContent @@ -13,16 +14,20 @@ export default function ReportInformationItem({ report, }: ReportInformationItemProps): ReactElement { const navigate = useNavigate() + const {t} = useTranslation() return ( navigate(`/reports/${report.id}`)}> {""} + {t("relations.report.emailMeta.emptySubject")} ) } - 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, + })} /> ) diff --git a/src/route-widgets/SettingsRoute/AliasesPreferencesForm.tsx b/src/route-widgets/SettingsRoute/AliasesPreferencesForm.tsx index b0770db..c50c73d 100644 --- a/src/route-widgets/SettingsRoute/AliasesPreferencesForm.tsx +++ b/src/route-widgets/SettingsRoute/AliasesPreferencesForm.tsx @@ -2,6 +2,7 @@ import * as yup from "yup" import {AxiosError} from "axios" import {useFormik} from "formik" import {MdCheckCircle, MdImage} from "react-icons/md" +import {useTranslation} from "react-i18next" import React, {ReactElement, useContext} from "react" import {useMutation} from "@tanstack/react-query" @@ -21,11 +22,7 @@ import { } from "@mui/material" import {LoadingButton} from "@mui/lab" -import { - ImageProxyFormatType, - ProxyUserAgentType, - SimpleDetailResponse, -} from "~/server-types" +import {ImageProxyFormatType, ProxyUserAgentType, SimpleDetailResponse} from "~/server-types" import {UpdatePreferencesData, updatePreferences} from "~/apis" import {useUser} from "~/hooks" import {parseFastAPIError} from "~/utils" @@ -33,7 +30,7 @@ import {SuccessSnack} from "~/components" import { IMAGE_PROXY_FORMAT_TYPE_NAME_MAP, IMAGE_PROXY_USER_AGENT_TYPE_NAME_MAP, -} from "~/constants/enum_mappings" +} from "~/constants/enum-mappings" import AuthContext from "~/AuthContext/AuthContext" import ErrorSnack from "~/components/ErrorSnack" @@ -47,23 +44,26 @@ interface Form { detail?: string } -const SCHEMA = yup.object().shape({ - removeTrackers: yup.boolean(), - createMailReport: yup.boolean(), - proxyImages: yup.boolean(), - imageProxyFormat: yup - .mixed() - .oneOf(Object.values(ImageProxyFormatType)) - .required(), - imageProxyUserAgent: yup - .mixed() - .oneOf(Object.values(ProxyUserAgentType)) - .required(), -}) - export default function AliasesPreferencesForm(): ReactElement { const {_updateUser} = useContext(AuthContext) 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() + .oneOf(Object.values(ImageProxyFormatType)) + .required() + .label(t("relations.alias.settings.imageProxyFormat.label")), + imageProxyUserAgent: yup + .mixed() + .oneOf(Object.values(ProxyUserAgentType)) + .required() + .label(t("relations.alias.settings.imageProxyUserAgent.label")), + }) + const {mutateAsync, data} = useMutation< SimpleDetailResponse, AxiosError, @@ -110,22 +110,15 @@ export default function AliasesPreferencesForm(): ReactElement { return ( <> - + - Aliases Preferences + {t("routes.SettingsRoute.forms.aliasPreferences.title")} - Select the default behavior for your aliases. This - will only affect aliases that do not have a custom - behavior set. + {t("routes.SettingsRoute.forms.aliasPreferences.description")} @@ -136,7 +129,7 @@ export default function AliasesPreferencesForm(): ReactElement { spacing={4} alignItems="flex-end" > - + @@ -162,11 +153,11 @@ export default function AliasesPreferencesForm(): ReactElement { > {(formik.touched.createMailReport && formik.errors.createMailReport) || - "Remove single-pixel image trackers as well as url trackers."} + t("relations.alias.settings.removeTrackers.helperText")} - + @@ -193,7 +181,9 @@ export default function AliasesPreferencesForm(): ReactElement { > {(formik.touched.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", + )} @@ -205,9 +195,7 @@ export default function AliasesPreferencesForm(): ReactElement { @@ -217,29 +205,24 @@ export default function AliasesPreferencesForm(): ReactElement { /> {(formik.touched.proxyImages && formik.errors.proxyImages) || - "Proxies images in your emails through this KleckRelay instance. This adds an extra layer of privacy. Images are loaded immediately after we receive the email. They then will be stored for some time (cache time). During that time, the image will be served from us. This means the original server has no idea you have opened the mail. After the cache time, the image is loaded from the original server, but it gets proxied by us. This means the original server will not be able to access neither your IP address nor your user agent."} + t("relations.alias.settings.proxyImages.helperText")} - + {Object.entries( - ImageProxyFormatType, - ).map(([key, value]) => ( - - { - IMAGE_PROXY_FORMAT_TYPE_NAME_MAP[ - value - ] as string - } + IMAGE_PROXY_FORMAT_TYPE_NAME_MAP, + ).map(([value, translationString]) => ( + + {t(translationString)} ))} - {formik.touched - .imageProxyFormat && - formik.errors - .imageProxyFormat} + {formik.touched.imageProxyFormat && + formik.errors.imageProxyFormat} - + {Object.entries( - ProxyUserAgentType, - ).map(([key, value]) => ( - - { - IMAGE_PROXY_USER_AGENT_TYPE_NAME_MAP[ - value - ] as string - } + IMAGE_PROXY_USER_AGENT_TYPE_NAME_MAP, + ).map(([value, translationString]) => ( + + {t(translationString)} ))} - {(formik.touched - .imageProxyUserAgent && - formik.errors - .imageProxyUserAgent) || - "An User Agent is a identifier each browser and email client sends when retrieving files, such as images. You can specify here what user agent you would like to be used by the proxy. User Agents are kept up-to-date."} + {(formik.touched.imageProxyUserAgent && + formik.errors.imageProxyUserAgent) || + t( + "relations.alias.settings.imageProxyUserAgent.helperText", + )} diff --git a/src/route-widgets/SettingsRoute/SelectField.tsx b/src/route-widgets/SettingsRoute/SelectField.tsx index 94fae3e..44d5336 100644 --- a/src/route-widgets/SettingsRoute/SelectField.tsx +++ b/src/route-widgets/SettingsRoute/SelectField.tsx @@ -1,4 +1,5 @@ import {ReactElement} from "react" +import {useTranslation} from "react-i18next" import { FormControl, @@ -21,26 +22,25 @@ export interface SelectFieldProps { icon?: ReactElement } -const BOOLEAN_SELECT_TEXT_MAP: Record = { - true: "Yes", - false: "No", -} - export default function SelectField({ label, formik, icon, name, - valueTextMap = BOOLEAN_SELECT_TEXT_MAP, + valueTextMap: parentValueTextMap, }: SelectFieldProps): ReactElement { const user = useUser() + const {t} = useTranslation() + const BOOLEAN_SELECT_TEXT_MAP: Record = { + true: "general.booleanSelection.true", + false: "general.booleanSelection.false", + } + const valueTextMap = parentValueTextMap ?? BOOLEAN_SELECT_TEXT_MAP const labelId = `${name}-label` - const preferenceName = `alias${ - name.charAt(0).toUpperCase() + name.slice(1) - }` + const preferenceName = `alias${name.charAt(0).toUpperCase() + name.slice(1)}` const value = user.preferences[preferenceName as keyof User["preferences"]] - const defaultValueText = valueTextMap[value.toString()] + const defaultValueText = t(valueTextMap[value.toString()]) return ( @@ -52,9 +52,7 @@ export default function SelectField({ label={label} labelId={labelId} startAdornment={ - icon ? ( - {icon} - ) : undefined + icon ? {icon} : undefined } value={(formik.values[name] ?? "null").toString()} onChange={event => { @@ -77,25 +75,31 @@ export default function SelectField({ error={Boolean(formik.touched[name] && formik.errors[name])} renderValue={value => value === "null" ? ( - {`<${defaultValueText}>`} + + {t("general.defaultValueSelectionRaw", { + value: defaultValueText, + })} + ) : ( - valueTextMap[value.toString()] + t(valueTextMap[value.toString()]) ) } > - {`Default <${defaultValueText}>`} + + {t("general.defaultValueSelection", { + value: defaultValueText, + })} + {valueTextMap && - Object.entries(valueTextMap).map(([value, text]) => ( + Object.entries(valueTextMap).map(([value, translationString]) => ( - {text} + {t(translationString)} ))} - + {formik.touched[name] && formik.errors[name]} diff --git a/src/route-widgets/SignupRoute/EmailForm/EmailForm.tsx b/src/route-widgets/SignupRoute/EmailForm/EmailForm.tsx index 1faaa4a..bc56aa3 100644 --- a/src/route-widgets/SignupRoute/EmailForm/EmailForm.tsx +++ b/src/route-widgets/SignupRoute/EmailForm/EmailForm.tsx @@ -13,6 +13,7 @@ import {MultiStepFormElement, SimpleForm} from "~/components" import {SignupResult, checkIsDomainDisposable, signup} from "~/apis" import {parseFastAPIError} from "~/utils" import {ServerSettings} from "~/server-types" +import {useTranslation} from "react-i18next" export interface EmailFormProps { serverSettings: ServerSettings @@ -24,20 +25,19 @@ interface Form { detail?: string } -const SCHEMA = yup.object().shape({ - email: yup.string().email().required(), -}) +export default function EmailForm({onSignUp, serverSettings}: EmailFormProps): ReactElement { + const {t} = useTranslation() + const SCHEMA = yup.object().shape({ + email: yup + .string() + .email() + .required() + .label(t("routes.SignupRoute.forms.email.form.email.label")), + }) -export default function EmailForm({ - onSignUp, - serverSettings, -}: EmailFormProps): ReactElement { - const {mutateAsync} = useMutation( - signup, - { - onSuccess: ({normalizedEmail}) => onSignUp(normalizedEmail), - }, - ) + const {mutateAsync} = useMutation(signup, { + onSuccess: ({normalizedEmail}) => onSignUp(normalizedEmail), + }) const formik = useFormik({ validationSchema: SCHEMA, initialValues: { @@ -46,9 +46,7 @@ export default function EmailForm({ onSubmit: async (values, {setErrors}) => { // Check is email disposable try { - const isDisposable = await checkIsDomainDisposable( - values.email.split("@")[1], - ) + const isDisposable = await checkIsDomainDisposable(values.email.split("@")[1]) if (isDisposable) { setErrors({ @@ -76,9 +74,9 @@ export default function EmailForm({ @@ -88,18 +86,16 @@ export default function EmailForm({ fullWidth name="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" value={formik.values.email} onChange={formik.handleChange} disabled={formik.isSubmitting} - error={ - formik.touched.email && - Boolean(formik.errors.email) - } - helperText={ - formik.touched.email && formik.errors.email - } + error={formik.touched.email && Boolean(formik.errors.email)} + helperText={formik.touched.email && formik.errors.email} InputProps={{ startAdornment: ( @@ -113,9 +109,7 @@ export default function EmailForm({ {!serverSettings.otherRelaysEnabled && ( - + )} ) diff --git a/src/route-widgets/SignupRoute/YouGotMail/ResendMailButton.tsx b/src/route-widgets/SignupRoute/YouGotMail/ResendMailButton.tsx index 6764404..b049c9e 100644 --- a/src/route-widgets/SignupRoute/YouGotMail/ResendMailButton.tsx +++ b/src/route-widgets/SignupRoute/YouGotMail/ResendMailButton.tsx @@ -1,6 +1,7 @@ import {AxiosError} from "axios" import {useLoaderData} from "react-router-dom" import {MdMail} from "react-icons/md" +import {useTranslation} from "react-i18next" import React, {ReactElement} from "react" import {useMutation} from "@tanstack/react-query" @@ -13,9 +14,8 @@ export interface ResendMailButtonProps { email: string } -export default function ResendMailButton({ - email, -}: ResendMailButtonProps): ReactElement { +export default function ResendMailButton({email}: ResendMailButtonProps): ReactElement { + const {t} = useTranslation() const settings = useLoaderData() as ServerSettings const mutation = useMutation(() => @@ -30,7 +30,7 @@ export default function ResendMailButton({ startIcon={} onClick={() => mutate()} > - Resend Mail + {t("components.ResendMailButton.label")} diff --git a/src/route-widgets/SignupRoute/YouGotMail/YouGotMail.tsx b/src/route-widgets/SignupRoute/YouGotMail/YouGotMail.tsx index e683a7e..a221697 100644 --- a/src/route-widgets/SignupRoute/YouGotMail/YouGotMail.tsx +++ b/src/route-widgets/SignupRoute/YouGotMail/YouGotMail.tsx @@ -14,6 +14,7 @@ import { } from "@mui/material" import {MultiStepFormElement, OpenMailButton} from "~/components" +import {useTranslation} from "react-i18next" import ResendMailButton from "~/route-widgets/SignupRoute/YouGotMail/ResendMailButton" export interface YouGotMailProps { @@ -21,10 +22,9 @@ export interface YouGotMailProps { onGoBack: () => void } -export default function YouGotMail({ - email, - onGoBack, -}: YouGotMailProps): ReactElement { +export default function YouGotMail({email, onGoBack}: YouGotMailProps): ReactElement { + const {t} = useTranslation() + const [askToEditEmail, setAskToEditEmail] = useState(false) const domain = email.split("@")[1] @@ -43,30 +43,21 @@ export default function YouGotMail({ > - You got mail! + {t("routes.SignupRoute.forms.mailVerification.title")} - We sent you an email with a link to confirm your - email address. Please check your inbox and click on - the link to continue. + {t("routes.SignupRoute.forms.mailVerification.description")} - + {email} - setAskToEditEmail(true)} - > + setAskToEditEmail(true)}> @@ -81,21 +72,21 @@ export default function YouGotMail({ - Edit email address? + + {t("routes.SignupRoute.forms.mailVerification.editEmail.title")} + - Would you like to return to the previous step and edit - your email address? + {t("routes.SignupRoute.forms.mailVerification.editEmail.description")} - + - diff --git a/src/routes/AliasDetailRoute.tsx b/src/routes/AliasDetailRoute.tsx index 3419fcd..a2c2b99 100644 --- a/src/routes/AliasDetailRoute.tsx +++ b/src/routes/AliasDetailRoute.tsx @@ -1,6 +1,7 @@ import {ReactElement, useContext} from "react" import {useParams} from "react-router-dom" import {AxiosError} from "axios" +import {useTranslation} from "react-i18next" import {useQuery} from "@tanstack/react-query" @@ -12,6 +13,7 @@ import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext" import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes" export default function AliasDetailRoute(): ReactElement { + const {t} = useTranslation() const params = useParams() const address = atob(params.addressInBase64 as string) const {_decryptUsingMasterPassword, encryptionStatus} = @@ -34,7 +36,7 @@ export default function AliasDetailRoute(): ReactElement { ) return ( - + query={query}> {alias => } diff --git a/src/routes/AliasesRoute.tsx b/src/routes/AliasesRoute.tsx index c2492a8..0d6d0c4 100644 --- a/src/routes/AliasesRoute.tsx +++ b/src/routes/AliasesRoute.tsx @@ -1,6 +1,7 @@ import {ReactElement, useState, useTransition} from "react" import {AxiosError} from "axios" import {MdSearch} from "react-icons/md" +import {useTranslation} from "react-i18next" import {useQuery} from "@tanstack/react-query" import {InputAdornment, TextField} from "@mui/material" @@ -11,6 +12,8 @@ import AliasesDetails from "~/route-widgets/AliasesRoute/AliasesDetails" import getAliases from "~/apis/get-aliases" export default function AliasesRoute(): ReactElement { + const {t} = useTranslation() + const [searchValue, setSearchValue] = useState("") const [queryValue, setQueryValue] = useState("") const [, startTransition] = useTransition() @@ -25,7 +28,7 @@ export default function AliasesRoute(): ReactElement { return (
- + @@ -40,7 +38,7 @@ export default function AuthenticateRoute(): ReactElement { size="small" startIcon={} > - Login + {t("components.AuthenticateRoute.login")} diff --git a/src/routes/CompleteAccountRoute.tsx b/src/routes/CompleteAccountRoute.tsx index 79bf548..e18cb10 100644 --- a/src/routes/CompleteAccountRoute.tsx +++ b/src/routes/CompleteAccountRoute.tsx @@ -1,4 +1,5 @@ import {ReactElement, useContext, useState} from "react" +import {useTranslation} from "react-i18next" 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" export default function CompleteAccountRoute(): ReactElement { + const {t} = useTranslation() const {encryptionStatus} = useContext(AuthContext) const navigateToNext = useNavigateToNext() // If query `setup` is `true`, skip directly to the setup - const [showGenerationReportForm, setShowGenerationReportForm] = useState( - () => { - const searchParams = new URLSearchParams(location.search) - return searchParams.get("setup") === "true" - }, - ) + const [showGenerationReportForm, setShowGenerationReportForm] = useState(() => { + const searchParams = new URLSearchParams(location.search) + return searchParams.get("setup") === "true" + }) if (encryptionStatus === EncryptionStatus.Unavailable) { return ( @@ -29,10 +29,7 @@ export default function CompleteAccountRoute(): ReactElement { onYes={() => setShowGenerationReportForm(true)} onNo={navigateToNext} />, - , + , ]} index={showGenerationReportForm ? 1 : 0} /> @@ -51,13 +48,12 @@ export default function CompleteAccountRoute(): ReactElement { > - Encryption already enabled + {t("routes.CompleteAccountRoute.forms.available.title")} - You already have encryption enabled. Changing passwords - is currently not supported. + {t("routes.CompleteAccountRoute.forms.available.description")} diff --git a/src/routes/EnterDecryptionPassword.tsx b/src/routes/EnterDecryptionPassword.tsx index dfbc0c8..43b38b1 100644 --- a/src/routes/EnterDecryptionPassword.tsx +++ b/src/routes/EnterDecryptionPassword.tsx @@ -1,13 +1,13 @@ import * as yup from "yup" import {ReactElement, useContext} from "react" -import {useLocation, useNavigate} from "react-router-dom" import {useFormik} from "formik" import {MdLock} from "react-icons/md" +import {useTranslation} from "react-i18next" import {InputAdornment} from "@mui/material" import {buildEncryptionPassword} from "~/utils" -import {useUser} from "~/hooks" +import {useNavigateToNext, useUser} from "~/hooks" import {PasswordField, SimpleForm} from "~/components" import AuthContext from "~/AuthContext/AuthContext" @@ -20,8 +20,8 @@ const schema = yup.object().shape({ }) export default function EnterDecryptionPassword(): ReactElement { - const navigate = useNavigate() - const location = useLocation() + const {t} = useTranslation() + const navigateToNext = useNavigateToNext() const user = useUser() const {_setDecryptionPassword} = useContext(AuthContext) @@ -37,11 +37,13 @@ export default function EnterDecryptionPassword(): ReactElement { ) if (!_setDecryptionPassword(decryptionPassword)) { - setErrors({password: "Password is invalid."}) + setErrors({ + password: t( + "components.EnterDecryptionPassword.form.password.errors.invalidPassword", + ), + }) } else { - const nextUrl = - new URLSearchParams(location.search).get("next") || "/" - setTimeout(() => navigate(nextUrl), 0) + navigateToNext() } }, }) @@ -49,10 +51,16 @@ export default function EnterDecryptionPassword(): ReactElement { return (
{[ @@ -61,7 +69,12 @@ export default function EnterDecryptionPassword(): ReactElement { fullWidth name="password" id="password" - label="Password" + label={t( + "components.EnterDecryptionPassword.form.password.label", + )} + placeholder={t( + "components.EnterDecryptionPassword.form.password.placeholder", + )} value={formik.values.password} onChange={formik.handleChange} disabled={formik.isSubmitting} diff --git a/src/routes/ReportDetailRoute.tsx b/src/routes/ReportDetailRoute.tsx index a147514..141b217 100644 --- a/src/routes/ReportDetailRoute.tsx +++ b/src/routes/ReportDetailRoute.tsx @@ -1,19 +1,21 @@ import {useParams} from "react-router-dom" import {AxiosError} from "axios" +import {useTranslation} from "react-i18next" import React, {ReactElement} from "react" 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 {DecryptReport} from "~/components" +import {DecryptReport, SimpleOverlayInformation, SimplePageBuilder} from "~/components" +import {WithEncryptionRequired} from "~/hocs" import ProxiedImagesListItem from "~/route-widgets/ReportDetailRoute/ProxiedImagesListItem" import QueryResult from "~/components/QueryResult" -import SimplePage from "~/components/SimplePage" import SinglePixelImageTrackersListItem from "~/route-widgets/ReportDetailRoute/SinglePixelImageTrackersListItem" -export default function ReportDetailRoute(): ReactElement { +function ReportDetailRoute(): ReactElement { + const {t} = useTranslation() const params = useParams() const query = useQuery(["get_report", params.id], () => @@ -21,102 +23,89 @@ export default function ReportDetailRoute(): ReactElement { ) return ( - + query={query}> {encryptedReport => ( - + {report => ( - - - - Email information - - - - - - From - - + {[ + + + {[ + { - report.messageDetails - .meta.from + (report as DecryptedReportContent) + .messageDetails.meta.from } - - - - - - - To - - , + + { - report.messageDetails - .meta.to + (report as DecryptedReportContent) + .messageDetails.meta.to } - - - - - - - Subject - - , + + { - report.messageDetails - .content.subject + (report as DecryptedReportContent) + .messageDetails.content.subject } - - - - - - - - Trackers - - - - - - - + , + ]} + + , + + + + + + + , + ]} + )} )} - + ) } + +export default WithEncryptionRequired(ReportDetailRoute) diff --git a/src/routes/ReportsRoute.tsx b/src/routes/ReportsRoute.tsx index a572822..c2ce26f 100644 --- a/src/routes/ReportsRoute.tsx +++ b/src/routes/ReportsRoute.tsx @@ -2,22 +2,18 @@ import {ReactElement, useState} from "react" import {AxiosError} from "axios" import {MdList} from "react-icons/md" import {FaMask} from "react-icons/fa" +import {useTranslation} from "react-i18next" import groupArray from "group-array" import sortArray from "sort-array" import {useQuery} from "@tanstack/react-query" -import { - InputAdornment, - List, - MenuItem, - TextField, - Typography, -} from "@mui/material" +import {InputAdornment, List, MenuItem, TextField, Typography} from "@mui/material" import {DecryptedReportContent, PaginationResult, Report} from "~/server-types" import {getReports} from "~/apis" import {WithEncryptionRequired} from "~/hocs" import {DecryptReport} from "~/components" +import {createEnumMapFromTranslation} from "~/utils" import QueryResult from "~/components/QueryResult" import ReportInformationItem from "~/route-widgets/ReportsRoute/ReportInformationItem" import SimplePage from "~/components/SimplePage" @@ -27,25 +23,21 @@ enum SortingView { GroupByAlias = "GroupByAlias", } -const SORTING_VIEW_NAME_MAP: Record = { - [SortingView.List]: "List reports by their date", - [SortingView.GroupByAlias]: "Group reports by their aliases", -} - const SORTING_VIEW_ICON_MAP: Record = { [SortingView.List]: , [SortingView.GroupByAlias]: , } +const SORTING_VIEW_NAME_MAP: Record = createEnumMapFromTranslation( + "routes.ReportsRoute.pageActions.sort", + SortingView, +) function ReportsRoute(): ReactElement { - const query = useQuery, AxiosError>( - ["get_reports"], - getReports, - ) + const {t} = useTranslation() - const [sortingView, setSortingView] = useState( - SortingView.List, - ) + const query = useQuery, AxiosError>(["get_reports"], getReports) + + const [sortingView, setSortingView] = useState(SortingView.List) return ( - setSortingView(event.target.value as SortingView) - } + onChange={event => setSortingView(event.target.value as SortingView)} label="Sorting" id="sorting" InputProps={{ @@ -69,7 +59,7 @@ function ReportsRoute(): ReactElement { > {Object.keys(SORTING_VIEW_NAME_MAP).map(name => ( - {SORTING_VIEW_NAME_MAP[name as SortingView]} + {t(SORTING_VIEW_NAME_MAP[name as SortingView])} ))} @@ -115,18 +105,12 @@ function ReportsRoute(): ReactElement { > {alias} - {reports.map( - report => ( - - ), - )} + {reports.map(report => ( + + ))} ), ) diff --git a/src/routes/SettingsRoute.tsx b/src/routes/SettingsRoute.tsx index 1095594..65b6543 100644 --- a/src/routes/SettingsRoute.tsx +++ b/src/routes/SettingsRoute.tsx @@ -1,20 +1,15 @@ +import {useTranslation} from "react-i18next" import React, {ReactElement} from "react" -import {Grid, Typography} from "@mui/material" - +import {SimplePageBuilder} from "~/components" import AliasesPreferencesForm from "~/route-widgets/SettingsRoute/AliasesPreferencesForm" export default function SettingsRoute(): ReactElement { + const {t} = useTranslation() + return ( - - - - Settings - - - - - - + + + ) } diff --git a/src/routes/VerifyEmailRoute.tsx b/src/routes/VerifyEmailRoute.tsx index 562a74b..412bcd2 100644 --- a/src/routes/VerifyEmailRoute.tsx +++ b/src/routes/VerifyEmailRoute.tsx @@ -3,6 +3,7 @@ import {useLoaderData, useNavigate} from "react-router-dom" import {useAsync, useLocalStorage} from "react-use" import {MdCancel} from "react-icons/md" import {AxiosError} from "axios" +import {useTranslation} from "react-i18next" import React, {ReactElement, useContext} from "react" import {Grid, Paper, Typography, useTheme} from "@mui/material" @@ -18,6 +19,8 @@ const emailSchema = yup.string().email() export default function VerifyEmailRoute(): ReactElement { const theme = useTheme() const navigate = useNavigate() + const {t} = useTranslation() + const {login} = useContext(AuthContext) const [_, setEmail] = useLocalStorage("signup-form-state-email", "") const {email, token} = useQueryParams<{ @@ -29,7 +32,7 @@ export default function VerifyEmailRoute(): ReactElement { const tokenSchema = yup .string() .length(serverSettings.emailVerificationLength) - .test("token", "Invalid token", token => { + .test("token", t("routes.VerifyEmailRoute.errors.code.invalid") as string, token => { if (!token) { return false } @@ -38,17 +41,16 @@ export default function VerifyEmailRoute(): ReactElement { return token.split("").every(char => chars.includes(char)) }) - const {mutateAsync} = useMutation< - AuthenticationDetails, - AxiosError, - VerifyEmailData - >(verifyEmail, { - onSuccess: ({user}) => { - setEmail("") - login(user) - navigate("/auth/complete-account") + const {mutateAsync} = useMutation( + verifyEmail, + { + onSuccess: ({user}) => { + setEmail("") + login(user) + navigate("/auth/complete-account") + }, }, - }) + ) const {loading} = useAsync(async () => { await emailSchema.validate(email) await tokenSchema.validate(token) @@ -71,34 +73,23 @@ export default function VerifyEmailRoute(): ReactElement { > - Verify your email + {t("routes.VerifyEmailRoute.title")} {loading ? ( - - Verifying your email... + + {t("routes.VerifyEmailRoute.isLoading")} ) : ( <> - + - - Sorry, but this verification code is invalid. + + {t("routes.VerifyEmailRoute.isCodeInvalid")} diff --git a/src/utils/create-enum-map-from-translation.ts b/src/utils/create-enum-map-from-translation.ts new file mode 100644 index 0000000..294adec --- /dev/null +++ b/src/utils/create-enum-map-from-translation.ts @@ -0,0 +1,9 @@ +export default function createEnumMapFromTranslation>( + prefix: string, + TEnum: T, +): Record { + return Object.fromEntries(Object.values(TEnum).map(key => [key, `${prefix}.${key}`])) as Record< + keyof T, + string + > +} diff --git a/src/utils/get-error-message.ts b/src/utils/get-error-message.ts deleted file mode 100644 index bc27353..0000000 --- a/src/utils/get-error-message.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {AxiosError} from "axios" - -import {FastAPIError} from "~/utils/parse-fastapi-error" - -export default function getErrorMessage( - error: AxiosError, -): string { - if (typeof error.response?.data?.detail === "string") { - return error.response.data.detail - } - - return "There was an error." -} diff --git a/src/utils/index.ts b/src/utils/index.ts index 7fb2424..a857ecd 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -12,3 +12,5 @@ export * from "./decrypt-string" export {default as decryptString} from "./decrypt-string" export * 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"