feat: remove 2FA password requirement (#1053)

This commit is contained in:
David Nguyen
2024-03-25 11:34:50 +08:00
committed by GitHub
parent 715c14a6ae
commit 43400c07de
12 changed files with 432 additions and 658 deletions

View File

@ -1,14 +1,14 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app'; import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-authenticator-app-dialog';
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes'; import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
import { PasswordForm } from '~/components/forms/password'; import { PasswordForm } from '~/components/forms/password';
export const metadata: Metadata = { export const metadata: Metadata = {
@ -25,11 +25,13 @@ export default async function SecuritySettingsPage() {
subtitle="Here you can manage your password and security settings." subtitle="Here you can manage your password and security settings."
/> />
{user.identityProvider === 'DOCUMENSO' ? ( {user.identityProvider === 'DOCUMENSO' && (
<div> <>
<PasswordForm user={user} /> <PasswordForm user={user} />
<hr className="border-border/50 mt-6" /> <hr className="border-border/50 mt-6" />
</>
)}
<Alert <Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
@ -39,12 +41,18 @@ export default async function SecuritySettingsPage() {
<AlertTitle>Two factor authentication</AlertTitle> <AlertTitle>Two factor authentication</AlertTitle>
<AlertDescription className="mr-4"> <AlertDescription className="mr-4">
Create one-time passwords that serve as a secondary authentication method for Add an authenticator to serve as a secondary authentication method{' '}
confirming your identity when requested during the sign-in process. {user.identityProvider === 'DOCUMENSO'
? 'when signing in, or when signing documents.'
: 'for signing documents.'}
</AlertDescription> </AlertDescription>
</div> </div>
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} /> {user.twoFactorEnabled ? (
<DisableAuthenticatorAppDialog />
) : (
<EnableAuthenticatorAppDialog />
)}
</Alert> </Alert>
{user.twoFactorEnabled && ( {user.twoFactorEnabled && (
@ -56,26 +64,12 @@ export default async function SecuritySettingsPage() {
<AlertTitle>Recovery codes</AlertTitle> <AlertTitle>Recovery codes</AlertTitle>
<AlertDescription className="mr-4"> <AlertDescription className="mr-4">
Two factor authentication recovery codes are used to access your account in the Two factor authentication recovery codes are used to access your account in the event
event that you lose access to your authenticator app. that you lose access to your authenticator app.
</AlertDescription> </AlertDescription>
</div> </div>
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} /> <ViewRecoveryCodesDialog />
</Alert>
)}
</div>
) : (
<Alert className="p-6" variant="neutral">
<AlertTitle>
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
</AlertTitle>
<AlertDescription>
To update your password, enable two-factor authentication, and manage other security
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
settings.
</AlertDescription>
</Alert> </Alert>
)} )}

View File

@ -1,43 +0,0 @@
'use client';
import { useState } from 'react';
import { Button } from '@documenso/ui/primitives/button';
import { DisableAuthenticatorAppDialog } from './disable-authenticator-app-dialog';
import { EnableAuthenticatorAppDialog } from './enable-authenticator-app-dialog';
type AuthenticatorAppProps = {
isTwoFactorEnabled: boolean;
};
export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps) => {
const [modalState, setModalState] = useState<'enable' | 'disable' | null>(null);
const isEnableDialogOpen = modalState === 'enable';
const isDisableDialogOpen = modalState === 'disable';
return (
<>
<div className="flex-shrink-0">
{isTwoFactorEnabled ? (
<Button variant="destructive" onClick={() => setModalState('disable')}>
Disable 2FA
</Button>
) : (
<Button onClick={() => setModalState('enable')}>Enable 2FA</Button>
)}
</div>
<EnableAuthenticatorAppDialog
open={isEnableDialogOpen}
onOpenChange={(open) => !open && setModalState(null)}
/>
<DisableAuthenticatorAppDialog
open={isDisableDialogOpen}
onOpenChange={(open) => !open && setModalState(null)}
/>
</>
);
};

View File

@ -1,3 +1,7 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@ -9,65 +13,51 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { import {
Form, Form,
FormControl, FormControl,
FormField, FormField,
FormItem, FormItem,
FormLabel,
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZDisableTwoFactorAuthenticationForm = z.object({ export const ZDisable2FAForm = z.object({
password: z.string().min(6).max(72), token: z.string(),
backupCode: z.string(),
}); });
export type TDisableTwoFactorAuthenticationForm = z.infer< export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
typeof ZDisableTwoFactorAuthenticationForm
>;
export type DisableAuthenticatorAppDialogProps = { export const DisableAuthenticatorAppDialog = () => {
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DisableAuthenticatorAppDialog = ({
open,
onOpenChange,
}: DisableAuthenticatorAppDialogProps) => {
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: disableTwoFactorAuthentication } = const [isOpen, setIsOpen] = useState(false);
trpc.twoFactorAuthentication.disable.useMutation();
const disableTwoFactorAuthenticationForm = useForm<TDisableTwoFactorAuthenticationForm>({ const { mutateAsync: disable2FA } = trpc.twoFactorAuthentication.disable.useMutation();
const disable2FAForm = useForm<TDisable2FAForm>({
defaultValues: { defaultValues: {
password: '', token: '',
backupCode: '',
}, },
resolver: zodResolver(ZDisableTwoFactorAuthenticationForm), resolver: zodResolver(ZDisable2FAForm),
}); });
const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } = const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState;
disableTwoFactorAuthenticationForm.formState;
const onDisableTwoFactorAuthenticationFormSubmit = async ({ const onDisable2FAFormSubmit = async ({ token }: TDisable2FAForm) => {
password,
backupCode,
}: TDisableTwoFactorAuthenticationForm) => {
try { try {
await disableTwoFactorAuthentication({ password, backupCode }); await disable2FA({ token });
toast({ toast({
title: 'Two-factor authentication disabled', title: 'Two-factor authentication disabled',
@ -76,7 +66,7 @@ export const DisableAuthenticatorAppDialog = ({
}); });
flushSync(() => { flushSync(() => {
onOpenChange(false); setIsOpen(false);
}); });
router.refresh(); router.refresh();
@ -91,74 +81,51 @@ export const DisableAuthenticatorAppDialog = ({
}; };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl"> <DialogTrigger asChild={true}>
<Button className="flex-shrink-0" variant="destructive">
Disable 2FA
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader> <DialogHeader>
<DialogTitle>Disable Authenticator App</DialogTitle> <DialogTitle>Disable 2FA</DialogTitle>
<DialogDescription> <DialogDescription>
To disable the Authenticator App for your account, please enter your password and a Please provide a token from the authenticator, or a backup code. If you do not have a
backup code. If you do not have a backup code available, please contact support. backup code available, please contact support.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Form {...disableTwoFactorAuthenticationForm}> <Form {...disable2FAForm}>
<form <form onSubmit={disable2FAForm.handleSubmit(onDisable2FAFormSubmit)}>
onSubmit={disableTwoFactorAuthenticationForm.handleSubmit( <fieldset className="flex flex-col gap-y-4" disabled={isDisable2FASubmitting}>
onDisableTwoFactorAuthenticationFormSubmit,
)}
className="flex flex-col gap-y-4"
>
<fieldset
className="flex flex-col gap-y-4"
disabled={isDisableTwoFactorAuthenticationSubmitting}
>
<FormField <FormField
name="password" name="token"
control={disableTwoFactorAuthenticationForm.control} control={disable2FAForm.control}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl> <FormControl>
<PasswordInput <Input {...field} placeholder="Token" />
{...field}
autoComplete="current-password"
value={field.value ?? ''}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField
name="backupCode"
control={disableTwoFactorAuthenticationForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Backup Code</FormLabel>
<FormControl>
<Input {...field} type="text" value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<DialogFooter> <DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> <DialogClose asChild>
<Button type="button" variant="secondary">
Cancel Cancel
</Button> </Button>
</DialogClose>
<Button <Button type="submit" variant="destructive" loading={isDisable2FASubmitting}>
type="submit"
variant="destructive"
loading={isDisableTwoFactorAuthenticationSubmitting}
>
Disable 2FA Disable 2FA
</Button> </Button>
</DialogFooter> </DialogFooter>
</fieldset>
</form> </form>
</Form> </Form>
</DialogContent> </DialogContent>

View File

@ -1,8 +1,11 @@
import { useEffect, useMemo } from 'react'; 'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { renderSVG } from 'uqr'; import { renderSVG } from 'uqr';
import { z } from 'zod'; import { z } from 'zod';
@ -11,11 +14,13 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { import {
Form, Form,
@ -26,85 +31,60 @@ import {
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecoveryCodeList } from './recovery-code-list'; import { RecoveryCodeList } from './recovery-code-list';
export const ZSetupTwoFactorAuthenticationForm = z.object({ export const ZEnable2FAForm = z.object({
password: z.string().min(6).max(72),
});
export type TSetupTwoFactorAuthenticationForm = z.infer<typeof ZSetupTwoFactorAuthenticationForm>;
export const ZEnableTwoFactorAuthenticationForm = z.object({
token: z.string(), token: z.string(),
}); });
export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>; export type TEnable2FAForm = z.infer<typeof ZEnable2FAForm>;
export type EnableAuthenticatorAppDialogProps = { export const EnableAuthenticatorAppDialog = () => {
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const EnableAuthenticatorAppDialog = ({
open,
onOpenChange,
}: EnableAuthenticatorAppDialogProps) => {
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter();
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } = const [isOpen, setIsOpen] = useState(false);
trpc.twoFactorAuthentication.setup.useMutation(); const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const { mutateAsync: enable2FA } = trpc.twoFactorAuthentication.enable.useMutation();
const { const {
mutateAsync: enableTwoFactorAuthentication, mutateAsync: setup2FA,
data: enableTwoFactorAuthenticationData, data: setup2FAData,
isLoading: isEnableTwoFactorAuthenticationDataLoading, isLoading: isSettingUp2FA,
} = trpc.twoFactorAuthentication.enable.useMutation(); } = trpc.twoFactorAuthentication.setup.useMutation({
onError: () => {
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({ toast({
defaultValues: { title: 'Unable to setup two-factor authentication',
password: '', description:
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.',
variant: 'destructive',
});
}, },
resolver: zodResolver(ZSetupTwoFactorAuthenticationForm),
}); });
const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } = const enable2FAForm = useForm<TEnable2FAForm>({
setupTwoFactorAuthenticationForm.formState;
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
defaultValues: { defaultValues: {
token: '', token: '',
}, },
resolver: zodResolver(ZEnableTwoFactorAuthenticationForm), resolver: zodResolver(ZEnable2FAForm),
}); });
const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } = const { isSubmitting: isEnabling2FA } = enable2FAForm.formState;
enableTwoFactorAuthenticationForm.formState;
const step = useMemo(() => { const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
if (!setupTwoFactorAuthenticationData || isSetupTwoFactorAuthenticationSubmitting) {
return 'setup';
}
if (!enableTwoFactorAuthenticationData || isEnableTwoFactorAuthenticationSubmitting) {
return 'enable';
}
return 'view';
}, [
setupTwoFactorAuthenticationData,
isSetupTwoFactorAuthenticationSubmitting,
enableTwoFactorAuthenticationData,
isEnableTwoFactorAuthenticationSubmitting,
]);
const onSetupTwoFactorAuthenticationFormSubmit = async ({
password,
}: TSetupTwoFactorAuthenticationForm) => {
try { try {
await setupTwoFactorAuthentication({ password }); const data = await enable2FA({ code: token });
setRecoveryCodes(data.recoveryCodes);
toast({
title: 'Two-factor authentication enabled',
description:
'You will now be required to enter a code from your authenticator app when signing in.',
});
} catch (_err) { } catch (_err) {
toast({ toast({
title: 'Unable to setup two-factor authentication', title: 'Unable to setup two-factor authentication',
@ -116,8 +96,8 @@ export const EnableAuthenticatorAppDialog = ({
}; };
const downloadRecoveryCodes = () => { const downloadRecoveryCodes = () => {
if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) { if (recoveryCodes) {
const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], { const blob = new Blob([recoveryCodes.join('\n')], {
type: 'text/plain', type: 'text/plain',
}); });
@ -128,111 +108,80 @@ export const EnableAuthenticatorAppDialog = ({
} }
}; };
const onEnableTwoFactorAuthenticationFormSubmit = async ({ const handleEnable2FA = async () => {
token, if (!setup2FAData) {
}: TEnableTwoFactorAuthenticationForm) => { await setup2FA();
try {
await enableTwoFactorAuthentication({ code: token });
toast({
title: 'Two-factor authentication enabled',
description:
'Two-factor authentication has been enabled for your account. You will now be required to enter a code from your authenticator app when signing in.',
});
} catch (_err) {
toast({
title: 'Unable to setup two-factor authentication',
description:
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
variant: 'destructive',
});
} }
setIsOpen(true);
}; };
useEffect(() => { useEffect(() => {
// Reset the form when the Dialog closes enable2FAForm.reset();
if (!open) {
setupTwoFactorAuthenticationForm.reset(); if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
setRecoveryCodes(null);
router.refresh();
} }
}, [open, setupTwoFactorAuthenticationForm]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl"> <DialogTrigger asChild={true}>
<Button
className="flex-shrink-0"
loading={isSettingUp2FA}
onClick={(e) => {
e.preventDefault();
void handleEnable2FA();
}}
>
Enable 2FA
</Button>
</DialogTrigger>
<DialogContent position="center">
{setup2FAData && (
<>
{recoveryCodes ? (
<div>
<DialogHeader> <DialogHeader>
<DialogTitle>Enable Authenticator App</DialogTitle> <DialogTitle>Backup codes</DialogTitle>
{step === 'setup' && (
<DialogDescription>
To enable two-factor authentication, please enter your password below.
</DialogDescription>
)}
{step === 'view' && (
<DialogDescription> <DialogDescription>
Your recovery codes are listed below. Please store them in a safe place. Your recovery codes are listed below. Please store them in a safe place.
</DialogDescription> </DialogDescription>
)}
</DialogHeader> </DialogHeader>
{match(step) <div className="mt-4">
.with('setup', () => { <RecoveryCodeList recoveryCodes={recoveryCodes} />
return ( </div>
<Form {...setupTwoFactorAuthenticationForm}>
<form
onSubmit={setupTwoFactorAuthenticationForm.handleSubmit(
onSetupTwoFactorAuthenticationFormSubmit,
)}
className="flex flex-col gap-y-4"
>
<FormField
name="password"
control={setupTwoFactorAuthenticationForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl>
<PasswordInput
{...field}
autoComplete="current-password"
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter> <DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> <DialogClose asChild>
Cancel <Button variant="secondary">Close</Button>
</Button> </DialogClose>
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}> <Button onClick={downloadRecoveryCodes}>Download</Button>
Continue
</Button>
</DialogFooter> </DialogFooter>
</form> </div>
</Form> ) : (
); <Form {...enable2FAForm}>
}) <form onSubmit={enable2FAForm.handleSubmit(onEnable2FAFormSubmit)}>
.with('enable', () => ( <DialogHeader>
<Form {...enableTwoFactorAuthenticationForm}> <DialogTitle>Enable Authenticator App</DialogTitle>
<form <DialogDescription>
onSubmit={enableTwoFactorAuthenticationForm.handleSubmit(
onEnableTwoFactorAuthenticationFormSubmit,
)}
className="flex flex-col gap-y-4"
>
<p className="text-muted-foreground text-sm">
To enable two-factor authentication, scan the following QR code using your To enable two-factor authentication, scan the following QR code using your
authenticator app. authenticator app.
</p> </DialogDescription>
</DialogHeader>
<fieldset disabled={isEnabling2FA} className="mt-4 flex flex-col gap-y-4">
<div <div
className="flex h-36 justify-center" className="flex h-36 justify-center"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''), __html: renderSVG(setup2FAData?.uri ?? ''),
}} }}
/> />
@ -242,7 +191,7 @@ export const EnableAuthenticatorAppDialog = ({
</p> </p>
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest"> <p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
{setupTwoFactorAuthenticationData?.secret} {setup2FAData?.secret}
</p> </p>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
@ -252,7 +201,7 @@ export const EnableAuthenticatorAppDialog = ({
<FormField <FormField
name="token" name="token"
control={enableTwoFactorAuthenticationForm.control} control={enable2FAForm.control}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="text-muted-foreground">Token</FormLabel> <FormLabel className="text-muted-foreground">Token</FormLabel>
@ -265,38 +214,20 @@ export const EnableAuthenticatorAppDialog = ({
/> />
<DialogFooter> <DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> <DialogClose asChild>
Cancel <Button variant="secondary">Cancel</Button>
</Button> </DialogClose>
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}> <Button type="submit" loading={isEnabling2FA}>
Enable 2FA Enable 2FA
</Button> </Button>
</DialogFooter> </DialogFooter>
</fieldset>
</form> </form>
</Form> </Form>
))
.with('view', () => (
<div>
{enableTwoFactorAuthenticationData?.recoveryCodes && (
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
)} )}
</>
<div className="mt-4 flex flex-row-reverse items-center gap-2"> )}
<Button onClick={() => onOpenChange(false)}>Complete</Button>
<Button
variant="secondary"
onClick={downloadRecoveryCodes}
disabled={!enableTwoFactorAuthenticationData?.recoveryCodes}
loading={isEnableTwoFactorAuthenticationDataLoading}
>
Download
</Button>
</div>
</div>
))
.exhaustive()}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -1,33 +0,0 @@
'use client';
import { useState } from 'react';
import { Button } from '@documenso/ui/primitives/button';
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
type RecoveryCodesProps = {
isTwoFactorEnabled: boolean;
};
export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button
className="flex-shrink-0"
onClick={() => setIsOpen(true)}
disabled={!isTwoFactorEnabled}
>
View Codes
</Button>
<ViewRecoveryCodesDialog
key={isOpen ? 'open' : 'closed'}
open={isOpen}
onOpenChange={setIsOpen}
/>
</>
);
};

View File

@ -1,4 +1,6 @@
import { useEffect, useMemo } from 'react'; 'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -6,69 +8,61 @@ import { match } from 'ts-pattern';
import { z } from 'zod'; import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file'; import { downloadFile } from '@documenso/lib/client-only/download-file';
import { AppError } from '@documenso/lib/errors/app-error';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { import {
Form, Form,
FormControl, FormControl,
FormField, FormField,
FormItem, FormItem,
FormLabel,
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecoveryCodeList } from './recovery-code-list'; import { RecoveryCodeList } from './recovery-code-list';
export const ZViewRecoveryCodesForm = z.object({ export const ZViewRecoveryCodesForm = z.object({
password: z.string().min(6).max(72), token: z.string().min(1, { message: 'Token is required' }),
}); });
export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>; export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
export type ViewRecoveryCodesDialogProps = { export const ViewRecoveryCodesDialog = () => {
open: boolean; const [isOpen, setIsOpen] = useState(false);
onOpenChange: (_open: boolean) => void;
};
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
const { toast } = useToast();
const { const {
mutateAsync: viewRecoveryCodes, data: recoveryCodes,
data: viewRecoveryCodesData, mutate,
isLoading: isViewRecoveryCodesDataLoading, isLoading,
isError,
error,
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); } = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
// error?.data?.code
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({ const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
defaultValues: { defaultValues: {
password: '', token: '',
}, },
resolver: zodResolver(ZViewRecoveryCodesForm), resolver: zodResolver(ZViewRecoveryCodesForm),
}); });
const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState;
const step = useMemo(() => {
if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) {
return 'authenticate';
}
return 'view';
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
const downloadRecoveryCodes = () => { const downloadRecoveryCodes = () => {
if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) { if (recoveryCodes) {
const blob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], { const blob = new Blob([recoveryCodes.join('\n')], {
type: 'text/plain', type: 'text/plain',
}); });
@ -79,105 +73,88 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
} }
}; };
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
try {
await viewRecoveryCodes({ password });
} catch (_err) {
toast({
title: 'Unable to view recovery codes',
description:
'We were unable to view your recovery codes. Please ensure that you have entered your password correctly and try again.',
variant: 'destructive',
});
}
};
useEffect(() => {
// Reset the form when the Dialog closes
if (!open) {
viewRecoveryCodesForm.reset();
}
}, [open, viewRecoveryCodesForm]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button className="flex-shrink-0">View Codes</Button>
</DialogTrigger>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl"> <DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
<DialogHeader> {recoveryCodes ? (
<div>
<DialogHeader className="mb-4">
<DialogTitle>View Recovery Codes</DialogTitle> <DialogTitle>View Recovery Codes</DialogTitle>
{step === 'authenticate' && (
<DialogDescription>
To view your recovery codes, please enter your password below.
</DialogDescription>
)}
{step === 'view' && (
<DialogDescription> <DialogDescription>
Your recovery codes are listed below. Please store them in a safe place. Your recovery codes are listed below. Please store them in a safe place.
</DialogDescription> </DialogDescription>
)}
</DialogHeader> </DialogHeader>
{match(step) <RecoveryCodeList recoveryCodes={recoveryCodes} />
.with('authenticate', () => {
return ( <DialogFooter className="mt-4">
<DialogClose asChild>
<Button variant="secondary">Close</Button>
</DialogClose>
<Button onClick={downloadRecoveryCodes}>Download</Button>
</DialogFooter>
</div>
) : (
<Form {...viewRecoveryCodesForm}> <Form {...viewRecoveryCodesForm}>
<form <form onSubmit={viewRecoveryCodesForm.handleSubmit((value) => mutate(value))}>
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)} <DialogHeader className="mb-4">
className="flex flex-col gap-y-4" <DialogTitle>View Recovery Codes</DialogTitle>
>
<DialogDescription>
Please provide a token from your authenticator, or a backup code.
</DialogDescription>
</DialogHeader>
<fieldset className="flex flex-col space-y-4" disabled={isLoading}>
<FormField <FormField
name="password" name="token"
control={viewRecoveryCodesForm.control} control={viewRecoveryCodesForm.control}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl> <FormControl>
<PasswordInput <Input {...field} placeholder="Token" />
{...field}
autoComplete="current-password"
value={field.value ?? ''}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<DialogFooter> {error && (
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> <Alert variant="destructive">
Cancel <AlertDescription>
</Button> {match(AppError.parseError(error).message)
.with(
<Button type="submit" loading={isViewRecoveryCodesSubmitting}> ErrorCode.INCORRECT_TWO_FACTOR_CODE,
Continue () => 'Invalid code. Please try again.',
</Button> )
</DialogFooter> .otherwise(
</form> () => 'Something went wrong. Please try again or contact support.',
</Form> )}
); </AlertDescription>
}) </Alert>
.with('view', () => (
<div>
{viewRecoveryCodesData?.recoveryCodes && (
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
)} )}
<div className="mt-4 flex flex-row-reverse items-center gap-2"> <DialogFooter>
<Button onClick={() => onOpenChange(false)}>Complete</Button> <DialogClose asChild>
<Button type="button" variant="secondary">
<Button Cancel
variant="secondary"
disabled={!viewRecoveryCodesData?.recoveryCodes}
loading={isViewRecoveryCodesDataLoading}
onClick={downloadRecoveryCodes}
>
Download
</Button> </Button>
</div> </DialogClose>
</div>
)) <Button type="submit" loading={isLoading}>
.exhaustive()} View
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -1,40 +1,30 @@
import { compare } from '@node-rs/bcrypt';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
import { UserSecurityAuditLogType } from '@documenso/prisma/client'; import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { ErrorCode } from '../../next-auth/error-codes'; import { AppError } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { validateTwoFactorAuthentication } from './validate-2fa'; import { validateTwoFactorAuthentication } from './validate-2fa';
type DisableTwoFactorAuthenticationOptions = { type DisableTwoFactorAuthenticationOptions = {
user: User; user: User;
backupCode: string; token: string;
password: string;
requestMetadata?: RequestMetadata; requestMetadata?: RequestMetadata;
}; };
export const disableTwoFactorAuthentication = async ({ export const disableTwoFactorAuthentication = async ({
backupCode, token,
user, user,
password,
requestMetadata, requestMetadata,
}: DisableTwoFactorAuthenticationOptions) => { }: DisableTwoFactorAuthenticationOptions) => {
if (!user.password) { let isValid = await validateTwoFactorAuthentication({ totpCode: token, user });
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
}
const isCorrectPassword = await compare(password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.INCORRECT_PASSWORD);
}
const isValid = await validateTwoFactorAuthentication({ backupCode, user });
if (!isValid) { if (!isValid) {
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE); isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
}
if (!isValid) {
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
} }
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {

View File

@ -1,7 +1,7 @@
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client'; import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client';
import { AppError } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getBackupCodes } from './get-backup-code'; import { getBackupCodes } from './get-backup-code';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token'; import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
@ -17,25 +17,38 @@ export const enableTwoFactorAuthentication = async ({
code, code,
requestMetadata, requestMetadata,
}: EnableTwoFactorAuthenticationOptions) => { }: EnableTwoFactorAuthenticationOptions) => {
if (user.identityProvider !== 'DOCUMENSO') {
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
}
if (user.twoFactorEnabled) { if (user.twoFactorEnabled) {
throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED); throw new AppError('TWO_FACTOR_ALREADY_ENABLED');
} }
if (!user.twoFactorSecret) { if (!user.twoFactorSecret) {
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED); throw new AppError('TWO_FACTOR_SETUP_REQUIRED');
} }
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code }); const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
if (!isValidToken) { if (!isValidToken) {
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE); throw new AppError('INCORRECT_TWO_FACTOR_CODE');
}
let recoveryCodes: string[] = [];
await prisma.$transaction(async (tx) => {
const updatedUser = await tx.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: true,
},
});
recoveryCodes = getBackupCodes({ user: updatedUser }) ?? [];
if (recoveryCodes.length === 0) {
throw new AppError('MISSING_BACKUP_CODE');
} }
const updatedUser = await prisma.$transaction(async (tx) => {
await tx.userSecurityAuditLog.create({ await tx.userSecurityAuditLog.create({
data: { data: {
userId: user.id, userId: user.id,
@ -44,18 +57,7 @@ export const enableTwoFactorAuthentication = async ({
ipAddress: requestMetadata?.ipAddress, ipAddress: requestMetadata?.ipAddress,
}, },
}); });
return await tx.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: true,
},
}); });
});
const recoveryCodes = getBackupCodes({ user: updatedUser });
return { recoveryCodes }; return { recoveryCodes };
}; };

View File

@ -1,4 +1,3 @@
import { compare } from '@node-rs/bcrypt';
import { base32 } from '@scure/base'; import { base32 } from '@scure/base';
import crypto from 'crypto'; import crypto from 'crypto';
import { createTOTPKeyURI } from 'oslo/otp'; import { createTOTPKeyURI } from 'oslo/otp';
@ -12,14 +11,12 @@ import { symmetricEncrypt } from '../../universal/crypto';
type SetupTwoFactorAuthenticationOptions = { type SetupTwoFactorAuthenticationOptions = {
user: User; user: User;
password: string;
}; };
const ISSUER = 'Documenso'; const ISSUER = 'Documenso';
export const setupTwoFactorAuthentication = async ({ export const setupTwoFactorAuthentication = async ({
user, user,
password,
}: SetupTwoFactorAuthenticationOptions) => { }: SetupTwoFactorAuthenticationOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY; const key = DOCUMENSO_ENCRYPTION_KEY;
@ -27,20 +24,6 @@ export const setupTwoFactorAuthentication = async ({
throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY); throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY);
} }
if (user.identityProvider !== 'DOCUMENSO') {
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
}
if (!user.password) {
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
}
const isCorrectPassword = await compare(password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.INCORRECT_PASSWORD);
}
const secret = crypto.randomBytes(10); const secret = crypto.randomBytes(10);
const backupCodes = Array.from({ length: 10 }) const backupCodes = Array.from({ length: 10 })

View File

@ -0,0 +1,30 @@
import type { User } from '@documenso/prisma/client';
import { AppError } from '../../errors/app-error';
import { getBackupCodes } from './get-backup-code';
import { validateTwoFactorAuthentication } from './validate-2fa';
type ViewBackupCodesOptions = {
user: User;
token: string;
};
export const viewBackupCodes = async ({ token, user }: ViewBackupCodesOptions) => {
let isValid = await validateTwoFactorAuthentication({ totpCode: token, user });
if (!isValid) {
isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
}
if (!isValid) {
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
}
const backupCodes = getBackupCodes({ user });
if (!backupCodes) {
throw new AppError('MISSING_BACKUP_CODE');
}
return backupCodes;
};

View File

@ -1,33 +1,33 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { AppError } from '@documenso/lib/errors/app-error';
import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa'; import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa';
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa'; import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
import { getBackupCodes } from '@documenso/lib/server-only/2fa/get-backup-code';
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa'; import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { compareSync } from '@documenso/lib/server-only/auth/hash'; import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { authenticatedProcedure, router } from '../trpc'; import { authenticatedProcedure, router } from '../trpc';
import { import {
ZDisableTwoFactorAuthenticationMutationSchema, ZDisableTwoFactorAuthenticationMutationSchema,
ZEnableTwoFactorAuthenticationMutationSchema, ZEnableTwoFactorAuthenticationMutationSchema,
ZSetupTwoFactorAuthenticationMutationSchema,
ZViewRecoveryCodesMutationSchema, ZViewRecoveryCodesMutationSchema,
} from './schema'; } from './schema';
export const twoFactorAuthenticationRouter = router({ export const twoFactorAuthenticationRouter = router({
setup: authenticatedProcedure setup: authenticatedProcedure.mutation(async ({ ctx }) => {
.input(ZSetupTwoFactorAuthenticationMutationSchema) try {
.mutation(async ({ ctx, input }) => {
const user = ctx.user;
const { password } = input;
return await setupTwoFactorAuthentication({ return await setupTwoFactorAuthentication({
user, user: ctx.user,
password,
}); });
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to setup two-factor authentication. Please try again later.',
});
}
}), }),
enable: authenticatedProcedure enable: authenticatedProcedure
@ -44,7 +44,11 @@ export const twoFactorAuthenticationRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req), requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
const error = AppError.parseError(err);
if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') {
console.error(err); console.error(err);
}
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
@ -59,16 +63,17 @@ export const twoFactorAuthenticationRouter = router({
try { try {
const user = ctx.user; const user = ctx.user;
const { password, backupCode } = input;
return await disableTwoFactorAuthentication({ return await disableTwoFactorAuthentication({
user, user,
password, token: input.token,
backupCode,
requestMetadata: extractNextApiRequestMetadata(ctx.req), requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
const error = AppError.parseError(err);
if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') {
console.error(err); console.error(err);
}
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
@ -81,38 +86,18 @@ export const twoFactorAuthenticationRouter = router({
.input(ZViewRecoveryCodesMutationSchema) .input(ZViewRecoveryCodesMutationSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { try {
const user = ctx.user; return await viewBackupCodes({
user: ctx.user,
const { password } = input; token: input.token,
if (!user.twoFactorEnabled) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: ErrorCode.TWO_FACTOR_SETUP_REQUIRED,
}); });
}
if (!user.password || !compareSync(password, user.password)) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: ErrorCode.INCORRECT_PASSWORD,
});
}
const recoveryCodes = await getBackupCodes({ user });
return { recoveryCodes };
} catch (err) { } catch (err) {
console.error(err); const error = AppError.parseError(err);
if (err instanceof TRPCError) { if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') {
throw err; console.error(err);
} }
throw new TRPCError({ throw AppError.parseErrorToTRPCError(err);
code: 'BAD_REQUEST',
message: 'We were unable to view your recovery codes. Please try again later.',
});
} }
}), }),
}); });

