diff --git a/package.json b/package.json index 9044539..45423c9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index fcb479e..7c6ebbf 100644 --- a/public/locales/en-US/translation.json +++ b/public/locales/en-US/translation.json @@ -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": { diff --git a/src/App.tsx b/src/App.tsx index b58d2d1..c54ecea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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: , }, + { + path: "/auth/recover-2fa", + element: , + }, { path: "/auth/signup", loader: getServerSettings, @@ -91,6 +97,14 @@ const router = createBrowserRouter([ path: "/settings", element: , }, + { + path: "/settings/alias-preferences", + element: , + }, + { + path: "/settings/2fa", + element: , + }, { path: "/reports", loader: getServerSettings, diff --git a/src/apis/delete-2fa.ts b/src/apis/delete-2fa.ts new file mode 100644 index 0000000..cf98b2b --- /dev/null +++ b/src/apis/delete-2fa.ts @@ -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 { + const {data} = await client.delete(`${import.meta.env.VITE_SERVER_BASE_URL}/v1/setup-otp`, { + withCredentials: true, + data: { + code, + recoveryCode, + }, + }) + + return data +} diff --git a/src/apis/get-has-2fa-enabled.ts b/src/apis/get-has-2fa-enabled.ts new file mode 100644 index 0000000..80063b2 --- /dev/null +++ b/src/apis/get-has-2fa-enabled.ts @@ -0,0 +1,9 @@ +import {client} from "~/constants/axios-client" + +export default async function getHas2FAEnabled(): Promise { + const {data} = await client.get(`${import.meta.env.VITE_SERVER_BASE_URL}/v1/setup-otp/`, { + withCredentials: true, + }) + + return data.enabled +} diff --git a/src/apis/index.ts b/src/apis/index.ts index fbb1e32..7340c69 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -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" diff --git a/src/apis/setup-2fa-verify.ts b/src/apis/setup-2fa-verify.ts new file mode 100644 index 0000000..372c13a --- /dev/null +++ b/src/apis/setup-2fa-verify.ts @@ -0,0 +1,19 @@ +import {client} from "~/constants/axios-client" + +export interface Verify2FASetupData { + code: string +} + +export default async function verify2FASetup({code}: Verify2FASetupData): Promise { + const {data} = await client.post( + `${import.meta.env.VITE_SERVER_BASE_URL}/v1/setup-otp/verify`, + { + code, + }, + { + withCredentials: true, + }, + ) + + return data +} diff --git a/src/apis/setup-2fa.ts b/src/apis/setup-2fa.ts new file mode 100644 index 0000000..4d3f03b --- /dev/null +++ b/src/apis/setup-2fa.ts @@ -0,0 +1,18 @@ +import {client} from "~/constants/axios-client" + +export interface Setup2FAResponse { + secret: string + recoveryCodes: string[] +} + +export default async function setup2FA(): Promise { + const {data} = await client.post( + `${import.meta.env.VITE_SERVER_BASE_URL}/v1/setup-otp/`, + {}, + { + withCredentials: true, + }, + ) + + return data +} diff --git a/src/apis/verify-login-with-email.ts b/src/apis/verify-login-with-email.ts index 9ae6004..92ca71a 100644 --- a/src/apis/verify-login-with-email.ts +++ b/src/apis/verify-login-with-email.ts @@ -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 { - const {data: user} = await client.post( +}: VerifyLoginWithEmailData): Promise { + 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) } diff --git a/src/apis/verify-otp.ts b/src/apis/verify-otp.ts new file mode 100644 index 0000000..fef215e --- /dev/null +++ b/src/apis/verify-otp.ts @@ -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 { + const {data} = await client.post( + `${import.meta.env.VITE_SERVER_BASE_URL}/v1/auth/login/verify-otp`, + { + code, + corsToken, + }, + { + withCredentials: true, + }, + ) + + return parseUser(data) +} diff --git a/src/components/widgets/StringPoolField/StringPoolField.tsx b/src/components/widgets/StringPoolField/StringPoolField.tsx index b3f91c0..1263769 100644 --- a/src/components/widgets/StringPoolField/StringPoolField.tsx +++ b/src/components/widgets/StringPoolField/StringPoolField.tsx @@ -120,9 +120,9 @@ export default function StringPoolField({ }, "") onChange!( - // @ts-ignore { ...event, + // @ts-ignore target: { ...event.target, value: value as string, diff --git a/src/hooks/use-error-success-snacks.ts b/src/hooks/use-error-success-snacks.ts index 009b244..11e301f 100644 --- a/src/hooks/use-error-success-snacks.ts +++ b/src/hooks/use-error-success-snacks.ts @@ -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 - try { - const parsedError = parseFastAPIError(error as AxiosError) + if (typeof error === "string") { + message = error + } else { + try { + const parsedError = parseFastAPIError(error as AxiosError) - message = parsedError.detail - } catch (e) {} + message = parsedError.detail + } catch (e) {} + } $errorSnackbarKey.current = enqueueSnackbar(message || t("general.defaultError"), { variant: "error", diff --git a/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx b/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx index 2b36c71..e08ca66 100644 --- a/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx +++ b/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx @@ -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 { diff --git a/src/route-widgets/SettingsRoute/SelectField.tsx b/src/route-widgets/AliasDetailRoute/SelectField.tsx similarity index 100% rename from src/route-widgets/SettingsRoute/SelectField.tsx rename to src/route-widgets/AliasDetailRoute/SelectField.tsx diff --git a/src/route-widgets/CreateReservedAliasRoute/UsersSelectField.tsx b/src/route-widgets/CreateReservedAliasRoute/UsersSelectField.tsx index 029d50e..9fb60f9 100644 --- a/src/route-widgets/CreateReservedAliasRoute/UsersSelectField.tsx +++ b/src/route-widgets/CreateReservedAliasRoute/UsersSelectField.tsx @@ -84,9 +84,9 @@ export default function UsersSelectField({ } onChange!( - // @ts-ignore { ...event, + // @ts-ignore target: { ...event.target, value: selectedUsers as GetAdminUsersResponse["users"], diff --git a/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx b/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx index cc89a1e..8cc1c31 100644 --- a/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx +++ b/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx @@ -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( - verifyLoginWithEmail, - { - onSuccess: onConfirm, + const {mutateAsync} = useMutation< + VerifyLoginWithEmailResponse, + AxiosError, + VerifyLoginWithEmailData + >(verifyLoginWithEmail, { + onSuccess: result => { + if (result.corsToken) { + onOTPRequested(result.corsToken) + } else { + onConfirm(result) + } }, - ) + }) const formik = useFormik
({ validationSchema: schema, initialValues: { diff --git a/src/route-widgets/LoginRoute/OTPForm.tsx b/src/route-widgets/LoginRoute/OTPForm.tsx new file mode 100644 index 0000000..457c21d --- /dev/null +++ b/src/route-widgets/LoginRoute/OTPForm.tsx @@ -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( + 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({ + 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 ( + + + + + + {t("routes.LoginRoute.forms.otp.title")} + + + + + + + + + + {t("routes.LoginRoute.forms.otp.description")} + + + + + + + ), + }} + /> + + + + + } + > + {t("routes.LoginRoute.forms.otp.submit")} + + + + + + + + + + + ) +} diff --git a/src/route-widgets/Settings2FARoute/Delete2FA.tsx b/src/route-widgets/Settings2FARoute/Delete2FA.tsx new file mode 100644 index 0000000..eb0c2a0 --- /dev/null +++ b/src/route-widgets/Settings2FARoute/Delete2FA.tsx @@ -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(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("") + + switch (view) { + case "showAction": + return ( + + ) + + case "askType": + return ( + + + + + + + + + ) + + case "askCode": + return ( + + + setValue(e.target.value)} + /> + + + mutate({code: value})} + variant="contained" + startIcon={} + > + {t("routes.SettingsRoute.2fa.delete.submit")} + + + + ) + + case "askRecoveryCode": + return ( + + + setValue(e.target.value)} + /> + + + mutate({recoveryCode: value.replaceAll("-", "")})} + variant="contained" + startIcon={} + > + {t("routes.SettingsRoute.2fa.delete.submit")} + + + + ) + } +} diff --git a/src/route-widgets/Settings2FARoute/Setup2FA.tsx b/src/route-widgets/Settings2FARoute/Setup2FA.tsx new file mode 100644 index 0000000..327e772 --- /dev/null +++ b/src/route-widgets/Settings2FARoute/Setup2FA.tsx @@ -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(setup2FA, { + onError: showError, + }) + + return ( + + + + {t("routes.SettingsRoute.2fa.setup.description")} + + + + {secret ? ( + { + reset() + mutate() + }} + secret={secret} + recoveryCodes={recoveryCodes!} + onSuccess={onSuccess} + /> + ) : ( + mutate()} + loading={isLoading} + variant="contained" + startIcon={} + > + {t("routes.SettingsRoute.2fa.setup.setupLabel")} + + )} + + + ) +} diff --git a/src/route-widgets/Settings2FARoute/VerifyOTPForm.tsx b/src/route-widgets/Settings2FARoute/VerifyOTPForm.tsx new file mode 100644 index 0000000..87082e1 --- /dev/null +++ b/src/route-widgets/Settings2FARoute/VerifyOTPForm.tsx @@ -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(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(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 ( + <> +
+ + +
+ +
+
+ + + + + + + ), + }} + onSubmit={() => formik.handleSubmit()} + /> + + + + {t("routes.SettingsRoute.2fa.setup.submit")} + + + + +
+
+ + {t("routes.SettingsRoute.2fa.setup.recoveryCodes.title")} + + + {recoveryCodes.map(code => ( +

{code}

+ ))} +
+ + {t("routes.SettingsRoute.2fa.setup.recoveryCodes.description")} + +
+ + + +
+ + ) +} diff --git a/src/route-widgets/SettingsRoute/AliasesPreferencesForm.tsx b/src/route-widgets/SettingsRoute/AliasesPreferencesForm.tsx deleted file mode 100644 index 1751b70..0000000 --- a/src/route-widgets/SettingsRoute/AliasesPreferencesForm.tsx +++ /dev/null @@ -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() - .oneOf(Object.values(ImageProxyFormatType)) - .required() - .label(t("relations.alias.settings.imageProxyFormat.label")), - proxyUserAgent: yup - .mixed() - .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( - updatePreferences, - { - onSuccess: (response, values) => { - const newUser = { - ...user, - preferences: { - ...user.preferences, - ...values, - }, - } - - if (response.detail) { - showSuccess(response?.detail) - } - - _updateUser(newUser) - }, - onError: showError, - }, - ) - const formik = useFormik
({ - 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 ( - - - - - {t("routes.SettingsRoute.forms.aliasPreferences.title")} - - - - - {t("routes.SettingsRoute.forms.aliasPreferences.description")} - - - - - - - - } - labelPlacement="start" - label={t("relations.alias.settings.removeTrackers.label")} - /> - - {(formik.touched.createMailReport && - formik.errors.createMailReport) || - t("relations.alias.settings.removeTrackers.helperText")} - - - - - - - } - labelPlacement="start" - label={t("relations.alias.settings.createMailReports.label")} - /> - - {(formik.touched.createMailReport && - formik.errors.createMailReport) || - t("relations.alias.settings.createMailReports.helperText")} - - - - - - - } - labelPlacement="start" - label={t("relations.alias.settings.proxyImages.label")} - /> - - {(formik.touched.proxyImages && formik.errors.proxyImages) || - t("relations.alias.settings.proxyImages.helperText")} - - - {t("general.experimentalFeature")} - - - - - - - - - - ), - }} - 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]) => ( - - {t(translationString)} - - ))} - - - {formik.touched.imageProxyFormat && - formik.errors.imageProxyFormat} - - - - - - - - - - {Object.entries(PROXY_USER_AGENT_TYPE_NAME_MAP).map( - ([value, translationString]) => ( - - {t(translationString)} - - ), - )} - - - {(formik.touched.proxyUserAgent && - formik.errors.proxyUserAgent) || - t("relations.alias.settings.proxyUserAgent.helperText")} - - - - - - - } - labelPlacement="start" - label={t("relations.alias.settings.expandUrlShorteners.label")} - /> - - {(formik.touched.expandUrlShorteners && - formik.errors.expandUrlShorteners) || - t( - "relations.alias.settings.expandUrlShorteners.helperText", - )} - - - {t("general.experimentalFeature")} - - - - - - - } - > - {t("routes.SettingsRoute.forms.aliasPreferences.saveAction")} - - - -
- ) -} diff --git a/src/routes/LoginRoute.tsx b/src/routes/LoginRoute.tsx index 2016bb6..7b4fda3 100644 --- a/src/routes/LoginRoute.tsx +++ b/src/routes/LoginRoute.tsx @@ -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) + }} + />, + setStep(0)} />, ]} index={step} diff --git a/src/routes/Recover2FARoute.tsx b/src/routes/Recover2FARoute.tsx new file mode 100644 index 0000000..9f88c16 --- /dev/null +++ b/src/routes/Recover2FARoute.tsx @@ -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(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
({ + validationSchema: schema, + initialValues: { + recoveryCode: "", + }, + onSubmit: async values => + mutateAsync({ + recoveryCode: values.recoveryCode.replaceAll("-", ""), + }), + }) + + return ( + + + + + + + {t("routes.Recover2FARoute.title")} + + + + + + + + + + {t("routes.Recover2FARoute.description")} + + + + + + + } + > + {t("routes.Recover2FARoute.forms.submit")} + + + + + +
+ ) +} diff --git a/src/routes/Settings2FARoute.tsx b/src/routes/Settings2FARoute.tsx new file mode 100644 index 0000000..068dc3c --- /dev/null +++ b/src/routes/Settings2FARoute.tsx @@ -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(queryKey, getHas2FAEnabled) + + return ( + + query={query}> + {has2FAEnabled => + has2FAEnabled ? ( + + + + {t("routes.SettingsRoute.2fa.alreadyEnabled")} + + + + + + + ) : ( + + ) + } + + + ) +} diff --git a/src/routes/SettingsAliasPreferencesRoute.tsx b/src/routes/SettingsAliasPreferencesRoute.tsx new file mode 100644 index 0000000..e8dd998 --- /dev/null +++ b/src/routes/SettingsAliasPreferencesRoute.tsx @@ -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() + .oneOf(Object.values(ImageProxyFormatType)) + .required() + .label(t("relations.alias.settings.imageProxyFormat.label")), + proxyUserAgent: yup + .mixed() + .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( + updatePreferences, + { + onSuccess: (response, values) => { + const newUser = { + ...user, + preferences: { + ...user.preferences, + ...values, + }, + } + + if (response.detail) { + showSuccess(response?.detail) + } + + _updateUser(newUser) + }, + onError: showError, + }, + ) + const formik = useFormik
({ + 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 ( + + + + + + + } + labelPlacement="start" + label={t("relations.alias.settings.removeTrackers.label")} + /> + + {(formik.touched.createMailReport && + formik.errors.createMailReport) || + t("relations.alias.settings.removeTrackers.helperText")} + + + + + + + } + labelPlacement="start" + label={t("relations.alias.settings.createMailReports.label")} + /> + + {(formik.touched.createMailReport && + formik.errors.createMailReport) || + t("relations.alias.settings.createMailReports.helperText")} + + + + + + + } + labelPlacement="start" + label={t("relations.alias.settings.proxyImages.label")} + /> + + {(formik.touched.proxyImages && formik.errors.proxyImages) || + t("relations.alias.settings.proxyImages.helperText")} + + + {t("general.experimentalFeature")} + + + + + + + + + + ), + }} + 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]) => ( + + {t(translationString)} + + ), + )} + + + {formik.touched.imageProxyFormat && + formik.errors.imageProxyFormat} + + + + + + + + + + {Object.entries(PROXY_USER_AGENT_TYPE_NAME_MAP).map( + ([value, translationString]) => ( + + {t(translationString)} + + ), + )} + + + {(formik.touched.proxyUserAgent && formik.errors.proxyUserAgent) || + t("relations.alias.settings.proxyUserAgent.helperText")} + + + + + + + } + labelPlacement="start" + label={t("relations.alias.settings.expandUrlShorteners.label")} + /> + + {(formik.touched.expandUrlShorteners && + formik.errors.expandUrlShorteners) || + t("relations.alias.settings.expandUrlShorteners.helperText")} + + + {t("general.experimentalFeature")} + + + + + } + > + {t("routes.SettingsRoute.forms.aliasPreferences.saveAction")} + + +
+ ) +} diff --git a/src/routes/SettingsRoute.tsx b/src/routes/SettingsRoute.tsx index 65b6543..f3e7b27 100644 --- a/src/routes/SettingsRoute.tsx +++ b/src/routes/SettingsRoute.tsx @@ -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 ( - + + + + + + + + + + + + + + ) } diff --git a/yarn.lock b/yarn.lock index cc78031..4d7337a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"