Compare commits

..

3 Commits

Author SHA1 Message Date
Mythie
48a8f5fe07 chore: add disclosure 2024-04-02 14:16:36 +07:00
David Nguyen
cbe6270494 feat: add passkey and 2FA document action auth options (#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 -->
2024-03-31 15:49:12 +08:00
David Nguyen
81ee582f1c fix: linting warnings (#1069)
## Description

Cleaned up code that was being highlighted in the dev tools
2024-03-30 13:43:28 +08:00
39 changed files with 1144 additions and 267 deletions

View File

@@ -58,6 +58,7 @@ export const UsersDataTable = ({
perPage, perPage,
}); });
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchString]); }, [debouncedSearchString]);
const onPaginationChange = (page: number, perPage: number) => { const onPaginationChange = (page: number, perPage: number) => {

View File

@@ -38,6 +38,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type CreatePasskeyDialogProps = { export type CreatePasskeyDialogProps = {
trigger?: React.ReactNode; trigger?: React.ReactNode;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>; } & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreatePasskeyFormSchema = z.object({ const ZCreatePasskeyFormSchema = z.object({
@@ -48,7 +49,7 @@ type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
const parser = new UAParser(); const parser = new UAParser();
export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogProps) => { export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
@@ -84,6 +85,7 @@ export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogPr
duration: 5000, duration: 5000,
}); });
onSuccess?.();
setOpen(false); setOpen(false);
} catch (err) { } catch (err) {
if (err.name === 'NotAllowedError') { if (err.name === 'NotAllowedError') {

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -1,13 +1,4 @@
/** import { P, match } from 'ts-pattern';
* Note: This file has some commented out stuff for password auth which is no longer possible.
*
* Leaving it here until after we add passkeys and 2FA since it can be reused.
*/
import { useState } from 'react';
import { DateTime } from 'luxon';
import { signOut } from 'next-auth/react';
import { match } from 'ts-pattern';
import { import {
DocumentAuth, DocumentAuth,
@@ -15,18 +6,17 @@ import {
type TRecipientActionAuthTypes, type TRecipientActionAuthTypes,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import type { FieldType } from '@documenso/prisma/client'; import type { FieldType } 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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { DocumentActionAuth2FA } from './document-action-auth-2fa';
import { DocumentActionAuthAccount } from './document-action-auth-account';
import { DocumentActionAuthPasskey } from './document-action-auth-passkey';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
export type DocumentActionAuthDialogProps = { export type DocumentActionAuthDialogProps = {
@@ -34,7 +24,6 @@ export type DocumentActionAuthDialogProps = {
documentAuthType: TRecipientActionAuthTypes; documentAuthType: TRecipientActionAuthTypes;
description?: string; description?: string;
actionTarget: FieldType | 'DOCUMENT'; actionTarget: FieldType | 'DOCUMENT';
isSubmitting?: boolean;
open: boolean; open: boolean;
onOpenChange: (value: boolean) => void; onOpenChange: (value: boolean) => void;
@@ -44,96 +33,24 @@ export type DocumentActionAuthDialogProps = {
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void; onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
}; };
// const ZReauthFormSchema = z.object({
// password: ZCurrentPasswordSchema,
// });
// type TReauthFormSchema = z.infer<typeof ZReauthFormSchema>;
export const DocumentActionAuthDialog = ({ export const DocumentActionAuthDialog = ({
title, title,
description, description,
documentAuthType, documentAuthType,
// onReauthFormSubmit,
isSubmitting,
open, open,
onOpenChange, onOpenChange,
onReauthFormSubmit,
}: DocumentActionAuthDialogProps) => { }: DocumentActionAuthDialogProps) => {
const { recipient } = useRequiredDocumentAuthContext(); const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentAuthContext();
// const form = useForm({
// resolver: zodResolver(ZReauthFormSchema),
// defaultValues: {
// password: '',
// },
// });
const [isSigningOut, setIsSigningOut] = useState(false);
const isLoading = isSigningOut || isSubmitting; // || form.formState.isSubmitting;
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
// const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
// const onFormSubmit = async (_values: TReauthFormSchema) => {
// const documentAuthValue: TRecipientActionAuth = match(documentAuthType)
// // Todo: Add passkey.
// // .with(DocumentAuthType.PASSKEY, (type) => ({
// // type,
// // value,
// // }))
// .otherwise((type) => ({
// type,
// }));
// try {
// await onReauthFormSubmit(documentAuthValue);
// onOpenChange(false);
// } catch (e) {
// const error = AppError.parseError(e);
// setFormErrorCode(error.code);
// // Suppress unauthorized errors since it's handled in this component.
// if (error.code === AppErrorCode.UNAUTHORIZED) {
// return;
// }
// throw error;
// }
// };
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.
}
};
const handleOnOpenChange = (value: boolean) => { const handleOnOpenChange = (value: boolean) => {
if (isLoading) { if (isCurrentlyAuthenticating) {
return; return;
} }
onOpenChange(value); onOpenChange(value);
}; };
// useEffect(() => {
// form.reset();
// setFormErrorCode(null);
// }, [open, form]);
return ( return (
<Dialog open={open} onOpenChange={handleOnOpenChange}> <Dialog open={open} onOpenChange={handleOnOpenChange}>
<DialogContent> <DialogContent>
@@ -141,100 +58,32 @@ export const DocumentActionAuthDialog = ({
<DialogTitle>{title || 'Sign field'}</DialogTitle> <DialogTitle>{title || 'Sign field'}</DialogTitle>
<DialogDescription> <DialogDescription>
{description || `Reauthentication is required to sign the field`} {description || 'Reauthentication is required to sign this field'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{match(documentAuthType) {match({ documentAuthType, user })
.with(DocumentAuth.ACCOUNT, () => ( .with(
<fieldset disabled={isSigningOut} className="space-y-4"> { documentAuthType: DocumentAuth.ACCOUNT },
<Alert> { user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
<AlertDescription> () => <DocumentActionAuthAccount onOpenChange={onOpenChange} />,
To sign this field, you need to be logged in as <strong>{recipient.email}</strong> )
</AlertDescription> .with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
</Alert> <DocumentActionAuthPasskey
open={open}
<DialogFooter> onOpenChange={onOpenChange}
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> onReauthFormSubmit={onReauthFormSubmit}
Cancel />
</Button>
<Button
type="submit"
onClick={async () => handleChangeAccount(recipient.email)}
loading={isSigningOut}
>
Login
</Button>
</DialogFooter>
</fieldset>
)) ))
.with(DocumentAuth.EXPLICIT_NONE, () => null) .with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
<DocumentActionAuth2FA
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
.exhaustive()} .exhaustive()}
{/* <Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col space-y-4" disabled={isLoading}>
<FormItem>
<FormLabel required>Email</FormLabel>
<FormControl>
<Input className="bg-background" value={recipient.email} disabled />
</FormControl>
</FormItem>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel required>Password</FormLabel>
<FormControl>
<PasswordInput className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{formErrorCode && (
<Alert variant="destructive">
{match(formErrorCode)
.with(AppErrorCode.UNAUTHORIZED, () => (
<>
<AlertTitle>Unauthorized</AlertTitle>
<AlertDescription>
We were unable to verify your details. Please ensure the details are
correct
</AlertDescription>
</>
))
.otherwise(() => (
<>
<AlertTitle>Something went wrong</AlertTitle>
<AlertDescription>
We were unable to sign this field at this time. Please try again or
contact support.
</AlertDescription>
</>
))}
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isLoading}>
Sign field
</Button>
</DialogFooter>
</fieldset>
</form>
</Form> */}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -0,0 +1,252 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
import { Loader } from 'lucide-react';
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 { trpc } from '@documenso/trpc/react';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { CreatePasskeyDialog } from '~/app/(dashboard)/settings/security/passkeys/create-passkey-dialog';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
export type DocumentActionAuthPasskeyProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
};
const ZPasskeyAuthFormSchema = z.object({
passkeyId: z.string(),
});
type TPasskeyAuthFormSchema = z.infer<typeof ZPasskeyAuthFormSchema>;
export const DocumentActionAuthPasskey = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
}: DocumentActionAuthPasskeyProps) => {
const {
recipient,
passkeyData,
preferredPasskeyId,
setPreferredPasskeyId,
isCurrentlyAuthenticating,
setIsCurrentlyAuthenticating,
refetchPasskeys,
} = useRequiredDocumentAuthContext();
const form = useForm<TPasskeyAuthFormSchema>({
resolver: zodResolver(ZPasskeyAuthFormSchema),
defaultValues: {
passkeyId: preferredPasskeyId || '',
},
});
const { mutateAsync: createPasskeyAuthenticationOptions } =
trpc.auth.createPasskeyAuthenticationOptions.useMutation();
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
const onFormSubmit = async ({ passkeyId }: TPasskeyAuthFormSchema) => {
try {
setPreferredPasskeyId(passkeyId);
setIsCurrentlyAuthenticating(true);
const { options, tokenReference } = await createPasskeyAuthenticationOptions({
preferredPasskeyId: passkeyId,
});
const authenticationResponse = await startAuthentication(options);
await onReauthFormSubmit({
type: DocumentAuth.PASSKEY,
authenticationResponse,
tokenReference,
});
setIsCurrentlyAuthenticating(false);
onOpenChange(false);
} catch (err) {
setIsCurrentlyAuthenticating(false);
if (err.name === 'NotAllowedError') {
return;
}
const error = AppError.parseError(err);
setFormErrorCode(error.code);
// Todo: Alert.
}
};
useEffect(() => {
form.reset({
passkeyId: preferredPasskeyId || '',
});
setFormErrorCode(null);
}, [open, form, preferredPasskeyId]);
if (!browserSupportsWebAuthn()) {
return (
<div className="space-y-4">
<Alert variant="warning">
<AlertDescription>
Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '}
this {actionTarget.toLowerCase()}.
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</div>
);
}
if (passkeyData.isInitialLoading || (passkeyData.isError && passkeyData.passkeys.length === 0)) {
return (
<div className="flex h-28 items-center justify-center">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
if (passkeyData.isError) {
return (
<div className="h-28 space-y-4">
<Alert variant="destructive">
<AlertDescription>Something went wrong while loading your passkeys.</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="button" onClick={() => void refetchPasskeys()}>
Retry
</Button>
</DialogFooter>
</div>
);
}
if (passkeyData.passkeys.length === 0) {
return (
<div className="space-y-4">
<Alert variant="warning">
<AlertDescription>
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
? 'You need to setup a passkey to mark this document as viewed.'
: `You need to setup a passkey to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<CreatePasskeyDialog
onSuccess={async () => refetchPasskeys()}
trigger={<Button>Setup</Button>}
/>
</DialogFooter>
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
<FormField
control={form.control}
name="passkeyId"
render={({ field }) => (
<FormItem>
<FormLabel required>Passkey</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue
data-testid="documentAccessSelectValue"
placeholder="Select passkey"
/>
</SelectTrigger>
<SelectContent position="popper">
{passkeyData.passkeys.map((passkey) => (
<SelectItem key={passkey.id} value={passkey.id}>
{passkey.name}
</SelectItem>
))}
</SelectContent>
</Select>
</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>
);
};

View File

@@ -1,10 +1,10 @@
'use client'; 'use client';
import { createContext, useContext, useMemo, useState } from 'react'; import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import type { import type {
TDocumentAuthOptions, TDocumentAuthOptions,
TRecipientAccessAuthTypes, TRecipientAccessAuthTypes,
@@ -13,11 +13,25 @@ import type {
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { DocumentAuth } from '@documenso/lib/types/document-auth'; import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { type Document, FieldType, type Recipient, type User } from '@documenso/prisma/client'; import {
type Document,
FieldType,
type Passkey,
type Recipient,
type User,
} from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import type { DocumentActionAuthDialogProps } from './document-action-auth-dialog'; import type { DocumentActionAuthDialogProps } from './document-action-auth-dialog';
import { DocumentActionAuthDialog } from './document-action-auth-dialog'; import { DocumentActionAuthDialog } from './document-action-auth-dialog';
type PasskeyData = {
passkeys: Omit<Passkey, 'credentialId' | 'credentialPublicKey'>[];
isInitialLoading: boolean;
isRefetching: boolean;
isError: boolean;
};
export type DocumentAuthContextValue = { export type DocumentAuthContextValue = {
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>; executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
document: Document; document: Document;
@@ -29,7 +43,13 @@ export type DocumentAuthContextValue = {
derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null; derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null;
derivedRecipientActionAuth: TRecipientActionAuthTypes | null; derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
isAuthRedirectRequired: boolean; isAuthRedirectRequired: boolean;
isCurrentlyAuthenticating: boolean;
setIsCurrentlyAuthenticating: (_value: boolean) => void;
passkeyData: PasskeyData;
preferredPasskeyId: string | null;
setPreferredPasskeyId: (_value: string | null) => void;
user?: User | null; user?: User | null;
refetchPasskeys: () => Promise<void>;
}; };
const DocumentAuthContext = createContext<DocumentAuthContextValue | null>(null); const DocumentAuthContext = createContext<DocumentAuthContextValue | null>(null);
@@ -64,6 +84,9 @@ export const DocumentAuthProvider = ({
const [document, setDocument] = useState(initialDocument); const [document, setDocument] = useState(initialDocument);
const [recipient, setRecipient] = useState(initialRecipient); const [recipient, setRecipient] = useState(initialRecipient);
const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
const [preferredPasskeyId, setPreferredPasskeyId] = useState<string | null>(null);
const { const {
documentAuthOption, documentAuthOption,
recipientAuthOption, recipientAuthOption,
@@ -78,6 +101,23 @@ export const DocumentAuthProvider = ({
[document, recipient], [document, recipient],
); );
const passkeyQuery = trpc.auth.findPasskeys.useQuery(
{
perPage: MAXIMUM_PASSKEYS,
},
{
keepPreviousData: true,
enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY,
},
);
const passkeyData: PasskeyData = {
passkeys: passkeyQuery.data?.data || [],
isInitialLoading: passkeyQuery.isInitialLoading,
isRefetching: passkeyQuery.isRefetching,
isError: passkeyQuery.isError,
};
const [documentAuthDialogPayload, setDocumentAuthDialogPayload] = const [documentAuthDialogPayload, setDocumentAuthDialogPayload] =
useState<ExecuteActionAuthProcedureOptions | null>(null); useState<ExecuteActionAuthProcedureOptions | null>(null);
@@ -101,7 +141,7 @@ export const DocumentAuthProvider = ({
.with(DocumentAuth.EXPLICIT_NONE, () => ({ .with(DocumentAuth.EXPLICIT_NONE, () => ({
type: DocumentAuth.EXPLICIT_NONE, type: DocumentAuth.EXPLICIT_NONE,
})) }))
.with(null, () => null) .with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null)
.exhaustive(); .exhaustive();
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
@@ -124,11 +164,27 @@ export const DocumentAuthProvider = ({
}); });
}; };
useEffect(() => {
const { passkeys } = passkeyData;
if (!preferredPasskeyId && passkeys.length > 0) {
setPreferredPasskeyId(passkeys[0].id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [passkeyData.passkeys]);
// Assume that a user must be logged in for any auth requirements.
const isAuthRedirectRequired = Boolean( const isAuthRedirectRequired = Boolean(
DOCUMENT_AUTH_TYPES[derivedRecipientActionAuth || '']?.isAuthRedirectRequired && derivedRecipientActionAuth &&
!preCalculatedActionAuthOptions, derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE &&
user?.email !== recipient.email,
); );
const refetchPasskeys = async () => {
await passkeyQuery.refetch();
};
return ( return (
<DocumentAuthContext.Provider <DocumentAuthContext.Provider
value={{ value={{
@@ -143,6 +199,12 @@ export const DocumentAuthProvider = ({
derivedRecipientAccessAuth, derivedRecipientAccessAuth,
derivedRecipientActionAuth, derivedRecipientActionAuth,
isAuthRedirectRequired, isAuthRedirectRequired,
isCurrentlyAuthenticating,
setIsCurrentlyAuthenticating,
passkeyData,
preferredPasskeyId,
setPreferredPasskeyId,
refetchPasskeys,
}} }}
> >
{children} {children}

View File

@@ -42,10 +42,10 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
const { mutateAsync: completeDocumentWithToken } = const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation(); trpc.recipient.completeDocumentWithToken.useMutation();
const { const { handleSubmit, formState } = useForm();
handleSubmit,
formState: { isSubmitting }, // Keep the loading state going if successful since the redirect may take some time.
} = useForm(); const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
const uninsertedFields = useMemo(() => { const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fields.filter((field) => !field.inserted)); return sortFieldsByPosition(fields.filter((field) => !field.inserted));

View File

@@ -7,9 +7,11 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter, DialogFooter,
DialogTitle,
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { SigningDisclosure } from '~/components/general/signing-disclosure';
import { truncateTitle } from '~/helpers/truncate-title'; import { truncateTitle } from '~/helpers/truncate-title';
export type SignDialogProps = { export type SignDialogProps = {
@@ -66,23 +68,39 @@ export const SignDialog = ({
{isComplete ? 'Complete' : 'Next field'} {isComplete ? 'Complete' : 'Next field'}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<div className="text-center"> <DialogTitle>
<div className="text-foreground text-xl font-semibold"> <div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && 'Mark Document as Viewed'} {role === RecipientRole.VIEWER && 'Complete Viewing'}
{role === RecipientRole.SIGNER && 'Sign Document'} {role === RecipientRole.SIGNER && 'Complete Signing'}
{role === RecipientRole.APPROVER && 'Approve Document'} {role === RecipientRole.APPROVER && 'Complete Approval'}
</div>
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
{role === RecipientRole.VIEWER &&
`You are about to finish viewing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.SIGNER &&
`You are about to finish signing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.APPROVER &&
`You are about to finish approving "${truncatedTitle}". Are you sure?`}
</div> </div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{role === RecipientRole.VIEWER && (
<span>
You are about to complete viewing "{truncatedTitle}".
<br /> Are you sure?
</span>
)}
{role === RecipientRole.SIGNER && (
<span>
You are about to complete signing "{truncatedTitle}".
<br /> Are you sure?
</span>
)}
{role === RecipientRole.APPROVER && (
<span>
You are about to complete approving "{truncatedTitle}".
<br /> Are you sure?
</span>
)}
</div> </div>
<SigningDisclosure className="mt-4" />
<DialogFooter> <DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4"> <div className="flex w-full flex-1 flex-nowrap gap-4">
<Button <Button

View File

@@ -18,6 +18,8 @@ import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { SigningDisclosure } from '~/components/general/signing-disclosure';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider'; import { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
@@ -200,6 +202,8 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
/> />
</div> </div>
<SigningDisclosure />
<DialogFooter> <DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4"> <div className="flex w-full flex-1 flex-nowrap gap-4">
<Button <Button

View File

@@ -0,0 +1,108 @@
import Link from 'next/link';
import { Button } from '@documenso/ui/primitives/button';
export default function SignatureDisclosure() {
return (
<div>
<article className="prose">
<h1>Electronic Signature Disclosure</h1>
<h2>Welcome</h2>
<p>
Thank you for using Documenso to perform your electronic document signing. The purpose of
this disclosure is to inform you about the process, legality, and your rights regarding
the use of electronic signatures on our platform. By opting to use an electronic
signature, you are agreeing to the terms and conditions outlined below.
</p>
<h2>Acceptance and Consent</h2>
<p>
When you use our platform to affix your electronic signature to documents, you are
consenting to do so under the Electronic Signatures in Global and National Commerce Act
(E-Sign Act) and other applicable laws. This action indicates your agreement to use
electronic means to sign documents and receive notifications.
</p>
<h2>Legality of Electronic Signatures</h2>
<p>
An electronic signature provided by you on our platform, achieved through clicking through
to a document and entering your name, or any other electronic signing method we provide,
is legally binding. It carries the same weight and enforceability as a manual signature
written with ink on paper.
</p>
<h2>System Requirements</h2>
<p>To use our electronic signature service, you must have access to:</p>
<ul>
<li>A stable internet connection</li>
<li>An email account</li>
<li>A device capable of accessing, opening, and reading documents</li>
<li>A means to print or download documents for your records</li>
</ul>
<h2>Electronic Delivery of Documents</h2>
<p>
All documents related to the electronic signing process will be provided to you
electronically through our platform or via email. It is your responsibility to ensure that
your email address is current and that you can receive and open our emails.
</p>
<h2>Consent to Electronic Transactions</h2>
<p>
By using the electronic signature feature, you are consenting to conduct transactions and
receive disclosures electronically. You acknowledge that your electronic signature on
documents is binding and that you accept the terms outlined in the documents you are
signing.
</p>
<h2>Withdrawing Consent</h2>
<p>
You have the right to withdraw your consent to use electronic signatures at any time
before completing the signing process. To withdraw your consent, please contact the sender
of the document. In failing to contact the sender you may reach out to{' '}
<a href="mailto:support@documenso.com">support@documenso.com</a> for assistance. Be aware
that withdrawing consent may delay or halt the completion of the related transaction or
service.
</p>
<h2>Updating Your Information</h2>
<p>
It is crucial to keep your contact information, especially your email address, up to date
with us. Please notify us immediately of any changes to ensure that you continue to
receive all necessary communications.
</p>
<h2>Retention of Documents</h2>
<p>
After signing a document electronically, you will be provided the opportunity to view,
download, and print the document for your records. It is highly recommended that you
retain a copy of all electronically signed documents for your personal records. We will
also retain a copy of the signed document for our records however we may not be able to
provide you with a copy of the signed document after a certain period of time.
</p>
<h2>Acknowledgment</h2>
<p>
By proceeding to use the electronic signature service provided by Documenso, you affirm
that you have read and understood this disclosure. You agree to all terms and conditions
related to the use of electronic signatures and electronic transactions as outlined
herein.
</p>
<h2>Contact Information</h2>
<p>
For any questions regarding this disclosure, electronic signatures, or any related
process, please contact us at:{' '}
<a href="mailto:support@documenso.com">support@documenso.com</a>
</p>
</article>
<div className="mt-8">
<Button asChild>
<Link href="/documents">Back to Documents</Link>
</Button>
</div>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { StackAvatar } from './stack-avatar'; import { StackAvatar } from './stack-avatar';

View File

@@ -1,4 +1,4 @@
import { SVGAttributes } from 'react'; import type { SVGAttributes } from 'react';
export type LogoProps = SVGAttributes<SVGSVGElement>; export type LogoProps = SVGAttributes<SVGSVGElement>;

View File

@@ -1,9 +1,9 @@
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { Globe, Lock } from 'lucide-react'; import { Globe, Lock } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client'; import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
type TemplateTypeIcon = { type TemplateTypeIcon = {

View File

@@ -41,8 +41,13 @@ export const ZEnable2FAForm = z.object({
export type TEnable2FAForm = z.infer<typeof ZEnable2FAForm>; export type TEnable2FAForm = z.infer<typeof ZEnable2FAForm>;
export const EnableAuthenticatorAppDialog = () => { export type EnableAuthenticatorAppDialogProps = {
onSuccess?: () => void;
};
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => {
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -79,6 +84,7 @@ export const EnableAuthenticatorAppDialog = () => {
const data = await enable2FA({ code: token }); const data = await enable2FA({ code: token });
setRecoveryCodes(data.recoveryCodes); setRecoveryCodes(data.recoveryCodes);
onSuccess?.();
toast({ toast({
title: 'Two-factor authentication enabled', title: 'Two-factor authentication enabled',
@@ -89,7 +95,7 @@ export const EnableAuthenticatorAppDialog = () => {
toast({ toast({
title: 'Unable to setup two-factor authentication', title: 'Unable to setup two-factor authentication',
description: description:
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.', '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', variant: 'destructive',
}); });
} }

View File

@@ -47,12 +47,9 @@ export const ViewRecoveryCodesDialog = () => {
data: recoveryCodes, data: recoveryCodes,
mutate, mutate,
isLoading, isLoading,
isError,
error, error,
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); } = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
// error?.data?.code
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({ const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
defaultValues: { defaultValues: {
token: '', token: '',

View File

@@ -55,11 +55,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
}); });
const isSubmitting = form.formState.isSubmitting; const isSubmitting = form.formState.isSubmitting;
const hasTwoFactorAuthentication = user.twoFactorEnabled;
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
trpc.profile.deleteAccount.useMutation();
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => { const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
try { try {

View File

@@ -124,7 +124,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
}; };
const onSignInWithPasskey = async () => { const onSignInWithPasskey = async () => {
if (!browserSupportsWebAuthn) { if (!browserSupportsWebAuthn()) {
toast({ toast({
title: 'Not supported', title: 'Not supported',
description: 'Passkeys are not supported on this browser', description: 'Passkeys are not supported on this browser',

View File

@@ -0,0 +1,29 @@
import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { cn } from '@documenso/ui/lib/utils';
export type SigningDisclosureProps = HTMLAttributes<HTMLParagraphElement>;
export const SigningDisclosure = ({ className, ...props }: SigningDisclosureProps) => {
return (
<p className={cn('text-muted-foreground text-xs', className)} {...props}>
By proceeding with your electronic signature, you acknowledge and consent that it will be used
to sign the given document and holds the same legal validity as a handwritten signature. By
completing the electronic signing process, you affirm your understanding and acceptance of
these conditions.
<span className="mt-2 block">
Read the full{' '}
<Link
className="text-documenso-700 underline"
href="/articles/signature-disclosure"
target="_blank"
>
signature disclosure
</Link>
.
</span>
</p>
);
};

View File

@@ -1,4 +1,4 @@
import { SVGAttributes } from 'react'; import type { SVGAttributes } from 'react';
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>; export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;

View File

@@ -3,7 +3,7 @@
import * as React from 'react'; import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes'; import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { ThemeProviderProps } from 'next-themes/dist/types'; import type { ThemeProviderProps } from 'next-themes/dist/types';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) { export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>; return <NextThemesProvider {...props}>{children}</NextThemesProvider>;

View File

@@ -191,7 +191,7 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth'
await page.locator(`#field-${field.id}`).getByRole('button').click(); await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText( await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign the field', 'Reauthentication is required to sign this field',
); );
await page.getByRole('button', { name: 'Cancel' }).click(); await page.getByRole('button', { name: 'Cancel' }).click();
} }
@@ -260,7 +260,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
await page.locator(`#field-${field.id}`).getByRole('button').click(); await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText( await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign the field', 'Reauthentication is required to sign this field',
); );
await page.getByRole('button', { name: 'Cancel' }).click(); await page.getByRole('button', { name: 'Cancel' }).click();
} }
@@ -371,7 +371,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
await page.locator(`#field-${field.id}`).getByRole('button').click(); await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText( await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign the field', 'Reauthentication is required to sign this field',
); );
await page.getByRole('button', { name: 'Cancel' }).click(); await page.getByRole('button', { name: 'Cancel' }).click();
} }

View File

@@ -1,13 +1,25 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import path from 'node:path'; import path from 'node:path';
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email'; import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from './fixtures/authentication'; import { apiSignin } from './fixtures/authentication';
const getDocumentByToken = async (token: string) => {
return await prisma.document.findFirstOrThrow({
where: {
Recipient: {
some: {
token,
},
},
},
});
};
test(`[PR-718]: should be able to create a document`, async ({ page }) => { test(`[PR-718]: should be able to create a document`, async ({ page }) => {
await page.goto('/signin'); await page.goto('/signin');
@@ -246,7 +258,7 @@ test('should be able to create, send and sign a document', async ({ page }) => {
await page.waitForURL(`/sign/${token}`); await page.waitForURL(`/sign/${token}`);
// Check if document has been viewed // Check if document has been viewed
const { status } = await getDocumentByToken({ token }); const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING); expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Complete' }).click();
@@ -257,7 +269,7 @@ test('should be able to create, send and sign a document', async ({ page }) => {
await expect(page.getByText('You have signed')).toBeVisible(); await expect(page.getByText('You have signed')).toBeVisible();
// Check if document has been signed // Check if document has been signed
const { status: completedStatus } = await getDocumentByToken({ token }); const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED); expect(completedStatus).toBe(DocumentStatus.COMPLETED);
}); });
@@ -331,7 +343,7 @@ test('should be able to create, send with redirect url, sign a document and redi
await page.waitForURL(`/sign/${token}`); await page.waitForURL(`/sign/${token}`);
// Check if document has been viewed // Check if document has been viewed
const { status } = await getDocumentByToken({ token }); const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING); expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Complete' }).click();
@@ -341,6 +353,6 @@ test('should be able to create, send with redirect url, sign a document and redi
await page.waitForURL('https://documenso.com'); await page.waitForURL('https://documenso.com');
// Check if document has been signed // Check if document has been signed
const { status: completedStatus } = await getDocumentByToken({ token }); const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED); expect(completedStatus).toBe(DocumentStatus.COMPLETED);
}); });

View File

@@ -4,26 +4,21 @@ import { DocumentAuth } from '../types/document-auth';
type DocumentAuthTypeData = { type DocumentAuthTypeData = {
key: TDocumentAuth; key: TDocumentAuth;
value: string; value: string;
/**
* Whether this authentication event will require the user to halt and
* redirect.
*
* Defaults to false.
*/
isAuthRedirectRequired?: boolean;
}; };
export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = { export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
[DocumentAuth.ACCOUNT]: { [DocumentAuth.ACCOUNT]: {
key: DocumentAuth.ACCOUNT, key: DocumentAuth.ACCOUNT,
value: 'Require account', value: 'Require account',
isAuthRedirectRequired: true,
}, },
// [DocumentAuthType.PASSKEY]: { [DocumentAuth.PASSKEY]: {
// key: DocumentAuthType.PASSKEY, key: DocumentAuth.PASSKEY,
// value: 'Require passkey', value: 'Require passkey',
// }, },
[DocumentAuth.TWO_FACTOR_AUTH]: {
key: DocumentAuth.TWO_FACTOR_AUTH,
value: 'Require 2FA',
},
[DocumentAuth.EXPLICIT_NONE]: { [DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE, key: DocumentAuth.EXPLICIT_NONE,
value: 'None (Overrides global settings)', value: 'None (Overrides global settings)',

View File

@@ -22,7 +22,7 @@ import { sendConfirmationToken } from '../server-only/user/send-confirmation-tok
import type { TAuthenticationResponseJSONSchema } from '../types/webauthn'; import type { TAuthenticationResponseJSONSchema } from '../types/webauthn';
import { ZAuthenticationResponseJSONSchema } from '../types/webauthn'; import { ZAuthenticationResponseJSONSchema } from '../types/webauthn';
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata'; import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
import { getAuthenticatorRegistrationOptions } from '../utils/authenticator'; import { getAuthenticatorOptions } from '../utils/authenticator';
import { ErrorCode } from './error-codes'; import { ErrorCode } from './error-codes';
export const NEXT_AUTH_OPTIONS: AuthOptions = { export const NEXT_AUTH_OPTIONS: AuthOptions = {
@@ -196,7 +196,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
const user = passkey.User; const user = passkey.User;
const { rpId, origin } = getAuthenticatorRegistrationOptions(); const { rpId, origin } = getAuthenticatorOptions();
const verification = await verifyAuthenticationResponse({ const verification = await verifyAuthenticationResponse({
response: requestBodyCrediential, response: requestBodyCrediential,

View File

@@ -0,0 +1,76 @@
import { generateAuthenticationOptions } from '@simplewebauthn/server';
import type { AuthenticatorTransportFuture } from '@simplewebauthn/types';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import type { Passkey } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeyAuthenticationOptions = {
userId: number;
/**
* The ID of the passkey to request authentication for.
*
* If not set, we allow the browser client to handle choosing.
*/
preferredPasskeyId?: string;
};
export const createPasskeyAuthenticationOptions = async ({
userId,
preferredPasskeyId,
}: CreatePasskeyAuthenticationOptions) => {
const { rpId, timeout } = getAuthenticatorOptions();
let preferredPasskey: Pick<Passkey, 'credentialId' | 'transports'> | null = null;
if (preferredPasskeyId) {
preferredPasskey = await prisma.passkey.findFirst({
where: {
userId,
id: preferredPasskeyId,
},
select: {
credentialId: true,
transports: true,
},
});
if (!preferredPasskey) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Requested passkey not found');
}
}
const options = await generateAuthenticationOptions({
rpID: rpId,
userVerification: 'preferred',
timeout,
allowCredentials: preferredPasskey
? [
{
id: preferredPasskey.credentialId,
type: 'public-key',
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
transports: preferredPasskey.transports as AuthenticatorTransportFuture[],
},
]
: undefined,
});
const { secondaryId } = await prisma.verificationToken.create({
data: {
userId,
token: options.challenge,
expires: DateTime.now().plus({ minutes: 2 }).toJSDate(),
identifier: 'PASSKEY_CHALLENGE',
},
});
return {
tokenReference: secondaryId,
options,
};
};

View File

@@ -5,7 +5,7 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { PASSKEY_TIMEOUT } from '../../constants/auth'; import { PASSKEY_TIMEOUT } from '../../constants/auth';
import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeyRegistrationOptions = { type CreatePasskeyRegistrationOptions = {
userId: number; userId: number;
@@ -27,7 +27,7 @@ export const createPasskeyRegistrationOptions = async ({
const { passkeys } = user; const { passkeys } = user;
const { rpName, rpId: rpID } = getAuthenticatorRegistrationOptions(); const { rpName, rpId: rpID } = getAuthenticatorOptions();
const options = await generateRegistrationOptions({ const options = await generateRegistrationOptions({
rpName, rpName,

View File

@@ -3,14 +3,14 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeySigninOptions = { type CreatePasskeySigninOptions = {
sessionId: string; sessionId: string;
}; };
export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => { export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => {
const { rpId, timeout } = getAuthenticatorRegistrationOptions(); const { rpId, timeout } = getAuthenticatorOptions();
const options = await generateAuthenticationOptions({ const options = await generateAuthenticationOptions({
rpID: rpId, rpID: rpId,

View File

@@ -7,7 +7,7 @@ import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { MAXIMUM_PASSKEYS } from '../../constants/auth'; import { MAXIMUM_PASSKEYS } from '../../constants/auth';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; import { getAuthenticatorOptions } from '../../utils/authenticator';
type CreatePasskeyOptions = { type CreatePasskeyOptions = {
userId: number; userId: number;
@@ -64,7 +64,7 @@ export const createPasskey = async ({
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired'); throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired');
} }
const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorRegistrationOptions(); const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorOptions();
const verification = await verifyRegistrationResponse({ const verification = await verifyRegistrationResponse({
response: verificationResponse, response: verificationResponse,

View File

@@ -11,6 +11,7 @@ export interface FindPasskeysOptions {
orderBy?: { orderBy?: {
column: keyof Passkey; column: keyof Passkey;
direction: 'asc' | 'desc'; direction: 'asc' | 'desc';
nulls?: Prisma.NullsOrder;
}; };
} }
@@ -21,8 +22,9 @@ export const findPasskeys = async ({
perPage = 10, perPage = 10,
orderBy, orderBy,
}: FindPasskeysOptions) => { }: FindPasskeysOptions) => {
const orderByColumn = orderBy?.column ?? 'name'; const orderByColumn = orderBy?.column ?? 'lastUsedAt';
const orderByDirection = orderBy?.direction ?? 'desc'; const orderByDirection = orderBy?.direction ?? 'desc';
const orderByNulls: Prisma.NullsOrder | undefined = orderBy?.nulls ?? 'last';
const whereClause: Prisma.PasskeyWhereInput = { const whereClause: Prisma.PasskeyWhereInput = {
userId, userId,
@@ -41,7 +43,10 @@ export const findPasskeys = async ({
skip: Math.max(page - 1, 0) * perPage, skip: Math.max(page - 1, 0) * perPage,
take: perPage, take: perPage,
orderBy: { orderBy: {
[orderByColumn]: orderByDirection, [orderByColumn]: {
sort: orderByDirection,
nulls: orderByNulls,
},
}, },
select: { select: {
id: true, id: true,

View File

@@ -1,10 +1,15 @@
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Document, Recipient } from '@documenso/prisma/client'; import type { Document, Recipient } from '@documenso/prisma/client';
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth'; import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth'; import { DocumentAuth } from '../../types/document-auth';
import type { TAuthenticationResponseJSONSchema } from '../../types/webauthn';
import { getAuthenticatorOptions } from '../../utils/authenticator';
import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { extractDocumentAuthMethods } from '../../utils/document-auth';
type IsRecipientAuthorizedOptions = { type IsRecipientAuthorizedOptions = {
@@ -63,17 +68,20 @@ export const isRecipientAuthorized = async ({
return true; return true;
} }
// Create auth options when none are passed for account.
if (!authOptions && authMethod === DocumentAuth.ACCOUNT) {
authOptions = {
type: DocumentAuth.ACCOUNT,
};
}
// Authentication required does not match provided method. // Authentication required does not match provided method.
if (authOptions && authOptions.type !== authMethod) { if (!authOptions || authOptions.type !== authMethod || !userId) {
return false; return false;
} }
return await match(authMethod) return await match(authOptions)
.with(DocumentAuth.ACCOUNT, async () => { .with({ type: DocumentAuth.ACCOUNT }, async () => {
if (userId === undefined) {
return false;
}
const recipientUser = await getUserByEmail(recipient.email); const recipientUser = await getUserByEmail(recipient.email);
if (!recipientUser) { if (!recipientUser) {
@@ -82,5 +90,124 @@ export const isRecipientAuthorized = async ({
return recipientUser.id === userId; return recipientUser.id === userId;
}) })
.with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
return await isPasskeyAuthValid({
userId,
authenticationResponse,
tokenReference,
});
})
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token }) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
});
// Should not be possible.
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
}
return await verifyTwoFactorAuthenticationToken({
user,
totpCode: token,
});
})
.exhaustive(); .exhaustive();
}; };
type VerifyPasskeyOptions = {
/**
* The ID of the user who initiated the request.
*/
userId: number;
/**
* The secondary ID of the verification token.
*/
tokenReference: string;
/**
* The response from the passkey authenticator.
*/
authenticationResponse: TAuthenticationResponseJSONSchema;
};
/**
* Whether the provided passkey authenticator response is valid and the user is
* authenticated.
*/
const isPasskeyAuthValid = async (options: VerifyPasskeyOptions): Promise<boolean> => {
return verifyPasskey(options)
.then(() => true)
.catch(() => false);
};
/**
* Verifies whether the provided passkey authenticator is valid and the user is
* authenticated.
*
* Will throw an error if the user should not be authenticated.
*/
const verifyPasskey = async ({
userId,
tokenReference,
authenticationResponse,
}: VerifyPasskeyOptions): Promise<void> => {
const passkey = await prisma.passkey.findFirst({
where: {
credentialId: Buffer.from(authenticationResponse.id, 'base64'),
userId,
},
});
if (!passkey) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found');
}
const verificationToken = await prisma.verificationToken
.delete({
where: {
userId,
secondaryId: tokenReference,
},
})
.catch(() => null);
if (!verificationToken) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Token not found');
}
if (verificationToken.expires < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Token expired');
}
const { rpId, origin } = getAuthenticatorOptions();
const verification = await verifyAuthenticationResponse({
response: authenticationResponse,
expectedChallenge: verificationToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator: {
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
},
}).catch(() => null); // May want to log this for insights.
if (verification?.verified !== true) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'User is not authorized');
}
await prisma.passkey.update({
where: {
id: passkey.id,
},
data: {
lastUsedAt: new Date(),
counter: verification.authenticationInfo.newCounter,
},
});
};

View File

@@ -1,9 +1,16 @@
import { z } from 'zod'; import { z } from 'zod';
import { ZAuthenticationResponseJSONSchema } from './webauthn';
/** /**
* All the available types of document authentication options for both access and action. * All the available types of document authentication options for both access and action.
*/ */
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'EXPLICIT_NONE']); export const ZDocumentAuthTypesSchema = z.enum([
'ACCOUNT',
'PASSKEY',
'TWO_FACTOR_AUTH',
'EXPLICIT_NONE',
]);
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum; export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
const ZDocumentAuthAccountSchema = z.object({ const ZDocumentAuthAccountSchema = z.object({
@@ -14,12 +21,25 @@ const ZDocumentAuthExplicitNoneSchema = z.object({
type: z.literal(DocumentAuth.EXPLICIT_NONE), type: z.literal(DocumentAuth.EXPLICIT_NONE),
}); });
const ZDocumentAuthPasskeySchema = z.object({
type: z.literal(DocumentAuth.PASSKEY),
authenticationResponse: ZAuthenticationResponseJSONSchema,
tokenReference: z.string().min(1),
});
const ZDocumentAuth2FASchema = z.object({
type: z.literal(DocumentAuth.TWO_FACTOR_AUTH),
token: z.string().min(4).max(10),
});
/** /**
* All the document auth methods for both accessing and actioning. * All the document auth methods for both accessing and actioning.
*/ */
export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema, ZDocumentAuthAccountSchema,
ZDocumentAuthExplicitNoneSchema, ZDocumentAuthExplicitNoneSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
]); ]);
/** /**
@@ -35,8 +55,16 @@ export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
* *
* Must keep these two in sync. * Must keep these two in sync.
*/ */
export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]); // Todo: Add passkeys here. export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
]);
export const ZDocumentActionAuthTypesSchema = z.enum([
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
]);
/** /**
* The recipient access auth methods. * The recipient access auth methods.
@@ -54,11 +82,15 @@ export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
* Must keep these two in sync. * Must keep these two in sync.
*/ */
export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema, // Todo: Add passkeys here. ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthExplicitNoneSchema, ZDocumentAuthExplicitNoneSchema,
]); ]);
export const ZRecipientActionAuthTypesSchema = z.enum([ export const ZRecipientActionAuthTypesSchema = z.enum([
DocumentAuth.ACCOUNT, DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXPLICIT_NONE, DocumentAuth.EXPLICIT_NONE,
]); ]);

View File

@@ -4,7 +4,7 @@ import { PASSKEY_TIMEOUT } from '../constants/auth';
/** /**
* Extracts common fields to identify the RP (relying party) * Extracts common fields to identify the RP (relying party)
*/ */
export const getAuthenticatorRegistrationOptions = () => { export const getAuthenticatorOptions = () => {
const webAppBaseUrl = new URL(WEBAPP_BASE_URL); const webAppBaseUrl = new URL(WEBAPP_BASE_URL);
const rpId = webAppBaseUrl.hostname; const rpId = webAppBaseUrl.hostname;

View File

@@ -0,0 +1,18 @@
/*
Warnings:
- A unique constraint covering the columns `[secondaryId]` on the table `VerificationToken` will be added. If there are existing duplicate values, this will fail.
- The required column `secondaryId` was added to the `VerificationToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- AlterTable
ALTER TABLE "VerificationToken" ADD COLUMN "secondaryId" TEXT;
-- Set all null secondaryId fields to a uuid
UPDATE "VerificationToken" SET "secondaryId" = gen_random_uuid()::text WHERE "secondaryId" IS NULL;
-- Restrict the VerificationToken to required
ALTER TABLE "VerificationToken" ALTER COLUMN "secondaryId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_secondaryId_key" ON "VerificationToken"("secondaryId");

View File

@@ -126,13 +126,14 @@ model AnonymousVerificationToken {
} }
model VerificationToken { model VerificationToken {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
identifier String secondaryId String @unique @default(cuid())
token String @unique identifier String
expires DateTime token String @unique
createdAt DateTime @default(now()) expires DateTime
userId Int createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
enum WebhookTriggerEvents { enum WebhookTriggerEvents {

View File

@@ -7,6 +7,7 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey'; import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options'; import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options';
import { createPasskeySigninOptions } from '@documenso/lib/server-only/auth/create-passkey-signin-options'; import { createPasskeySigninOptions } from '@documenso/lib/server-only/auth/create-passkey-signin-options';
import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey'; import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey';
@@ -19,6 +20,7 @@ import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-
import { authenticatedProcedure, procedure, router } from '../trpc'; import { authenticatedProcedure, procedure, router } from '../trpc';
import { import {
ZCreatePasskeyAuthenticationOptionsMutationSchema,
ZCreatePasskeyMutationSchema, ZCreatePasskeyMutationSchema,
ZDeletePasskeyMutationSchema, ZDeletePasskeyMutationSchema,
ZFindPasskeysQuerySchema, ZFindPasskeysQuerySchema,
@@ -115,6 +117,25 @@ export const authRouter = router({
} }
}), }),
createPasskeyAuthenticationOptions: authenticatedProcedure
.input(ZCreatePasskeyAuthenticationOptionsMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
return await createPasskeyAuthenticationOptions({
userId: ctx.user.id,
preferredPasskeyId: input?.preferredPasskeyId,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'We were unable to create the authentication options for the passkey. Please try again later.',
});
}
}),
createPasskeyRegistrationOptions: authenticatedProcedure.mutation(async ({ ctx }) => { createPasskeyRegistrationOptions: authenticatedProcedure.mutation(async ({ ctx }) => {
try { try {
return await createPasskeyRegistrationOptions({ return await createPasskeyRegistrationOptions({

View File

@@ -40,6 +40,12 @@ export const ZCreatePasskeyMutationSchema = z.object({
verificationResponse: ZRegistrationResponseJSONSchema, verificationResponse: ZRegistrationResponseJSONSchema,
}); });
export const ZCreatePasskeyAuthenticationOptionsMutationSchema = z
.object({
preferredPasskeyId: z.string().optional(),
})
.optional();
export const ZDeletePasskeyMutationSchema = z.object({ export const ZDeletePasskeyMutationSchema = z.object({
passkeyId: z.string().trim().min(1), passkeyId: z.string().trim().min(1),
}); });

View File

@@ -219,6 +219,10 @@ export const AddSettingsFormPartial = ({
<li> <li>
<strong>Require account</strong> - The recipient must be signed in <strong>Require account</strong> - The recipient must be signed in
</li> </li>
<li>
<strong>Require passkey</strong> - The recipient must have an account
and passkey configured via their settings
</li>
<li> <li>
<strong>None</strong> - No authentication required <strong>None</strong> - No authentication required
</li> </li>

View File

@@ -287,6 +287,10 @@ export const AddSignersFormPartial = ({
<strong>Require account</strong> - The recipient must be <strong>Require account</strong> - The recipient must be
signed in signed in
</li> </li>
<li>
<strong>Require passkey</strong> - The recipient must have
an account and passkey configured via their settings
</li>
<li> <li>
<strong>None</strong> - No authentication required <strong>None</strong> - No authentication required
</li> </li>