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": [
"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",

View File

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

View File

@ -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"

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 VerifyEmailRoute from "~/routes/VerifyEmailRoute"
import "./init-i18n"
const router = createBrowserRouter([
{
path: "/",

View File

@ -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, ReactElement> = {
[AliasType.CUSTOM]: <FaHashtag />,
}
const ALIAS_TYPE_TOOLTIP_MAP: Record<AliasType, string> = {
[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 (
<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">
{ALIAS_TYPE_ICON_MAP[type]}
</Box>

View File

@ -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 (
<Grid
paddingY={2}
padding={4}
bgcolor={theme.palette.background.default}
container
gap={2}
@ -31,12 +33,12 @@ export default function DecryptionPasswordMissingAlert({
>
<Grid item>
<Typography variant="h6" component="h2">
Encryption required
{t("components.DecryptionPasswordMissingAlert.unavailable.title")}
</Typography>
</Grid>
<Grid item>
<Typography>
You need to set up encryption to use this feature.
{t("components.DecryptionPasswordMissingAlert.unavailable.description")}
</Typography>
</Grid>
<Grid item>
@ -47,7 +49,9 @@ export default function DecryptionPasswordMissingAlert({
startIcon={<MdLock />}
onClick={handleAnchorClick}
>
Setup encryption
{t(
"components.DecryptionPasswordMissingAlert.unavailable.continueAction",
)}
</Button>
</Grid>
</Grid>
@ -57,7 +61,7 @@ export default function DecryptionPasswordMissingAlert({
case EncryptionStatus.PasswordRequired: {
return (
<Grid
paddingY={2}
padding={4}
bgcolor={theme.palette.background.default}
container
gap={2}
@ -66,13 +70,14 @@ export default function DecryptionPasswordMissingAlert({
>
<Grid item>
<Typography variant="h6" component="h2">
Password required
{t("components.DecryptionPasswordMissingAlert.passwordRequired.title")}
</Typography>
</Grid>
<Grid item>
<Typography>
Your decryption password is required to view this
section.
{t(
"components.DecryptionPasswordMissingAlert.passwordRequired.description",
)}
</Typography>
</Grid>
<Grid item>
@ -82,7 +87,9 @@ export default function DecryptionPasswordMissingAlert({
startIcon={<MdLock />}
onClick={handleAnchorClick}
>
Enter Password
{t(
"components.DecryptionPasswordMissingAlert.passwordRequired.continueAction",
)}
</Button>
</Grid>
</Grid>

View File

@ -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 (
<Grid container spacing={2} flexDirection="column" alignItems="center">
<Grid item>
<Alert severity="error">{message}</Alert>
</Grid>
<Grid item>
<Button onClick={onRetry}>Try Again</Button>
<Button onClick={onRetry}>
{t("components.ErrorLoadingDataMessage.tryAgain")}
</Button>
</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"
@ -6,24 +7,8 @@ export interface LoadingDataProps {
message?: string
}
export default function LoadingData({
message = "Loading",
}: 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)
}, [])
export default function LoadingData({message = "Loading"}: LoadingDataProps): ReactElement {
const {t} = useTranslation()
return (
<Grid container spacing={2} direction="column" alignItems="center">
@ -31,7 +16,7 @@ export default function LoadingData({
<CircularProgress />
</Grid>
<Grid item>
<Typography variant="caption">Loading{ellipsis}</Typography>
<Typography variant="caption">{t("general.loading")}</Typography>
</Grid>
</Grid>
)

View File

@ -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<TData, TError, TVariables, TContext>): ReactElement {
const {t} = useTranslation()
const $severity = useRef<AlertProps["severity"]>()
const $message = useRef<string>()
@ -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 (
<Snackbar
open={open}
onClose={() => setOpen(false)}
autoHideDuration={5000}
>
<Snackbar open={open} onClose={() => setOpen(false)} autoHideDuration={5000}>
<Alert severity={$severity.current} variant="filled">
{$message.current}
</Alert>

View File

@ -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 (
<Button
startIcon={<IoMdMailOpen />}
variant="text"
href={APP_LINK_MAP[domain].android}
>
Open Mail
<Button startIcon={<IoMdMailOpen />} variant="text" href={APP_LINK_MAP[domain].android}>
{t("components.OpenMailButton.label")}
</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 {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)
}}
>
<span>{children}</span>
{secondsLeft > 0 && <span> ({secondsLeft})</span>}
<span>{children} </span>
{secondsLeft > 0 && (
<span>{t("components.TimedButton.remainingTime", {count: secondsLeft})}</span>
)}
</LoadingButton>
)
}

View File

@ -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"

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

View File

@ -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<Alias | DecryptedAlias>(
aliasValue,
)
const [aliasUIState, setAliasUIState] = useUIState<Alias | DecryptedAlias>(aliasValue)
return (
<Grid container spacing={4}>
<Grid item>
<SimplePageBuilder.MultipleSections>
{[
<Grid container spacing={1} direction="row" alignItems="center">
<Grid item>
<AliasTypeIndicator type={aliasUIState.type} />
@ -48,45 +41,25 @@ export default function AliasDetails({
onChanged={setAliasUIState}
/>
</Grid>
</Grid>
</Grid>
<Grid item width="100%">
{encryptionStatus === EncryptionStatus.Available ? (
<AliasNotesForm
id={aliasUIState.id}
notes={(aliasUIState as DecryptedAlias).notes}
onChanged={setAliasUIState}
/>
) : (
<DecryptionPasswordMissingAlert />
)}
</Grid>
<Grid item>
<Grid container spacing={4}>
<Grid item>
<Typography variant="h6" component="h3">
Settings
</Typography>
</Grid>
<Grid item>
<AliasPreferencesForm
alias={aliasUIState}
</Grid>,
<div key="notes">
{encryptionStatus === EncryptionStatus.Available ? (
<AliasNotesForm
id={aliasUIState.id}
notes={(aliasUIState as DecryptedAlias).notes}
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>
) : (
<DecryptionPasswordMissingAlert />
)}
</div>,
<SimplePageBuilder.Section
label={t("routes.AliasDetailRoute.sections.settings.title")}
key="settings"
>
<AliasPreferencesForm alias={aliasUIState} onChanged={setAliasUIState} />
</SimplePageBuilder.Section>,
]}
</SimplePageBuilder.MultipleSections>
)
}

View File

@ -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<Alias, AxiosError, UpdateAliasData>(
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 (
<>
<form onSubmit={formik.handleSubmit}>
<Grid container direction="column" spacing={4}>
<Grid container direction="column" spacing={1}>
<Grid item>
<Grid container spacing={1} direction="row">
<Grid item>
<Typography variant="h6" component="h3">
Notes
{t("routes.AliasDetailRoute.sections.notes.title")}
</Typography>
</Grid>
<Grid item>
@ -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 ? (
<MdCheckCircle />
) : (
<FaPen />
)}
{isInEditMode ? <MdCheckCircle /> : <FaPen />}
</IconButton>
</Grid>
</Grid>
</Grid>
<Grid item>
<Grid container spacing={4} direction="column">
<Grid container spacing={2} direction="column">
{notes.data.createdAt && (
<Grid item>
<Grid
container
spacing={1}
flexDirection="row"
alignItems="center"
<SimpleOverlayInformation
emptyText={t("general.emptyUnavailableValue")}
icon={<MdEditCalendar />}
label={t(
"routes.AliasDetailRoute.sections.notes.form.createdAt.label",
)}
>
<Grid item>
<MdEditCalendar />
</Grid>
<Grid item>
<Tooltip
title={notes.data.createdAt.toISOString()}
>
{notes.data.createdAt && (
<Tooltip title={notes.data.createdAt.toISOString()}>
<Typography variant="body1">
{format(
notes.data.createdAt,
"Pp",
)}
{format(notes.data.createdAt, "Pp")}
</Typography>
</Tooltip>
</Grid>
</Grid>
)}
</SimpleOverlayInformation>
</Grid>
)}
<Grid item>
<Grid container spacing={1} direction="column">
<Grid item>
<Typography variant="overline">
Personal Notes
</Typography>
</Grid>
<Grid item>
{isInEditMode ? (
<TextField
label="Personal Notes"
multiline
fullWidth
key="personalNotes"
id="personalNotes"
name="personalNotes"
value={
formik.values.personalNotes
}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={formik.isSubmitting}
error={
formik.touched
.personalNotes &&
Boolean(
formik.errors
.personalNotes,
)
}
helperText={
(formik.touched
.personalNotes &&
formik.errors
.personalNotes) ||
"You can enter personal notes for this alias here. Notes are encrypted."
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<RiStickyNoteFill />
</InputAdornment>
),
}}
/>
) : (
<Typography>
{notes.data.personalNotes}
</Typography>
)}
</Grid>
</Grid>
<SimpleOverlayInformation
label={t(
"routes.AliasDetailRoute.sections.notes.form.personalNotes.label",
)}
>
{isInEditMode ? (
<TextField
label="Personal Notes"
multiline
fullWidth
key="personalNotes"
id="personalNotes"
name="personalNotes"
value={formik.values.personalNotes}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={formik.isSubmitting}
error={
formik.touched.personalNotes &&
Boolean(formik.errors.personalNotes)
}
helperText={
(formik.touched.personalNotes &&
formik.errors.personalNotes) ||
t(
"routes.AliasDetailRoute.sections.notes.form.personalNotes.helperText",
)
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<RiStickyNoteFill />
</InputAdornment>
),
}}
/>
) : (
notes.data.personalNotes
)}
</SimpleOverlayInformation>
</Grid>
<Grid item>
<Grid container spacing={1} direction="column">
<Grid item>
<Typography variant="overline">
Websites
</Typography>
</Grid>
<SimpleOverlayInformation
label={t(
"routes.AliasDetailRoute.sections.notes.form.websites.label",
)}
emptyText={t(
"routes.AliasDetailRoute.sections.notes.form.websites.emptyText",
)}
>
{isInEditMode ? (
<Grid item>
<AddWebsiteField
onAdd={async website => {
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 => (
<List>
{formik.values.websites.map(
(
website,
index,
) => (
<ListItem
key={
website.url
}
>
(website, index) => (
<ListItem key={website.url}>
<ListItemIcon>
<FaviconImage
url={
website.url
}
url={website.url}
/>
</ListItemIcon>
<ListItemText>
{
website.url
}
{website.url}
</ListItemText>
<ListItemSecondaryAction>
<IconButton
@ -309,45 +264,24 @@ export default function AliasNotesForm({
/>
</FormikProvider>
</Grid>
) : (
) : notes.data.websites.length ? (
<Grid item>
{notes.data.websites.length ? (
<List>
{notes.data.websites.map(
website => (
<ListItem
key={
website.url
}
>
<ListItemIcon>
<FaviconImage
width={
20
}
url={
website.url
}
/>
</ListItemIcon>
<ListItemText>
{
website.url
}
</ListItemText>
</ListItem>
),
)}
</List>
) : (
<Typography variant="body2">
You haven&apos;t used this
alias on any site yet.
</Typography>
)}
<List>
{notes.data.websites.map(website => (
<ListItem key={website.url}>
<ListItemIcon>
<FaviconImage
width={20}
url={website.url}
/>
</ListItemIcon>
<ListItemText>{website.url}</ListItemText>
</ListItem>
))}
</List>
</Grid>
)}
</Grid>
) : null}
</SimpleOverlayInformation>
</Grid>
</Grid>
</Grid>
@ -355,7 +289,7 @@ export default function AliasNotesForm({
</form>
<ErrorSnack message={formik.errors.detail} />
<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 {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<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({
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<boolean | null>()
.oneOf([true, false, null])
.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>({
enableReinitialize: true,
initialValues: {
@ -97,6 +100,7 @@ export default function AliasPreferencesForm({
<form onSubmit={formik.handleSubmit}>
<Grid
container
marginTop={1}
spacing={4}
flexDirection="column"
alignItems="center"
@ -105,7 +109,7 @@ export default function AliasPreferencesForm({
<Grid container spacing={4}>
<Grid item xs={12} sm={6}>
<SelectField
label="Remove Trackers"
label={t("relations.alias.settings.removeTrackers.label")}
formik={formik}
icon={<BsShieldShaded />}
name="removeTrackers"
@ -113,14 +117,9 @@ export default function AliasPreferencesForm({
</Grid>
<Grid item xs={12} sm={6}>
<SelectField
label="Create Reports"
label={t("relations.alias.settings.createMailReports.label")}
formik={formik}
icon={
<Icon
path={mdiTextBoxMultiple}
size={0.8}
/>
}
icon={<Icon path={mdiTextBoxMultiple} size={0.8} />}
name="createMailReport"
/>
</Grid>
@ -128,23 +127,20 @@ export default function AliasPreferencesForm({
<Grid container spacing={2}>
<Grid item xs={12}>
<SelectField
label="Proxy Images"
label={t("relations.alias.settings.proxyImages.label")}
formik={formik}
icon={<BsImage />}
name="proxyImages"
/>
</Grid>
<Grid item xs={12}>
<Collapse
in={
formik.values.proxyImages !==
false
}
>
<Collapse in={formik.values.proxyImages !== false}>
<Grid container spacing={4}>
<Grid item xs={12} sm={6}>
<SelectField
label="Image File Type"
label={t(
"relations.alias.settings.imageProxyFormat.label",
)}
formik={formik}
icon={<FaFile />}
name="imageProxyFormat"
@ -155,7 +151,9 @@ export default function AliasPreferencesForm({
</Grid>
<Grid item xs={12} sm={6}>
<SelectField
label="Image Proxy User Agent"
label={t(
"relations.alias.settings.imageProxyUserAgent.label",
)}
formik={formik}
name="imageProxyUserAgent"
valueTextMap={
@ -177,15 +175,20 @@ export default function AliasPreferencesForm({
type="submit"
startIcon={<MdCheckCircle />}
>
Save Settings
{t("relations.alias.settings.saveAction")}
</LoadingButton>
</Grid>
<Grid item>
<Typography variant="body2">
{t("routes.AliasDetailRoute.sections.settings.description")}
</Typography>
</Grid>
</Grid>
</form>
<FormikAutoLockNavigation formik={formik} />
<ErrorSnack message={formik.errors.detail} />
<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 {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<boolean>(true)
const [isActiveUIState, setIsActiveUIState] = useUIState<boolean>(isActive)
const [successMessage, setSuccessMessage] = useState<string>("")
const [errorMessage, setErrorMessage] = useState<string>("")
@ -50,10 +53,6 @@ export default function ChangeAliasActivationStatusSwitch({
setErrorMessage(parseFastAPIError(error).detail as string),
})
useEffect(() => {
setIsActiveUIState(isActive)
}, [isActive])
return (
<>
<Switch
@ -68,9 +67,17 @@ export default function ChangeAliasActivationStatusSwitch({
})
if (!isActiveUIState) {
setSuccessMessage("Alias activated successfully!")
setSuccessMessage(
t(
"relations.alias.mutations.success.aliasChangedToEnabled",
) as string,
)
} else {
setSuccessMessage("Alias deactivated successfully!")
setSuccessMessage(
t(
"relations.alias.mutations.success.aliasChangedToDisabled",
) as string,
)
}
} catch {}
}}

View File

@ -21,6 +21,7 @@ import {Alias, AliasType} from "~/server-types"
import {parseFastAPIError} from "~/utils"
import {ErrorSnack, SuccessSnack} from "~/components"
import {DEFAULT_ALIAS_NOTE} from "~/constants/values"
import {useTranslation} from "react-i18next"
import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext"
import CustomAliasDialog from "~/route-widgets/AliasesRoute/CustomAliasDialog"
@ -31,6 +32,7 @@ export interface CreateAliasButtonProps {
export default function CreateAliasButton({
onCreated,
}: CreateAliasButtonProps): ReactElement {
const {t} = useTranslation()
const {_encryptUsingMasterPassword, encryptionStatus} =
useContext(AuthContext)
@ -83,7 +85,7 @@ export default function CreateAliasButton({
})
}
>
Create random alias
{t("routes.AliasesRoute.actions.createRandomAlias.label")}
</Button>
<Button
size="small"
@ -108,7 +110,11 @@ export default function CreateAliasButton({
<ListItemIcon>
<FaPen />
</ListItemIcon>
<ListItemText primary="Create Custom Alias" />
<ListItemText
primary={t(
"routes.AliasesRoute.actions.createCustomAlias.label",
)}
/>
</MenuItem>
</MenuList>
</Menu>
@ -123,7 +129,10 @@ export default function CreateAliasButton({
/>
<ErrorSnack message={errorMessage} />
<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 {TiCancel} from "react-icons/ti"
import {FaPen} from "react-icons/fa"
import {useTranslation} from "react-i18next"
import {
Box,
@ -44,6 +45,7 @@ export default function CustomAliasDialog({
onClose,
}: CustomAliasDialogProps): ReactElement {
const serverSettings = useLoaderData() as ServerSettings
const {t} = useTranslation()
const schema = yup.object().shape({
local: yup
@ -75,11 +77,14 @@ export default function CustomAliasDialog({
return (
<Dialog onClose={onClose} open={visible} keepMounted={false}>
<form onSubmit={formik.handleSubmit}>
<DialogTitle>Create Custom Alias</DialogTitle>
<DialogTitle>
{t("routes.AliasesRoute.actions.createCustomAlias.label")}
</DialogTitle>
<DialogContent>
<DialogContentText>
You can define your own custom alias. Note that a random
suffix will be added at the end to avoid duplicates.
{t(
"routes.AliasesRoute.actions.createCustomAlias.description",
)}
</DialogContentText>
<Box paddingY={4}>
<TextField
@ -88,7 +93,12 @@ export default function CustomAliasDialog({
autoFocus
name="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}
onChange={formik.handleChange}
disabled={formik.isSubmitting}
@ -122,7 +132,7 @@ export default function CustomAliasDialog({
</DialogContent>
<DialogActions>
<Button onClick={onClose} startIcon={<TiCancel />}>
Cancel
{t("general.cancelLabel")}
</Button>
<Button
onClick={() => {}}
@ -131,7 +141,9 @@ export default function CustomAliasDialog({
variant="contained"
type="submit"
>
Create Alias
{t(
"routes.AliasesRoute.actions.createCustomAlias.continueAction",
)}
</Button>
</DialogActions>
</form>

View File

@ -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<NavigationSection, ReactElement> = {
}
const SECTION_TEXT_MAP: Record<NavigationSection, string> = {
[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<string, NavigationSection> = {
@ -44,12 +45,13 @@ const PATH_SECTION_MAP: Record<string, NavigationSection> = {
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 (
<Button

View File

@ -5,6 +5,7 @@ import React, {ReactElement} from "react"
import {Box, Button, Grid, Typography} from "@mui/material"
import {MultiStepFormElement} from "~/components"
import {mdiTextBoxMultiple} from "@mdi/js/commonjs/mdi"
import {useTranslation} from "react-i18next"
import Icon from "@mdi/react"
export interface GenerateEmailReportsFormProps {
@ -16,6 +17,8 @@ export default function GenerateEmailReportsForm({
onNo,
onYes,
}: GenerateEmailReportsFormProps): ReactElement {
const {t} = useTranslation()
return (
<MultiStepFormElement>
<Grid
@ -29,40 +32,26 @@ export default function GenerateEmailReportsForm({
justifyContent="center"
>
<Grid item>
<Grid
container
direction="column"
spacing={4}
alignItems="center"
>
<Grid container direction="column" spacing={4} alignItems="center">
<Grid item>
<Grid container spacing={4} direction="column">
<Grid item>
<Typography
variant="h6"
component="h2"
align="center"
>
Generate Email Reports?
<Typography variant="h6" component="h2" align="center">
{t(
"routes.CompleteAccountRoute.forms.generateReports.title",
)}
</Typography>
</Grid>
<Grid item>
<Box display="flex" justifyContent="center">
<Icon
path={mdiTextBoxMultiple}
size={2}
/>
<Icon path={mdiTextBoxMultiple} size={2} />
</Box>
</Grid>
<Grid item>
<Typography
variant="subtitle1"
component="p"
>
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 variant="subtitle1" component="p">
{t(
"routes.CompleteAccountRoute.forms.generateReports.description",
)}
</Typography>
</Grid>
</Grid>
@ -72,12 +61,10 @@ export default function GenerateEmailReportsForm({
<Grid item>
<Grid container spacing={2} direction="row">
<Grid item>
<Button
startIcon={<TiCancel />}
color="secondary"
onClick={onNo}
>
No
<Button startIcon={<TiCancel />} color="secondary" onClick={onNo}>
{t(
"routes.CompleteAccountRoute.forms.generateReports.cancelAction",
)}
</Button>
</Grid>
<Grid item>
@ -86,7 +73,9 @@ export default function GenerateEmailReportsForm({
color="primary"
onClick={onYes}
>
Yes
{t(
"routes.CompleteAccountRoute.forms.generateReports.continueAction",
)}
</Button>
</Grid>
</Grid>

View File

@ -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<AuthenticationDetails, AxiosError, UpdateAccountData>(
updateAccount,
{
onSuccess: ({user}) => {
login(user)
onDone()
},
},
})
)
const formik = useFormik<Form>({
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({
<Grid item>
<Grid container spacing={2} direction="column">
<Grid item>
<Typography
variant="h6"
component="h2"
align="center"
>
Set up your password
<Typography variant="h6" component="h2" align="center">
{t("routes.CompleteAccountRoute.forms.password.title")}
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle1" component="p">
Please enter a safe password so that we can
encrypt your data.
{t("routes.CompleteAccountRoute.forms.password.description")}
</Typography>
</Grid>
</Grid>
@ -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: (
<InputAdornment position="start">
@ -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={<MdChevronRight />}
>
Continue
{t("routes.CompleteAccountRoute.forms.password.continueAction")}
</LoadingButton>
</Grid>
</Grid>

View File

@ -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<AuthenticationDetails, AxiosError, VerifyLoginWithEmailData>(
verifyLoginWithEmail,
{
onSuccess: ({user}) => onConfirm(user),
},
)
const formik = useFormik<Form>({
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({
>
<Grid item>
<Typography variant="h6" component="h1" align="center">
You got mail!
{t("routes.LoginRoute.forms.confirmCode.title")}
</Typography>
</Grid>
<Grid item>
@ -80,13 +103,8 @@ export default function ConfirmCodeForm({
</Box>
</Grid>
<Grid item>
<Typography
variant="subtitle1"
component="p"
align="center"
>
We sent you a code to your email. Enter it below to
login.
<Typography variant="subtitle1" component="p" align="center">
{t("routes.LoginRoute.forms.confirmCode.description")}
</Typography>
</Grid>
<Grid item>
@ -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: (
<InputAdornment position="start">
@ -116,12 +129,7 @@ export default function ConfirmCodeForm({
/>
</Grid>
<Grid item>
<Grid
width="100%"
container
display="flex"
justifyContent="space-between"
>
<Grid width="100%" container display="flex" justifyContent="space-between">
<Grid item>
<ResendMailButton
email={email}
@ -135,7 +143,7 @@ export default function ConfirmCodeForm({
type="submit"
startIcon={<MdChevronRight />}
>
Login
{t("routes.LoginRoute.forms.confirmCode.continueAction")}
</LoadingButton>
</Grid>
</Grid>

View File

@ -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<SimpleDetailResponse, AxiosError, void>(() =>
resendEmailLoginCode({
@ -35,7 +37,7 @@ export default function ResendMailButton({
startIcon={<MdMail />}
onClick={() => mutate()}
>
Resend Mail
{t("components.ResendMailButton.label")}
</TimedButton>
<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 {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<LoginWithEmailResult, AxiosError, string>(
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<LoginWithEmailResult, AxiosError, string>(loginWithEmail, {
onSuccess: ({sameRequestToken}) => onLogin(formik.values.email, sameRequestToken),
})
const formik = useFormik<Form>({
validationSchema: SCHEMA,
initialValues: {
@ -51,9 +52,9 @@ export default function EmailForm({onLogin}: EmailFormProps): ReactElement {
<MultiStepFormElement>
<form onSubmit={formik.handleSubmit}>
<SimpleForm
title="Sign in"
description="We'll send you a verification code to your email."
continueActionLabel="Send code"
title={t("routes.LoginRoute.forms.email.title")}
description={t("routes.LoginRoute.forms.email.description")}
continueActionLabel={t("routes.LoginRoute.forms.email.continueAction")}
nonFieldError={formik.errors.detail}
isSubmitting={formik.isSubmitting}
>
@ -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: (
<InputAdornment position="start">

View File

@ -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({
<ListItemIcon>
<BsImage />
</ListItemIcon>
<ListItemText>Proxying {images.length} images</ListItemText>
<ListItemText>
{t("routes.ReportDetailRoute.sections.trackers.results.proxiedImages.text", {
count: images.length,
})}
</ListItemText>
</ListItemButton>
<Collapse in={showProxiedImages}>
<Box bgcolor={theme.palette.background.default}>
<List>
{images.map(image => (
<ListItemButton
href={image.serverUrl}
href={isDev ? image.url : image.serverUrl}
target="_blank"
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 {
return "Proxying through Server."
return t(
"routes.ReportDetailRoute.sections.trackers.results.proxiedImages.status.isProxying",
)
}
})()}
</Grid>

View File

@ -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<boolean>(false)
@ -45,30 +47,24 @@ export default function SinglePixelImageTrackersListItem({
<BsShieldShaded />
</ListItemIcon>
<ListItemText>
Removed {images.length} image trackers
{t("routes.ReportDetailRoute.sections.trackers.results.imageTrackers.text", {
count: images.length,
})}
</ListItemText>
</ListItemButton>
<Collapse in={showImageTrackers}>
<Box bgcolor={theme.palette.background.default}>
<List>
{Object.entries(imagesPerTracker).map(
([trackerName, images]) => (
<>
<Typography
variant="caption"
component="h3"
ml={1}
>
{trackerName}
</Typography>
{images.map(image => (
<ListItem key={image.source}>
{image.source}
</ListItem>
))}
</>
),
)}
{Object.entries(imagesPerTracker).map(([trackerName, images]) => (
<>
<Typography variant="caption" component="h3" ml={1}>
{trackerName}
</Typography>
{images.map(image => (
<ListItem key={image.source}>{image.source}</ListItem>
))}
</>
))}
</List>
</Box>
</Collapse>

View File

@ -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 (
<ListItemButton onClick={() => navigate(`/reports/${report.id}`)}>
<ListItemText
primary={
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>
)

View File

@ -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<ImageProxyFormatType>()
.oneOf(Object.values(ImageProxyFormatType))
.required(),
imageProxyUserAgent: yup
.mixed<ProxyUserAgentType>()
.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<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<
SimpleDetailResponse,
AxiosError,
@ -110,22 +110,15 @@ export default function AliasesPreferencesForm(): ReactElement {
return (
<>
<form onSubmit={formik.handleSubmit}>
<Grid
container
spacing={4}
flexDirection="column"
alignItems="center"
>
<Grid container spacing={4} flexDirection="column" alignItems="center">
<Grid item>
<Typography variant="h6" component="h3">
Aliases Preferences
{t("routes.SettingsRoute.forms.aliasPreferences.title")}
</Typography>
</Grid>
<Grid item>
<Typography variant="body1" component="p">
Select the default behavior for your aliases. This
will only affect aliases that do not have a custom
behavior set.
{t("routes.SettingsRoute.forms.aliasPreferences.description")}
</Typography>
</Grid>
<Grid item>
@ -136,7 +129,7 @@ export default function AliasesPreferencesForm(): ReactElement {
spacing={4}
alignItems="flex-end"
>
<Grid item md={6}>
<Grid item md={6} xs={12}>
<FormGroup>
<FormControlLabel
disabled={formik.isSubmitting}
@ -144,9 +137,7 @@ export default function AliasesPreferencesForm(): ReactElement {
<Checkbox
name="removeTrackers"
id="removeTrackers"
checked={
formik.values.removeTrackers
}
checked={formik.values.removeTrackers}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
@ -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")}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item md={6}>
<Grid item md={6} xs={12}>
<FormGroup>
<FormControlLabel
disabled={formik.isSubmitting}
@ -174,10 +165,7 @@ export default function AliasesPreferencesForm(): ReactElement {
<Checkbox
name="createMailReport"
id="createMailReport"
checked={
formik.values
.createMailReport
}
checked={formik.values.createMailReport}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
@ -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",
)}
</FormHelperText>
</FormGroup>
</Grid>
@ -205,9 +195,7 @@ export default function AliasesPreferencesForm(): ReactElement {
<Checkbox
name="proxyImages"
id="proxyImages"
checked={
formik.values.proxyImages
}
checked={formik.values.proxyImages}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
@ -217,29 +205,24 @@ export default function AliasesPreferencesForm(): ReactElement {
/>
<FormHelperText
error={Boolean(
formik.touched.proxyImages &&
formik.errors.proxyImages,
formik.touched.proxyImages && formik.errors.proxyImages,
)}
>
{(formik.touched.proxyImages &&
formik.errors.proxyImages) ||
"Proxies images in your emails through this KleckRelay instance. This adds an extra layer of privacy. Images are loaded immediately after we receive the email. They then will be stored for some time (cache time). During that time, the image will be served from us. This means the original server has no idea you have opened the mail. After the cache time, the image is loaded from the original server, but it gets proxied by us. This means the original server will not be able to access neither your IP address nor your user agent."}
t("relations.alias.settings.proxyImages.helperText")}
</FormHelperText>
</FormGroup>
<Collapse in={formik.values.proxyImages}>
<Grid
display="flex"
flexDirection={
isLarge ? "row" : "column"
}
flexDirection={isLarge ? "row" : "column"}
container
marginY={2}
spacing={4}
alignItems={
isLarge ? "flex-start" : "flex-end"
}
alignItems={isLarge ? "flex-start" : "flex-end"}
>
<Grid item md={6}>
<Grid item md={6} xs={12}>
<FormGroup>
<TextField
fullWidth
@ -254,62 +237,38 @@ export default function AliasesPreferencesForm(): ReactElement {
name="imageProxyFormat"
id="imageProxyFormat"
label="Image File Type"
value={
formik.values
.imageProxyFormat
}
onChange={
formik.handleChange
}
disabled={
formik.isSubmitting
}
value={formik.values.imageProxyFormat}
onChange={formik.handleChange}
disabled={formik.isSubmitting}
error={
formik.touched
.imageProxyFormat &&
Boolean(
formik.errors
.imageProxyFormat,
)
formik.touched.imageProxyFormat &&
Boolean(formik.errors.imageProxyFormat)
}
helperText={
formik.touched
.imageProxyFormat &&
formik.errors
.imageProxyFormat
formik.touched.imageProxyFormat &&
formik.errors.imageProxyFormat
}
>
{Object.entries(
ImageProxyFormatType,
).map(([key, value]) => (
<MenuItem
key={key}
value={value}
>
{
IMAGE_PROXY_FORMAT_TYPE_NAME_MAP[
value
] as string
}
IMAGE_PROXY_FORMAT_TYPE_NAME_MAP,
).map(([value, translationString]) => (
<MenuItem key={value} value={value}>
{t(translationString)}
</MenuItem>
))}
</TextField>
<FormHelperText
error={Boolean(
formik.touched
.imageProxyFormat &&
formik.errors
.imageProxyFormat,
formik.touched.imageProxyFormat &&
formik.errors.imageProxyFormat,
)}
>
{formik.touched
.imageProxyFormat &&
formik.errors
.imageProxyFormat}
{formik.touched.imageProxyFormat &&
formik.errors.imageProxyFormat}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item md={6}>
<Grid item md={6} xs={12}>
<FormGroup>
<TextField
fullWidth
@ -317,59 +276,37 @@ export default function AliasesPreferencesForm(): ReactElement {
name="imageProxyUserAgent"
id="imageProxyUserAgent"
label="Image Proxy User Agent"
value={
formik.values
.imageProxyUserAgent
}
onChange={
formik.handleChange
}
disabled={
formik.isSubmitting
}
value={formik.values.imageProxyUserAgent}
onChange={formik.handleChange}
disabled={formik.isSubmitting}
error={
formik.touched
.imageProxyUserAgent &&
Boolean(
formik.errors
.imageProxyUserAgent,
)
formik.touched.imageProxyUserAgent &&
Boolean(formik.errors.imageProxyUserAgent)
}
helperText={
formik.touched
.imageProxyUserAgent &&
formik.errors
.imageProxyUserAgent
formik.touched.imageProxyUserAgent &&
formik.errors.imageProxyUserAgent
}
>
{Object.entries(
ProxyUserAgentType,
).map(([key, value]) => (
<MenuItem
key={key}
value={value}
>
{
IMAGE_PROXY_USER_AGENT_TYPE_NAME_MAP[
value
] as string
}
IMAGE_PROXY_USER_AGENT_TYPE_NAME_MAP,
).map(([value, translationString]) => (
<MenuItem key={value} value={value}>
{t(translationString)}
</MenuItem>
))}
</TextField>
<FormHelperText
error={Boolean(
formik.touched
.imageProxyUserAgent &&
formik.errors
.imageProxyUserAgent,
formik.touched.imageProxyUserAgent &&
formik.errors.imageProxyUserAgent,
)}
>
{(formik.touched
.imageProxyUserAgent &&
formik.errors
.imageProxyUserAgent) ||
"An User Agent is a identifier each browser and email client sends when retrieving files, such as images. You can specify here what user agent you would like to be used by the proxy. User Agents are kept up-to-date."}
{(formik.touched.imageProxyUserAgent &&
formik.errors.imageProxyUserAgent) ||
t(
"relations.alias.settings.imageProxyUserAgent.helperText",
)}
</FormHelperText>
</FormGroup>
</Grid>

View File

@ -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<string, string> = {
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<string, string> = {
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 (
<FormControl fullWidth>
@ -52,9 +52,7 @@ export default function SelectField({
label={label}
labelId={labelId}
startAdornment={
icon ? (
<InputAdornment position="start">{icon}</InputAdornment>
) : undefined
icon ? <InputAdornment position="start">{icon}</InputAdornment> : 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" ? (
<i>{`<${defaultValueText}>`}</i>
<i>
{t("general.defaultValueSelectionRaw", {
value: defaultValueText,
})}
</i>
) : (
valueTextMap[value.toString()]
t(valueTextMap[value.toString()])
)
}
>
<MenuItem value="null">
<i>{`Default <${defaultValueText}>`}</i>
<i>
{t("general.defaultValueSelection", {
value: defaultValueText,
})}
</i>
</MenuItem>
{valueTextMap &&
Object.entries(valueTextMap).map(([value, text]) => (
Object.entries(valueTextMap).map(([value, translationString]) => (
<MenuItem key={value} value={value}>
{text}
{t(translationString)}
</MenuItem>
))}
</Select>
<FormHelperText
error={Boolean(formik.touched[name] && formik.errors[name])}
>
<FormHelperText error={Boolean(formik.touched[name] && formik.errors[name])}>
{formik.touched[name] && formik.errors[name]}
</FormHelperText>
</FormControl>

View File

@ -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<SignupResult, AxiosError, string>(
signup,
{
onSuccess: ({normalizedEmail}) => onSignUp(normalizedEmail),
},
)
const {mutateAsync} = useMutation<SignupResult, AxiosError, string>(signup, {
onSuccess: ({normalizedEmail}) => onSignUp(normalizedEmail),
})
const formik = useFormik<Form>({
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({
<MultiStepFormElement>
<form onSubmit={formik.handleSubmit}>
<SimpleForm
title="Sign up"
description="We only need your email and you are ready to go!"
continueActionLabel="Sign up"
title={t("routes.SignupRoute.forms.email.title")}
description={t("routes.SignupRoute.forms.email.description")}
continueActionLabel={t("routes.SignupRoute.forms.email.continueAction")}
nonFieldError={formik.errors.detail}
isSubmitting={formik.isSubmitting}
>
@ -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: (
<InputAdornment position="start">
@ -113,9 +109,7 @@ export default function EmailForm({
</form>
</MultiStepFormElement>
{!serverSettings.otherRelaysEnabled && (
<DetectEmailAutofillService
domains={serverSettings.otherRelayDomains}
/>
<DetectEmailAutofillService domains={serverSettings.otherRelayDomains} />
)}
</>
)

View File

@ -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<SimpleDetailResponse, AxiosError, void>(() =>
@ -30,7 +30,7 @@ export default function ResendMailButton({
startIcon={<MdMail />}
onClick={() => mutate()}
>
Resend Mail
{t("components.ResendMailButton.label")}
</TimedButton>
<MutationStatusSnackbar mutation={mutation} />
</>

View File

@ -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<boolean>(false)
const domain = email.split("@")[1]
@ -43,30 +43,21 @@ export default function YouGotMail({
>
<Grid item>
<Typography variant="h6" component="h2" align="center">
You got mail!
{t("routes.SignupRoute.forms.mailVerification.title")}
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle1" component="p">
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")}
</Typography>
</Grid>
<Grid item>
<Grid
container
alignItems="center"
direction="row"
spacing={2}
>
<Grid container alignItems="center" direction="row" spacing={2}>
<Grid item>
<code>{email}</code>
</Grid>
<Grid item>
<IconButton
onClick={() => setAskToEditEmail(true)}
>
<IconButton onClick={() => setAskToEditEmail(true)}>
<MdEdit />
</IconButton>
</Grid>
@ -81,21 +72,21 @@ export default function YouGotMail({
</Grid>
</MultiStepFormElement>
<Dialog open={askToEditEmail}>
<DialogTitle>Edit email address?</DialogTitle>
<DialogTitle>
{t("routes.SignupRoute.forms.mailVerification.editEmail.title")}
</DialogTitle>
<DialogContent>
<DialogContentText>
Would you like to return to the previous step and edit
your email address?
{t("routes.SignupRoute.forms.mailVerification.editEmail.description")}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
startIcon={<MdCancel />}
onClick={() => setAskToEditEmail(false)}
>
Cancel
<Button startIcon={<MdCancel />} onClick={() => setAskToEditEmail(false)}>
{t("general.cancelLabel")}
</Button>
<Button onClick={onGoBack}>
{t("routes.SignupRoute.forms.mailVerification.editEmail.continueAction")}
</Button>
<Button onClick={onGoBack}>Yes, edit email</Button>
</DialogActions>
</Dialog>
</>

View File

@ -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 (
<SimplePage title="Alias Details">
<SimplePage title={t("routes.AliasDetailRoute.title")}>
<QueryResult<Alias | DecryptedAlias> query={query}>
{alias => <AliasDetails alias={alias} />}
</QueryResult>

View File

@ -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<string>("")
const [queryValue, setQueryValue] = useState<string>("")
const [, startTransition] = useTransition()
@ -25,7 +28,7 @@ export default function AliasesRoute(): ReactElement {
return (
<SimplePage
title="Aliases"
title={t("routes.AliasesRoute.title")}
pageOptionsActions={
<TextField
value={searchValue}
@ -35,7 +38,10 @@ export default function AliasesRoute(): ReactElement {
setQueryValue(event.target.value)
})
}}
label="Search"
label={t("routes.AliasesRoute.pageActions.search.label")}
placeholder={t(
"routes.AliasesRoute.pageActions.search.placeholder",
)}
id="search"
InputProps={{
startAdornment: (

View File

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

View File

@ -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}
/>,
<PasswordForm
onDone={navigateToNext}
key="password_form"
/>,
<PasswordForm onDone={navigateToNext} key="password_form" />,
]}
index={showGenerationReportForm ? 1 : 0}
/>
@ -51,13 +48,12 @@ export default function CompleteAccountRoute(): ReactElement {
>
<Grid item>
<Typography variant="h6" component="h1" align="center">
Encryption already enabled
{t("routes.CompleteAccountRoute.forms.available.title")}
</Typography>
</Grid>
<Grid item>
<Typography variant="body1">
You already have encryption enabled. Changing passwords
is currently not supported.
{t("routes.CompleteAccountRoute.forms.available.description")}
</Typography>
</Grid>
</Grid>

View File

@ -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 (
<form onSubmit={formik.handleSubmit}>
<SimpleForm
title="Decrypt reports"
description="Please enter your password so that your reports can de decrypted."
cancelActionLabel="Don't decrypt"
continueActionLabel="Continue"
title={t("components.EnterDecryptionPassword.title")}
description={t(
"components.EnterDecryptionPassword.description",
)}
cancelActionLabel={t(
"components.EnterDecryptionPassword.cancelAction",
)}
continueActionLabel={t(
"components.EnterDecryptionPassword.continueAction",
)}
isSubmitting={formik.isSubmitting}
>
{[
@ -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}

View File

@ -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<Report, AxiosError>(["get_report", params.id], () =>
@ -21,102 +23,89 @@ export default function ReportDetailRoute(): ReactElement {
)
return (
<SimplePage title="Report Details">
<SimplePageBuilder.Page title="Report Details">
<QueryResult<Report> query={query}>
{encryptedReport => (
<DecryptReport
encryptedContent={encryptedReport.encryptedContent}
>
<DecryptReport encryptedContent={encryptedReport.encryptedContent}>
{report => (
<Grid container spacing={4}>
<Grid item xs={12}>
<Typography variant="h6" component="h2">
Email information
</Typography>
<Grid container columnSpacing={4}>
<Grid item xs={12} md={6} lg={4}>
<Box component="dl">
<Typography
variant="overline"
component="dt"
>
From
</Typography>
<Typography
variant="body1"
component="dd"
<SimplePageBuilder.MultipleSections>
{[
<SimplePageBuilder.Section
key="information"
label={t(
"routes.ReportDetailRoute.sections.information.title",
)}
>
<SimplePageBuilder.InformationContainer>
{[
<SimpleOverlayInformation
key="from"
label={t(
"routes.ReportDetailRoute.sections.information.form.from.label",
)}
>
{
report.messageDetails
.meta.from
(report as DecryptedReportContent)
.messageDetails.meta.from
}
</Typography>
</Box>
</Grid>
<Grid item xs={12} md={6} lg={4}>
<Box component="dl">
<Typography
variant="overline"
component="dt"
>
To
</Typography>
<Typography
variant="body1"
component="dd"
</SimpleOverlayInformation>,
<SimpleOverlayInformation
key="to"
label={t(
"routes.ReportDetailRoute.sections.information.form.to.label",
)}
>
{
report.messageDetails
.meta.to
(report as DecryptedReportContent)
.messageDetails.meta.to
}
</Typography>
</Box>
</Grid>
<Grid item xs={12} lg={4}>
<Box component="dl">
<Typography
variant="overline"
component="dt"
>
Subject
</Typography>
<Typography
variant="body1"
component="dd"
</SimpleOverlayInformation>,
<SimpleOverlayInformation
key="subject"
label={t(
"routes.ReportDetailRoute.sections.information.form.subject.label",
)}
>
{
report.messageDetails
.content.subject
(report as DecryptedReportContent)
.messageDetails.content.subject
}
</Typography>
</Box>
</Grid>
</Grid>
</Grid>
<Grid item>
<Typography variant="h6" component="h2">
Trackers
</Typography>
<List>
<SinglePixelImageTrackersListItem
images={
report.messageDetails.content
.singlePixelImages
}
/>
<ProxiedImagesListItem
images={
report.messageDetails.content
.proxiedImages
}
/>
</List>
</Grid>
</Grid>
</SimpleOverlayInformation>,
]}
</SimplePageBuilder.InformationContainer>
</SimplePageBuilder.Section>,
<SimplePageBuilder.Section
key="trackers"
label={t(
"routes.ReportDetailRoute.sections.trackers.title",
)}
>
<List>
<SinglePixelImageTrackersListItem
images={
(report as DecryptedReportContent)
.messageDetails.content.singlePixelImages
}
/>
<ProxiedImagesListItem
images={
(report as DecryptedReportContent)
.messageDetails.content.proxiedImages
}
/>
</List>
</SimplePageBuilder.Section>,
]}
</SimplePageBuilder.MultipleSections>
)}
</DecryptReport>
)}
</QueryResult>
</SimplePage>
</SimplePageBuilder.Page>
)
}
export default WithEncryptionRequired(ReportDetailRoute)

View File

@ -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, string> = {
[SortingView.List]: "List reports by their date",
[SortingView.GroupByAlias]: "Group reports by their aliases",
}
const SORTING_VIEW_ICON_MAP: Record<SortingView, ReactElement> = {
[SortingView.List]: <MdList />,
[SortingView.GroupByAlias]: <FaMask />,
}
const SORTING_VIEW_NAME_MAP: Record<SortingView, string> = createEnumMapFromTranslation(
"routes.ReportsRoute.pageActions.sort",
SortingView,
)
function ReportsRoute(): ReactElement {
const query = useQuery<PaginationResult<Report>, AxiosError>(
["get_reports"],
getReports,
)
const {t} = useTranslation()
const [sortingView, setSortingView] = useState<SortingView>(
SortingView.List,
)
const query = useQuery<PaginationResult<Report>, AxiosError>(["get_reports"], getReports)
const [sortingView, setSortingView] = useState<SortingView>(SortingView.List)
return (
<SimplePage
@ -53,9 +45,7 @@ function ReportsRoute(): ReactElement {
pageOptionsActions={
<TextField
value={sortingView}
onChange={event =>
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 => (
<MenuItem key={name} value={name}>
{SORTING_VIEW_NAME_MAP[name as SortingView]}
{t(SORTING_VIEW_NAME_MAP[name as SortingView])}
</MenuItem>
))}
</TextField>
@ -115,18 +105,12 @@ function ReportsRoute(): ReactElement {
>
{alias}
</Typography>
{reports.map(
report => (
<ReportInformationItem
report={
report
}
key={
report.id
}
/>
),
)}
{reports.map(report => (
<ReportInformationItem
report={report}
key={report.id}
/>
))}
</>
),
)

View File

@ -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 (
<Grid container spacing={4}>
<Grid item>
<Typography variant="h5" component="h2">
Settings
</Typography>
</Grid>
<Grid item>
<AliasesPreferencesForm />
</Grid>
</Grid>
<SimplePageBuilder.Page title={t("routes.SettingsRoute.title")}>
<AliasesPreferencesForm />
</SimplePageBuilder.Page>
)
}

View File

@ -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<string>("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<AuthenticationDetails, AxiosError, VerifyEmailData>(
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 {
>
<Grid item>
<Typography variant="h5" component="h1" align="center">
Verify your email
{t("routes.VerifyEmailRoute.title")}
</Typography>
</Grid>
{loading ? (
<Grid item>
<Typography
variant="subtitle1"
component="p"
align="center"
>
Verifying your email...
<Typography variant="subtitle1" component="p" align="center">
{t("routes.VerifyEmailRoute.isLoading")}
</Typography>
</Grid>
) : (
<>
<Grid item>
<MdCancel
size={100}
color={theme.palette.error.main}
/>
<MdCancel size={100} color={theme.palette.error.main} />
</Grid>
<Grid item>
<Typography
variant="subtitle1"
component="p"
align="center"
>
Sorry, but this verification code is invalid.
<Typography variant="subtitle1" component="p" align="center">
{t("routes.VerifyEmailRoute.isCodeInvalid")}
</Typography>
</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 * 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"