mirror of
https://github.com/Myzel394/kleckrelay-website.git
synced 2025-06-19 15:55:26 +02:00
feat: Add setup 2FA
This commit is contained in:
parent
31288c9e79
commit
564861fc9f
@ -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",
|
||||||
|
@ -291,7 +291,23 @@
|
|||||||
},
|
},
|
||||||
"2fa": {
|
"2fa": {
|
||||||
"title": "Two-Factor-Authentication",
|
"title": "Two-Factor-Authentication",
|
||||||
"alreadyEnabled": "You have successfully enabled 2FA!"
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"LogoutRoute": {
|
"LogoutRoute": {
|
||||||
|
@ -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"
|
||||||
|
@ -58,3 +58,9 @@ 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"
|
||||||
|
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,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,16 @@ 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
|
||||||
|
|
||||||
try {
|
if (typeof error !== "string") {
|
||||||
const parsedError = parseFastAPIError(error as AxiosError)
|
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",
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
158
src/route-widgets/Settings2FARoute/VerifyOTPForm.tsx
Normal file
158
src/route-widgets/Settings2FARoute/VerifyOTPForm.tsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
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,
|
||||||
|
} 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 {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>({
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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} onClose={() => setShowRecoveryCodes(false)}>
|
||||||
|
<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
|
||||||
|
onClick={() => {
|
||||||
|
setShowRecoveryCodes(false)
|
||||||
|
onSuccess()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("routes.SettingsRoute.2fa.setup.recoveryCodes.submit")}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -6,11 +6,13 @@ import {useQuery} from "@tanstack/react-query"
|
|||||||
import {Alert} from "@mui/material"
|
import {Alert} from "@mui/material"
|
||||||
|
|
||||||
import {QueryResult, SimplePageBuilder} from "~/components"
|
import {QueryResult, SimplePageBuilder} from "~/components"
|
||||||
|
import Setup2FA from "~/route-widgets/Settings2FARoute/Setup2FA"
|
||||||
import getHas2FAEnabled from "~/apis/get-has-2fa-enabled"
|
import getHas2FAEnabled from "~/apis/get-has-2fa-enabled"
|
||||||
|
|
||||||
export default function Settings2FARoute(): ReactElement {
|
export default function Settings2FARoute(): ReactElement {
|
||||||
const {t} = useTranslation()
|
const {t} = useTranslation()
|
||||||
const query = useQuery<boolean, AxiosError>(["get_2fa_enabled"], getHas2FAEnabled)
|
const queryKey = ["get_2fa_enabled"]
|
||||||
|
const query = useQuery<boolean, AxiosError>(queryKey, getHas2FAEnabled)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SimplePageBuilder.Page title={t("routes.SettingsRoute.2fa.title")}>
|
<SimplePageBuilder.Page title={t("routes.SettingsRoute.2fa.title")}>
|
||||||
@ -23,7 +25,7 @@ export default function Settings2FARoute(): ReactElement {
|
|||||||
</Alert>
|
</Alert>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<></>
|
<Setup2FA onSuccess={query.refetch} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</QueryResult>
|
</QueryResult>
|
||||||
|
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