forked from documenso/documenso
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add passkey and 2FA document action auth options (documenso#1065)
## Description Add the following document action auth options: - 2FA - Passkey If the user does not have the required auth setup, we onboard them directly. ## Changes made Note: Added secondaryId to the VerificationToken schema ## Testing Performed Tested locally, pending preview tests ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have added/updated tests that prove the effectiveness of these changes. - [X] I have followed the project's coding style guidelines. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced components for 2FA, account, and passkey authentication during document signing. - Added "Require passkey" option to document settings and signer authentication settings. - Enhanced form submission and loading states for improved user experience. - **Refactor** - Optimized authentication components to efficiently support multiple authentication methods. - **Chores** - Updated and renamed functions and components for clarity and consistency across the authentication system. - Refined sorting options and database schema to support new authentication features. - **Bug Fixes** - Adjusted SignInForm to verify browser support for WebAuthn before proceeding. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
- Loading branch information
Showing
27 changed files
with
966 additions
and
243 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
172 changes: 172 additions & 0 deletions
172
apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,172 @@ | ||
import { useEffect, useState } from 'react'; | ||
|
||
import { zodResolver } from '@hookform/resolvers/zod'; | ||
import { useForm } from 'react-hook-form'; | ||
import { z } from 'zod'; | ||
|
||
import { AppError } from '@documenso/lib/errors/app-error'; | ||
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; | ||
import { RecipientRole } from '@documenso/prisma/client'; | ||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; | ||
import { Button } from '@documenso/ui/primitives/button'; | ||
import { DialogFooter } from '@documenso/ui/primitives/dialog'; | ||
import { | ||
Form, | ||
FormControl, | ||
FormField, | ||
FormItem, | ||
FormLabel, | ||
FormMessage, | ||
} from '@documenso/ui/primitives/form/form'; | ||
import { Input } from '@documenso/ui/primitives/input'; | ||
|
||
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog'; | ||
|
||
import { useRequiredDocumentAuthContext } from './document-auth-provider'; | ||
|
||
export type DocumentActionAuth2FAProps = { | ||
actionTarget?: 'FIELD' | 'DOCUMENT'; | ||
actionVerb?: string; | ||
open: boolean; | ||
onOpenChange: (value: boolean) => void; | ||
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void; | ||
}; | ||
|
||
const Z2FAAuthFormSchema = z.object({ | ||
token: z | ||
.string() | ||
.min(4, { message: 'Token must at least 4 characters long' }) | ||
.max(10, { message: 'Token must be at most 10 characters long' }), | ||
}); | ||
|
||
type T2FAAuthFormSchema = z.infer<typeof Z2FAAuthFormSchema>; | ||
|
||
export const DocumentActionAuth2FA = ({ | ||
actionTarget = 'FIELD', | ||
actionVerb = 'sign', | ||
onReauthFormSubmit, | ||
open, | ||
onOpenChange, | ||
}: DocumentActionAuth2FAProps) => { | ||
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = | ||
useRequiredDocumentAuthContext(); | ||
|
||
const form = useForm<T2FAAuthFormSchema>({ | ||
resolver: zodResolver(Z2FAAuthFormSchema), | ||
defaultValues: { | ||
token: '', | ||
}, | ||
}); | ||
|
||
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false); | ||
const [formErrorCode, setFormErrorCode] = useState<string | null>(null); | ||
|
||
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => { | ||
try { | ||
setIsCurrentlyAuthenticating(true); | ||
|
||
await onReauthFormSubmit({ | ||
type: DocumentAuth.TWO_FACTOR_AUTH, | ||
token, | ||
}); | ||
|
||
setIsCurrentlyAuthenticating(false); | ||
|
||
onOpenChange(false); | ||
} catch (err) { | ||
setIsCurrentlyAuthenticating(false); | ||
|
||
const error = AppError.parseError(err); | ||
setFormErrorCode(error.code); | ||
|
||
// Todo: Alert. | ||
} | ||
}; | ||
|
||
useEffect(() => { | ||
form.reset({ | ||
token: '', | ||
}); | ||
|
||
setIs2FASetupSuccessful(false); | ||
setFormErrorCode(null); | ||
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
}, [open]); | ||
|
||
if (!user?.twoFactorEnabled && !is2FASetupSuccessful) { | ||
return ( | ||
<div className="space-y-4"> | ||
<Alert variant="warning"> | ||
<AlertDescription> | ||
<p> | ||
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' | ||
? 'You need to setup 2FA to mark this document as viewed.' | ||
: `You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`} | ||
</p> | ||
|
||
{user?.identityProvider === 'DOCUMENSO' && ( | ||
<p className="mt-2"> | ||
By enabling 2FA, you will be required to enter a code from your authenticator app | ||
every time you sign in. | ||
</p> | ||
)} | ||
</AlertDescription> | ||
</Alert> | ||
|
||
<DialogFooter> | ||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> | ||
Close | ||
</Button> | ||
|
||
<EnableAuthenticatorAppDialog onSuccess={() => setIs2FASetupSuccessful(true)} /> | ||
</DialogFooter> | ||
</div> | ||
); | ||
} | ||
|
||
return ( | ||
<Form {...form}> | ||
<form onSubmit={form.handleSubmit(onFormSubmit)}> | ||
<fieldset disabled={isCurrentlyAuthenticating}> | ||
<div className="space-y-4"> | ||
<FormField | ||
control={form.control} | ||
name="token" | ||
render={({ field }) => ( | ||
<FormItem> | ||
<FormLabel required>2FA token</FormLabel> | ||
|
||
<FormControl> | ||
<Input {...field} placeholder="Token" /> | ||
</FormControl> | ||
|
||
<FormMessage /> | ||
</FormItem> | ||
)} | ||
/> | ||
|
||
{formErrorCode && ( | ||
<Alert variant="destructive"> | ||
<AlertTitle>Unauthorized</AlertTitle> | ||
<AlertDescription> | ||
We were unable to verify your details. Please try again or contact support | ||
</AlertDescription> | ||
</Alert> | ||
)} | ||
|
||
<DialogFooter> | ||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> | ||
Cancel | ||
</Button> | ||
|
||
<Button type="submit" loading={isCurrentlyAuthenticating}> | ||
Sign | ||
</Button> | ||
</DialogFooter> | ||
</div> | ||
</fieldset> | ||
</form> | ||
</Form> | ||
); | ||
}; |
79 changes: 79 additions & 0 deletions
79
apps/web/src/app/(signing)/sign/[token]/document-action-auth-account.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import { useState } from 'react'; | ||
|
||
import { DateTime } from 'luxon'; | ||
import { signOut } from 'next-auth/react'; | ||
|
||
import { RecipientRole } from '@documenso/prisma/client'; | ||
import { trpc } from '@documenso/trpc/react'; | ||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; | ||
import { Button } from '@documenso/ui/primitives/button'; | ||
import { DialogFooter } from '@documenso/ui/primitives/dialog'; | ||
|
||
import { useRequiredDocumentAuthContext } from './document-auth-provider'; | ||
|
||
export type DocumentActionAuthAccountProps = { | ||
actionTarget?: 'FIELD' | 'DOCUMENT'; | ||
actionVerb?: string; | ||
onOpenChange: (value: boolean) => void; | ||
}; | ||
|
||
export const DocumentActionAuthAccount = ({ | ||
actionTarget = 'FIELD', | ||
actionVerb = 'sign', | ||
onOpenChange, | ||
}: DocumentActionAuthAccountProps) => { | ||
const { recipient } = useRequiredDocumentAuthContext(); | ||
|
||
const [isSigningOut, setIsSigningOut] = useState(false); | ||
|
||
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation(); | ||
|
||
const handleChangeAccount = async (email: string) => { | ||
try { | ||
setIsSigningOut(true); | ||
|
||
const encryptedEmail = await encryptSecondaryData({ | ||
data: email, | ||
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(), | ||
}); | ||
|
||
await signOut({ | ||
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`, | ||
}); | ||
} catch { | ||
setIsSigningOut(false); | ||
|
||
// Todo: Alert. | ||
} | ||
}; | ||
|
||
return ( | ||
<fieldset disabled={isSigningOut} className="space-y-4"> | ||
<Alert variant="warning"> | ||
<AlertDescription> | ||
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? ( | ||
<span> | ||
To mark this document as viewed, you need to be logged in as{' '} | ||
<strong>{recipient.email}</strong> | ||
</span> | ||
) : ( | ||
<span> | ||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged | ||
in as <strong>{recipient.email}</strong> | ||
</span> | ||
)} | ||
</AlertDescription> | ||
</Alert> | ||
|
||
<DialogFooter> | ||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> | ||
Cancel | ||
</Button> | ||
|
||
<Button onClick={async () => handleChangeAccount(recipient.email)} loading={isSigningOut}> | ||
Login | ||
</Button> | ||
</DialogFooter> | ||
</fieldset> | ||
); | ||
}; |
Oops, something went wrong.