mirror of
https://github.com/Myzel394/kleckrelay-website.git
synced 2025-06-19 15:55:26 +02:00
commit
0d7be5c069
@ -45,6 +45,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-i18next": "^12.0.0",
|
"react-i18next": "^12.0.0",
|
||||||
"react-icons": "^4.4.0",
|
"react-icons": "^4.4.0",
|
||||||
|
"react-qr-code": "^2.0.11",
|
||||||
"react-router-dom": "^6.4.2",
|
"react-router-dom": "^6.4.2",
|
||||||
"react-use": "^17.4.0",
|
"react-use": "^17.4.0",
|
||||||
"secure-random-password": "^0.2.3",
|
"secure-random-password": "^0.2.3",
|
||||||
@ -61,7 +62,6 @@
|
|||||||
"@types/deep-equal": "^1.0.1",
|
"@types/deep-equal": "^1.0.1",
|
||||||
"@types/jest": "^29.2.4",
|
"@types/jest": "^29.2.4",
|
||||||
"@types/lodash": "^4.14.191",
|
"@types/lodash": "^4.14.191",
|
||||||
"@types/node": "^18.14.0",
|
|
||||||
"@types/openpgp": "^4.4.18",
|
"@types/openpgp": "^4.4.18",
|
||||||
"@types/react-dom": "^18.0.11",
|
"@types/react-dom": "^18.0.11",
|
||||||
"@types/react-icons": "^3.0.0",
|
"@types/react-icons": "^3.0.0",
|
||||||
|
@ -57,6 +57,16 @@
|
|||||||
"confirmFromDifferentDevice": {
|
"confirmFromDifferentDevice": {
|
||||||
"title": "Login failed",
|
"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."
|
"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": {
|
"CompleteAccountRoute": {
|
||||||
"forms": {
|
"forms": {
|
||||||
"generateReports": {
|
"generateReports": {
|
||||||
@ -284,6 +307,46 @@
|
|||||||
"description": "Select default values for your aliases. This only affects aliases you haven't set a custom value for.",
|
"description": "Select default values for your aliases. This only affects aliases you haven't set a custom value for.",
|
||||||
"saveAction": "Save preferences"
|
"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": {
|
"LogoutRoute": {
|
||||||
|
18
src/App.tsx
18
src/App.tsx
@ -1,9 +1,8 @@
|
|||||||
import {RouterProvider, createBrowserRouter} from "react-router-dom"
|
import {RouterProvider, createBrowserRouter} from "react-router-dom"
|
||||||
import {SnackbarProvider} from "notistack"
|
import {SnackbarProvider} from "notistack"
|
||||||
import React, {ReactElement} from "react"
|
|
||||||
|
|
||||||
import {QueryClientProvider} from "@tanstack/react-query"
|
import {QueryClientProvider} from "@tanstack/react-query"
|
||||||
import {CssBaseline, Theme, ThemeProvider} from "@mui/material"
|
import {CssBaseline, Theme, ThemeProvider} from "@mui/material"
|
||||||
|
import React, {ReactElement} from "react"
|
||||||
|
|
||||||
import {queryClient} from "~/constants/react-query"
|
import {queryClient} from "~/constants/react-query"
|
||||||
import {getServerSettings} from "~/apis"
|
import {getServerSettings} from "~/apis"
|
||||||
@ -21,12 +20,15 @@ import GlobalSettingsRoute from "~/routes/GlobalSettingsRoute"
|
|||||||
import I18nHandler from "./I18nHandler"
|
import I18nHandler from "./I18nHandler"
|
||||||
import LoginRoute from "~/routes/LoginRoute"
|
import LoginRoute from "~/routes/LoginRoute"
|
||||||
import LogoutRoute from "~/routes/LogoutRoute"
|
import LogoutRoute from "~/routes/LogoutRoute"
|
||||||
|
import Recover2FARoute from "./routes/Recover2FARoute"
|
||||||
import RedirectRoute from "./routes/RedirectRoute"
|
import RedirectRoute from "./routes/RedirectRoute"
|
||||||
import ReportDetailRoute from "~/routes/ReportDetailRoute"
|
import ReportDetailRoute from "~/routes/ReportDetailRoute"
|
||||||
import ReportsRoute from "~/routes/ReportsRoute"
|
import ReportsRoute from "~/routes/ReportsRoute"
|
||||||
import ReservedAliasDetailRoute from "~/routes/ReservedAliasDetailRoute"
|
import ReservedAliasDetailRoute from "~/routes/ReservedAliasDetailRoute"
|
||||||
import ReservedAliasesRoute from "~/routes/ReservedAliasesRoute"
|
import ReservedAliasesRoute from "~/routes/ReservedAliasesRoute"
|
||||||
import RootRoute from "~/routes/Root"
|
import RootRoute from "~/routes/Root"
|
||||||
|
import Settings2FARoute from "~/routes/Settings2FARoute"
|
||||||
|
import SettingsAliasPreferencesRoute from "~/routes/SettingsAliasPreferencesRoute"
|
||||||
import SettingsRoute from "~/routes/SettingsRoute"
|
import SettingsRoute from "~/routes/SettingsRoute"
|
||||||
import SignupRoute from "~/routes/SignupRoute"
|
import SignupRoute from "~/routes/SignupRoute"
|
||||||
import VerifyEmailRoute from "~/routes/VerifyEmailRoute"
|
import VerifyEmailRoute from "~/routes/VerifyEmailRoute"
|
||||||
@ -52,6 +54,10 @@ const router = createBrowserRouter([
|
|||||||
loader: getServerSettings,
|
loader: getServerSettings,
|
||||||
element: <LoginRoute />,
|
element: <LoginRoute />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/auth/recover-2fa",
|
||||||
|
element: <Recover2FARoute />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/auth/signup",
|
path: "/auth/signup",
|
||||||
loader: getServerSettings,
|
loader: getServerSettings,
|
||||||
@ -91,6 +97,14 @@ const router = createBrowserRouter([
|
|||||||
path: "/settings",
|
path: "/settings",
|
||||||
element: <SettingsRoute />,
|
element: <SettingsRoute />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/settings/alias-preferences",
|
||||||
|
element: <SettingsAliasPreferencesRoute />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/settings/2fa",
|
||||||
|
element: <Settings2FARoute />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/reports",
|
path: "/reports",
|
||||||
loader: getServerSettings,
|
loader: getServerSettings,
|
||||||
|
22
src/apis/delete-2fa.ts
Normal file
22
src/apis/delete-2fa.ts
Normal 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
|
||||||
|
}
|
9
src/apis/get-has-2fa-enabled.ts
Normal file
9
src/apis/get-has-2fa-enabled.ts
Normal 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
|
||||||
|
}
|
@ -58,3 +58,13 @@ export * from "./delete-reserved-alias"
|
|||||||
export {default as deleteReservedAlias} from "./delete-reserved-alias"
|
export {default as deleteReservedAlias} from "./delete-reserved-alias"
|
||||||
export * from "./get-latest-cron-report"
|
export * from "./get-latest-cron-report"
|
||||||
export {default as getLatestCronReport} 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"
|
||||||
|
19
src/apis/setup-2fa-verify.ts
Normal file
19
src/apis/setup-2fa-verify.ts
Normal 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
18
src/apis/setup-2fa.ts
Normal 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
|
||||||
|
}
|
@ -8,12 +8,16 @@ export interface VerifyLoginWithEmailData {
|
|||||||
sameRequestToken?: string
|
sameRequestToken?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VerifyLoginWithEmailResponse = ServerUser & {
|
||||||
|
corsToken?: string
|
||||||
|
}
|
||||||
|
|
||||||
export default async function verifyLoginWithEmail({
|
export default async function verifyLoginWithEmail({
|
||||||
email,
|
email,
|
||||||
token,
|
token,
|
||||||
sameRequestToken,
|
sameRequestToken,
|
||||||
}: VerifyLoginWithEmailData): Promise<ServerUser> {
|
}: VerifyLoginWithEmailData): Promise<VerifyLoginWithEmailResponse> {
|
||||||
const {data: user} = await client.post(
|
const {data} = await client.post(
|
||||||
`${import.meta.env.VITE_SERVER_BASE_URL}/v1/auth/login/email-token/verify`,
|
`${import.meta.env.VITE_SERVER_BASE_URL}/v1/auth/login/email-token/verify`,
|
||||||
{
|
{
|
||||||
email,
|
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
23
src/apis/verify-otp.ts
Normal 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)
|
||||||
|
}
|
@ -120,9 +120,9 @@ export default function StringPoolField({
|
|||||||
}, "")
|
}, "")
|
||||||
|
|
||||||
onChange!(
|
onChange!(
|
||||||
// @ts-ignore
|
|
||||||
{
|
{
|
||||||
...event,
|
...event,
|
||||||
|
// @ts-ignore
|
||||||
target: {
|
target: {
|
||||||
...event.target,
|
...event.target,
|
||||||
value: value as string,
|
value: value as string,
|
||||||
|
@ -8,7 +8,7 @@ import {parseFastAPIError} from "~/utils"
|
|||||||
|
|
||||||
export interface UseErrorSuccessSnacksResult {
|
export interface UseErrorSuccessSnacksResult {
|
||||||
showSuccess: (message: string) => void
|
showSuccess: (message: string) => void
|
||||||
showError: (error: Error) => void
|
showError: (error: Error | string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useErrorSuccessSnacks(): UseErrorSuccessSnacksResult {
|
export default function useErrorSuccessSnacks(): UseErrorSuccessSnacksResult {
|
||||||
@ -27,14 +27,18 @@ export default function useErrorSuccessSnacks(): UseErrorSuccessSnacksResult {
|
|||||||
autoHideDuration: SUCCESS_SNACKBAR_SHOW_DURATION,
|
autoHideDuration: SUCCESS_SNACKBAR_SHOW_DURATION,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const showError = (error: Error) => {
|
const showError = (error: Error | string) => {
|
||||||
let message
|
let message: string | undefined
|
||||||
|
|
||||||
try {
|
if (typeof error === "string") {
|
||||||
const parsedError = parseFastAPIError(error as AxiosError)
|
message = error
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const parsedError = parseFastAPIError(error as AxiosError)
|
||||||
|
|
||||||
message = parsedError.detail
|
message = parsedError.detail
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
$errorSnackbarKey.current = enqueueSnackbar(message || t("general.defaultError"), {
|
$errorSnackbarKey.current = enqueueSnackbar(message || t("general.defaultError"), {
|
||||||
variant: "error",
|
variant: "error",
|
||||||
|
@ -21,7 +21,7 @@ import {
|
|||||||
import {useErrorSuccessSnacks} from "~/hooks"
|
import {useErrorSuccessSnacks} from "~/hooks"
|
||||||
import {queryClient} from "~/constants/react-query"
|
import {queryClient} from "~/constants/react-query"
|
||||||
import {AuthContext, FormikAutoLockNavigation} from "~/components"
|
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"
|
import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes"
|
||||||
|
|
||||||
export interface AliasPreferencesFormProps {
|
export interface AliasPreferencesFormProps {
|
||||||
|
@ -84,9 +84,9 @@ export default function UsersSelectField({
|
|||||||
}
|
}
|
||||||
|
|
||||||
onChange!(
|
onChange!(
|
||||||
// @ts-ignore
|
|
||||||
{
|
{
|
||||||
...event,
|
...event,
|
||||||
|
// @ts-ignore
|
||||||
target: {
|
target: {
|
||||||
...event.target,
|
...event.target,
|
||||||
value: selectedUsers as GetAdminUsersResponse["users"],
|
value: selectedUsers as GetAdminUsersResponse["users"],
|
||||||
|
@ -25,7 +25,7 @@ import {
|
|||||||
import {LoadingButton} from "@mui/lab"
|
import {LoadingButton} from "@mui/lab"
|
||||||
|
|
||||||
import {ServerSettings, ServerUser, SimpleDetailResponse} from "~/server-types"
|
import {ServerSettings, ServerUser, SimpleDetailResponse} from "~/server-types"
|
||||||
import {VerifyLoginWithEmailData, verifyLoginWithEmail} from "~/apis"
|
import {VerifyLoginWithEmailData, VerifyLoginWithEmailResponse, verifyLoginWithEmail} from "~/apis"
|
||||||
import {MultiStepFormElement} from "~/components"
|
import {MultiStepFormElement} from "~/components"
|
||||||
import {parseFastAPIError} from "~/utils"
|
import {parseFastAPIError} from "~/utils"
|
||||||
import {isDev} from "~/constants/development"
|
import {isDev} from "~/constants/development"
|
||||||
@ -36,6 +36,7 @@ import ResendMailButton from "./ResendMailButton"
|
|||||||
export interface ConfirmCodeFormProps {
|
export interface ConfirmCodeFormProps {
|
||||||
onConfirm: (user: ServerUser) => void
|
onConfirm: (user: ServerUser) => void
|
||||||
onCodeExpired: () => void
|
onCodeExpired: () => void
|
||||||
|
onOTPRequested: (corsToken: string) => void
|
||||||
email: string
|
email: string
|
||||||
sameRequestToken: string
|
sameRequestToken: string
|
||||||
}
|
}
|
||||||
@ -48,6 +49,7 @@ interface Form {
|
|||||||
export default function ConfirmCodeForm({
|
export default function ConfirmCodeForm({
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCodeExpired,
|
onCodeExpired,
|
||||||
|
onOTPRequested,
|
||||||
email,
|
email,
|
||||||
sameRequestToken,
|
sameRequestToken,
|
||||||
}: ConfirmCodeFormProps): ReactElement {
|
}: ConfirmCodeFormProps): ReactElement {
|
||||||
@ -79,12 +81,19 @@ export default function ConfirmCodeForm({
|
|||||||
.label(t("routes.LoginRoute.forms.confirmCode.form.code.label")),
|
.label(t("routes.LoginRoute.forms.confirmCode.form.code.label")),
|
||||||
})
|
})
|
||||||
|
|
||||||
const {mutateAsync} = useMutation<ServerUser, AxiosError, VerifyLoginWithEmailData>(
|
const {mutateAsync} = useMutation<
|
||||||
verifyLoginWithEmail,
|
VerifyLoginWithEmailResponse,
|
||||||
{
|
AxiosError,
|
||||||
onSuccess: onConfirm,
|
VerifyLoginWithEmailData
|
||||||
|
>(verifyLoginWithEmail, {
|
||||||
|
onSuccess: result => {
|
||||||
|
if (result.corsToken) {
|
||||||
|
onOTPRequested(result.corsToken)
|
||||||
|
} else {
|
||||||
|
onConfirm(result)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
})
|
||||||
const formik = useFormik<Form>({
|
const formik = useFormik<Form>({
|
||||||
validationSchema: schema,
|
validationSchema: schema,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
|
142
src/route-widgets/LoginRoute/OTPForm.tsx
Normal file
142
src/route-widgets/LoginRoute/OTPForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
108
src/route-widgets/Settings2FARoute/Delete2FA.tsx
Normal file
108
src/route-widgets/Settings2FARoute/Delete2FA.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
64
src/route-widgets/Settings2FARoute/Setup2FA.tsx
Normal file
64
src/route-widgets/Settings2FARoute/Setup2FA.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
166
src/route-widgets/Settings2FARoute/VerifyOTPForm.tsx
Normal file
166
src/route-widgets/Settings2FARoute/VerifyOTPForm.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
@ -7,6 +7,7 @@ import {useQueryParams} from "~/hooks"
|
|||||||
import ConfirmCodeForm from "~/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm"
|
import ConfirmCodeForm from "~/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm"
|
||||||
import ConfirmFromDifferentDevice from "~/route-widgets/LoginRoute/ConfirmFromDifferentDevice"
|
import ConfirmFromDifferentDevice from "~/route-widgets/LoginRoute/ConfirmFromDifferentDevice"
|
||||||
import EmailForm from "~/route-widgets/LoginRoute/EmailForm"
|
import EmailForm from "~/route-widgets/LoginRoute/EmailForm"
|
||||||
|
import OTPForm from "~/route-widgets/LoginRoute/OTPForm"
|
||||||
|
|
||||||
export default function LoginRoute(): ReactElement {
|
export default function LoginRoute(): ReactElement {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@ -53,6 +54,16 @@ export default function LoginRoute(): ReactElement {
|
|||||||
onCodeExpired={() => {
|
onCodeExpired={() => {
|
||||||
setStep(0)
|
setStep(0)
|
||||||
}}
|
}}
|
||||||
|
onOTPRequested={corsToken => {
|
||||||
|
setStep(2)
|
||||||
|
setSameRequestToken(corsToken)
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
<OTPForm
|
||||||
|
onConfirm={login}
|
||||||
|
key={`otp_form:${email}:${step}`}
|
||||||
|
corsToken={sameRequestToken}
|
||||||
|
onCodeUnavailable={() => setStep(0)}
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
index={step}
|
index={step}
|
||||||
|
123
src/routes/Recover2FARoute.tsx
Normal file
123
src/routes/Recover2FARoute.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
40
src/routes/Settings2FARoute.tsx
Normal file
40
src/routes/Settings2FARoute.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
365
src/routes/SettingsAliasPreferencesRoute.tsx
Normal file
365
src/routes/SettingsAliasPreferencesRoute.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -1,15 +1,32 @@
|
|||||||
import {useTranslation} from "react-i18next"
|
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 React, {ReactElement} from "react"
|
||||||
|
|
||||||
|
import {List, ListItemButton, ListItemIcon, ListItemText} from "@mui/material"
|
||||||
|
|
||||||
import {SimplePageBuilder} from "~/components"
|
import {SimplePageBuilder} from "~/components"
|
||||||
import AliasesPreferencesForm from "~/route-widgets/SettingsRoute/AliasesPreferencesForm"
|
|
||||||
|
|
||||||
export default function SettingsRoute(): ReactElement {
|
export default function SettingsRoute(): ReactElement {
|
||||||
const {t} = useTranslation()
|
const {t} = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SimplePageBuilder.Page title={t("routes.SettingsRoute.title")}>
|
<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>
|
</SimplePageBuilder.Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
18
yarn.lock
18
yarn.lock
@ -1406,11 +1406,6 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.5.tgz#6a31f820c1077c3f8ce44f9e203e68a176e8f59e"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.5.tgz#6a31f820c1077c3f8ce44f9e203e68a176e8f59e"
|
||||||
integrity sha512-Bq7G3AErwe5A/Zki5fdD3O6+0zDChhg671NfPjtIcbtzDNZTv4NPKMRFr7gtYPG7y+B8uTiNK4Ngd9T0FTar6Q==
|
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":
|
"@types/openpgp@^4.4.18":
|
||||||
version "4.4.18"
|
version "4.4.18"
|
||||||
resolved "https://registry.yarnpkg.com/@types/openpgp/-/openpgp-4.4.18.tgz#b523e7a97646069756a01faf0569e198fe4d0dc1"
|
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"
|
resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3"
|
||||||
integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==
|
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:
|
querystringify@^2.1.1:
|
||||||
version "2.2.0"
|
version "2.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6"
|
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"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
||||||
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
|
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:
|
react-refresh@^0.14.0:
|
||||||
version "0.14.0"
|
version "0.14.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e"
|
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user