View File

@ -1,13 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
export const ZSetupTwoFactorAuthenticationMutationSchema = z.object({
password: z.string().min(1),
});
export type TSetupTwoFactorAuthenticationMutationSchema = z.infer<
typeof ZSetupTwoFactorAuthenticationMutationSchema
>;
export const ZEnableTwoFactorAuthenticationMutationSchema = z.object({ export const ZEnableTwoFactorAuthenticationMutationSchema = z.object({
code: z.string().min(6).max(6), code: z.string().min(6).max(6),
}); });
@ -17,8 +9,7 @@ export type TEnableTwoFactorAuthenticationMutationSchema = z.infer<
>; >;
export const ZDisableTwoFactorAuthenticationMutationSchema = z.object({ export const ZDisableTwoFactorAuthenticationMutationSchema = z.object({
password: z.string().min(6).max(72), token: z.string().trim().min(1),
backupCode: z.string().trim(),
}); });
export type TDisableTwoFactorAuthenticationMutationSchema = z.infer< export type TDisableTwoFactorAuthenticationMutationSchema = z.infer<
@ -26,7 +17,7 @@ export type TDisableTwoFactorAuthenticationMutationSchema = z.infer<
>; >;
export const ZViewRecoveryCodesMutationSchema = z.object({ export const ZViewRecoveryCodesMutationSchema = z.object({
password: z.string().min(6).max(72), token: z.string().trim().min(1),
}); });
export type TViewRecoveryCodesMutationSchema = z.infer<typeof ZViewRecoveryCodesMutationSchema>; export type TViewRecoveryCodesMutationSchema = z.infer<typeof ZViewRecoveryCodesMutationSchema>;