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,57 +25,51 @@ 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"
variant="neutral" variant="neutral"
> >
<div className="mb-4 sm:mb-0"> <div className="mb-4 sm:mb-0">
<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'
</AlertDescription> ? 'when signing in, or when signing documents.'
</div> : 'for signing documents.'}
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
</Alert>
{user.twoFactorEnabled && (
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Recovery codes</AlertTitle>
<AlertDescription className="mr-4">
Two factor authentication recovery codes are used to access your account in the
event that you lose access to your authenticator app.
</AlertDescription>
</div>
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
</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> </AlertDescription>
</div>
{user.twoFactorEnabled ? (
<DisableAuthenticatorAppDialog />
) : (
<EnableAuthenticatorAppDialog />
)}
</Alert>
{user.twoFactorEnabled && (
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Recovery codes</AlertTitle>
<AlertDescription className="mr-4">
Two factor authentication recovery codes are used to access your account in the event
that you lose access to your authenticator app.
</AlertDescription>
</div>
<ViewRecoveryCodesDialog />
</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 <DialogFooter>
name="backupCode" <DialogClose asChild>
control={disableTwoFactorAuthenticationForm.control} <Button type="button" variant="secondary">
render={({ field }) => ( Cancel
<FormItem> </Button>
<FormLabel className="text-muted-foreground">Backup Code</FormLabel> </DialogClose>
<FormControl>
<Input {...field} type="text" value={field.value ?? ''} /> <Button type="submit" variant="destructive" loading={isDisable2FASubmitting}>
</FormControl> Disable 2FA
<FormMessage /> </Button>
</FormItem> </DialogFooter>
)}
/>
</fieldset> </fieldset>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
loading={isDisableTwoFactorAuthenticationSubmitting}
>
Disable 2FA
</Button>
</DialogFooter>
</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,175 +108,126 @@ 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}>
<DialogHeader> <Button
<DialogTitle>Enable Authenticator App</DialogTitle> className="flex-shrink-0"
loading={isSettingUp2FA}
onClick={(e) => {
e.preventDefault();
void handleEnable2FA();
}}
>
Enable 2FA
</Button>
</DialogTrigger>
{step === 'setup' && ( <DialogContent position="center">
<DialogDescription> {setup2FAData && (
To enable two-factor authentication, please enter your password below. <>
</DialogDescription> {recoveryCodes ? (
)} <div>
<DialogHeader>
<DialogTitle>Backup codes</DialogTitle>
<DialogDescription>
Your recovery codes are listed below. Please store them in a safe place.
</DialogDescription>
</DialogHeader>
{step === 'view' && ( <div className="mt-4">
<DialogDescription> <RecoveryCodeList recoveryCodes={recoveryCodes} />
Your recovery codes are listed below. Please store them in a safe place. </div>
</DialogDescription>
)}
</DialogHeader>
{match(step) <DialogFooter className="mt-4">
.with('setup', () => { <DialogClose asChild>
return ( <Button variant="secondary">Close</Button>
<Form {...setupTwoFactorAuthenticationForm}> </DialogClose>
<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> <Button onClick={downloadRecoveryCodes}>Download</Button>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> </DialogFooter>
Cancel </div>
</Button> ) : (
<Form {...enable2FAForm}>
<form onSubmit={enable2FAForm.handleSubmit(onEnable2FAFormSubmit)}>
<DialogHeader>
<DialogTitle>Enable Authenticator App</DialogTitle>
<DialogDescription>
To enable two-factor authentication, scan the following QR code using your
authenticator app.
</DialogDescription>
</DialogHeader>
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}> <fieldset disabled={isEnabling2FA} className="mt-4 flex flex-col gap-y-4">
Continue <div
</Button> className="flex h-36 justify-center"
</DialogFooter> dangerouslySetInnerHTML={{
__html: renderSVG(setup2FAData?.uri ?? ''),
}}
/>
<p className="text-muted-foreground text-sm">
If your authenticator app does not support QR codes, you can use the following
code instead:
</p>
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
{setup2FAData?.secret}
</p>
<p className="text-muted-foreground text-sm">
Once you have scanned the QR code or entered the code manually, enter the code
provided by your authenticator app below.
</p>
<FormField
name="token"
control={enable2FAForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Token</FormLabel>
<FormControl>
<Input {...field} type="text" value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button type="submit" loading={isEnabling2FA}>
Enable 2FA
</Button>
</DialogFooter>
</fieldset>
</form> </form>
</Form> </Form>
); )}
}) </>
.with('enable', () => ( )}
<Form {...enableTwoFactorAuthenticationForm}>
<form
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
authenticator app.
</p>
<div
className="flex h-36 justify-center"
dangerouslySetInnerHTML={{
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''),
}}
/>
<p className="text-muted-foreground text-sm">
If your authenticator app does not support QR codes, you can use the following
code instead:
</p>
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
{setupTwoFactorAuthenticationData?.secret}
</p>
<p className="text-muted-foreground text-sm">
Once you have scanned the QR code or entered the code manually, enter the code
provided by your authenticator app below.
</p>
<FormField
name="token"
control={enableTwoFactorAuthenticationForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Token</FormLabel>
<FormControl>
<Input {...field} type="text" value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
Enable 2FA
</Button>
</DialogFooter>
</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 ? (
<DialogTitle>View Recovery Codes</DialogTitle> <div>
<DialogHeader className="mb-4">
<DialogTitle>View Recovery Codes</DialogTitle>
{step === 'authenticate' && ( <DialogDescription>
<DialogDescription> Your recovery codes are listed below. Please store them in a safe place.
To view your recovery codes, please enter your password below. </DialogDescription>
</DialogDescription> </DialogHeader>
)}
{step === 'view' && ( <RecoveryCodeList recoveryCodes={recoveryCodes} />
<DialogDescription>
Your recovery codes are listed below. Please store them in a safe place.
</DialogDescription>
)}
</DialogHeader>
{match(step) <DialogFooter className="mt-4">
.with('authenticate', () => { <DialogClose asChild>
return ( <Button variant="secondary">Close</Button>
<Form {...viewRecoveryCodesForm}> </DialogClose>
<form
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)}
className="flex flex-col gap-y-4"
>
<FormField
name="password"
control={viewRecoveryCodesForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl>
<PasswordInput
{...field}
autoComplete="current-password"
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter> <Button onClick={downloadRecoveryCodes}>Download</Button>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> </DialogFooter>
</div>
) : (
<Form {...viewRecoveryCodesForm}>
<form onSubmit={viewRecoveryCodesForm.handleSubmit((value) => mutate(value))}>
<DialogHeader className="mb-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
name="token"
control={viewRecoveryCodesForm.control}
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} placeholder="Token" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{error && (
<Alert variant="destructive">
<AlertDescription>
{match(AppError.parseError(error).message)
.with(
ErrorCode.INCORRECT_TWO_FACTOR_CODE,
() => 'Invalid code. Please try again.',
)
.otherwise(
() => 'Something went wrong. Please try again or contact support.',
)}
</AlertDescription>
</Alert>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Cancel Cancel
</Button> </Button>
</DialogClose>
<Button type="submit" loading={isViewRecoveryCodesSubmitting}> <Button type="submit" loading={isLoading}>
Continue View
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </fieldset>
</Form> </form>
); </Form>
}) )}
.with('view', () => (
<div>
{viewRecoveryCodesData?.recoveryCodes && (
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
)}
<div className="mt-4 flex flex-row-reverse items-center gap-2">
<Button onClick={() => onOpenChange(false)}>Complete</Button>
<Button
variant="secondary"
disabled={!viewRecoveryCodesData?.recoveryCodes}
loading={isViewRecoveryCodesDataLoading}
onClick={downloadRecoveryCodes}
>
Download
</Button>
</div>
</div>
))
.exhaustive()}
</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');
} }
const updatedUser = await prisma.$transaction(async (tx) => { 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');
}
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,34 +1,34 @@
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
.input(ZEnableTwoFactorAuthenticationMutationSchema) .input(ZEnableTwoFactorAuthenticationMutationSchema)
@ -44,7 +44,11 @@ export const twoFactorAuthenticationRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req), requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
console.error(err); const error = AppError.parseError(err);
if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') {
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) {
console.error(err); const error = AppError.parseError(err);
if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') {
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) {
console.error(err);
if (err instanceof TRPCError) {
throw err;
}
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to view your recovery codes. Please try again later.',
}); });
} catch (err) {
const error = AppError.parseError(err);
if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') {
console.error(err);
}
throw AppError.parseErrorToTRPCError(err);
} }
}), }),
}); });

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>;