Merge pull request #14 from Myzel394/add-2fa

Add 2fa
This commit is contained in:
Myzel394 2023-03-01 19:20:22 +01:00 committed by GitHub
commit 0d7be5c069
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1272 additions and 411 deletions

View File

@ -45,6 +45,7 @@
"react-dom": "^18.2.0",
"react-i18next": "^12.0.0",
"react-icons": "^4.4.0",
"react-qr-code": "^2.0.11",
"react-router-dom": "^6.4.2",
"react-use": "^17.4.0",
"secure-random-password": "^0.2.3",
@ -61,7 +62,6 @@
"@types/deep-equal": "^1.0.1",
"@types/jest": "^29.2.4",
"@types/lodash": "^4.14.191",
"@types/node": "^18.14.0",
"@types/openpgp": "^4.4.18",
"@types/react-dom": "^18.0.11",
"@types/react-icons": "^3.0.0",

View File

@ -57,6 +57,16 @@
"confirmFromDifferentDevice": {
"title": "Login failed",
"description": "You could not be logged in. This could either be because you are not allowed to login from different devices or the verification code is invalid or expired."
},
"otp": {
"title": "Two-factor authentication",
"description": "Please enter the code from your authenticator app.",
"submit": "Log in",
"lost": "I lost my codes",
"code": {
"label": "Code"
},
"unavailable": "Your OTP verification time expired or you exceeded the maximum number of attempts. Please log in again."
}
}
},
@ -94,6 +104,19 @@
}
}
},
"Recover2FARoute": {
"title": "Recover Two-Factor-Authentication",
"description": "We are very sorry if you lost your codes. Please enter a recovery code to continue. Note that this will disable two-factor authentication for your account. You can enable it again in the settings.",
"forms": {
"recoveryCode": {
"label": "Recovery Code"
},
"submit": "Disable 2FA"
},
"unauthorized": "Please make sure to log in first and then reset your two-factor authentication on its screen.",
"canLoginNow": "Two-factor authentication has been disabled. You can now log in.",
"loggedIn": "Two-factor authentication has been disabled. You are now logged in."
},
"CompleteAccountRoute": {
"forms": {
"generateReports": {
@ -284,6 +307,46 @@
"description": "Select default values for your aliases. This only affects aliases you haven't set a custom value for.",
"saveAction": "Save preferences"
}
},
"actions": {
"enable2fa": "Two-Factor-Authentication",
"aliasPreferences": "Alias Preferences"
},
"2fa": {
"title": "Two-Factor-Authentication",
"alreadyEnabled": "You have successfully enabled 2FA!",
"setup": {
"description": "Enable 2FA to add an extra layer of security to your account. Each time you log in, you will need to enter a code generated from your authenticator app. This makes it harder for an attacker to hack into your account as they would need to have access to your phone.",
"setupLabel": "Enable 2FA",
"code": {
"label": "Code",
"description": "Enter the code generated by your authenticator app.",
"onlyDigits": "The code can only contain digits."
},
"submit": "Enable 2FA",
"expired": "The verification time for your current Two-Factor-Authentication code has expired. A new code has been generated.",
"recoveryCodes": {
"title": "Note down your recovery codes",
"description": "These codes are used to recover your account if you lose access to your authenticator app. Note them down and store them in a safe place. You will not be able to view them again. Do not store them in your password manager. IF YOU LOSE YOUR RECOVERY CODES, YOU WILL LOSE ACCESS TO YOUR ACCOUNT. WE WILL NOT BE ABLE TO HELP YOU.",
"submit": "I have noted down my recovery codes"
},
"success": "You have successfully enabled 2FA!"
},
"delete": {
"showAction": "Disable 2FA",
"askType": {
"code": "I have my 2FA code",
"recoveryCode": "I have a recovery code"
},
"askCode": {
"label": "Code"
},
"askRecoveryCode": {
"label": "Recovery Code"
},
"submit": "Disable 2FA",
"success": "You have successfully disabled 2FA!"
}
}
},
"LogoutRoute": {

View File

@ -1,9 +1,8 @@
import {RouterProvider, createBrowserRouter} from "react-router-dom"
import {SnackbarProvider} from "notistack"
import React, {ReactElement} from "react"
import {QueryClientProvider} from "@tanstack/react-query"
import {CssBaseline, Theme, ThemeProvider} from "@mui/material"
import React, {ReactElement} from "react"
import {queryClient} from "~/constants/react-query"
import {getServerSettings} from "~/apis"
@ -21,12 +20,15 @@ import GlobalSettingsRoute from "~/routes/GlobalSettingsRoute"
import I18nHandler from "./I18nHandler"
import LoginRoute from "~/routes/LoginRoute"
import LogoutRoute from "~/routes/LogoutRoute"
import Recover2FARoute from "./routes/Recover2FARoute"
import RedirectRoute from "./routes/RedirectRoute"
import ReportDetailRoute from "~/routes/ReportDetailRoute"
import ReportsRoute from "~/routes/ReportsRoute"
import ReservedAliasDetailRoute from "~/routes/ReservedAliasDetailRoute"
import ReservedAliasesRoute from "~/routes/ReservedAliasesRoute"
import RootRoute from "~/routes/Root"
import Settings2FARoute from "~/routes/Settings2FARoute"
import SettingsAliasPreferencesRoute from "~/routes/SettingsAliasPreferencesRoute"
import SettingsRoute from "~/routes/SettingsRoute"
import SignupRoute from "~/routes/SignupRoute"
import VerifyEmailRoute from "~/routes/VerifyEmailRoute"
@ -52,6 +54,10 @@ const router = createBrowserRouter([
loader: getServerSettings,
element: <LoginRoute />,
},
{
path: "/auth/recover-2fa",
element: <Recover2FARoute />,
},
{
path: "/auth/signup",
loader: getServerSettings,
@ -91,6 +97,14 @@ const router = createBrowserRouter([
path: "/settings",
element: <SettingsRoute />,
},
{
path: "/settings/alias-preferences",
element: <SettingsAliasPreferencesRoute />,
},
{
path: "/settings/2fa",
element: <Settings2FARoute />,
},
{
path: "/reports",
loader: getServerSettings,

22
src/apis/delete-2fa.ts Normal file
View File

@ -0,0 +1,22 @@
import {SimpleDetailResponse} from "~/server-types"
import {client} from "~/constants/axios-client"
export interface Delete2FAData {
code?: string
recoveryCode?: string
}
export default async function delete2FA({
recoveryCode,
code,
}: Delete2FAData): Promise<SimpleDetailResponse> {
const {data} = await client.delete(`${import.meta.env.VITE_SERVER_BASE_URL}/v1/setup-otp`, {
withCredentials: true,
data: {
code,
recoveryCode,
},
})
return data
}

View File

@ -0,0 +1,9 @@
import {client} from "~/constants/axios-client"
export default async function getHas2FAEnabled(): Promise<boolean> {
const {data} = await client.get(`${import.meta.env.VITE_SERVER_BASE_URL}/v1/setup-otp/`, {
withCredentials: true,
})
return data.enabled
}

View File

@ -58,3 +58,13 @@ export * from "./delete-reserved-alias"
export {default as deleteReservedAlias} from "./delete-reserved-alias"
export * from "./get-latest-cron-report"
export {default as getLatestCronReport} from "./get-latest-cron-report"
export * from "./get-has-2fa-enabled"
export {default as getHas2FAEnabled} from "./get-has-2fa-enabled"
export * from "./setup-2fa"
export {default as setup2FA} from "./setup-2fa"
export * from "./setup-2fa-verify"
export {default as verify2FASetup} from "./setup-2fa-verify"
export * from "./delete-2fa"
export {default as delete2FA} from "./delete-2fa"
export * from "./verify-otp"
export {default as verifyOTP} from "./verify-otp"

View File

@ -0,0 +1,19 @@
import {client} from "~/constants/axios-client"
export interface Verify2FASetupData {
code: string
}
export default async function verify2FASetup({code}: Verify2FASetupData): Promise<void> {
const {data} = await client.post(
`${import.meta.env.VITE_SERVER_BASE_URL}/v1/setup-otp/verify`,
{
code,
},
{
withCredentials: true,
},
)
return data
}

18
src/apis/setup-2fa.ts Normal file
View File

@ -0,0 +1,18 @@
import {client} from "~/constants/axios-client"
export interface Setup2FAResponse {
secret: string
recoveryCodes: string[]
}
export default async function setup2FA(): Promise<Setup2FAResponse> {
const {data} = await client.post(
`${import.meta.env.VITE_SERVER_BASE_URL}/v1/setup-otp/`,
{},
{
withCredentials: true,
},
)
return data
}

View File

@ -8,12 +8,16 @@ export interface VerifyLoginWithEmailData {
sameRequestToken?: string
}
export type VerifyLoginWithEmailResponse = ServerUser & {
corsToken?: string
}
export default async function verifyLoginWithEmail({
email,
token,
sameRequestToken,
}: VerifyLoginWithEmailData): Promise<ServerUser> {
const {data: user} = await client.post(
}: VerifyLoginWithEmailData): Promise<VerifyLoginWithEmailResponse> {
const {data} = await client.post(
`${import.meta.env.VITE_SERVER_BASE_URL}/v1/auth/login/email-token/verify`,
{
email,
@ -25,5 +29,9 @@ export default async function verifyLoginWithEmail({
},
)
return parseUser(user)
if (data.corsToken) {
return data
}
return parseUser(data)
}

23
src/apis/verify-otp.ts Normal file
View File

@ -0,0 +1,23 @@
import {ServerUser} from "~/server-types"
import {client} from "~/constants/axios-client"
import parseUser from "./helpers/parse-user"
export interface VerifyOTPData {
code: string
corsToken: string
}
export default async function verifyOTP({code, corsToken}: VerifyOTPData): Promise<ServerUser> {
const {data} = await client.post(
`${import.meta.env.VITE_SERVER_BASE_URL}/v1/auth/login/verify-otp`,
{
code,
corsToken,
},
{
withCredentials: true,
},
)
return parseUser(data)
}

View File

@ -120,9 +120,9 @@ export default function StringPoolField({
}, "")
onChange!(
// @ts-ignore
{
...event,
// @ts-ignore
target: {
...event.target,
value: value as string,

View File

@ -8,7 +8,7 @@ import {parseFastAPIError} from "~/utils"
export interface UseErrorSuccessSnacksResult {
showSuccess: (message: string) => void
showError: (error: Error) => void
showError: (error: Error | string) => void
}
export default function useErrorSuccessSnacks(): UseErrorSuccessSnacksResult {
@ -27,14 +27,18 @@ export default function useErrorSuccessSnacks(): UseErrorSuccessSnacksResult {
autoHideDuration: SUCCESS_SNACKBAR_SHOW_DURATION,
})
}
const showError = (error: Error) => {
let message
const showError = (error: Error | string) => {
let message: string | undefined
if (typeof error === "string") {
message = error
} else {
try {
const parsedError = parseFastAPIError(error as AxiosError)
message = parsedError.detail
} catch (e) {}
}
$errorSnackbarKey.current = enqueueSnackbar(message || t("general.defaultError"), {
variant: "error",

View File

@ -21,7 +21,7 @@ import {
import {useErrorSuccessSnacks} from "~/hooks"
import {queryClient} from "~/constants/react-query"
import {AuthContext, FormikAutoLockNavigation} from "~/components"
import SelectField from "~/route-widgets/SettingsRoute/SelectField"
import SelectField from "~/route-widgets/AliasDetailRoute/SelectField"
import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes"
export interface AliasPreferencesFormProps {

View File

@ -84,9 +84,9 @@ export default function UsersSelectField({
}
onChange!(
// @ts-ignore
{
...event,
// @ts-ignore
target: {
...event.target,
value: selectedUsers as GetAdminUsersResponse["users"],

View File

@ -25,7 +25,7 @@ import {
import {LoadingButton} from "@mui/lab"
import {ServerSettings, ServerUser, SimpleDetailResponse} from "~/server-types"
import {VerifyLoginWithEmailData, verifyLoginWithEmail} from "~/apis"
import {VerifyLoginWithEmailData, VerifyLoginWithEmailResponse, verifyLoginWithEmail} from "~/apis"
import {MultiStepFormElement} from "~/components"
import {parseFastAPIError} from "~/utils"
import {isDev} from "~/constants/development"
@ -36,6 +36,7 @@ import ResendMailButton from "./ResendMailButton"
export interface ConfirmCodeFormProps {
onConfirm: (user: ServerUser) => void
onCodeExpired: () => void
onOTPRequested: (corsToken: string) => void
email: string
sameRequestToken: string
}
@ -48,6 +49,7 @@ interface Form {
export default function ConfirmCodeForm({
onConfirm,
onCodeExpired,
onOTPRequested,
email,
sameRequestToken,
}: ConfirmCodeFormProps): ReactElement {
@ -79,12 +81,19 @@ export default function ConfirmCodeForm({
.label(t("routes.LoginRoute.forms.confirmCode.form.code.label")),
})
const {mutateAsync} = useMutation<ServerUser, AxiosError, VerifyLoginWithEmailData>(
verifyLoginWithEmail,
{
onSuccess: onConfirm,
const {mutateAsync} = useMutation<
VerifyLoginWithEmailResponse,
AxiosError,
VerifyLoginWithEmailData
>(verifyLoginWithEmail, {
onSuccess: result => {
if (result.corsToken) {
onOTPRequested(result.corsToken)
} else {
onConfirm(result)
}
},
)
})
const formik = useFormik<Form>({
validationSchema: schema,
initialValues: {

View File

@ -0,0 +1,142 @@
import * as yup from "yup"
import {ReactElement} from "react"
import {useMutation} from "@tanstack/react-query"
import {ServerUser} from "~/server-types"
import {AxiosError} from "axios"
import {verifyOTP} from "~/apis"
import {useTranslation} from "react-i18next"
import {useFormik} from "formik"
import {parseFastAPIError} from "~/utils"
import {MultiStepFormElement} from "~/components"
import {Box, Button, Grid, InputAdornment, TextField, Typography} from "@mui/material"
import {BsPhone, BsShieldLockFill} from "react-icons/bs"
import {LoadingButton} from "@mui/lab"
import {MdChevronRight} from "react-icons/md"
import {useErrorSuccessSnacks} from "~/hooks"
import {Link as RouterLink} from "react-router-dom"
interface Form {
code: string
}
export interface OTPFormProps {
corsToken: string
onConfirm: (user: ServerUser) => void
onCodeUnavailable: () => void
}
export default function OTPForm({
corsToken,
onConfirm,
onCodeUnavailable,
}: OTPFormProps): ReactElement {
const {t} = useTranslation()
const {showError} = useErrorSuccessSnacks()
const {mutateAsync} = useMutation<ServerUser, AxiosError, string>(
code =>
verifyOTP({
code,
corsToken,
}),
{
onSuccess: onConfirm,
onError: error => {
if (error.response?.status === 410 || error.response?.status === 404) {
showError(t("routes.LoginRoute.forms.otp.unavailable").toString())
onCodeUnavailable()
}
},
},
)
const schema = yup.object().shape({
code: yup.string().required().label(t("routes.LoginRoute.forms.otp.code.label")),
})
const formik = useFormik<Form>({
validationSchema: schema,
initialValues: {
code: "",
},
onSubmit: async (values, {setErrors}) => {
try {
await mutateAsync(values.code)
// await mutateAsync(values)
} catch (error) {
const errors = parseFastAPIError(error as AxiosError)
setErrors({code: errors.detail})
}
},
})
return (
<MultiStepFormElement>
<form onSubmit={formik.handleSubmit}>
<Grid
container
spacing={4}
padding={4}
justifyContent="center"
flexDirection="column"
>
<Grid item>
<Typography variant="h6" component="h1" align="center">
{t("routes.LoginRoute.forms.otp.title")}
</Typography>
</Grid>
<Grid item>
<Box display="flex" justifyContent="center">
<BsShieldLockFill size={64} />
</Box>
</Grid>
<Grid item>
<Typography variant="subtitle1" component="p" align="center">
{t("routes.LoginRoute.forms.otp.description")}
</Typography>
</Grid>
<Grid item>
<TextField
autoFocus
key="code"
fullWidth
name="code"
id="code"
label={t("routes.LoginRoute.forms.otp.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}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<BsPhone />
</InputAdornment>
),
}}
/>
</Grid>
<Grid item>
<Grid width="100%" container spacing={2} alignItems="center">
<Grid item>
<LoadingButton
loading={formik.isSubmitting}
variant="contained"
type="submit"
startIcon={<MdChevronRight />}
>
{t("routes.LoginRoute.forms.otp.submit")}
</LoadingButton>
</Grid>
<Grid item>
<Button component={RouterLink} to="/auth/recover-2fa">
{t("routes.LoginRoute.forms.otp.lost")}
</Button>
</Grid>
</Grid>
</Grid>
</Grid>
</form>
</MultiStepFormElement>
)
}

View File

@ -0,0 +1,108 @@
import {ReactElement, useState} from "react"
import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import {BsPhone, BsShieldLockFill} from "react-icons/bs"
import {MdSettingsBackupRestore} from "react-icons/md"
import {useMutation} from "@tanstack/react-query"
import {Button, Grid, TextField} from "@mui/material"
import {LoadingButton} from "@mui/lab"
import {Delete2FAData, delete2FA} from "~/apis"
import {useErrorSuccessSnacks} from "~/hooks"
import {SimpleDetailResponse} from "~/server-types"
export interface Delete2FAProps {
onSuccess: () => void
}
export default function Delete2FA({onSuccess}: Delete2FAProps): ReactElement {
const {t} = useTranslation()
const {showSuccess, showError} = useErrorSuccessSnacks()
const {mutate} = useMutation<SimpleDetailResponse, AxiosError, Delete2FAData>(delete2FA, {
onSuccess: () => {
showSuccess(t("routes.SettingsRoute.2fa.delete.success"))
onSuccess()
},
onError: showError,
})
const [view, setView] = useState<"showAction" | "askType" | "askCode" | "askRecoveryCode">(
"showAction",
)
const [value, setValue] = useState<string>("")
switch (view) {
case "showAction":
return (
<Button onClick={() => setView("askType")} startIcon={<BsShieldLockFill />}>
{t("routes.SettingsRoute.2fa.delete.showAction")}
</Button>
)
case "askType":
return (
<Grid container spacing={2}>
<Grid item>
<Button onClick={() => setView("askCode")} startIcon={<BsPhone />}>
{t("routes.SettingsRoute.2fa.delete.askType.code")}
</Button>
</Grid>
<Grid item>
<Button
onClick={() => setView("askRecoveryCode")}
startIcon={<MdSettingsBackupRestore />}
>
{t("routes.SettingsRoute.2fa.delete.askType.recoveryCode")}
</Button>
</Grid>
</Grid>
)
case "askCode":
return (
<Grid container spacing={2} alignItems="center">
<Grid item>
<TextField
fullWidth
label={t("routes.SettingsRoute.2fa.delete.askCode.label")}
value={value}
onChange={e => setValue(e.target.value)}
/>
</Grid>
<Grid item>
<LoadingButton
onClick={() => mutate({code: value})}
variant="contained"
startIcon={<BsShieldLockFill />}
>
{t("routes.SettingsRoute.2fa.delete.submit")}
</LoadingButton>
</Grid>
</Grid>
)
case "askRecoveryCode":
return (
<Grid container spacing={2} alignItems="center">
<Grid item>
<TextField
fullWidth
label={t("routes.SettingsRoute.2fa.delete.askRecoveryCode.label")}
value={value}
onChange={e => setValue(e.target.value)}
/>
</Grid>
<Grid item>
<LoadingButton
onClick={() => mutate({recoveryCode: value.replaceAll("-", "")})}
variant="contained"
startIcon={<BsShieldLockFill />}
>
{t("routes.SettingsRoute.2fa.delete.submit")}
</LoadingButton>
</Grid>
</Grid>
)
}
}

View File

@ -0,0 +1,64 @@
import {ReactElement} from "react"
import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import {BsShieldLockFill} from "react-icons/bs"
// @ts-ignore
import {authenticator} from "@otplib/preset-browser"
import {useMutation} from "@tanstack/react-query"
import {LoadingButton} from "@mui/lab"
import {Grid, Typography} from "@mui/material"
import {Setup2FAResponse, setup2FA} from "~/apis"
import {useErrorSuccessSnacks} from "~/hooks"
import VerifyOTPForm from "./VerifyOTPForm"
export interface Setup2FAProps {
onSuccess: () => void
}
export default function Setup2FA({onSuccess}: Setup2FAProps): ReactElement {
const {t} = useTranslation()
const {showError} = useErrorSuccessSnacks()
const {
data: {secret, recoveryCodes} = {},
mutate,
isLoading,
reset,
} = useMutation<Setup2FAResponse, AxiosError, void>(setup2FA, {
onError: showError,
})
return (
<Grid container spacing={4} direction="column">
<Grid item>
<Typography variant="body1">
{t("routes.SettingsRoute.2fa.setup.description")}
</Typography>
</Grid>
<Grid item alignSelf="center">
{secret ? (
<VerifyOTPForm
onRecreateRequired={() => {
reset()
mutate()
}}
secret={secret}
recoveryCodes={recoveryCodes!}
onSuccess={onSuccess}
/>
) : (
<LoadingButton
onClick={() => mutate()}
loading={isLoading}
variant="contained"
startIcon={<BsShieldLockFill />}
>
{t("routes.SettingsRoute.2fa.setup.setupLabel")}
</LoadingButton>
)}
</Grid>
</Grid>
)
}

View File

@ -0,0 +1,166 @@
import * as yup from "yup"
import {ReactElement, useState} from "react"
import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import {useFormik} from "formik"
import {BsShieldLockFill} from "react-icons/bs"
import QRCode from "react-qr-code"
import {useMutation} from "@tanstack/react-query"
import {LoadingButton} from "@mui/lab"
import {Verify2FASetupData, verify2FASetup} from "~/apis"
import {useErrorSuccessSnacks, useUser} from "~/hooks"
import {
Alert,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
InputAdornment,
TextField,
useTheme,
} from "@mui/material"
import {parseFastAPIError} from "~/utils"
export interface VerifyOTPFormProps {
onSuccess: () => void
onRecreateRequired: () => void
secret: string
recoveryCodes: string[]
}
const generateOTPAuthUri = (secret: string, email: string): string =>
`otpauth://totp/KleckRelay:${email}?secret=${secret}&issuer=KleckRelay`
export default function Settings2FARoute({
onSuccess,
recoveryCodes,
onRecreateRequired,
secret,
}: VerifyOTPFormProps): ReactElement {
const {t} = useTranslation()
const {showSuccess, showError} = useErrorSuccessSnacks()
const user = useUser()
const theme = useTheme()
const [showRecoveryCodes, setShowRecoveryCodes] = useState<boolean>(false)
const schema = yup.object().shape({
code: yup
.string()
.required()
.length(6)
.matches(/^[0-9]+$/, t("routes.SettingsRoute.2fa.setup.code.onlyDigits").toString())
.label(t("routes.SettingsRoute.2fa.setup.code.label")),
})
const {mutateAsync} = useMutation<void, AxiosError, Verify2FASetupData>(verify2FASetup, {
onSuccess: () => setShowRecoveryCodes(true),
onError: error => {
if (error.response?.status === 409 || error.response?.status === 410) {
showError(t("routes.SettingsRoute.2fa.setup.expired").toString())
onRecreateRequired()
} else {
showError(error)
}
},
})
const formik = useFormik<
Verify2FASetupData & {
detail?: string
}
>({
initialValues: {
code: "",
},
validationSchema: schema,
onSubmit: async (values, {setErrors}) => {
try {
schema.validateSync(values)
await mutateAsync(values)
} catch (error) {
setErrors(parseFastAPIError(error as AxiosError))
}
},
})
return (
<>
<form onSubmit={formik.handleSubmit}>
<Grid container spacing={4} direction="column">
<Grid item alignSelf="center">
<div style={{background: "white", padding: "2rem"}}>
<QRCode value={generateOTPAuthUri(secret, user.email.address)} />
</div>
</Grid>
<Grid item>
<Grid container alignItems="center" spacing={2} direction="row">
<Grid item>
<TextField
fullWidth
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={!!formik.errors.code}
helperText={formik.errors.code}
name="code"
label={t("routes.SettingsRoute.2fa.setup.code.label")}
disabled={formik.isSubmitting}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<BsShieldLockFill />
</InputAdornment>
),
}}
onSubmit={() => formik.handleSubmit()}
/>
</Grid>
<Grid item>
<LoadingButton
type="submit"
variant="contained"
loading={formik.isSubmitting}
>
{t("routes.SettingsRoute.2fa.setup.submit")}
</LoadingButton>
</Grid>
</Grid>
</Grid>
</Grid>
</form>
<Dialog open={showRecoveryCodes}>
<DialogTitle>{t("routes.SettingsRoute.2fa.setup.recoveryCodes.title")}</DialogTitle>
<DialogContent
sx={{
background: theme.palette.background.default,
}}
>
<code>
{recoveryCodes.map(code => (
<p key={code}>{code}</p>
))}
</code>
<Alert severity="warning">
{t("routes.SettingsRoute.2fa.setup.recoveryCodes.description")}
</Alert>
</DialogContent>
<DialogActions>
<Button
variant="contained"
onClick={() => {
showSuccess(t("routes.SettingsRoute.2fa.setup.success"))
setShowRecoveryCodes(false)
onSuccess()
}}
>
{t("routes.SettingsRoute.2fa.setup.recoveryCodes.submit")}
</Button>
</DialogActions>
</Dialog>
</>
)
}

View File

@ -1,382 +0,0 @@
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"
import {
Alert,
Checkbox,
Collapse,
FormControlLabel,
FormGroup,
FormHelperText,
Grid,
InputAdornment,
MenuItem,
TextField,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material"
import {LoadingButton} from "@mui/lab"
import {ImageProxyFormatType, ProxyUserAgentType, SimpleDetailResponse} from "~/server-types"
import {UpdatePreferencesData, updatePreferences} from "~/apis"
import {useErrorSuccessSnacks, useUser} from "~/hooks"
import {parseFastAPIError} from "~/utils"
import {
IMAGE_PROXY_FORMAT_TYPE_NAME_MAP,
PROXY_USER_AGENT_TYPE_NAME_MAP,
} from "~/constants/enum-mappings"
import {AuthContext} from "~/components"
interface Form {
removeTrackers: boolean
createMailReport: boolean
proxyImages: boolean
imageProxyFormat: ImageProxyFormatType
proxyUserAgent: ProxyUserAgentType
expandUrlShorteners: boolean
detail?: string
}
export default function AliasesPreferencesForm(): ReactElement {
const {_updateUser} = useContext(AuthContext)
const user = useUser()
const {showError, showSuccess} = useErrorSuccessSnacks()
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.createMailReports.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")),
proxyUserAgent: yup
.mixed<ProxyUserAgentType>()
.oneOf(Object.values(ProxyUserAgentType))
.required()
.label(t("relations.alias.settings.proxyUserAgent.label")),
expandUrlShorteners: yup
.boolean()
.label(t("relations.alias.settings.expandUrlShorteners.label")),
})
const {mutateAsync} = useMutation<SimpleDetailResponse, AxiosError, UpdatePreferencesData>(
updatePreferences,
{
onSuccess: (response, values) => {
const newUser = {
...user,
preferences: {
...user.preferences,
...values,
},
}
if (response.detail) {
showSuccess(response?.detail)
}
_updateUser(newUser)
},
onError: showError,
},
)
const formik = useFormik<Form>({
validationSchema: schema,
initialValues: {
removeTrackers: user.preferences.aliasRemoveTrackers,
createMailReport: user.preferences.aliasCreateMailReport,
proxyImages: user.preferences.aliasProxyImages,
imageProxyFormat: user.preferences.aliasImageProxyFormat,
proxyUserAgent: user.preferences.aliasProxyUserAgent,
expandUrlShorteners: user.preferences.aliasExpandUrlShorteners,
},
onSubmit: async (values, {setErrors}) => {
try {
await mutateAsync({
aliasRemoveTrackers: values.removeTrackers,
aliasCreateMailReport: values.createMailReport,
aliasProxyImages: values.proxyImages,
aliasImageProxyFormat: values.imageProxyFormat,
aliasProxyUserAgent: values.proxyUserAgent,
aliasExpandUrlShorteners: values.expandUrlShorteners,
})
} catch (error) {
setErrors(parseFastAPIError(error as AxiosError))
}
},
})
const theme = useTheme()
const isLarge = useMediaQuery(theme.breakpoints.up("md"))
return (
<form onSubmit={formik.handleSubmit}>
<Grid container spacing={4} flexDirection="column" alignItems="center">
<Grid item>
<Typography variant="h6" component="h3">
{t("routes.SettingsRoute.forms.aliasPreferences.title")}
</Typography>
</Grid>
<Grid item>
<Typography variant="body1" component="p">
{t("routes.SettingsRoute.forms.aliasPreferences.description")}
</Typography>
</Grid>
<Grid item>
<Grid
display="flex"
flexDirection="row"
container
spacing={4}
alignItems="flex-end"
>
<Grid item md={6} xs={12}>
<FormGroup>
<FormControlLabel
disabled={formik.isSubmitting}
control={
<Checkbox
name="removeTrackers"
id="removeTrackers"
checked={formik.values.removeTrackers}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
}
labelPlacement="start"
label={t("relations.alias.settings.removeTrackers.label")}
/>
<FormHelperText
error={Boolean(
formik.touched.createMailReport &&
formik.errors.createMailReport,
)}
>
{(formik.touched.createMailReport &&
formik.errors.createMailReport) ||
t("relations.alias.settings.removeTrackers.helperText")}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item md={6} xs={12}>
<FormGroup>
<FormControlLabel
disabled={formik.isSubmitting}
control={
<Checkbox
name="createMailReport"
id="createMailReport"
checked={formik.values.createMailReport}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
}
labelPlacement="start"
label={t("relations.alias.settings.createMailReports.label")}
/>
<FormHelperText
error={Boolean(
formik.touched.createMailReport &&
formik.errors.createMailReport,
)}
>
{(formik.touched.createMailReport &&
formik.errors.createMailReport) ||
t("relations.alias.settings.createMailReports.helperText")}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item xs={12}>
<FormGroup>
<FormControlLabel
disabled={formik.isSubmitting}
control={
<Checkbox
name="proxyImages"
id="proxyImages"
checked={formik.values.proxyImages}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
}
labelPlacement="start"
label={t("relations.alias.settings.proxyImages.label")}
/>
<FormHelperText
error={Boolean(
formik.touched.proxyImages && formik.errors.proxyImages,
)}
>
{(formik.touched.proxyImages && formik.errors.proxyImages) ||
t("relations.alias.settings.proxyImages.helperText")}
</FormHelperText>
<Alert
sx={{width: "fit-content", alignSelf: "end", marginTop: 1}}
severity="warning"
>
{t("general.experimentalFeature")}
</Alert>
</FormGroup>
<Collapse in={formik.values.proxyImages}>
<Grid
display="flex"
flexDirection={isLarge ? "row" : "column"}
container
marginY={2}
spacing={4}
alignItems={isLarge ? "flex-start" : "flex-end"}
>
<Grid item md={6} xs={12}>
<FormGroup>
<TextField
fullWidth
select
InputProps={{
startAdornment: (
<InputAdornment position="start">
<MdImage />
</InputAdornment>
),
}}
name="imageProxyFormat"
id="imageProxyFormat"
label={t(
"relations.alias.settings.imageProxyFormat.label",
)}
value={formik.values.imageProxyFormat}
onChange={formik.handleChange}
disabled={formik.isSubmitting}
error={
formik.touched.imageProxyFormat &&
Boolean(formik.errors.imageProxyFormat)
}
helperText={
formik.touched.imageProxyFormat &&
formik.errors.imageProxyFormat
}
>
{Object.entries(
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}
</FormHelperText>
</FormGroup>
</Grid>
</Grid>
</Collapse>
</Grid>
<Grid item xs={12}>
<FormGroup>
<TextField
fullWidth
select
name="proxyUserAgent"
id="proxyUserAgent"
label={t("relations.alias.settings.proxyUserAgent.label")}
value={formik.values.proxyUserAgent}
onChange={formik.handleChange}
disabled={formik.isSubmitting}
error={
formik.touched.proxyUserAgent &&
Boolean(formik.errors.proxyUserAgent)
}
helperText={
formik.touched.proxyUserAgent &&
formik.errors.proxyUserAgent
}
>
{Object.entries(PROXY_USER_AGENT_TYPE_NAME_MAP).map(
([value, translationString]) => (
<MenuItem key={value} value={value}>
{t(translationString)}
</MenuItem>
),
)}
</TextField>
<FormHelperText
error={Boolean(
formik.touched.proxyUserAgent &&
formik.errors.proxyUserAgent,
)}
>
{(formik.touched.proxyUserAgent &&
formik.errors.proxyUserAgent) ||
t("relations.alias.settings.proxyUserAgent.helperText")}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item xs={12}>
<FormGroup>
<FormControlLabel
disabled={formik.isSubmitting}
control={
<Checkbox
name="expandUrlShorteners"
id="expandUrlShorteners"
checked={formik.values.expandUrlShorteners}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
}
labelPlacement="start"
label={t("relations.alias.settings.expandUrlShorteners.label")}
/>
<FormHelperText
error={Boolean(
formik.touched.expandUrlShorteners &&
formik.errors.expandUrlShorteners,
)}
>
{(formik.touched.expandUrlShorteners &&
formik.errors.expandUrlShorteners) ||
t(
"relations.alias.settings.expandUrlShorteners.helperText",
)}
</FormHelperText>
<Alert
sx={{width: "fit-content", alignSelf: "end", marginTop: 1}}
severity="warning"
>
{t("general.experimentalFeature")}
</Alert>
</FormGroup>
</Grid>
</Grid>
</Grid>
<Grid item>
<LoadingButton
loading={formik.isSubmitting}
variant="contained"
type="submit"
startIcon={<MdCheckCircle />}
>
{t("routes.SettingsRoute.forms.aliasPreferences.saveAction")}
</LoadingButton>
</Grid>
</Grid>
</form>
)
}

View File

@ -7,6 +7,7 @@ import {useQueryParams} from "~/hooks"
import ConfirmCodeForm from "~/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm"
import ConfirmFromDifferentDevice from "~/route-widgets/LoginRoute/ConfirmFromDifferentDevice"
import EmailForm from "~/route-widgets/LoginRoute/EmailForm"
import OTPForm from "~/route-widgets/LoginRoute/OTPForm"
export default function LoginRoute(): ReactElement {
const navigate = useNavigate()
@ -53,6 +54,16 @@ export default function LoginRoute(): ReactElement {
onCodeExpired={() => {
setStep(0)
}}
onOTPRequested={corsToken => {
setStep(2)
setSameRequestToken(corsToken)
}}
/>,
<OTPForm
onConfirm={login}
key={`otp_form:${email}:${step}`}
corsToken={sameRequestToken}
onCodeUnavailable={() => setStep(0)}
/>,
]}
index={step}

View File

@ -0,0 +1,123 @@
import * as yup from "yup"
import {ReactElement, useContext} from "react"
import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import {BsShieldLockFill} from "react-icons/bs"
import {useFormik} from "formik"
import {useNavigate} from "react-router-dom"
import {useMutation} from "@tanstack/react-query"
import {LoadingButton} from "@mui/lab"
import {Box, Grid, Paper, TextField, Typography} from "@mui/material"
import {SimpleDetailResponse} from "~/server-types"
import {Delete2FAData, delete2FA, getMe} from "~/apis"
import {useErrorSuccessSnacks} from "~/hooks"
import {AuthContext} from "~/components"
interface Form {
recoveryCode: string
}
export default function Recover2FARoute(): ReactElement {
const {t} = useTranslation()
const {showError, showSuccess} = useErrorSuccessSnacks()
const {login} = useContext(AuthContext)
const navigate = useNavigate()
const {mutateAsync} = useMutation<SimpleDetailResponse, AxiosError, Delete2FAData>(delete2FA, {
onSuccess: async () => {
try {
const user = await getMe()
showSuccess(t("routes.Recover2FARoute.loggedIn").toString())
login(user)
navigate("/aliases")
} catch (error) {
showSuccess(t("routes.Recover2FARoute.canLoginNow").toString())
navigate("/auth/login")
}
},
onError: error => {
if (error.response?.status == 401) {
showError(t("routes.Recover2FARoute.unauthorized").toString())
navigate("/auth/login")
} else {
showError(error)
}
},
})
const schema = yup.object().shape({
recoveryCode: yup.string().required().label(t("routes.LoginRoute.forms.otp.code.label")),
})
const formik = useFormik<Form>({
validationSchema: schema,
initialValues: {
recoveryCode: "",
},
onSubmit: async values =>
mutateAsync({
recoveryCode: values.recoveryCode.replaceAll("-", ""),
}),
})
return (
<Paper>
<Box maxWidth="sm">
<form onSubmit={formik.handleSubmit}>
<Grid
container
spacing={4}
padding={4}
alignItems="center"
justifyContent="center"
flexDirection="column"
>
<Grid item>
<Typography variant="h5" component="h1" align="center">
{t("routes.Recover2FARoute.title")}
</Typography>
</Grid>
<Grid item>
<Box display="flex" justifyContent="center">
<BsShieldLockFill size={64} />
</Box>
</Grid>
<Grid item>
<Typography variant="body1" align="center">
{t("routes.Recover2FARoute.description")}
</Typography>
</Grid>
<Grid item>
<TextField
autoFocus
key="recoveryCode"
fullWidth
name="recoveryCode"
id="recoveryCode"
label={t("routes.Recover2FARoute.forms.recoveryCode.label")}
value={formik.values.recoveryCode}
onChange={formik.handleChange}
error={Boolean(
formik.touched.recoveryCode && formik.errors.recoveryCode,
)}
helperText={formik.errors.recoveryCode}
/>
</Grid>
<Grid item>
<LoadingButton
type="submit"
variant="contained"
loading={formik.isSubmitting}
startIcon={<BsShieldLockFill />}
>
{t("routes.Recover2FARoute.forms.submit")}
</LoadingButton>
</Grid>
</Grid>
</form>
</Box>
</Paper>
)
}

View File

@ -0,0 +1,40 @@
import {ReactElement} from "react"
import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import {useQuery} from "@tanstack/react-query"
import {Alert, Grid} from "@mui/material"
import {QueryResult, SimplePageBuilder} from "~/components"
import Delete2FA from "~/route-widgets/Settings2FARoute/Delete2FA"
import Setup2FA from "~/route-widgets/Settings2FARoute/Setup2FA"
import getHas2FAEnabled from "~/apis/get-has-2fa-enabled"
export default function Settings2FARoute(): ReactElement {
const {t} = useTranslation()
const queryKey = ["get_2fa_enabled"]
const query = useQuery<boolean, AxiosError>(queryKey, getHas2FAEnabled)
return (
<SimplePageBuilder.Page title={t("routes.SettingsRoute.2fa.title")}>
<QueryResult<boolean, AxiosError> query={query}>
{has2FAEnabled =>
has2FAEnabled ? (
<Grid container spacing={4} direction="column" alignItems="center">
<Grid item>
<Alert severity="success">
{t("routes.SettingsRoute.2fa.alreadyEnabled")}
</Alert>
</Grid>
<Grid item>
<Delete2FA onSuccess={query.refetch} />
</Grid>
</Grid>
) : (
<Setup2FA onSuccess={query.refetch} />
)
}
</QueryResult>
</SimplePageBuilder.Page>
)
}

View File

@ -0,0 +1,365 @@
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"
import {
Alert,
Checkbox,
Collapse,
FormControlLabel,
FormGroup,
FormHelperText,
Grid,
InputAdornment,
MenuItem,
TextField,
useMediaQuery,
useTheme,
} from "@mui/material"
import {LoadingButton} from "@mui/lab"
import {ImageProxyFormatType, ProxyUserAgentType, SimpleDetailResponse} from "~/server-types"
import {UpdatePreferencesData, updatePreferences} from "~/apis"
import {useErrorSuccessSnacks, useUser} from "~/hooks"
import {parseFastAPIError} from "~/utils"
import {
IMAGE_PROXY_FORMAT_TYPE_NAME_MAP,
PROXY_USER_AGENT_TYPE_NAME_MAP,
} from "~/constants/enum-mappings"
import {AuthContext, SimplePageBuilder} from "~/components"
interface Form {
removeTrackers: boolean
createMailReport: boolean
proxyImages: boolean
imageProxyFormat: ImageProxyFormatType
proxyUserAgent: ProxyUserAgentType
expandUrlShorteners: boolean
detail?: string
}
export default function SettingsAliasPreferencesRoute(): ReactElement {
const {_updateUser} = useContext(AuthContext)
const user = useUser()
const {showError, showSuccess} = useErrorSuccessSnacks()
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.createMailReports.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")),
proxyUserAgent: yup
.mixed<ProxyUserAgentType>()
.oneOf(Object.values(ProxyUserAgentType))
.required()
.label(t("relations.alias.settings.proxyUserAgent.label")),
expandUrlShorteners: yup
.boolean()
.label(t("relations.alias.settings.expandUrlShorteners.label")),
})
const {mutateAsync} = useMutation<SimpleDetailResponse, AxiosError, UpdatePreferencesData>(
updatePreferences,
{
onSuccess: (response, values) => {
const newUser = {
...user,
preferences: {
...user.preferences,
...values,
},
}
if (response.detail) {
showSuccess(response?.detail)
}
_updateUser(newUser)
},
onError: showError,
},
)
const formik = useFormik<Form>({
validationSchema: schema,
initialValues: {
removeTrackers: user.preferences.aliasRemoveTrackers,
createMailReport: user.preferences.aliasCreateMailReport,
proxyImages: user.preferences.aliasProxyImages,
imageProxyFormat: user.preferences.aliasImageProxyFormat,
proxyUserAgent: user.preferences.aliasProxyUserAgent,
expandUrlShorteners: user.preferences.aliasExpandUrlShorteners,
},
onSubmit: async (values, {setErrors}) => {
try {
await mutateAsync({
aliasRemoveTrackers: values.removeTrackers,
aliasCreateMailReport: values.createMailReport,
aliasProxyImages: values.proxyImages,
aliasImageProxyFormat: values.imageProxyFormat,
aliasProxyUserAgent: values.proxyUserAgent,
aliasExpandUrlShorteners: values.expandUrlShorteners,
})
} catch (error) {
setErrors(parseFastAPIError(error as AxiosError))
}
},
})
const theme = useTheme()
const isLarge = useMediaQuery(theme.breakpoints.up("md"))
return (
<SimplePageBuilder.Page
title={t("routes.SettingsRoute.forms.aliasPreferences.title")}
description={t("routes.SettingsRoute.forms.aliasPreferences.description")}
>
<form onSubmit={formik.handleSubmit}>
<Grid
display="flex"
flexDirection="row"
container
spacing={4}
alignItems="flex-end"
>
<Grid item md={6} xs={12}>
<FormGroup>
<FormControlLabel
disabled={formik.isSubmitting}
control={
<Checkbox
name="removeTrackers"
id="removeTrackers"
checked={formik.values.removeTrackers}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
}
labelPlacement="start"
label={t("relations.alias.settings.removeTrackers.label")}
/>
<FormHelperText
error={Boolean(
formik.touched.createMailReport &&
formik.errors.createMailReport,
)}
>
{(formik.touched.createMailReport &&
formik.errors.createMailReport) ||
t("relations.alias.settings.removeTrackers.helperText")}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item md={6} xs={12}>
<FormGroup>
<FormControlLabel
disabled={formik.isSubmitting}
control={
<Checkbox
name="createMailReport"
id="createMailReport"
checked={formik.values.createMailReport}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
}
labelPlacement="start"
label={t("relations.alias.settings.createMailReports.label")}
/>
<FormHelperText
error={Boolean(
formik.touched.createMailReport &&
formik.errors.createMailReport,
)}
>
{(formik.touched.createMailReport &&
formik.errors.createMailReport) ||
t("relations.alias.settings.createMailReports.helperText")}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item xs={12}>
<FormGroup>
<FormControlLabel
disabled={formik.isSubmitting}
control={
<Checkbox
name="proxyImages"
id="proxyImages"
checked={formik.values.proxyImages}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
}
labelPlacement="start"
label={t("relations.alias.settings.proxyImages.label")}
/>
<FormHelperText
error={Boolean(
formik.touched.proxyImages && formik.errors.proxyImages,
)}
>
{(formik.touched.proxyImages && formik.errors.proxyImages) ||
t("relations.alias.settings.proxyImages.helperText")}
</FormHelperText>
<Alert
sx={{width: "fit-content", alignSelf: "end", marginTop: 1}}
severity="warning"
>
{t("general.experimentalFeature")}
</Alert>
</FormGroup>
<Collapse in={formik.values.proxyImages}>
<Grid
display="flex"
flexDirection={isLarge ? "row" : "column"}
container
marginY={2}
spacing={4}
alignItems={isLarge ? "flex-start" : "flex-end"}
>
<Grid item md={6} xs={12}>
<FormGroup>
<TextField
fullWidth
select
InputProps={{
startAdornment: (
<InputAdornment position="start">
<MdImage />
</InputAdornment>
),
}}
name="imageProxyFormat"
id="imageProxyFormat"
label={t(
"relations.alias.settings.imageProxyFormat.label",
)}
value={formik.values.imageProxyFormat}
onChange={formik.handleChange}
disabled={formik.isSubmitting}
error={
formik.touched.imageProxyFormat &&
Boolean(formik.errors.imageProxyFormat)
}
helperText={
formik.touched.imageProxyFormat &&
formik.errors.imageProxyFormat
}
>
{Object.entries(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}
</FormHelperText>
</FormGroup>
</Grid>
</Grid>
</Collapse>
</Grid>
<Grid item xs={12}>
<FormGroup>
<TextField
fullWidth
select
name="proxyUserAgent"
id="proxyUserAgent"
label={t("relations.alias.settings.proxyUserAgent.label")}
value={formik.values.proxyUserAgent}
onChange={formik.handleChange}
disabled={formik.isSubmitting}
error={
formik.touched.proxyUserAgent &&
Boolean(formik.errors.proxyUserAgent)
}
helperText={
formik.touched.proxyUserAgent && formik.errors.proxyUserAgent
}
>
{Object.entries(PROXY_USER_AGENT_TYPE_NAME_MAP).map(
([value, translationString]) => (
<MenuItem key={value} value={value}>
{t(translationString)}
</MenuItem>
),
)}
</TextField>
<FormHelperText
error={Boolean(
formik.touched.proxyUserAgent && formik.errors.proxyUserAgent,
)}
>
{(formik.touched.proxyUserAgent && formik.errors.proxyUserAgent) ||
t("relations.alias.settings.proxyUserAgent.helperText")}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item xs={12}>
<FormGroup>
<FormControlLabel
disabled={formik.isSubmitting}
control={
<Checkbox
name="expandUrlShorteners"
id="expandUrlShorteners"
checked={formik.values.expandUrlShorteners}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
}
labelPlacement="start"
label={t("relations.alias.settings.expandUrlShorteners.label")}
/>
<FormHelperText
error={Boolean(
formik.touched.expandUrlShorteners &&
formik.errors.expandUrlShorteners,
)}
>
{(formik.touched.expandUrlShorteners &&
formik.errors.expandUrlShorteners) ||
t("relations.alias.settings.expandUrlShorteners.helperText")}
</FormHelperText>
<Alert
sx={{width: "fit-content", alignSelf: "end", marginTop: 1}}
severity="warning"
>
{t("general.experimentalFeature")}
</Alert>
</FormGroup>
</Grid>
</Grid>
<LoadingButton
loading={formik.isSubmitting}
variant="contained"
type="submit"
startIcon={<MdCheckCircle />}
>
{t("routes.SettingsRoute.forms.aliasPreferences.saveAction")}
</LoadingButton>
</form>
</SimplePageBuilder.Page>
)
}

View File

@ -1,15 +1,32 @@
import {useTranslation} from "react-i18next"
import {GoSettings} from "react-icons/go"
import {BsShieldLockFill} from "react-icons/bs"
import {Link} from "react-router-dom"
import React, {ReactElement} from "react"
import {List, ListItemButton, ListItemIcon, ListItemText} from "@mui/material"
import {SimplePageBuilder} from "~/components"
import AliasesPreferencesForm from "~/route-widgets/SettingsRoute/AliasesPreferencesForm"
export default function SettingsRoute(): ReactElement {
const {t} = useTranslation()
return (
<SimplePageBuilder.Page title={t("routes.SettingsRoute.title")}>
<AliasesPreferencesForm />
<List>
<ListItemButton component={Link} to="/settings/alias-preferences">
<ListItemIcon>
<GoSettings />
</ListItemIcon>
<ListItemText primary={t("routes.SettingsRoute.actions.aliasPreferences")} />
</ListItemButton>
<ListItemButton component={Link} to="/settings/2fa">
<ListItemIcon>
<BsShieldLockFill />
</ListItemIcon>
<ListItemText primary={t("routes.SettingsRoute.actions.enable2fa")} />
</ListItemButton>
</List>
</SimplePageBuilder.Page>
)
}

View File

@ -1406,11 +1406,6 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.5.tgz#6a31f820c1077c3f8ce44f9e203e68a176e8f59e"
integrity sha512-Bq7G3AErwe5A/Zki5fdD3O6+0zDChhg671NfPjtIcbtzDNZTv4NPKMRFr7gtYPG7y+B8uTiNK4Ngd9T0FTar6Q==
"@types/node@^18.14.0":
version "18.14.0"
resolved "https://registry.npmjs.org/@types/node/-/node-18.14.0.tgz#94c47b9217bbac49d4a67a967fdcdeed89ebb7d0"
integrity sha512-5EWrvLmglK+imbCJY0+INViFWUHg1AHel1sq4ZVSfdcNqGy9Edv3UB9IIzzg+xPaUcAgZYcfVs2fBcwDeZzU0A==
"@types/openpgp@^4.4.18":
version "4.4.18"
resolved "https://registry.yarnpkg.com/@types/openpgp/-/openpgp-4.4.18.tgz#b523e7a97646069756a01faf0569e198fe4d0dc1"
@ -4643,6 +4638,11 @@ pvutils@^1.1.3:
resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3"
integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==
qr.js@0.0.0:
version "0.0.0"
resolved "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz#cace86386f59a0db8050fa90d9b6b0e88a1e364f"
integrity sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==
querystringify@^2.1.1:
version "2.2.0"
resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6"
@ -4699,6 +4699,14 @@ react-is@^18.0.0, react-is@^18.2.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
react-qr-code@^2.0.11:
version "2.0.11"
resolved "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.11.tgz#444c759a2100424972e17135fbe0e32eaffa19e8"
integrity sha512-P7mvVM5vk9NjGdHMt4Z0KWeeJYwRAtonHTghZT2r+AASinLUUKQ9wfsGH2lPKsT++gps7hXmaiMGRvwTDEL9OA==
dependencies:
prop-types "^15.8.1"
qr.js "0.0.0"
react-refresh@^0.14.0:
version "0.14.0"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e"