Skip to content

Commit

Permalink
feat: add passkey and 2FA document action auth options (documenso#1065)
Browse files Browse the repository at this point in the history
## 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
dguyen authored Mar 31, 2024
1 parent 81ee582 commit cbe6270
Show file tree
Hide file tree
Showing 27 changed files with 966 additions and 243 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';

export type CreatePasskeyDialogProps = {
trigger?: React.ReactNode;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;

const ZCreatePasskeyFormSchema = z.object({
Expand All @@ -48,7 +49,7 @@ type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;

const parser = new UAParser();

export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogProps) => {
export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => {
const [open, setOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(null);

Expand Down Expand Up @@ -84,6 +85,7 @@ export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogPr
duration: 5000,
});

onSuccess?.();
setOpen(false);
} catch (err) {
if (err.name === 'NotAllowedError') {
Expand Down
172 changes: 172 additions & 0 deletions apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx
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>
);
};
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>
);
};
Loading

0 comments on commit cbe6270

Please sign in to comment.