mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 08:42:12 +10:00
feat: add document auth passkey
This commit is contained in:
@ -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') {
|
||||||
|
|||||||
@ -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>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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,16 @@ 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 { 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 +23,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 +32,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 +57,25 @@ 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)
|
|
||||||
.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(() => (
|
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
|
||||||
<>
|
.exhaustive()}
|
||||||
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,255 @@
|
|||||||
|
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.isRefetching && 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>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,9 +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 { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||||
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||||
import type {
|
import type {
|
||||||
TDocumentAuthOptions,
|
TDocumentAuthOptions,
|
||||||
@ -13,11 +14,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 +44,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 +85,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 +102,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 +142,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, null, () => null)
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
|
|
||||||
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
|
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
|
||||||
@ -124,11 +165,25 @@ 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]);
|
||||||
|
|
||||||
const isAuthRedirectRequired = Boolean(
|
const isAuthRedirectRequired = Boolean(
|
||||||
DOCUMENT_AUTH_TYPES[derivedRecipientActionAuth || '']?.isAuthRedirectRequired &&
|
DOCUMENT_AUTH_TYPES[derivedRecipientActionAuth || '']?.isAuthRedirectRequired &&
|
||||||
!preCalculatedActionAuthOptions,
|
!preCalculatedActionAuthOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const refetchPasskeys = async () => {
|
||||||
|
await passkeyQuery.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentAuthContext.Provider
|
<DocumentAuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@ -143,6 +198,12 @@ export const DocumentAuthProvider = ({
|
|||||||
derivedRecipientAccessAuth,
|
derivedRecipientAccessAuth,
|
||||||
derivedRecipientActionAuth,
|
derivedRecipientActionAuth,
|
||||||
isAuthRedirectRequired,
|
isAuthRedirectRequired,
|
||||||
|
isCurrentlyAuthenticating,
|
||||||
|
setIsCurrentlyAuthenticating,
|
||||||
|
passkeyData,
|
||||||
|
preferredPasskeyId,
|
||||||
|
setPreferredPasskeyId,
|
||||||
|
refetchPasskeys,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,10 +20,10 @@ export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
|
|||||||
value: 'Require account',
|
value: 'Require account',
|
||||||
isAuthRedirectRequired: true,
|
isAuthRedirectRequired: true,
|
||||||
},
|
},
|
||||||
// [DocumentAuthType.PASSKEY]: {
|
[DocumentAuth.PASSKEY]: {
|
||||||
// key: DocumentAuthType.PASSKEY,
|
key: DocumentAuth.PASSKEY,
|
||||||
// value: 'Require passkey',
|
value: 'Require passkey',
|
||||||
// },
|
},
|
||||||
[DocumentAuth.EXPLICIT_NONE]: {
|
[DocumentAuth.EXPLICIT_NONE]: {
|
||||||
key: DocumentAuth.EXPLICIT_NONE,
|
key: DocumentAuth.EXPLICIT_NONE,
|
||||||
value: 'None (Overrides global settings)',
|
value: 'None (Overrides global settings)',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
|
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 { 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 +67,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 await match(authMethod)
|
|
||||||
.with(DocumentAuth.ACCOUNT, async () => {
|
|
||||||
if (userId === undefined) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await match(authOptions)
|
||||||
|
.with({ type: DocumentAuth.ACCOUNT }, async () => {
|
||||||
const recipientUser = await getUserByEmail(recipient.email);
|
const recipientUser = await getUserByEmail(recipient.email);
|
||||||
|
|
||||||
if (!recipientUser) {
|
if (!recipientUser) {
|
||||||
@ -82,5 +89,107 @@ 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,
|
||||||
|
});
|
||||||
|
})
|
||||||
.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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
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', 'EXPLICIT_NONE']);
|
||||||
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
|
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
|
||||||
|
|
||||||
const ZDocumentAuthAccountSchema = z.object({
|
const ZDocumentAuthAccountSchema = z.object({
|
||||||
@ -14,12 +16,19 @@ 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),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -35,8 +44,11 @@ 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,
|
||||||
|
]);
|
||||||
|
export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The recipient access auth methods.
|
* The recipient access auth methods.
|
||||||
@ -54,11 +66,13 @@ 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,
|
||||||
ZDocumentAuthExplicitNoneSchema,
|
ZDocumentAuthExplicitNoneSchema,
|
||||||
]);
|
]);
|
||||||
export const ZRecipientActionAuthTypesSchema = z.enum([
|
export const ZRecipientActionAuthTypesSchema = z.enum([
|
||||||
DocumentAuth.ACCOUNT,
|
DocumentAuth.ACCOUNT,
|
||||||
|
DocumentAuth.PASSKEY,
|
||||||
DocumentAuth.EXPLICIT_NONE,
|
DocumentAuth.EXPLICIT_NONE,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
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 NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "VerificationToken_secondaryId_key" ON "VerificationToken"("secondaryId");
|
||||||
@ -127,6 +127,7 @@ model AnonymousVerificationToken {
|
|||||||
|
|
||||||
model VerificationToken {
|
model VerificationToken {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
secondaryId String @unique @default(cuid())
|
||||||
identifier String
|
identifier String
|
||||||
token String @unique
|
token String @unique
|
||||||
expires DateTime
|
expires DateTime
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user