mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
16 Commits
1650c55b19
...
feat/docum
| Author | SHA1 | Date | |
|---|---|---|---|
| fd881572f8 | |||
| 3282481ad7 | |||
| 1ed18059fb | |||
| d45bed6930 | |||
| 87b79451d5 | |||
| e4ad940a06 | |||
| cb020cc7d0 | |||
| 5033799724 | |||
| b22de4bd71 | |||
| aa926d6642 | |||
| a802f0bceb | |||
| 034318e571 | |||
| 75319f20cb | |||
| b348e3c952 | |||
| 280a258529 | |||
| 8d7541aa7a |
@ -161,6 +161,7 @@ export const SinglePlayerClient = () => {
|
|||||||
signingStatus: 'NOT_SIGNED',
|
signingStatus: 'NOT_SIGNED',
|
||||||
sendStatus: 'NOT_SENT',
|
sendStatus: 'NOT_SENT',
|
||||||
role: 'SIGNER',
|
role: 'SIGNER',
|
||||||
|
authOptions: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
|
|||||||
@ -22,6 +22,8 @@
|
|||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
"@tanstack/react-query": "^4.29.5",
|
"@tanstack/react-query": "^4.29.5",
|
||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
@ -51,6 +53,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
|
"@simplewebauthn/types": "^9.0.1",
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
@ -67,4 +70,4 @@
|
|||||||
"next": "$next"
|
"next": "$next"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7,7 +7,6 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import {
|
import {
|
||||||
type DocumentData,
|
type DocumentData,
|
||||||
type DocumentMeta,
|
type DocumentMeta,
|
||||||
DocumentStatus,
|
|
||||||
type Field,
|
type Field,
|
||||||
type Recipient,
|
type Recipient,
|
||||||
type User,
|
type User,
|
||||||
@ -18,12 +17,12 @@ import { cn } from '@documenso/ui/lib/utils';
|
|||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||||
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
|
import { AddSettingsFormPartial } from '@documenso/ui/primitives/document-flow/add-settings';
|
||||||
|
import type { TAddSettingsFormSchema } from '@documenso/ui/primitives/document-flow/add-settings.types';
|
||||||
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
||||||
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||||
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
||||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||||
import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title';
|
|
||||||
import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types';
|
|
||||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
@ -43,8 +42,8 @@ export type EditDocumentFormProps = {
|
|||||||
documentRootPath: string;
|
documentRootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject';
|
||||||
const EditDocumentSteps: EditDocumentStep[] = ['title', 'signers', 'fields', 'subject'];
|
const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields', 'subject'];
|
||||||
|
|
||||||
export const EditDocumentForm = ({
|
export const EditDocumentForm = ({
|
||||||
className,
|
className,
|
||||||
@ -62,7 +61,8 @@ export const EditDocumentForm = ({
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
|
const { mutateAsync: setSettingsForDocument } =
|
||||||
|
trpc.document.setSettingsForDocument.useMutation();
|
||||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
||||||
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
|
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
|
||||||
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
|
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
|
||||||
@ -70,9 +70,9 @@ export const EditDocumentForm = ({
|
|||||||
trpc.document.setPasswordForDocument.useMutation();
|
trpc.document.setPasswordForDocument.useMutation();
|
||||||
|
|
||||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
title: {
|
settings: {
|
||||||
title: 'Add Title',
|
title: 'General',
|
||||||
description: 'Add the title to the document.',
|
description: 'Configure general settings for the document.',
|
||||||
stepIndex: 1,
|
stepIndex: 1,
|
||||||
},
|
},
|
||||||
signers: {
|
signers: {
|
||||||
@ -96,8 +96,7 @@ export const EditDocumentForm = ({
|
|||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined;
|
const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined;
|
||||||
|
|
||||||
let initialStep: EditDocumentStep =
|
let initialStep: EditDocumentStep = 'settings';
|
||||||
document.status === DocumentStatus.DRAFT ? 'title' : 'signers';
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
searchParamStep &&
|
searchParamStep &&
|
||||||
@ -110,13 +109,23 @@ export const EditDocumentForm = ({
|
|||||||
return initialStep;
|
return initialStep;
|
||||||
});
|
});
|
||||||
|
|
||||||
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
|
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
||||||
try {
|
try {
|
||||||
// Custom invocation server action
|
const { timezone, dateFormat, redirectUrl } = data.meta;
|
||||||
await addTitle({
|
|
||||||
|
await setSettingsForDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
title: data.title,
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
globalAccessAuth: data.globalAccessAuth ?? null,
|
||||||
|
globalActionAuth: data.globalActionAuth ?? null,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timezone,
|
||||||
|
dateFormat,
|
||||||
|
redirectUrl,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@ -127,7 +136,7 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'An error occurred while updating title.',
|
description: 'An error occurred while updating the general settings.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -139,7 +148,11 @@ export const EditDocumentForm = ({
|
|||||||
await addSigners({
|
await addSigners({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
signers: data.signers,
|
signers: data.signers.map((signer) => ({
|
||||||
|
...signer,
|
||||||
|
// Explicitly set to null to indicate we want to remove auth if required.
|
||||||
|
actionAuth: signer.actionAuth || null,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@ -177,7 +190,7 @@ export const EditDocumentForm = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||||
const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
|
const { subject, message } = data.meta;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendDocument({
|
await sendDocument({
|
||||||
@ -186,9 +199,6 @@ export const EditDocumentForm = ({
|
|||||||
meta: {
|
meta: {
|
||||||
subject,
|
subject,
|
||||||
message,
|
message,
|
||||||
dateFormat,
|
|
||||||
timezone,
|
|
||||||
redirectUrl,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -245,23 +255,23 @@ export const EditDocumentForm = ({
|
|||||||
currentStep={currentDocumentFlow.stepIndex}
|
currentStep={currentDocumentFlow.stepIndex}
|
||||||
setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
|
setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
|
||||||
>
|
>
|
||||||
<AddTitleFormPartial
|
<AddSettingsFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.title}
|
documentFlow={documentFlow.settings}
|
||||||
document={document}
|
document={document}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddTitleFormSubmit}
|
onSubmit={onAddSettingsFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddSignersFormPartial
|
<AddSignersFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
document={document}
|
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddFieldsFormPartial
|
<AddFieldsFormPartial
|
||||||
key={fields.length}
|
key={fields.length}
|
||||||
documentFlow={documentFlow.fields}
|
documentFlow={documentFlow.fields}
|
||||||
@ -269,6 +279,7 @@ export const EditDocumentForm = ({
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddSubjectFormPartial
|
<AddSubjectFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.subject}
|
documentFlow={documentFlow.subject}
|
||||||
|
|||||||
@ -15,15 +15,14 @@ export default function SettingsSecurityActivityPage() {
|
|||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title="Security activity"
|
title="Security activity"
|
||||||
subtitle="View all recent security activity related to your account."
|
subtitle="View all recent security activity related to your account."
|
||||||
|
hideDivider={true}
|
||||||
>
|
>
|
||||||
<div>
|
<ActivityPageBackButton />
|
||||||
<ActivityPageBackButton />
|
|
||||||
</div>
|
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
<hr className="my-4" />
|
<div className="mt-4">
|
||||||
|
<UserSecurityActivityDataTable />
|
||||||
<UserSecurityActivityDataTable />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
|
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
@ -18,6 +19,8 @@ export const metadata: Metadata = {
|
|||||||
export default async function SecuritySettingsPage() {
|
export default async function SecuritySettingsPage() {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
@ -47,6 +50,25 @@ export default async function SecuritySettingsPage() {
|
|||||||
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
{isPasskeyEnabled && (
|
||||||
|
<Alert
|
||||||
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>Passkeys</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-4">
|
||||||
|
Allows authenticating using biometrics, password managers, hardware keys, etc.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild variant="outline" className="bg-background">
|
||||||
|
<Link href="/settings/security/passkeys">Manage passkeys</Link>
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{user.twoFactorEnabled && (
|
{user.twoFactorEnabled && (
|
||||||
<Alert
|
<Alert
|
||||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
@ -91,7 +113,7 @@ export default async function SecuritySettingsPage() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button asChild>
|
<Button asChild variant="outline" className="bg-background">
|
||||||
<Link href="/settings/security/activity">View activity</Link>
|
<Link href="/settings/security/activity">View activity</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@ -0,0 +1,237 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
|
import { KeyRoundIcon } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} 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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type CreatePasskeyDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZCreatePasskeyFormSchema = z.object({
|
||||||
|
passkeyName: z.string().min(3),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
|
||||||
|
|
||||||
|
const parser = new UAParser();
|
||||||
|
|
||||||
|
export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<TCreatePasskeyFormSchema>({
|
||||||
|
resolver: zodResolver(ZCreatePasskeyFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
passkeyName: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createPasskeyRegistrationOptions, isLoading } =
|
||||||
|
trpc.auth.createPasskeyRegistrationOptions.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => {
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const passkeyRegistrationOptions = await createPasskeyRegistrationOptions();
|
||||||
|
|
||||||
|
const registrationResult = await startRegistration(passkeyRegistrationOptions);
|
||||||
|
|
||||||
|
await createPasskey({
|
||||||
|
passkeyName,
|
||||||
|
verificationResponse: registrationResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: 'Successfully created passkey',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
setOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
setFormError(err.code || error.code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractDefaultPasskeyName = () => {
|
||||||
|
if (!window || !window.navigator) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parser.setUA(window.navigator.userAgent);
|
||||||
|
|
||||||
|
const result = parser.getResult();
|
||||||
|
const operatingSystem = result.os.name;
|
||||||
|
const browser = result.browser.name;
|
||||||
|
|
||||||
|
let passkeyName = '';
|
||||||
|
|
||||||
|
if (operatingSystem && browser) {
|
||||||
|
passkeyName = `${browser} (${operatingSystem})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return passkeyName;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
const defaultPasskeyName = extractDefaultPasskeyName();
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
passkeyName: defaultPasskeyName,
|
||||||
|
});
|
||||||
|
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="secondary" loading={isLoading}>
|
||||||
|
<KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />
|
||||||
|
Add passkey
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add passkey</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
Passkeys allow you to sign in and authenticate using biometrics, password managers, etc.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="passkeyName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Passkey name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" placeholder="eg. Mac" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<AlertDescription>
|
||||||
|
When you click continue, you will be prompted to add the first available
|
||||||
|
authenticator on your system.
|
||||||
|
</AlertDescription>
|
||||||
|
|
||||||
|
<AlertDescription className="mt-2">
|
||||||
|
If you do not want to use the authenticator prompted, you can close it, which will
|
||||||
|
then display the next available authenticator.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
{match(formError)
|
||||||
|
.with('ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', () => (
|
||||||
|
<AlertDescription>This passkey has already been registered.</AlertDescription>
|
||||||
|
))
|
||||||
|
.with('TOO_MANY_PASSKEYS', () => (
|
||||||
|
<AlertDescription>
|
||||||
|
You cannot have more than {MAXIMUM_PASSKEYS} passkeys.
|
||||||
|
</AlertDescription>
|
||||||
|
))
|
||||||
|
.with('InvalidStateError', () => (
|
||||||
|
<>
|
||||||
|
<AlertTitle className="text-sm">
|
||||||
|
Passkey creation cancelled due to one of the following reasons:
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<ul className="mt-1 list-inside list-disc">
|
||||||
|
<li>Cancelled by user</li>
|
||||||
|
<li>Passkey already exists for the provided authenticator</li>
|
||||||
|
<li>Exceeded timeout</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<AlertDescription>
|
||||||
|
Something went wrong. Please try again or contact support.
|
||||||
|
</AlertDescription>
|
||||||
|
))}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
|
|
||||||
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
|
||||||
|
import { CreatePasskeyDialog } from './create-passkey-dialog';
|
||||||
|
import { UserPasskeysDataTable } from './user-passkeys-data-table';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Manage passkeys',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function SettingsManagePasskeysPage() {
|
||||||
|
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
||||||
|
|
||||||
|
if (!isPasskeyEnabled) {
|
||||||
|
redirect('/settings/security');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader title="Passkeys" subtitle="Manage your passkeys." hideDivider={true}>
|
||||||
|
<CreatePasskeyDialog />
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<UserPasskeysDataTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,202 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} 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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type UserPasskeysDataTableActionsProps = {
|
||||||
|
className?: string;
|
||||||
|
passkeyId: string;
|
||||||
|
passkeyName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZUpdatePasskeySchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
|
||||||
|
|
||||||
|
export const UserPasskeysDataTableActions = ({
|
||||||
|
className,
|
||||||
|
passkeyId,
|
||||||
|
passkeyName,
|
||||||
|
}: UserPasskeysDataTableActionsProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<TUpdatePasskeySchema>({
|
||||||
|
resolver: zodResolver(ZUpdatePasskeySchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: passkeyName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: updatePasskey, isLoading: isUpdatingPasskey } =
|
||||||
|
trpc.auth.updatePasskey.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Passkey has been updated',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description:
|
||||||
|
'We are unable to update this passkey at the moment. Please try again later.',
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: deletePasskey, isLoading: isDeletingPasskey } =
|
||||||
|
trpc.auth.deletePasskey.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Passkey has been removed',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description:
|
||||||
|
'We are unable to remove this passkey at the moment. Please try again later.',
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex justify-end space-x-2', className)}>
|
||||||
|
<Dialog
|
||||||
|
open={isUpdateDialogOpen}
|
||||||
|
onOpenChange={(value) => !isUpdatingPasskey && setIsUpdateDialogOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
<Button variant="outline">Edit</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Update passkey</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
You are currently updating the <strong>{passkeyName}</strong> passkey.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(async ({ name }) =>
|
||||||
|
updatePasskey({
|
||||||
|
passkeyId,
|
||||||
|
name,
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<fieldset className="flex h-full flex-col" disabled={isUpdatingPasskey}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel required>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isUpdatingPasskey}>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={(value) => !isDeletingPasskey && setIsDeleteDialogOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
|
<Button variant="destructive">Delete</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete passkey</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
Are you sure you want to remove the <strong>{passkeyName}</strong> passkey.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
void deletePasskey({
|
||||||
|
passkeyId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<fieldset className="flex h-full flex-col space-y-4" disabled={isDeletingPasskey}>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button type="submit" variant="destructive" loading={isDeletingPasskey}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
import { UserPasskeysDataTableActions } from './user-passkeys-data-table-actions';
|
||||||
|
|
||||||
|
export const UserPasskeysDataTable = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||||
|
Object.fromEntries(searchParams ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.auth.findPasskeys.useQuery(
|
||||||
|
{
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Created',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => DateTime.fromJSDate(row.original.createdAt).toRelative(),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
header: 'Last used',
|
||||||
|
accessorKey: 'updatedAt',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.lastUsedAt
|
||||||
|
? DateTime.fromJSDate(row.original.lastUsedAt).toRelative()
|
||||||
|
: 'Never',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<UserPasskeysDataTableActions
|
||||||
|
className="justify-end"
|
||||||
|
passkeyId={row.original.id}
|
||||||
|
passkeyName={row.original.name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined}
|
||||||
|
onClearFilters={() => router.push(pathname ?? '/')}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-row space-x-2">
|
||||||
|
<Skeleton className="h-8 w-16 rounded" />
|
||||||
|
<Skeleton className="h-8 w-16 rounded" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -6,7 +6,9 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||||
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
@ -17,6 +19,7 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
|||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
|
import { SigningAuthPageView } from '../signing-auth-page';
|
||||||
import { DocumentPreviewButton } from './document-preview-button';
|
import { DocumentPreviewButton } from './document-preview-button';
|
||||||
|
|
||||||
export type CompletedSigningPageProps = {
|
export type CompletedSigningPageProps = {
|
||||||
@ -32,8 +35,11 @@ export default async function CompletedSigningPage({
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { user } = await getServerComponentSession();
|
||||||
|
|
||||||
const document = await getDocumentAndSenderByToken({
|
const document = await getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
|
requireAccessAuth: false,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!document || !document.documentData) {
|
if (!document || !document.documentData) {
|
||||||
@ -53,6 +59,17 @@ export default async function CompletedSigningPage({
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||||
|
type: 'ACCESS',
|
||||||
|
document,
|
||||||
|
recipient,
|
||||||
|
userId: user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isDocumentAccessValid) {
|
||||||
|
return <SigningAuthPageView email={recipient.email} />;
|
||||||
|
}
|
||||||
|
|
||||||
const signatures = await getRecipientSignatures({ recipientId: recipient.id });
|
const signatures = await getRecipientSignatures({ recipientId: recipient.id });
|
||||||
|
|
||||||
const recipientName =
|
const recipientName =
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import {
|
|||||||
convertToLocalSystemFormat,
|
convertToLocalSystemFormat,
|
||||||
} from '@documenso/lib/constants/date-formats';
|
} from '@documenso/lib/constants/date-formats';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -53,16 +55,23 @@ export const DateField = ({
|
|||||||
|
|
||||||
const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`;
|
const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`;
|
||||||
|
|
||||||
const onSign = async () => {
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
await signFieldWithToken({
|
await signFieldWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@ -0,0 +1,83 @@
|
|||||||
|
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
|
||||||
|
type="submit"
|
||||||
|
onClick={async () => handleChangeAccount(recipient.email)}
|
||||||
|
loading={isSigningOut}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
|
import {
|
||||||
|
DocumentAuth,
|
||||||
|
type TRecipientActionAuth,
|
||||||
|
type TRecipientActionAuthTypes,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} 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';
|
||||||
|
|
||||||
|
export type DocumentActionAuthDialogProps = {
|
||||||
|
title?: string;
|
||||||
|
documentAuthType: TRecipientActionAuthTypes;
|
||||||
|
description?: string;
|
||||||
|
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (value: boolean) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback to run when the reauth form is filled out.
|
||||||
|
*/
|
||||||
|
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentActionAuthDialog = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
documentAuthType,
|
||||||
|
actionTarget = 'FIELD',
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onReauthFormSubmit,
|
||||||
|
}: DocumentActionAuthDialogProps) => {
|
||||||
|
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
|
const handleOnOpenChange = (value: boolean) => {
|
||||||
|
if (isCurrentlyAuthenticating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionVerb =
|
||||||
|
actionTarget === 'DOCUMENT' ? RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb : 'Sign';
|
||||||
|
|
||||||
|
const defaultTitleDescription = useMemo(() => {
|
||||||
|
if (recipient.role === 'VIEWER' && actionTarget === 'DOCUMENT') {
|
||||||
|
return {
|
||||||
|
title: 'Mark document as viewed',
|
||||||
|
description: 'Reauthentication is required to mark this document as viewed.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${actionVerb} ${actionTarget.toLowerCase()}`,
|
||||||
|
description: `Reauthentication is required to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}`,
|
||||||
|
};
|
||||||
|
}, [recipient.role, actionVerb, actionTarget]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOnOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title || defaultTitleDescription.title}</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
{description || defaultTitleDescription.description}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{match({ documentAuthType, user })
|
||||||
|
.with(
|
||||||
|
{ documentAuthType: DocumentAuth.ACCOUNT },
|
||||||
|
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auths requires them to be logged in.
|
||||||
|
() => (
|
||||||
|
<DocumentActionAuthAccount
|
||||||
|
actionVerb={actionVerb}
|
||||||
|
actionTarget={actionTarget}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
||||||
|
<DocumentActionAuthPasskey
|
||||||
|
actionTarget={actionTarget}
|
||||||
|
actionVerb={actionVerb}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onReauthFormSubmit={onReauthFormSubmit}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
|
||||||
|
.exhaustive()}
|
||||||
|
</DialogContent>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,223 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||||
|
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||||
|
import type {
|
||||||
|
TDocumentAuthOptions,
|
||||||
|
TRecipientAccessAuthTypes,
|
||||||
|
TRecipientActionAuthTypes,
|
||||||
|
TRecipientAuthOptions,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
|
import { DocumentAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import type { Document, Passkey, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
|
||||||
|
import type { DocumentActionAuthDialogProps } 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 = {
|
||||||
|
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
|
||||||
|
document: Document;
|
||||||
|
documentAuthOption: TDocumentAuthOptions;
|
||||||
|
setDocument: (_value: Document) => void;
|
||||||
|
recipient: Recipient;
|
||||||
|
recipientAuthOption: TRecipientAuthOptions;
|
||||||
|
setRecipient: (_value: Recipient) => void;
|
||||||
|
derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null;
|
||||||
|
derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
|
||||||
|
isAuthRedirectRequired: boolean;
|
||||||
|
isCurrentlyAuthenticating: boolean;
|
||||||
|
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
||||||
|
passkeyData: PasskeyData;
|
||||||
|
preferredPasskeyId: string | null;
|
||||||
|
setPreferredPasskeyId: (_value: string | null) => void;
|
||||||
|
user?: User | null;
|
||||||
|
refetchPasskeys: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentAuthContext = createContext<DocumentAuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useDocumentAuthContext = () => {
|
||||||
|
return useContext(DocumentAuthContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRequiredDocumentAuthContext = () => {
|
||||||
|
const context = useDocumentAuthContext();
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Document auth context is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DocumentAuthProviderProps {
|
||||||
|
document: Document;
|
||||||
|
recipient: Recipient;
|
||||||
|
user?: User | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocumentAuthProvider = ({
|
||||||
|
document: initialDocument,
|
||||||
|
recipient: initialRecipient,
|
||||||
|
user,
|
||||||
|
children,
|
||||||
|
}: DocumentAuthProviderProps) => {
|
||||||
|
const [document, setDocument] = useState(initialDocument);
|
||||||
|
const [recipient, setRecipient] = useState(initialRecipient);
|
||||||
|
|
||||||
|
const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
|
||||||
|
const [preferredPasskeyId, setPreferredPasskeyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
documentAuthOption,
|
||||||
|
recipientAuthOption,
|
||||||
|
derivedRecipientAccessAuth,
|
||||||
|
derivedRecipientActionAuth,
|
||||||
|
} = useMemo(
|
||||||
|
() =>
|
||||||
|
extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipient.authOptions,
|
||||||
|
}),
|
||||||
|
[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] =
|
||||||
|
useState<ExecuteActionAuthProcedureOptions | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pre calculated auth payload if the current user is authenticated correctly
|
||||||
|
* for the `derivedRecipientActionAuth`.
|
||||||
|
*
|
||||||
|
* Will be `null` if the user still requires authentication, or if they don't need
|
||||||
|
* authentication.
|
||||||
|
*/
|
||||||
|
const preCalculatedActionAuthOptions = match(derivedRecipientActionAuth)
|
||||||
|
.with(DocumentAuth.ACCOUNT, () => {
|
||||||
|
if (recipient.email !== user?.email) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: DocumentAuth.ACCOUNT,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with(DocumentAuth.EXPLICIT_NONE, () => ({
|
||||||
|
type: DocumentAuth.EXPLICIT_NONE,
|
||||||
|
}))
|
||||||
|
.with(DocumentAuth.PASSKEY, null, () => null)
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
|
||||||
|
// Directly run callback if no auth required.
|
||||||
|
if (!derivedRecipientActionAuth) {
|
||||||
|
await options.onReauthFormSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run callback with precalculated auth options if available.
|
||||||
|
if (preCalculatedActionAuthOptions) {
|
||||||
|
setDocumentAuthDialogPayload(null);
|
||||||
|
await options.onReauthFormSubmit(preCalculatedActionAuthOptions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request the required auth from the user.
|
||||||
|
setDocumentAuthDialogPayload({
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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(
|
||||||
|
DOCUMENT_AUTH_TYPES[derivedRecipientActionAuth || '']?.isAuthRedirectRequired &&
|
||||||
|
!preCalculatedActionAuthOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchPasskeys = async () => {
|
||||||
|
await passkeyQuery.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentAuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
document,
|
||||||
|
setDocument,
|
||||||
|
executeActionAuthProcedure,
|
||||||
|
recipient,
|
||||||
|
setRecipient,
|
||||||
|
documentAuthOption,
|
||||||
|
recipientAuthOption,
|
||||||
|
derivedRecipientAccessAuth,
|
||||||
|
derivedRecipientActionAuth,
|
||||||
|
isAuthRedirectRequired,
|
||||||
|
isCurrentlyAuthenticating,
|
||||||
|
setIsCurrentlyAuthenticating,
|
||||||
|
passkeyData,
|
||||||
|
preferredPasskeyId,
|
||||||
|
setPreferredPasskeyId,
|
||||||
|
refetchPasskeys,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{documentAuthDialogPayload && derivedRecipientActionAuth && (
|
||||||
|
<DocumentActionAuthDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={() => setDocumentAuthDialogPayload(null)}
|
||||||
|
onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit}
|
||||||
|
actionTarget={documentAuthDialogPayload.actionTarget}
|
||||||
|
documentAuthType={derivedRecipientActionAuth}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DocumentAuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExecuteActionAuthProcedureOptions = Omit<
|
||||||
|
DocumentActionAuthDialogProps,
|
||||||
|
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole'
|
||||||
|
>;
|
||||||
|
|
||||||
|
DocumentAuthProvider.displayName = 'DocumentAuthProvider';
|
||||||
@ -6,6 +6,8 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -38,17 +40,24 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
|||||||
|
|
||||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
const onSign = async () => {
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
await signFieldWithToken({
|
await signFieldWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: providedEmail ?? '',
|
value: providedEmail ?? '',
|
||||||
isBase64: false,
|
isBase64: false,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { useSession } from 'next-auth/react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
|
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -19,6 +20,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
import { useRequiredSigningContext } from './provider';
|
import { useRequiredSigningContext } from './provider';
|
||||||
import { SignDialog } from './sign-dialog';
|
import { SignDialog } from './sign-dialog';
|
||||||
|
|
||||||
@ -35,6 +37,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
||||||
|
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||||
|
|
||||||
@ -64,9 +67,17 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: completeDocument,
|
||||||
|
actionTarget: 'DOCUMENT',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
|
||||||
await completeDocumentWithToken({
|
await completeDocumentWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -15,6 +17,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
@ -31,6 +34,8 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
const { fullName: providedFullName, setFullName: setProvidedFullName } =
|
const { fullName: providedFullName, setFullName: setProvidedFullName } =
|
||||||
useRequiredSigningContext();
|
useRequiredSigningContext();
|
||||||
|
|
||||||
|
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
@ -46,9 +51,32 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
const [showFullNameModal, setShowFullNameModal] = useState(false);
|
const [showFullNameModal, setShowFullNameModal] = useState(false);
|
||||||
const [localFullName, setLocalFullName] = useState('');
|
const [localFullName, setLocalFullName] = useState('');
|
||||||
|
|
||||||
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
const onPreSign = () => {
|
||||||
|
if (!providedFullName) {
|
||||||
|
setShowFullNameModal(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the user clicks the sign button in the dialog where they enter their full name.
|
||||||
|
*/
|
||||||
|
const onDialogSignClick = () => {
|
||||||
|
setShowFullNameModal(false);
|
||||||
|
setProvidedFullName(localFullName);
|
||||||
|
|
||||||
|
void executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localFullName),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => {
|
||||||
try {
|
try {
|
||||||
if (!providedFullName && !localFullName) {
|
const value = name || providedFullName;
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
setShowFullNameModal(true);
|
setShowFullNameModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -56,18 +84,19 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
await signFieldWithToken({
|
await signFieldWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: source === 'local' && localFullName ? localFullName : providedFullName ?? '',
|
value,
|
||||||
isBase64: false,
|
isBase64: false,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (source === 'local' && !providedFullName) {
|
|
||||||
setProvidedFullName(localFullName);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocalFullName('');
|
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -98,7 +127,13 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Name">
|
<SigningFieldContainer
|
||||||
|
field={field}
|
||||||
|
onPreSign={onPreSign}
|
||||||
|
onSign={onSign}
|
||||||
|
onRemove={onRemove}
|
||||||
|
type="Name"
|
||||||
|
>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
@ -147,10 +182,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!localFullName}
|
disabled={!localFullName}
|
||||||
onClick={() => {
|
onClick={() => onDialogSignClick()}
|
||||||
setShowFullNameModal(false);
|
|
||||||
void onSign('local');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Sign
|
Sign
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -1,35 +1,24 @@
|
|||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
|
||||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
|
||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { DocumentAuthProvider } from './document-auth-provider';
|
||||||
|
|
||||||
import { DateField } from './date-field';
|
|
||||||
import { EmailField } from './email-field';
|
|
||||||
import { SigningForm } from './form';
|
|
||||||
import { NameField } from './name-field';
|
|
||||||
import { NoLongerAvailable } from './no-longer-available';
|
import { NoLongerAvailable } from './no-longer-available';
|
||||||
import { SigningProvider } from './provider';
|
import { SigningProvider } from './provider';
|
||||||
import { SignatureField } from './signature-field';
|
import { SigningAuthPageView } from './signing-auth-page';
|
||||||
import { TextField } from './text-field';
|
import { SigningPageView } from './signing-page-view';
|
||||||
|
|
||||||
export type SigningPageProps = {
|
export type SigningPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@ -42,6 +31,8 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { user } = await getServerComponentSession();
|
||||||
|
|
||||||
const requestHeaders = Object.fromEntries(headers().entries());
|
const requestHeaders = Object.fromEntries(headers().entries());
|
||||||
|
|
||||||
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||||
@ -49,21 +40,40 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
const [document, fields, recipient] = await Promise.all([
|
const [document, fields, recipient] = await Promise.all([
|
||||||
getDocumentAndSenderByToken({
|
getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
|
userId: user?.id,
|
||||||
|
requireAccessAuth: false,
|
||||||
}).catch(() => null),
|
}).catch(() => null),
|
||||||
getFieldsForToken({ token }),
|
getFieldsForToken({ token }),
|
||||||
getRecipientByToken({ token }).catch(() => null),
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
viewedDocument({ token, requestMetadata }).catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!document || !document.documentData || !recipient) {
|
if (!document || !document.documentData || !recipient) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const truncatedTitle = truncateTitle(document.title);
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipient.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
const { documentData, documentMeta } = document;
|
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||||
|
type: 'ACCESS',
|
||||||
|
document,
|
||||||
|
recipient,
|
||||||
|
userId: user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
const { user } = await getServerComponentSession();
|
if (!isDocumentAccessValid) {
|
||||||
|
return <SigningAuthPageView email={recipient.email} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
await viewedDocument({
|
||||||
|
token,
|
||||||
|
requestMetadata,
|
||||||
|
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
const { documentMeta } = document;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
document.status === DocumentStatus.COMPLETED ||
|
document.status === DocumentStatus.COMPLETED ||
|
||||||
@ -109,73 +119,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
fullName={user?.email === recipient.email ? user.name : recipient.name}
|
fullName={user?.email === recipient.email ? user.name : recipient.name}
|
||||||
signature={user?.email === recipient.email ? user.signature : undefined}
|
signature={user?.email === recipient.email ? user.signature : undefined}
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-screen-xl">
|
<DocumentAuthProvider document={document} recipient={recipient} user={user}>
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
<SigningPageView recipient={recipient} document={document} fields={fields} />
|
||||||
{truncatedTitle}
|
</DocumentAuthProvider>
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{document.User.name} ({document.User.email}) has invited you to{' '}
|
|
||||||
{recipient.role === RecipientRole.VIEWER && 'view'}
|
|
||||||
{recipient.role === RecipientRole.SIGNER && 'sign'}
|
|
||||||
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
|
||||||
<Card
|
|
||||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
|
||||||
gradient
|
|
||||||
>
|
|
||||||
<CardContent className="p-2">
|
|
||||||
<LazyPDFViewer
|
|
||||||
key={documentData.id}
|
|
||||||
documentData={documentData}
|
|
||||||
document={document}
|
|
||||||
password={documentMeta?.password}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
|
||||||
<SigningForm
|
|
||||||
document={document}
|
|
||||||
recipient={recipient}
|
|
||||||
fields={fields}
|
|
||||||
redirectUrl={documentMeta?.redirectUrl}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
|
||||||
{fields.map((field) =>
|
|
||||||
match(field.type)
|
|
||||||
.with(FieldType.SIGNATURE, () => (
|
|
||||||
<SignatureField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.with(FieldType.NAME, () => (
|
|
||||||
<NameField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.with(FieldType.DATE, () => (
|
|
||||||
<DateField
|
|
||||||
key={field.id}
|
|
||||||
field={field}
|
|
||||||
recipient={recipient}
|
|
||||||
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
|
||||||
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
.with(FieldType.EMAIL, () => (
|
|
||||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.with(FieldType.TEXT, () => (
|
|
||||||
<TextField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.otherwise(() => null),
|
|
||||||
)}
|
|
||||||
</ElementVisible>
|
|
||||||
</div>
|
|
||||||
</SigningProvider>
|
</SigningProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import {
|
|||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
|
|
||||||
export type SignDialogProps = {
|
export type SignDialogProps = {
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
document: Document;
|
document: Document;
|
||||||
@ -29,12 +31,34 @@ export const SignDialog = ({
|
|||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
role,
|
role,
|
||||||
}: SignDialogProps) => {
|
}: SignDialogProps) => {
|
||||||
|
const { executeActionAuthProcedure, isAuthRedirectRequired, isCurrentlyAuthenticating } =
|
||||||
|
useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const truncatedTitle = truncateTitle(document.title);
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
const isComplete = fields.every((field) => field.inserted);
|
const isComplete = fields.every((field) => field.inserted);
|
||||||
|
|
||||||
|
const handleOpenChange = async (open: boolean) => {
|
||||||
|
if (isSubmitting || !isComplete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthRedirectRequired) {
|
||||||
|
await executeActionAuthProcedure({
|
||||||
|
actionTarget: 'DOCUMENT',
|
||||||
|
onReauthFormSubmit: () => {
|
||||||
|
// Do nothing since the user should be redirected.
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowDialog(open);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showDialog && isComplete} onOpenChange={setShowDialog}>
|
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@ -80,7 +104,7 @@ export const SignDialog = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!isComplete}
|
disabled={!isComplete}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting || isCurrentlyAuthenticating}
|
||||||
onClick={onSignatureComplete}
|
onClick={onSignatureComplete}
|
||||||
>
|
>
|
||||||
{role === RecipientRole.VIEWER && 'Mark as Viewed'}
|
{role === RecipientRole.VIEWER && 'Mark as Viewed'}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, useTransition } from 'react';
|
import { useMemo, useState, useTransition } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -15,6 +17,7 @@ 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 { 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';
|
||||||
|
|
||||||
@ -29,9 +32,12 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { signature: providedSignature, setSignature: setProvidedSignature } =
|
const { signature: providedSignature, setSignature: setProvidedSignature } =
|
||||||
useRequiredSigningContext();
|
useRequiredSigningContext();
|
||||||
|
|
||||||
|
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
@ -48,7 +54,6 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
|
|
||||||
const [showSignatureModal, setShowSignatureModal] = useState(false);
|
const [showSignatureModal, setShowSignatureModal] = useState(false);
|
||||||
const [localSignature, setLocalSignature] = useState<string | null>(null);
|
const [localSignature, setLocalSignature] = useState<string | null>(null);
|
||||||
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
|
|
||||||
|
|
||||||
const state = useMemo<SignatureFieldState>(() => {
|
const state = useMemo<SignatureFieldState>(() => {
|
||||||
if (!field.inserted) {
|
if (!field.inserted) {
|
||||||
@ -62,23 +67,37 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
return 'signed-text';
|
return 'signed-text';
|
||||||
}, [field.inserted, signature?.signatureImageAsBase64]);
|
}, [field.inserted, signature?.signatureImageAsBase64]);
|
||||||
|
|
||||||
useEffect(() => {
|
const onPreSign = () => {
|
||||||
if (!showSignatureModal && !isLocalSignatureSet) {
|
if (!providedSignature) {
|
||||||
setLocalSignature(null);
|
setShowSignatureModal(true);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}, [showSignatureModal, isLocalSignatureSet]);
|
|
||||||
|
|
||||||
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the user clicks the sign button in the dialog where they enter their signature.
|
||||||
|
*/
|
||||||
|
const onDialogSignClick = () => {
|
||||||
|
setShowSignatureModal(false);
|
||||||
|
setProvidedSignature(localSignature);
|
||||||
|
|
||||||
|
if (!localSignature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localSignature),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSign = async (authOptions?: TRecipientActionAuth, signature?: string) => {
|
||||||
try {
|
try {
|
||||||
if (!providedSignature && !localSignature) {
|
const value = signature || providedSignature;
|
||||||
setIsLocalSignatureSet(false);
|
|
||||||
setShowSignatureModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = source === 'local' && localSignature ? localSignature : providedSignature ?? '';
|
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
setShowSignatureModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,16 +106,17 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value,
|
value,
|
||||||
isBase64: true,
|
isBase64: true,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (source === 'local' && !providedSignature) {
|
|
||||||
setProvidedSignature(localSignature);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocalSignature(null);
|
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -127,7 +147,13 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
|
<SigningFieldContainer
|
||||||
|
field={field}
|
||||||
|
onPreSign={onPreSign}
|
||||||
|
onSign={onSign}
|
||||||
|
onRemove={onRemove}
|
||||||
|
type="Signature"
|
||||||
|
>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
@ -190,11 +216,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!localSignature}
|
disabled={!localSignature}
|
||||||
onClick={() => {
|
onClick={() => onDialogSignClick()}
|
||||||
setShowSignatureModal(false);
|
|
||||||
setIsLocalSignatureSet(true);
|
|
||||||
void onSign('local');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Sign
|
Sign
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type SigningAuthPageViewProps = {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
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 {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'We were unable to log you out at this time.',
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSigningOut(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold">Authentication required</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
You need to be logged in as <strong>{email}</strong> to view this page.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="mt-4 w-full"
|
||||||
|
type="submit"
|
||||||
|
onClick={async () => handleChangeAccount(email)}
|
||||||
|
loading={isSigningOut}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -2,15 +2,37 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
|
|
||||||
export type SignatureFieldProps = {
|
export type SignatureFieldProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignature;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onSign?: () => Promise<void> | void;
|
|
||||||
|
/**
|
||||||
|
* A function that is called before the field requires to be signed, or reauthed.
|
||||||
|
*
|
||||||
|
* Example, you may want to show a dialog prior to signing where they can enter a value.
|
||||||
|
*
|
||||||
|
* Once that action is complete, you will need to call `executeActionAuthProcedure` to proceed
|
||||||
|
* regardless if it requires reauth or not.
|
||||||
|
*
|
||||||
|
* If the function returns true, we will proceed with the signing process. Otherwise if
|
||||||
|
* false is returned we will not proceed.
|
||||||
|
*/
|
||||||
|
onPreSign?: () => Promise<boolean> | boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function required to be executed to insert the field.
|
||||||
|
*
|
||||||
|
* The auth values will be passed in if available.
|
||||||
|
*/
|
||||||
|
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
|
||||||
onRemove?: () => Promise<void> | void;
|
onRemove?: () => Promise<void> | void;
|
||||||
type?: 'Date' | 'Email' | 'Name' | 'Signature';
|
type?: 'Date' | 'Email' | 'Name' | 'Signature';
|
||||||
tooltipText?: string | null;
|
tooltipText?: string | null;
|
||||||
@ -19,18 +41,42 @@ export type SignatureFieldProps = {
|
|||||||
export const SigningFieldContainer = ({
|
export const SigningFieldContainer = ({
|
||||||
field,
|
field,
|
||||||
loading,
|
loading,
|
||||||
|
onPreSign,
|
||||||
onSign,
|
onSign,
|
||||||
onRemove,
|
onRemove,
|
||||||
children,
|
children,
|
||||||
type,
|
type,
|
||||||
tooltipText,
|
tooltipText,
|
||||||
}: SignatureFieldProps) => {
|
}: SignatureFieldProps) => {
|
||||||
const onSignFieldClick = async () => {
|
const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext();
|
||||||
if (field.inserted) {
|
|
||||||
|
const handleInsertField = async () => {
|
||||||
|
if (field.inserted || !onSign) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await onSign?.();
|
if (isAuthRedirectRequired) {
|
||||||
|
await executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: () => {
|
||||||
|
// Do nothing since the user should be redirected.
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle any presign requirements, and halt if required.
|
||||||
|
if (onPreSign) {
|
||||||
|
const preSignResult = await onPreSign();
|
||||||
|
|
||||||
|
if (preSignResult === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: onSign,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRemoveSignedFieldClick = async () => {
|
const onRemoveSignedFieldClick = async () => {
|
||||||
@ -47,7 +93,7 @@ export const SigningFieldContainer = ({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="absolute inset-0 z-10 h-full w-full"
|
className="absolute inset-0 z-10 h-full w-full"
|
||||||
onClick={onSignFieldClick}
|
onClick={async () => handleInsertField()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
102
apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx
Normal file
102
apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
|
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
|
import { DateField } from './date-field';
|
||||||
|
import { EmailField } from './email-field';
|
||||||
|
import { SigningForm } from './form';
|
||||||
|
import { NameField } from './name-field';
|
||||||
|
import { SignatureField } from './signature-field';
|
||||||
|
import { TextField } from './text-field';
|
||||||
|
|
||||||
|
export type SigningPageViewProps = {
|
||||||
|
document: DocumentAndSender;
|
||||||
|
recipient: Recipient;
|
||||||
|
fields: Field[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SigningPageView = ({ document, recipient, fields }: SigningPageViewProps) => {
|
||||||
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
|
|
||||||
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl">
|
||||||
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
|
{truncatedTitle}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{document.User.name} ({document.User.email}) has invited you to{' '}
|
||||||
|
{recipient.role === RecipientRole.VIEWER && 'view'}
|
||||||
|
{recipient.role === RecipientRole.SIGNER && 'sign'}
|
||||||
|
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
||||||
|
<Card
|
||||||
|
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
||||||
|
gradient
|
||||||
|
>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<LazyPDFViewer
|
||||||
|
key={documentData.id}
|
||||||
|
documentData={documentData}
|
||||||
|
document={document}
|
||||||
|
password={documentMeta?.password}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
||||||
|
<SigningForm
|
||||||
|
document={document}
|
||||||
|
recipient={recipient}
|
||||||
|
fields={fields}
|
||||||
|
redirectUrl={documentMeta?.redirectUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
|
{fields.map((field) =>
|
||||||
|
match(field.type)
|
||||||
|
.with(FieldType.SIGNATURE, () => (
|
||||||
|
<SignatureField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
|
.with(FieldType.NAME, () => (
|
||||||
|
<NameField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
|
.with(FieldType.DATE, () => (
|
||||||
|
<DateField
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
recipient={recipient}
|
||||||
|
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||||
|
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with(FieldType.EMAIL, () => (
|
||||||
|
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
|
.with(FieldType.TEXT, () => (
|
||||||
|
<TextField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
|
.otherwise(() => null),
|
||||||
|
)}
|
||||||
|
</ElementVisible>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -6,6 +6,8 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -15,6 +17,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
import { SigningFieldContainer } from './signing-field-container';
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
|
|
||||||
export type TextFieldProps = {
|
export type TextFieldProps = {
|
||||||
@ -27,6 +30,8 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
@ -41,22 +46,35 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|||||||
|
|
||||||
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
|
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
|
||||||
const [localText, setLocalCustomText] = useState('');
|
const [localText, setLocalCustomText] = useState('');
|
||||||
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showCustomTextModal && !isLocalSignatureSet) {
|
if (!showCustomTextModal) {
|
||||||
setLocalCustomText('');
|
setLocalCustomText('');
|
||||||
}
|
}
|
||||||
}, [showCustomTextModal, isLocalSignatureSet]);
|
}, [showCustomTextModal]);
|
||||||
|
|
||||||
const onSign = async () => {
|
/**
|
||||||
|
* When the user clicks the sign button in the dialog where they enter the text field.
|
||||||
|
*/
|
||||||
|
const onDialogSignClick = () => {
|
||||||
|
setShowCustomTextModal(false);
|
||||||
|
|
||||||
|
void executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPreSign = () => {
|
||||||
|
if (!localText) {
|
||||||
|
setShowCustomTextModal(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
if (!localText) {
|
|
||||||
setIsLocalSignatureSet(false);
|
|
||||||
setShowCustomTextModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!localText) {
|
if (!localText) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -66,12 +84,19 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: localText,
|
value: localText,
|
||||||
isBase64: true,
|
isBase64: true,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
setLocalCustomText('');
|
setLocalCustomText('');
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -102,7 +127,13 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
|
<SigningFieldContainer
|
||||||
|
field={field}
|
||||||
|
onPreSign={onPreSign}
|
||||||
|
onSign={onSign}
|
||||||
|
onRemove={onRemove}
|
||||||
|
type="Signature"
|
||||||
|
>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
@ -149,11 +180,7 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!localText}
|
disabled={!localText}
|
||||||
onClick={() => {
|
onClick={() => onDialogSignClick()}
|
||||||
setShowCustomTextModal(false);
|
|
||||||
setIsLocalSignatureSet(true);
|
|
||||||
void onSign();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Save Text
|
Save Text
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -5,11 +5,18 @@ import { cn } from '@documenso/ui/lib/utils';
|
|||||||
export type SettingsHeaderProps = {
|
export type SettingsHeaderProps = {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
hideDivider?: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsHeader = ({ children, title, subtitle, className }: SettingsHeaderProps) => {
|
export const SettingsHeader = ({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
className,
|
||||||
|
hideDivider,
|
||||||
|
}: SettingsHeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn('flex flex-row items-center justify-between', className)}>
|
<div className={cn('flex flex-row items-center justify-between', className)}>
|
||||||
@ -22,7 +29,7 @@ export const SettingsHeader = ({ children, title, subtitle, className }: Setting
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="my-4" />
|
{!hideDivider && <hr className="my-4" />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { match } from 'ts-pattern';
|
|||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
|
||||||
|
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs';
|
import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -79,7 +80,11 @@ export const DocumentHistorySheet = ({
|
|||||||
* @param text The text to format
|
* @param text The text to format
|
||||||
* @returns The formatted text
|
* @returns The formatted text
|
||||||
*/
|
*/
|
||||||
const formatGenericText = (text: string) => {
|
const formatGenericText = (text?: string | null) => {
|
||||||
|
if (!text) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
|
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -219,6 +224,24 @@ export const DocumentHistorySheet = ({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.with(
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED },
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED },
|
||||||
|
({ data }) => (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
key: 'Old',
|
||||||
|
value: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'New',
|
||||||
|
value: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
|
||||||
if (data.changes.length === 0) {
|
if (data.changes.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@ -281,6 +304,7 @@ export const DocumentHistorySheet = ({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
|
|
||||||
{isUserDetailsVisible && (
|
{isUserDetailsVisible && (
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type FormErrorMessageProps = {
|
|
||||||
className?: string;
|
|
||||||
error: { message?: string } | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => {
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{error && (
|
|
||||||
<motion.p
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
y: -10,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
y: 10,
|
|
||||||
}}
|
|
||||||
className={cn('text-xs text-red-500', className)}
|
|
||||||
>
|
|
||||||
{error.message}
|
|
||||||
</motion.p>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -16,7 +16,8 @@ export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
className="flex-shrink-0"
|
variant="outline"
|
||||||
|
className="bg-background flex-shrink-0"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
disabled={!isTwoFactorEnabled}
|
disabled={!isTwoFactorEnabled}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -6,12 +6,18 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||||
|
import { KeyRoundIcon } from 'lucide-react';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { FcGoogle } from 'react-icons/fc';
|
import { FcGoogle } from 'react-icons/fc';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -66,14 +72,24 @@ export type SignInFormProps = {
|
|||||||
|
|
||||||
export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
|
export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||||
'totp' | 'backup'
|
'totp' | 'backup'
|
||||||
>('totp');
|
>('totp');
|
||||||
|
|
||||||
|
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||||
|
|
||||||
|
const isPasskeyEnabled = getFlag('app_passkey');
|
||||||
|
|
||||||
|
const { mutateAsync: createPasskeySigninOptions } =
|
||||||
|
trpc.auth.createPasskeySigninOptions.useMutation();
|
||||||
|
|
||||||
const form = useForm<TSignInFormSchema>({
|
const form = useForm<TSignInFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
email: initialEmail ?? '',
|
email: initialEmail ?? '',
|
||||||
@ -107,6 +123,63 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
setTwoFactorAuthenticationMethod(method);
|
setTwoFactorAuthenticationMethod(method);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSignInWithPasskey = async () => {
|
||||||
|
if (!browserSupportsWebAuthn()) {
|
||||||
|
toast({
|
||||||
|
title: 'Not supported',
|
||||||
|
description: 'Passkeys are not supported on this browser',
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsPasskeyLoading(true);
|
||||||
|
|
||||||
|
const options = await createPasskeySigninOptions();
|
||||||
|
|
||||||
|
const credential = await startAuthentication(options);
|
||||||
|
|
||||||
|
const result = await signIn('webauthn', {
|
||||||
|
credential: JSON.stringify(credential),
|
||||||
|
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.url || result.error) {
|
||||||
|
throw new AppError(result?.error ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = result.url;
|
||||||
|
} catch (err) {
|
||||||
|
setIsPasskeyLoading(false);
|
||||||
|
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
const errorMessage = match(error.code)
|
||||||
|
.with(
|
||||||
|
AppErrorCode.NOT_SETUP,
|
||||||
|
() =>
|
||||||
|
'This passkey is not configured for this application. Please login and add one in the user settings.',
|
||||||
|
)
|
||||||
|
.with(AppErrorCode.EXPIRED_CODE, () => 'This session has expired. Please try again.')
|
||||||
|
.otherwise(() => 'Please try again later or login using your normal details');
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: errorMessage,
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
|
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const credentials: Record<string, string> = {
|
const credentials: Record<string, string> = {
|
||||||
@ -189,7 +262,10 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
<fieldset
|
||||||
|
className="flex w-full flex-col gap-y-4"
|
||||||
|
disabled={isSubmitting || isPasskeyLoading}
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
@ -217,6 +293,8 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
<PasswordInput {...field} />
|
<PasswordInput {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
|
||||||
<p className="mt-2 text-right">
|
<p className="mt-2 text-right">
|
||||||
<Link
|
<Link
|
||||||
href="/forgot-password"
|
href="/forgot-password"
|
||||||
@ -225,29 +303,28 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
Forgot your password?
|
Forgot your password?
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="lg"
|
size="lg"
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
className="dark:bg-documenso dark:hover:opacity-90"
|
className="dark:bg-documenso dark:hover:opacity-90"
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isGoogleSSOEnabled && (
|
{(isGoogleSSOEnabled || isPasskeyEnabled) && (
|
||||||
<>
|
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="bg-border h-px flex-1" />
|
||||||
<span className="text-muted-foreground bg-transparent">Or continue with</span>
|
<span className="text-muted-foreground bg-transparent">Or continue with</span>
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="bg-border h-px flex-1" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isGoogleSSOEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
@ -259,8 +336,23 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
Google
|
Google
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
|
{isPasskeyEnabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
loading={isPasskeyLoading}
|
||||||
|
className="bg-background text-muted-foreground border"
|
||||||
|
onClick={onSignInWithPasskey}
|
||||||
|
>
|
||||||
|
{!isPasskeyLoading && <KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />}
|
||||||
|
Passkey
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|||||||
163
package-lock.json
generated
163
package-lock.json
generated
@ -99,6 +99,8 @@
|
|||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.3",
|
||||||
"@tanstack/react-query": "^4.29.5",
|
"@tanstack/react-query": "^4.29.5",
|
||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
@ -128,6 +130,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
|
"@simplewebauthn/types": "^9.0.1",
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
@ -2153,6 +2156,11 @@
|
|||||||
"@hapi/hoek": "^9.0.0"
|
"@hapi/hoek": "^9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@hexagon/base64": {
|
||||||
|
"version": "1.1.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||||
|
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="
|
||||||
|
},
|
||||||
"node_modules/@hookform/resolvers": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz",
|
||||||
@ -2765,6 +2773,11 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@levischuck/tiny-cbor": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-f5CnPw997Y2GQ8FAvtuVVC19FX8mwNNC+1XJcIi16n/LTJifKO6QBgGLgN3YEmqtGMk17SKSuoWES3imJVxAVw=="
|
||||||
|
},
|
||||||
"node_modules/@manypkg/find-root": {
|
"node_modules/@manypkg/find-root": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-2.2.1.tgz",
|
||||||
@ -4132,6 +4145,60 @@
|
|||||||
"pako": "^1.0.10"
|
"pako": "^1.0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@peculiar/asn1-android": {
|
||||||
|
"version": "2.3.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.3.10.tgz",
|
||||||
|
"integrity": "sha512-z9Rx9cFJv7UUablZISe7uksNbFJCq13hO0yEAOoIpAymALTLlvUOSLnGiQS7okPaM5dP42oTLhezH6XDXRXjGw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.3.8",
|
||||||
|
"asn1js": "^3.0.5",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-ecc": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-Ah/Q15y3A/CtxbPibiLM/LKcMbnLTdUdLHUgdpB5f60sSvGkXzxJCu5ezGTFHogZXWNX3KSmYqilCrfdmBc6pQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.3.8",
|
||||||
|
"@peculiar/asn1-x509": "^2.3.8",
|
||||||
|
"asn1js": "^3.0.5",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-rsa": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-ES/RVEHu8VMYXgrg3gjb1m/XG0KJWnV4qyZZ7mAg7rrF3VTmRbLxO8mk+uy0Hme7geSMebp+Wvi2U6RLLEs12Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.3.8",
|
||||||
|
"@peculiar/asn1-x509": "^2.3.8",
|
||||||
|
"asn1js": "^3.0.5",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-schema": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1js": "^3.0.5",
|
||||||
|
"pvtsutils": "^1.3.5",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-x509": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-voKxGfDU1c6r9mKiN5ZUsZWh3Dy1BABvTM3cimf0tztNwyMJPhiXY94eRTgsMQe6ViLfT6EoXxkWVzcm3mFAFw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.3.8",
|
||||||
|
"asn1js": "^3.0.5",
|
||||||
|
"ipaddr.js": "^2.1.0",
|
||||||
|
"pvtsutils": "^1.3.5",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@ -5793,6 +5860,38 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
|
||||||
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
|
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@simplewebauthn/browser": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-wD2WpbkaEP4170s13/HUxPcAV5y4ZXaKo1TfNklS5zDefPinIgXOpgz1kpEvobAsaLPa2KeH7AKKX/od1mrBJw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@simplewebauthn/types": "^9.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@simplewebauthn/server": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-FMZieoBosrVLFxCnxPFD9Enhd1U7D8nidVDT4MsHc6l4fdVcjoeHjDueeXCloO1k5O/fZg1fsSXXPKbY2XTzDA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@hexagon/base64": "^1.1.27",
|
||||||
|
"@levischuck/tiny-cbor": "^0.2.2",
|
||||||
|
"@peculiar/asn1-android": "^2.3.10",
|
||||||
|
"@peculiar/asn1-ecc": "^2.3.8",
|
||||||
|
"@peculiar/asn1-rsa": "^2.3.8",
|
||||||
|
"@peculiar/asn1-schema": "^2.3.8",
|
||||||
|
"@peculiar/asn1-x509": "^2.3.8",
|
||||||
|
"@simplewebauthn/types": "^9.0.1",
|
||||||
|
"cross-fetch": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@simplewebauthn/types": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w=="
|
||||||
|
},
|
||||||
"node_modules/@sindresorhus/slugify": {
|
"node_modules/@sindresorhus/slugify": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz",
|
||||||
@ -7974,6 +8073,19 @@
|
|||||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="
|
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1js": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"pvtsutils": "^1.3.2",
|
||||||
|
"pvutils": "^1.1.3",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ast-types-flow": {
|
"node_modules/ast-types-flow": {
|
||||||
"version": "0.0.8",
|
"version": "0.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||||
@ -9321,6 +9433,33 @@
|
|||||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||||
"devOptional": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-fetch": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
|
||||||
|
"dependencies": {
|
||||||
|
"node-fetch": "^2.6.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-fetch/node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||||
@ -12640,6 +12779,14 @@
|
|||||||
"loose-envify": "^1.0.0"
|
"loose-envify": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ipaddr.js": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-alphabetical": {
|
"node_modules/is-alphabetical": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||||
@ -16744,6 +16891,22 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pvtsutils": {
|
||||||
|
"version": "1.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz",
|
||||||
|
"integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pvutils": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.11.2",
|
"version": "6.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
|
||||||
|
|||||||
97
packages/app-tests/e2e/document-auth/access-auth.spec.ts
Normal file
97
packages/app-tests/e2e/document-auth/access-auth.spec.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test('[DOCUMENT_AUTH]: should grant access when not required', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithAccount = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(user, [
|
||||||
|
recipientWithAccount,
|
||||||
|
'recipientwithoutaccount@documenso.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const recipients = await prisma.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = recipients.map((recipient) => recipient.token);
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
await page.goto(`/sign/${token}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithAccount = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(
|
||||||
|
user,
|
||||||
|
[recipientWithAccount, 'recipientwithoutaccount@documenso.com'],
|
||||||
|
{
|
||||||
|
createDocumentOptions: {
|
||||||
|
authOptions: createDocumentAuthOptions({
|
||||||
|
globalAccessAuth: 'ACCOUNT',
|
||||||
|
globalActionAuth: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const recipients = await prisma.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that both are denied access.
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const { email, token } = recipient;
|
||||||
|
|
||||||
|
await page.goto(`/sign/${token}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('paragraph')).toContainText(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: recipientWithAccount.email,
|
||||||
|
redirectPath: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that the one logged in is granted access.
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const { email, token } = recipient;
|
||||||
|
|
||||||
|
await page.goto(`/sign/${token}`);
|
||||||
|
|
||||||
|
// Recipient should be granted access.
|
||||||
|
if (recipient.email === recipientWithAccount.email) {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient should still be denied.
|
||||||
|
if (recipient.email !== recipientWithAccount.email) {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('paragraph')).toContainText(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
await unseedUser(recipientWithAccount.id);
|
||||||
|
});
|
||||||
405
packages/app-tests/e2e/document-auth/action-auth.spec.ts
Normal file
405
packages/app-tests/e2e/document-auth/action-auth.spec.ts
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||||
|
import {
|
||||||
|
createDocumentAuthOptions,
|
||||||
|
createRecipientAuthOptions,
|
||||||
|
} from '@documenso/lib/utils/document-auth';
|
||||||
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
import {
|
||||||
|
seedPendingDocumentNoFields,
|
||||||
|
seedPendingDocumentWithFullFields,
|
||||||
|
} from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithAccount = await seedUser();
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [recipientWithAccount, seedTestEmail()],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that both are granted access.
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const { token, Field } = recipient;
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add signature.
|
||||||
|
const canvas = page.locator('canvas');
|
||||||
|
const box = await canvas.boundingBox();
|
||||||
|
if (box) {
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of Field) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.getByLabel('Custom Text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save Text' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
await unseedUser(recipientWithAccount.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithAccount = await seedUser();
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [recipientWithAccount],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
authOptions: createDocumentAuthOptions({
|
||||||
|
globalAccessAuth: null,
|
||||||
|
globalActionAuth: 'ACCOUNT',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipient = recipients[0];
|
||||||
|
|
||||||
|
const { token, Field } = recipient;
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: recipientWithAccount.email,
|
||||||
|
redirectPath: signUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add signature.
|
||||||
|
const canvas = page.locator('canvas');
|
||||||
|
const box = await canvas.boundingBox();
|
||||||
|
if (box) {
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of Field) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.getByLabel('Custom Text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save Text' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
await unseedUser(recipientWithAccount.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_AUTH]: should deny signing document when required for global auth', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithAccount = await seedUser();
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentNoFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [recipientWithAccount],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
authOptions: createDocumentAuthOptions({
|
||||||
|
globalAccessAuth: null,
|
||||||
|
globalActionAuth: 'ACCOUNT',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipient = recipients[0];
|
||||||
|
|
||||||
|
const { token } = recipient;
|
||||||
|
|
||||||
|
await page.goto(`/sign/${token}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await expect(page.getByRole('paragraph')).toContainText(
|
||||||
|
'Reauthentication is required to sign this document',
|
||||||
|
);
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
await unseedUser(recipientWithAccount.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithAccount = await seedUser();
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [recipientWithAccount, seedTestEmail()],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
authOptions: createDocumentAuthOptions({
|
||||||
|
globalAccessAuth: null,
|
||||||
|
globalActionAuth: 'ACCOUNT',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that both are denied access.
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const { token, Field } = recipient;
|
||||||
|
|
||||||
|
await page.goto(`/sign/${token}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
for (const field of Field) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
await expect(page.getByRole('paragraph')).toContainText(
|
||||||
|
'Reauthentication is required to sign this field',
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
await unseedUser(recipientWithAccount.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient auth', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithInheritAuth = await seedUser();
|
||||||
|
const recipientWithExplicitNoneAuth = await seedUser();
|
||||||
|
const recipientWithExplicitAccountAuth = await seedUser();
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [
|
||||||
|
recipientWithInheritAuth,
|
||||||
|
recipientWithExplicitNoneAuth,
|
||||||
|
recipientWithExplicitAccountAuth,
|
||||||
|
],
|
||||||
|
recipientsCreateOptions: [
|
||||||
|
{
|
||||||
|
authOptions: createRecipientAuthOptions({
|
||||||
|
accessAuth: null,
|
||||||
|
actionAuth: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
authOptions: createRecipientAuthOptions({
|
||||||
|
accessAuth: null,
|
||||||
|
actionAuth: 'EXPLICIT_NONE',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
authOptions: createRecipientAuthOptions({
|
||||||
|
accessAuth: null,
|
||||||
|
actionAuth: 'ACCOUNT',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fields: [FieldType.DATE],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const { token, Field } = recipient;
|
||||||
|
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
||||||
|
|
||||||
|
// This document has no global action auth, so only account should require auth.
|
||||||
|
const isAuthRequired = actionAuth === 'ACCOUNT';
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
if (isAuthRequired) {
|
||||||
|
for (const field of Field) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
await expect(page.getByRole('paragraph')).toContainText(
|
||||||
|
'Reauthentication is required to sign this field',
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign in and it should work.
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: recipient.email,
|
||||||
|
redirectPath: signUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add signature.
|
||||||
|
const canvas = page.locator('canvas');
|
||||||
|
const box = await canvas.boundingBox();
|
||||||
|
if (box) {
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of Field) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.getByLabel('Custom Text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save Text' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
|
||||||
|
if (isAuthRequired) {
|
||||||
|
await apiSignout({ page });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient and global auth', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithInheritAuth = await seedUser();
|
||||||
|
const recipientWithExplicitNoneAuth = await seedUser();
|
||||||
|
const recipientWithExplicitAccountAuth = await seedUser();
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [
|
||||||
|
recipientWithInheritAuth,
|
||||||
|
recipientWithExplicitNoneAuth,
|
||||||
|
recipientWithExplicitAccountAuth,
|
||||||
|
],
|
||||||
|
recipientsCreateOptions: [
|
||||||
|
{
|
||||||
|
authOptions: createRecipientAuthOptions({
|
||||||
|
accessAuth: null,
|
||||||
|
actionAuth: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
authOptions: createRecipientAuthOptions({
|
||||||
|
accessAuth: null,
|
||||||
|
actionAuth: 'EXPLICIT_NONE',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
authOptions: createRecipientAuthOptions({
|
||||||
|
accessAuth: null,
|
||||||
|
actionAuth: 'ACCOUNT',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fields: [FieldType.DATE],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
authOptions: createDocumentAuthOptions({
|
||||||
|
globalAccessAuth: null,
|
||||||
|
globalActionAuth: 'ACCOUNT',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const { token, Field } = recipient;
|
||||||
|
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
||||||
|
|
||||||
|
// This document HAS global action auth, so account and inherit should require auth.
|
||||||
|
const isAuthRequired = actionAuth === 'ACCOUNT' || actionAuth === null;
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
if (isAuthRequired) {
|
||||||
|
for (const field of Field) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
await expect(page.getByRole('paragraph')).toContainText(
|
||||||
|
'Reauthentication is required to sign this field',
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign in and it should work.
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: recipient.email,
|
||||||
|
redirectPath: signUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add signature.
|
||||||
|
const canvas = page.locator('canvas');
|
||||||
|
const box = await canvas.boundingBox();
|
||||||
|
if (box) {
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of Field) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.getByLabel('Custom Text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save Text' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
|
||||||
|
if (isAuthRequired) {
|
||||||
|
await apiSignout({ page });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
74
packages/app-tests/e2e/document-flow/settings-step.spec.ts
Normal file
74
packages/app-tests/e2e/document-flow/settings-step.spec.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
seedBlankDocument,
|
||||||
|
seedDraftDocument,
|
||||||
|
seedPendingDocument,
|
||||||
|
} from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const document = await seedBlankDocument(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/documents/${document.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set title.
|
||||||
|
await page.getByLabel('Title').fill('New Title');
|
||||||
|
|
||||||
|
// Set access auth.
|
||||||
|
await page.getByTestId('documentAccessSelectValue').click();
|
||||||
|
await page.getByLabel('Require account').getByText('Require account').click();
|
||||||
|
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||||
|
|
||||||
|
// Set action auth.
|
||||||
|
await page.getByTestId('documentActionSelectValue').click();
|
||||||
|
await page.getByLabel('Require account').getByText('Require account').click();
|
||||||
|
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
|
||||||
|
|
||||||
|
// Save the settings by going to the next step.
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
|
||||||
|
// Return to the settings step to check that the results are saved correctly.
|
||||||
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||||
|
|
||||||
|
// Todo: Verify that the values are correct once we fix the issue where going back
|
||||||
|
// does not show the updated values.
|
||||||
|
// await expect(page.getByLabel('Title')).toContainText('New Title');
|
||||||
|
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||||
|
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_FLOW]: title should be disabled depending on document status', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const pendingDocument = await seedPendingDocument(user, []);
|
||||||
|
const draftDocument = await seedDraftDocument(user, []);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/documents/${pendingDocument.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be disabled for pending documents.
|
||||||
|
await expect(page.getByLabel('Title')).toBeDisabled();
|
||||||
|
|
||||||
|
// Should be enabled for draft documents.
|
||||||
|
await page.goto(`/documents/${draftDocument.id}/edit`);
|
||||||
|
await expect(page.getByLabel('Title')).toBeEnabled();
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
});
|
||||||
63
packages/app-tests/e2e/document-flow/signers-step.spec.ts
Normal file
63
packages/app-tests/e2e/document-flow/signers-step.spec.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
// Note: Not complete yet due to issue with back button.
|
||||||
|
test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const document = await seedBlankDocument(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/documents/${document.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save the settings by going to the next step.
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add 2 signers.
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||||
|
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
|
||||||
|
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
|
||||||
|
|
||||||
|
// Display advanced settings.
|
||||||
|
await page.getByLabel('Show advanced settings').click();
|
||||||
|
|
||||||
|
// Navigate to the next step and back.
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
|
||||||
|
// Todo: Fix stepper component back issue before finishing test.
|
||||||
|
|
||||||
|
// // Expect that the advanced settings is unchecked, since no advanced settings were applied.
|
||||||
|
// await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
|
||||||
|
|
||||||
|
// // Add advanced settings for a single recipient.
|
||||||
|
// await page.getByLabel('Show advanced settings').click();
|
||||||
|
// await page.getByRole('combobox').first().click();
|
||||||
|
// await page.getByLabel('Require account').click();
|
||||||
|
|
||||||
|
// // Navigate to the next step and back.
|
||||||
|
// await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
// await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
// await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
|
// await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
|
||||||
|
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
|
||||||
|
// settings were applied.
|
||||||
|
|
||||||
|
// Todo: Fix stepper component back issue before finishing test.
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
});
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import type { Page } from '@playwright/test';
|
import { type Page } from '@playwright/test';
|
||||||
|
|
||||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
|
||||||
type ManualLoginOptions = {
|
type LoginOptions = {
|
||||||
page: Page;
|
page: Page;
|
||||||
email?: string;
|
email?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
@ -18,7 +18,7 @@ export const manualLogin = async ({
|
|||||||
email = 'example@documenso.com',
|
email = 'example@documenso.com',
|
||||||
password = 'password',
|
password = 'password',
|
||||||
redirectPath,
|
redirectPath,
|
||||||
}: ManualLoginOptions) => {
|
}: LoginOptions) => {
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/signin`);
|
await page.goto(`${WEBAPP_BASE_URL}/signin`);
|
||||||
|
|
||||||
await page.getByLabel('Email').click();
|
await page.getByLabel('Email').click();
|
||||||
@ -33,9 +33,63 @@ export const manualLogin = async ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const manualSignout = async ({ page }: ManualLoginOptions) => {
|
export const manualSignout = async ({ page }: LoginOptions) => {
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await page.getByTestId('menu-switcher').click();
|
await page.getByTestId('menu-switcher').click();
|
||||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||||
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
|
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const apiSignin = async ({
|
||||||
|
page,
|
||||||
|
email = 'example@documenso.com',
|
||||||
|
password = 'password',
|
||||||
|
redirectPath = '/',
|
||||||
|
}: LoginOptions) => {
|
||||||
|
const { request } = page.context();
|
||||||
|
|
||||||
|
const csrfToken = await getCsrfToken(page);
|
||||||
|
|
||||||
|
await request.post(`${WEBAPP_BASE_URL}/api/auth/callback/credentials`, {
|
||||||
|
form: {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
json: true,
|
||||||
|
csrfToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (redirectPath) {
|
||||||
|
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiSignout = async ({ page }: { page: Page }) => {
|
||||||
|
const { request } = page.context();
|
||||||
|
|
||||||
|
const csrfToken = await getCsrfToken(page);
|
||||||
|
|
||||||
|
await request.post(`${WEBAPP_BASE_URL}/api/auth/signout`, {
|
||||||
|
form: {
|
||||||
|
csrfToken,
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${WEBAPP_BASE_URL}/signin`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCsrfToken = async (page: Page) => {
|
||||||
|
const { request } = page.context();
|
||||||
|
|
||||||
|
const response = await request.fetch(`${WEBAPP_BASE_URL}/api/auth/csrf`, {
|
||||||
|
method: 'get',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { csrfToken } = await response.json();
|
||||||
|
if (!csrfToken) {
|
||||||
|
throw new Error('Invalid session');
|
||||||
|
}
|
||||||
|
|
||||||
|
return csrfToken;
|
||||||
|
};
|
||||||
|
|||||||
@ -28,8 +28,8 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => {
|
|||||||
// Wait to be redirected to the edit page
|
// Wait to be redirected to the edit page
|
||||||
await page.waitForURL(/\/documents\/\d+/);
|
await page.waitForURL(/\/documents\/\d+/);
|
||||||
|
|
||||||
// Set title
|
// Set general settings
|
||||||
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel('Title').fill(documentTitle);
|
await page.getByLabel('Title').fill(documentTitle);
|
||||||
|
|
||||||
|
|||||||
@ -4,14 +4,14 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
|||||||
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
import { manualLogin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
test('[TEAMS]: create team', async ({ page }) => {
|
test('[TEAMS]: create team', async ({ page }) => {
|
||||||
const user = await seedUser();
|
const user = await seedUser();
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
redirectPath: '/settings/teams',
|
redirectPath: '/settings/teams',
|
||||||
@ -38,7 +38,7 @@ test('[TEAMS]: create team', async ({ page }) => {
|
|||||||
test('[TEAMS]: delete team', async ({ page }) => {
|
test('[TEAMS]: delete team', async ({ page }) => {
|
||||||
const team = await seedTeam();
|
const team = await seedTeam();
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: team.owner.email,
|
email: team.owner.email,
|
||||||
redirectPath: `/t/${team.url}/settings`,
|
redirectPath: `/t/${team.url}/settings`,
|
||||||
@ -56,7 +56,7 @@ test('[TEAMS]: delete team', async ({ page }) => {
|
|||||||
test('[TEAMS]: update team', async ({ page }) => {
|
test('[TEAMS]: update team', async ({ page }) => {
|
||||||
const team = await seedTeam();
|
const team = await seedTeam();
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: team.owner.email,
|
email: team.owner.email,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documen
|
|||||||
import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams';
|
import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
import { manualLogin, manualSignout } from '../fixtures/authentication';
|
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
|
|||||||
|
|
||||||
// Run the test twice, once with the team owner and once with a team member to ensure the counts are the same.
|
// Run the test twice, once with the team owner and once with a team member to ensure the counts are the same.
|
||||||
for (const user of [team.owner, teamMember2]) {
|
for (const user of [team.owner, teamMember2]) {
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
redirectPath: `/t/${team.url}/documents`,
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
@ -55,7 +55,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
|
|||||||
await checkDocumentTabCount(page, 'Draft', 1);
|
await checkDocumentTabCount(page, 'Draft', 1);
|
||||||
await checkDocumentTabCount(page, 'All', 3);
|
await checkDocumentTabCount(page, 'All', 3);
|
||||||
|
|
||||||
await manualSignout({ page });
|
await apiSignout({ page });
|
||||||
}
|
}
|
||||||
|
|
||||||
await unseedTeam(team.url);
|
await unseedTeam(team.url);
|
||||||
@ -126,7 +126,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
|
|||||||
|
|
||||||
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
|
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
|
||||||
for (const user of [team.owner, teamEmailMember]) {
|
for (const user of [team.owner, teamEmailMember]) {
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
redirectPath: `/t/${team.url}/documents`,
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
@ -151,7 +151,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
|
|||||||
await checkDocumentTabCount(page, 'Draft', 1);
|
await checkDocumentTabCount(page, 'Draft', 1);
|
||||||
await checkDocumentTabCount(page, 'All', 3);
|
await checkDocumentTabCount(page, 'All', 3);
|
||||||
|
|
||||||
await manualSignout({ page });
|
await apiSignout({ page });
|
||||||
}
|
}
|
||||||
|
|
||||||
await unseedTeamEmail({ teamId: team.id });
|
await unseedTeamEmail({ teamId: team.id });
|
||||||
@ -216,7 +216,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: teamMember2.email,
|
email: teamMember2.email,
|
||||||
redirectPath: `/t/${team.url}/documents`,
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
@ -248,7 +248,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
|
|||||||
test('[TEAMS]: delete pending team document', async ({ page }) => {
|
test('[TEAMS]: delete pending team document', async ({ page }) => {
|
||||||
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: currentUser.email,
|
email: currentUser.email,
|
||||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||||
@ -266,7 +266,7 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
|
|||||||
test('[TEAMS]: resend pending team document', async ({ page }) => {
|
test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||||
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: currentUser.email,
|
email: currentUser.email,
|
||||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||||
|
|||||||
@ -4,14 +4,14 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
|||||||
import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams';
|
import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
import { manualLogin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
test('[TEAMS]: send team email request', async ({ page }) => {
|
test('[TEAMS]: send team email request', async ({ page }) => {
|
||||||
const team = await seedTeam();
|
const team = await seedTeam();
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: team.owner.email,
|
email: team.owner.email,
|
||||||
password: 'password',
|
password: 'password',
|
||||||
@ -57,7 +57,7 @@ test('[TEAMS]: delete team email', async ({ page }) => {
|
|||||||
createTeamEmail: true,
|
createTeamEmail: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: team.owner.email,
|
email: team.owner.email,
|
||||||
redirectPath: `/t/${team.url}/settings`,
|
redirectPath: `/t/${team.url}/settings`,
|
||||||
@ -86,7 +86,7 @@ test('[TEAMS]: team email owner removes access', async ({ page }) => {
|
|||||||
email: team.teamEmail.email,
|
email: team.teamEmail.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: teamEmailOwner.email,
|
email: teamEmailOwner.email,
|
||||||
redirectPath: `/settings/teams`,
|
redirectPath: `/settings/teams`,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
|||||||
import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams';
|
import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
import { manualLogin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
@ -13,7 +13,7 @@ test('[TEAMS]: update team member role', async ({ page }) => {
|
|||||||
createTeamMembers: 1,
|
createTeamMembers: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: team.owner.email,
|
email: team.owner.email,
|
||||||
password: 'password',
|
password: 'password',
|
||||||
@ -75,7 +75,7 @@ test('[TEAMS]: member can leave team', async ({ page }) => {
|
|||||||
|
|
||||||
const teamMember = team.members[1];
|
const teamMember = team.members[1];
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: teamMember.user.email,
|
email: teamMember.user.email,
|
||||||
password: 'password',
|
password: 'password',
|
||||||
@ -97,7 +97,7 @@ test('[TEAMS]: owner cannot leave team', async ({ page }) => {
|
|||||||
createTeamMembers: 1,
|
createTeamMembers: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: team.owner.email,
|
email: team.owner.email,
|
||||||
password: 'password',
|
password: 'password',
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams';
|
import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
|
|
||||||
import { manualLogin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
@ -14,7 +14,7 @@ test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
|
|||||||
|
|
||||||
const teamMember = team.members[1];
|
const teamMember = team.members[1];
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: team.owner.email,
|
email: team.owner.email,
|
||||||
password: 'password',
|
password: 'password',
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
|||||||
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
|
||||||
import { manualLogin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
|
|||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: owner.email,
|
email: owner.email,
|
||||||
redirectPath: '/templates',
|
redirectPath: '/templates',
|
||||||
@ -81,7 +81,7 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
|
|||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: owner.email,
|
email: owner.email,
|
||||||
redirectPath: '/templates',
|
redirectPath: '/templates',
|
||||||
@ -135,7 +135,7 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
|
|||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: owner.email,
|
email: owner.email,
|
||||||
redirectPath: '/templates',
|
redirectPath: '/templates',
|
||||||
@ -181,7 +181,7 @@ test('[TEMPLATES]: use template', async ({ page }) => {
|
|||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: owner.email,
|
email: owner.email,
|
||||||
redirectPath: '/templates',
|
redirectPath: '/templates',
|
||||||
|
|||||||
@ -16,10 +16,24 @@ export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: s
|
|||||||
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
|
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
|
||||||
[UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
|
[UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
|
||||||
[UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled',
|
[UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled',
|
||||||
|
[UserSecurityAuditLogType.PASSKEY_CREATED]: 'Passkey created',
|
||||||
|
[UserSecurityAuditLogType.PASSKEY_DELETED]: 'Passkey deleted',
|
||||||
|
[UserSecurityAuditLogType.PASSKEY_UPDATED]: 'Passkey updated',
|
||||||
[UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset',
|
[UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset',
|
||||||
[UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated',
|
[UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated',
|
||||||
[UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out',
|
[UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out',
|
||||||
[UserSecurityAuditLogType.SIGN_IN]: 'Signed In',
|
[UserSecurityAuditLogType.SIGN_IN]: 'Signed In',
|
||||||
[UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed',
|
[UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed',
|
||||||
|
[UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL]: 'Passkey sign in failed',
|
||||||
[UserSecurityAuditLogType.SIGN_IN_2FA_FAIL]: 'Sign in 2FA attempt failed',
|
[UserSecurityAuditLogType.SIGN_IN_2FA_FAIL]: 'Sign in 2FA attempt failed',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The duration to wait for a passkey to be verified in MS.
|
||||||
|
*/
|
||||||
|
export const PASSKEY_TIMEOUT = 60000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum number of passkeys are user can have.
|
||||||
|
*/
|
||||||
|
export const MAXIMUM_PASSKEYS = 50;
|
||||||
|
|||||||
31
packages/lib/constants/document-auth.ts
Normal file
31
packages/lib/constants/document-auth.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { TDocumentAuth } from '../types/document-auth';
|
||||||
|
import { DocumentAuth } from '../types/document-auth';
|
||||||
|
|
||||||
|
type DocumentAuthTypeData = {
|
||||||
|
key: TDocumentAuth;
|
||||||
|
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> = {
|
||||||
|
[DocumentAuth.ACCOUNT]: {
|
||||||
|
key: DocumentAuth.ACCOUNT,
|
||||||
|
value: 'Require account',
|
||||||
|
isAuthRedirectRequired: true,
|
||||||
|
},
|
||||||
|
[DocumentAuth.PASSKEY]: {
|
||||||
|
key: DocumentAuth.PASSKEY,
|
||||||
|
value: 'Require passkey',
|
||||||
|
},
|
||||||
|
[DocumentAuth.EXPLICIT_NONE]: {
|
||||||
|
key: DocumentAuth.EXPLICIT_NONE,
|
||||||
|
value: 'None (Overrides global settings)',
|
||||||
|
},
|
||||||
|
} satisfies Record<TDocumentAuth, DocumentAuthTypeData>;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { env } from 'next-runtime-env';
|
import { env } from 'next-runtime-env';
|
||||||
|
|
||||||
import { APP_BASE_URL } from './app';
|
import { APP_BASE_URL, WEBAPP_BASE_URL } from './app';
|
||||||
|
|
||||||
const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
|
const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
|
||||||
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
|
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
|
||||||
@ -23,6 +23,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
|
|||||||
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||||
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
|
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
|
||||||
app_document_page_view_history_sheet: false,
|
app_document_page_view_history_sheet: false,
|
||||||
|
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.
|
||||||
marketing_header_single_player_mode: false,
|
marketing_header_single_player_mode: false,
|
||||||
marketing_profiles_announcement_bar: true,
|
marketing_profiles_announcement_bar: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -137,12 +137,16 @@ export class AppError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static parseFromJSONString(jsonString: string): AppError | null {
|
static parseFromJSONString(jsonString: string): AppError | null {
|
||||||
const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
|
try {
|
||||||
|
const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
|
||||||
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
/// <reference types="../types/next-auth.d.ts" />
|
/// <reference types="../types/next-auth.d.ts" />
|
||||||
import { PrismaAdapter } from '@next-auth/prisma-adapter';
|
import { PrismaAdapter } from '@next-auth/prisma-adapter';
|
||||||
import { compare } from '@node-rs/bcrypt';
|
import { compare } from '@node-rs/bcrypt';
|
||||||
|
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import type { AuthOptions, Session, User } from 'next-auth';
|
import type { AuthOptions, Session, User } from 'next-auth';
|
||||||
import type { JWT } from 'next-auth/jwt';
|
import type { JWT } from 'next-auth/jwt';
|
||||||
@ -12,12 +13,16 @@ import { env } from 'next-runtime-env';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||||
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||||
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
||||||
import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id';
|
import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id';
|
||||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||||
import { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
|
import { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
|
||||||
|
import type { TAuthenticationResponseJSONSchema } from '../types/webauthn';
|
||||||
|
import { ZAuthenticationResponseJSONSchema } from '../types/webauthn';
|
||||||
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
|
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
|
||||||
|
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 = {
|
||||||
@ -131,6 +136,114 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
CredentialsProvider({
|
||||||
|
id: 'webauthn',
|
||||||
|
name: 'Keypass',
|
||||||
|
credentials: {
|
||||||
|
csrfToken: { label: 'csrfToken', type: 'csrfToken' },
|
||||||
|
},
|
||||||
|
async authorize(credentials, req) {
|
||||||
|
const csrfToken = credentials?.csrfToken;
|
||||||
|
|
||||||
|
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestBodyCrediential: TAuthenticationResponseJSONSchema | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedBodyCredential = JSON.parse(req.body?.credential);
|
||||||
|
requestBodyCrediential = ZAuthenticationResponseJSONSchema.parse(parsedBodyCredential);
|
||||||
|
} catch {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
const challengeToken = await prisma.anonymousVerificationToken
|
||||||
|
.delete({
|
||||||
|
where: {
|
||||||
|
id: csrfToken,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (!challengeToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (challengeToken.expiresAt < new Date()) {
|
||||||
|
throw new AppError(AppErrorCode.EXPIRED_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passkey = await prisma.passkey.findFirst({
|
||||||
|
where: {
|
||||||
|
credentialId: Buffer.from(requestBodyCrediential.id, 'base64'),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
User: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
emailVerified: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!passkey) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_SETUP);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = passkey.User;
|
||||||
|
|
||||||
|
const { rpId, origin } = getAuthenticatorOptions();
|
||||||
|
|
||||||
|
const verification = await verifyAuthenticationResponse({
|
||||||
|
response: requestBodyCrediential,
|
||||||
|
expectedChallenge: challengeToken.token,
|
||||||
|
expectedOrigin: origin,
|
||||||
|
expectedRPID: rpId,
|
||||||
|
authenticator: {
|
||||||
|
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
|
||||||
|
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
|
||||||
|
counter: Number(passkey.counter),
|
||||||
|
},
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
const requestMetadata = extractNextAuthRequestMetadata(req);
|
||||||
|
|
||||||
|
// Explicit success state to reduce chances of bugs.
|
||||||
|
if (verification?.verified === true) {
|
||||||
|
await prisma.passkey.update({
|
||||||
|
where: {
|
||||||
|
id: passkey.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
lastUsedAt: new Date(),
|
||||||
|
counter: verification.authenticationInfo.newCounter,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(user.id),
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
emailVerified: user.emailVerified?.toISOString() ?? null,
|
||||||
|
} satisfies User;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
ipAddress: requestMetadata.ipAddress,
|
||||||
|
userAgent: requestMetadata.userAgent,
|
||||||
|
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, user, trigger, account }) {
|
async jwt({ token, user, trigger, account }) {
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
import { generateRegistrationOptions } from '@simplewebauthn/server';
|
||||||
|
import type { AuthenticatorTransportFuture } from '@simplewebauthn/types';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { PASSKEY_TIMEOUT } from '../../constants/auth';
|
||||||
|
import { getAuthenticatorOptions } from '../../utils/authenticator';
|
||||||
|
|
||||||
|
type CreatePasskeyRegistrationOptions = {
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPasskeyRegistrationOptions = async ({
|
||||||
|
userId,
|
||||||
|
}: CreatePasskeyRegistrationOptions) => {
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
passkeys: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { passkeys } = user;
|
||||||
|
|
||||||
|
const { rpName, rpId: rpID } = getAuthenticatorOptions();
|
||||||
|
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpName,
|
||||||
|
rpID,
|
||||||
|
userID: userId.toString(),
|
||||||
|
userName: user.email,
|
||||||
|
userDisplayName: user.name ?? undefined,
|
||||||
|
timeout: PASSKEY_TIMEOUT,
|
||||||
|
attestationType: 'none',
|
||||||
|
excludeCredentials: passkeys.map((passkey) => ({
|
||||||
|
id: passkey.credentialId,
|
||||||
|
type: 'public-key',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
transports: passkey.transports as AuthenticatorTransportFuture[],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.verificationToken.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
token: options.challenge,
|
||||||
|
expires: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||||
|
identifier: 'PASSKEY_CHALLENGE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return options;
|
||||||
|
};
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import { generateAuthenticationOptions } from '@simplewebauthn/server';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { getAuthenticatorOptions } from '../../utils/authenticator';
|
||||||
|
|
||||||
|
type CreatePasskeySigninOptions = {
|
||||||
|
sessionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => {
|
||||||
|
const { rpId, timeout } = getAuthenticatorOptions();
|
||||||
|
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: rpId,
|
||||||
|
userVerification: 'preferred',
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { challenge } = options;
|
||||||
|
|
||||||
|
await prisma.anonymousVerificationToken.upsert({
|
||||||
|
where: {
|
||||||
|
id: sessionId,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
token: challenge,
|
||||||
|
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: sessionId,
|
||||||
|
token: challenge,
|
||||||
|
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return options;
|
||||||
|
};
|
||||||
106
packages/lib/server-only/auth/create-passkey.ts
Normal file
106
packages/lib/server-only/auth/create-passkey.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { verifyRegistrationResponse } from '@simplewebauthn/server';
|
||||||
|
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { MAXIMUM_PASSKEYS } from '../../constants/auth';
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
|
import { getAuthenticatorOptions } from '../../utils/authenticator';
|
||||||
|
|
||||||
|
type CreatePasskeyOptions = {
|
||||||
|
userId: number;
|
||||||
|
passkeyName: string;
|
||||||
|
verificationResponse: RegistrationResponseJSON;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPasskey = async ({
|
||||||
|
userId,
|
||||||
|
passkeyName,
|
||||||
|
verificationResponse,
|
||||||
|
requestMetadata,
|
||||||
|
}: CreatePasskeyOptions) => {
|
||||||
|
const { _count } = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
passkeys: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_count.passkeys >= MAXIMUM_PASSKEYS) {
|
||||||
|
throw new AppError('TOO_MANY_PASSKEYS');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationToken = await prisma.verificationToken.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
identifier: 'PASSKEY_CHALLENGE',
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verificationToken) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, 'Challenge token not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.verificationToken.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
identifier: 'PASSKEY_CHALLENGE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (verificationToken.expires < new Date()) {
|
||||||
|
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorOptions();
|
||||||
|
|
||||||
|
const verification = await verifyRegistrationResponse({
|
||||||
|
response: verificationResponse,
|
||||||
|
expectedChallenge: verificationToken.token,
|
||||||
|
expectedOrigin,
|
||||||
|
expectedRPID,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verification.verified || !verification.registrationInfo) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Verification failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { credentialPublicKey, credentialID, counter, credentialDeviceType, credentialBackedUp } =
|
||||||
|
verification.registrationInfo;
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.passkey.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
name: passkeyName,
|
||||||
|
credentialId: Buffer.from(credentialID),
|
||||||
|
credentialPublicKey: Buffer.from(credentialPublicKey),
|
||||||
|
counter,
|
||||||
|
credentialDeviceType,
|
||||||
|
credentialBackedUp,
|
||||||
|
transports: verificationResponse.response.transports,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: UserSecurityAuditLogType.PASSKEY_CREATED,
|
||||||
|
userAgent: requestMetadata?.userAgent,
|
||||||
|
ipAddress: requestMetadata?.ipAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
41
packages/lib/server-only/auth/delete-passkey.ts
Normal file
41
packages/lib/server-only/auth/delete-passkey.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
|
|
||||||
|
export interface DeletePasskeyOptions {
|
||||||
|
userId: number;
|
||||||
|
passkeyId: string;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deletePasskey = async ({
|
||||||
|
userId,
|
||||||
|
passkeyId,
|
||||||
|
requestMetadata,
|
||||||
|
}: DeletePasskeyOptions) => {
|
||||||
|
await prisma.passkey.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: passkeyId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.passkey.delete({
|
||||||
|
where: {
|
||||||
|
id: passkeyId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: UserSecurityAuditLogType.PASSKEY_DELETED,
|
||||||
|
userAgent: requestMetadata?.userAgent,
|
||||||
|
ipAddress: requestMetadata?.ipAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
76
packages/lib/server-only/auth/find-passkeys.ts
Normal file
76
packages/lib/server-only/auth/find-passkeys.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { Passkey } from '@documenso/prisma/client';
|
||||||
|
import { Prisma } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export interface FindPasskeysOptions {
|
||||||
|
userId: number;
|
||||||
|
term?: string;
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
orderBy?: {
|
||||||
|
column: keyof Passkey;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
nulls?: Prisma.NullsOrder;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findPasskeys = async ({
|
||||||
|
userId,
|
||||||
|
term = '',
|
||||||
|
page = 1,
|
||||||
|
perPage = 10,
|
||||||
|
orderBy,
|
||||||
|
}: FindPasskeysOptions) => {
|
||||||
|
const orderByColumn = orderBy?.column ?? 'lastUsedAt';
|
||||||
|
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||||
|
const orderByNulls: Prisma.NullsOrder | undefined = orderBy?.nulls ?? 'last';
|
||||||
|
|
||||||
|
const whereClause: Prisma.PasskeyWhereInput = {
|
||||||
|
userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (term.length > 0) {
|
||||||
|
whereClause.name = {
|
||||||
|
contains: term,
|
||||||
|
mode: Prisma.QueryMode.insensitive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, count] = await Promise.all([
|
||||||
|
prisma.passkey.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
skip: Math.max(page - 1, 0) * perPage,
|
||||||
|
take: perPage,
|
||||||
|
orderBy: {
|
||||||
|
[orderByColumn]: {
|
||||||
|
sort: orderByDirection,
|
||||||
|
nulls: orderByNulls,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
lastUsedAt: true,
|
||||||
|
counter: true,
|
||||||
|
credentialDeviceType: true,
|
||||||
|
credentialBackedUp: true,
|
||||||
|
transports: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.passkey.count({
|
||||||
|
where: whereClause,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
count,
|
||||||
|
currentPage: Math.max(page, 1),
|
||||||
|
perPage,
|
||||||
|
totalPages: Math.ceil(count / perPage),
|
||||||
|
} satisfies FindResultSet<typeof data>;
|
||||||
|
};
|
||||||
51
packages/lib/server-only/auth/update-passkey.ts
Normal file
51
packages/lib/server-only/auth/update-passkey.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
|
|
||||||
|
export interface UpdateAuthenticatorsOptions {
|
||||||
|
userId: number;
|
||||||
|
passkeyId: string;
|
||||||
|
name: string;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updatePasskey = async ({
|
||||||
|
userId,
|
||||||
|
passkeyId,
|
||||||
|
name,
|
||||||
|
requestMetadata,
|
||||||
|
}: UpdateAuthenticatorsOptions) => {
|
||||||
|
const passkey = await prisma.passkey.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: passkeyId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (passkey.name === name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.passkey.update({
|
||||||
|
where: {
|
||||||
|
id: passkeyId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: UserSecurityAuditLogType.PASSKEY_UPDATED,
|
||||||
|
userAgent: requestMetadata?.userAgent,
|
||||||
|
ipAddress: requestMetadata?.ipAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -7,13 +7,19 @@ import { prisma } from '@documenso/prisma';
|
|||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||||
|
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||||
import { sealDocument } from './seal-document';
|
import { sealDocument } from './seal-document';
|
||||||
import { sendPendingEmail } from './send-pending-email';
|
import { sendPendingEmail } from './send-pending-email';
|
||||||
|
|
||||||
export type CompleteDocumentWithTokenOptions = {
|
export type CompleteDocumentWithTokenOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
|
userId?: number;
|
||||||
|
authOptions?: TRecipientActionAuth;
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -40,6 +46,8 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
|
|||||||
export const completeDocumentWithToken = async ({
|
export const completeDocumentWithToken = async ({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
|
userId,
|
||||||
|
authOptions,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: CompleteDocumentWithTokenOptions) => {
|
}: CompleteDocumentWithTokenOptions) => {
|
||||||
'use server';
|
'use server';
|
||||||
@ -71,32 +79,52 @@ export const completeDocumentWithToken = async ({
|
|||||||
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.recipient.update({
|
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||||
where: {
|
documentAuth: document.authOptions,
|
||||||
id: recipient.id,
|
recipientAuth: recipient.authOptions,
|
||||||
},
|
|
||||||
data: {
|
|
||||||
signingStatus: SigningStatus.SIGNED,
|
|
||||||
signedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.documentAuditLog.create({
|
const isValid = await isRecipientAuthorized({
|
||||||
data: createDocumentAuditLogData({
|
type: 'ACTION',
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
document: document,
|
||||||
documentId: document.id,
|
recipient: recipient,
|
||||||
user: {
|
userId,
|
||||||
name: recipient.name,
|
authOptions,
|
||||||
email: recipient.email,
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.recipient.update({
|
||||||
|
where: {
|
||||||
|
id: recipient.id,
|
||||||
},
|
},
|
||||||
requestMetadata,
|
|
||||||
data: {
|
data: {
|
||||||
recipientEmail: recipient.email,
|
signingStatus: SigningStatus.SIGNED,
|
||||||
recipientName: recipient.name,
|
signedAt: new Date(),
|
||||||
recipientId: recipient.id,
|
|
||||||
recipientRole: recipient.role,
|
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||||
|
documentId: document.id,
|
||||||
|
user: {
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
},
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
recipientEmail: recipient.email,
|
||||||
|
recipientName: recipient.name,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
recipientRole: recipient.role,
|
||||||
|
actionAuth: derivedRecipientActionAuth || undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const pendingRecipients = await prisma.recipient.count({
|
const pendingRecipients = await prisma.recipient.count({
|
||||||
|
|||||||
@ -1,16 +1,43 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import type { TDocumentAuthMethods } from '../../types/document-auth';
|
||||||
|
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||||
|
|
||||||
export interface GetDocumentAndSenderByTokenOptions {
|
export interface GetDocumentAndSenderByTokenOptions {
|
||||||
token: string;
|
token: string;
|
||||||
|
userId?: number;
|
||||||
|
accessAuth?: TDocumentAuthMethods;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether we enforce the access requirement.
|
||||||
|
*
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
requireAccessAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetDocumentAndRecipientByTokenOptions {
|
export interface GetDocumentAndRecipientByTokenOptions {
|
||||||
token: string;
|
token: string;
|
||||||
|
userId?: number;
|
||||||
|
accessAuth?: TDocumentAuthMethods;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether we enforce the access requirement.
|
||||||
|
*
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
requireAccessAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DocumentAndSender = Awaited<ReturnType<typeof getDocumentAndSenderByToken>>;
|
||||||
|
|
||||||
export const getDocumentAndSenderByToken = async ({
|
export const getDocumentAndSenderByToken = async ({
|
||||||
token,
|
token,
|
||||||
|
userId,
|
||||||
|
accessAuth,
|
||||||
|
requireAccessAuth = true,
|
||||||
}: GetDocumentAndSenderByTokenOptions) => {
|
}: GetDocumentAndSenderByTokenOptions) => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Missing token');
|
throw new Error('Missing token');
|
||||||
@ -28,12 +55,40 @@ export const getDocumentAndSenderByToken = async ({
|
|||||||
User: true,
|
User: true,
|
||||||
documentData: true,
|
documentData: true,
|
||||||
documentMeta: true,
|
documentMeta: true,
|
||||||
|
Recipient: {
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||||
const { password: _password, ...User } = result.User;
|
const { password: _password, ...User } = result.User;
|
||||||
|
|
||||||
|
const recipient = result.Recipient[0];
|
||||||
|
|
||||||
|
// Sanity check, should not be possible.
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Error('Missing recipient');
|
||||||
|
}
|
||||||
|
|
||||||
|
let documentAccessValid = true;
|
||||||
|
|
||||||
|
if (requireAccessAuth) {
|
||||||
|
documentAccessValid = await isRecipientAuthorized({
|
||||||
|
type: 'ACCESS',
|
||||||
|
document: result,
|
||||||
|
recipient,
|
||||||
|
userId,
|
||||||
|
authOptions: accessAuth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!documentAccessValid) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
User,
|
User,
|
||||||
@ -45,6 +100,9 @@ export const getDocumentAndSenderByToken = async ({
|
|||||||
*/
|
*/
|
||||||
export const getDocumentAndRecipientByToken = async ({
|
export const getDocumentAndRecipientByToken = async ({
|
||||||
token,
|
token,
|
||||||
|
userId,
|
||||||
|
accessAuth,
|
||||||
|
requireAccessAuth = true,
|
||||||
}: GetDocumentAndRecipientByTokenOptions): Promise<DocumentWithRecipient> => {
|
}: GetDocumentAndRecipientByTokenOptions): Promise<DocumentWithRecipient> => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Missing token');
|
throw new Error('Missing token');
|
||||||
@ -68,6 +126,29 @@ export const getDocumentAndRecipientByToken = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const recipient = result.Recipient[0];
|
||||||
|
|
||||||
|
// Sanity check, should not be possible.
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Error('Missing recipient');
|
||||||
|
}
|
||||||
|
|
||||||
|
let documentAccessValid = true;
|
||||||
|
|
||||||
|
if (requireAccessAuth) {
|
||||||
|
documentAccessValid = await isRecipientAuthorized({
|
||||||
|
type: 'ACCESS',
|
||||||
|
document: result,
|
||||||
|
recipient,
|
||||||
|
userId,
|
||||||
|
authOptions: accessAuth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!documentAccessValid) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
Recipient: result.Recipient,
|
Recipient: result.Recipient,
|
||||||
|
|||||||
209
packages/lib/server-only/document/is-recipient-authorized.ts
Normal file
209
packages/lib/server-only/document/is-recipient-authorized.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import type { TDocumentAuth, TDocumentAuthMethods } 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';
|
||||||
|
|
||||||
|
type IsRecipientAuthorizedOptions = {
|
||||||
|
type: 'ACCESS' | 'ACTION';
|
||||||
|
document: Document;
|
||||||
|
recipient: Recipient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the user who initiated the request.
|
||||||
|
*/
|
||||||
|
userId?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The auth details to check.
|
||||||
|
*
|
||||||
|
* Optional because there are scenarios where no auth options are required such as
|
||||||
|
* using the user ID.
|
||||||
|
*/
|
||||||
|
authOptions?: TDocumentAuthMethods;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRecipient = async (email: string) => {
|
||||||
|
return await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the recipient is authorized to perform the requested operation on a
|
||||||
|
* document, given the provided auth options.
|
||||||
|
*
|
||||||
|
* @returns True if the recipient can perform the requested operation.
|
||||||
|
*/
|
||||||
|
export const isRecipientAuthorized = async ({
|
||||||
|
type,
|
||||||
|
document,
|
||||||
|
recipient,
|
||||||
|
userId,
|
||||||
|
authOptions,
|
||||||
|
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
|
||||||
|
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipient.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authMethod: TDocumentAuth | null =
|
||||||
|
type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth;
|
||||||
|
|
||||||
|
// Early true return when auth is not required.
|
||||||
|
if (!authMethod || authMethod === DocumentAuth.EXPLICIT_NONE) {
|
||||||
|
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.
|
||||||
|
if (!authOptions || authOptions.type !== authMethod) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await match(authOptions)
|
||||||
|
.with({ type: DocumentAuth.ACCOUNT }, async () => {
|
||||||
|
if (userId === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientUser = await getRecipient(recipient.email);
|
||||||
|
|
||||||
|
if (!recipientUser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return recipientUser.id === userId;
|
||||||
|
})
|
||||||
|
.with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
|
||||||
|
if (!userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await isPasskeyAuthValid({
|
||||||
|
userId,
|
||||||
|
authenticationResponse,
|
||||||
|
tokenReference,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.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 to throw errors when the user fails verification instead of returning
|
||||||
|
* false.
|
||||||
|
*/
|
||||||
|
throwError?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const verificationToken = await prisma.verificationToken
|
||||||
|
.delete({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
secondaryId: tokenReference,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (!passkey) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -95,7 +95,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
|||||||
data: {
|
data: {
|
||||||
emailType: 'DOCUMENT_COMPLETED',
|
emailType: 'DOCUMENT_COMPLETED',
|
||||||
recipientEmail: owner.email,
|
recipientEmail: owner.email,
|
||||||
recipientName: owner.name,
|
recipientName: owner.name ?? '',
|
||||||
recipientId: owner.id,
|
recipientId: owner.id,
|
||||||
recipientRole: 'OWNER',
|
recipientRole: 'OWNER',
|
||||||
isResending: false,
|
isResending: false,
|
||||||
|
|||||||
162
packages/lib/server-only/document/update-document-settings.ts
Normal file
162
packages/lib/server-only/document/update-document-settings.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
|
||||||
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||||
|
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||||
|
|
||||||
|
export type UpdateDocumentSettingsOptions = {
|
||||||
|
userId: number;
|
||||||
|
teamId?: number;
|
||||||
|
documentId: number;
|
||||||
|
data: {
|
||||||
|
title?: string;
|
||||||
|
globalAccessAuth?: TDocumentAccessAuthTypes | null;
|
||||||
|
globalActionAuth?: TDocumentActionAuthTypes | null;
|
||||||
|
};
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateDocumentSettings = async ({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
documentId,
|
||||||
|
data,
|
||||||
|
requestMetadata,
|
||||||
|
}: UpdateDocumentSettingsOptions) => {
|
||||||
|
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
...(teamId
|
||||||
|
? {
|
||||||
|
team: {
|
||||||
|
id: teamId,
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
userId,
|
||||||
|
teamId: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { documentAuthOption } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
|
||||||
|
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
|
||||||
|
|
||||||
|
// If the new global auth values aren't passed in, fallback to the current document values.
|
||||||
|
const newGlobalAccessAuth =
|
||||||
|
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
|
||||||
|
const newGlobalActionAuth =
|
||||||
|
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
||||||
|
|
||||||
|
const isTitleSame = data.title === document.title;
|
||||||
|
const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth;
|
||||||
|
const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth;
|
||||||
|
|
||||||
|
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
|
||||||
|
|
||||||
|
if (!isTitleSame && document.status !== DocumentStatus.DRAFT) {
|
||||||
|
throw new AppError(
|
||||||
|
AppErrorCode.INVALID_BODY,
|
||||||
|
'You cannot update the title if the document has been sent',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTitleSame) {
|
||||||
|
auditLogs.push(
|
||||||
|
createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
|
||||||
|
documentId,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
from: document.title,
|
||||||
|
to: data.title || '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isGlobalAccessSame) {
|
||||||
|
auditLogs.push(
|
||||||
|
createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED,
|
||||||
|
documentId,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
from: documentGlobalAccessAuth,
|
||||||
|
to: newGlobalAccessAuth,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isGlobalActionSame) {
|
||||||
|
auditLogs.push(
|
||||||
|
createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED,
|
||||||
|
documentId,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
from: documentGlobalActionAuth,
|
||||||
|
to: newGlobalActionAuth,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Early return if nothing is required.
|
||||||
|
if (auditLogs.length === 0) {
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const authOptions = createDocumentAuthOptions({
|
||||||
|
globalAccessAuth: newGlobalAccessAuth,
|
||||||
|
globalActionAuth: newGlobalActionAuth,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedDocument = await tx.document.update({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
authOptions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.createMany({
|
||||||
|
data: auditLogs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedDocument;
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -5,15 +5,21 @@ import { prisma } from '@documenso/prisma';
|
|||||||
import { ReadStatus } from '@documenso/prisma/client';
|
import { ReadStatus } from '@documenso/prisma/client';
|
||||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import type { TDocumentAccessAuthTypes } from '../../types/document-auth';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
import { getDocumentAndRecipientByToken } from './get-document-by-token';
|
import { getDocumentAndRecipientByToken } from './get-document-by-token';
|
||||||
|
|
||||||
export type ViewedDocumentOptions = {
|
export type ViewedDocumentOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
|
recipientAccessAuth?: TDocumentAccessAuthTypes | null;
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentOptions) => {
|
export const viewedDocument = async ({
|
||||||
|
token,
|
||||||
|
recipientAccessAuth,
|
||||||
|
requestMetadata,
|
||||||
|
}: ViewedDocumentOptions) => {
|
||||||
const recipient = await prisma.recipient.findFirst({
|
const recipient = await prisma.recipient.findFirst({
|
||||||
where: {
|
where: {
|
||||||
token,
|
token,
|
||||||
@ -51,12 +57,13 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO
|
|||||||
recipientId: recipient.id,
|
recipientId: recipient.id,
|
||||||
recipientName: recipient.name,
|
recipientName: recipient.name,
|
||||||
recipientRole: recipient.role,
|
recipientRole: recipient.role,
|
||||||
|
accessAuth: recipientAccessAuth || undefined,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const document = await getDocumentAndRecipientByToken({ token });
|
const document = await getDocumentAndRecipientByToken({ token, requireAccessAuth: false });
|
||||||
|
|
||||||
await triggerWebhook({
|
await triggerWebhook({
|
||||||
event: WebhookTriggerEvents.DOCUMENT_OPENED,
|
event: WebhookTriggerEvents.DOCUMENT_OPENED,
|
||||||
|
|||||||
@ -8,15 +8,21 @@ import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/clie
|
|||||||
|
|
||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||||
|
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
|
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||||
|
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
|
||||||
|
|
||||||
export type SignFieldWithTokenOptions = {
|
export type SignFieldWithTokenOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
fieldId: number;
|
fieldId: number;
|
||||||
value: string;
|
value: string;
|
||||||
isBase64?: boolean;
|
isBase64?: boolean;
|
||||||
|
userId?: number;
|
||||||
|
authOptions?: TRecipientActionAuth;
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -25,6 +31,8 @@ export const signFieldWithToken = async ({
|
|||||||
fieldId,
|
fieldId,
|
||||||
value,
|
value,
|
||||||
isBase64,
|
isBase64,
|
||||||
|
userId,
|
||||||
|
authOptions,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: SignFieldWithTokenOptions) => {
|
}: SignFieldWithTokenOptions) => {
|
||||||
const field = await prisma.field.findFirstOrThrow({
|
const field = await prisma.field.findFirstOrThrow({
|
||||||
@ -71,6 +79,23 @@ export const signFieldWithToken = async ({
|
|||||||
throw new Error(`Field ${fieldId} has no recipientId`);
|
throw new Error(`Field ${fieldId} has no recipientId`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipient.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isValid = await isRecipientAuthorized({
|
||||||
|
type: 'ACTION',
|
||||||
|
document: document,
|
||||||
|
recipient: recipient,
|
||||||
|
userId,
|
||||||
|
authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
|
||||||
|
}
|
||||||
|
|
||||||
const documentMeta = await prisma.documentMeta.findFirst({
|
const documentMeta = await prisma.documentMeta.findFirst({
|
||||||
where: {
|
where: {
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
@ -158,9 +183,11 @@ export const signFieldWithToken = async ({
|
|||||||
data: updatedField.customText,
|
data: updatedField.customText,
|
||||||
}))
|
}))
|
||||||
.exhaustive(),
|
.exhaustive(),
|
||||||
fieldSecurity: {
|
fieldSecurity: derivedRecipientActionAuth
|
||||||
type: 'NONE',
|
? {
|
||||||
},
|
type: derivedRecipientActionAuth,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { FieldType, Team } from '@documenso/prisma/client';
|
import type { FieldType, Team } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData, diffFieldChanges } from '../../utils/document-audit-logs';
|
||||||
|
|
||||||
export type UpdateFieldOptions = {
|
export type UpdateFieldOptions = {
|
||||||
fieldId: number;
|
fieldId: number;
|
||||||
@ -33,7 +34,7 @@ export const updateField = async ({
|
|||||||
pageHeight,
|
pageHeight,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: UpdateFieldOptions) => {
|
}: UpdateFieldOptions) => {
|
||||||
const field = await prisma.field.update({
|
const oldField = await prisma.field.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: fieldId,
|
id: fieldId,
|
||||||
Document: {
|
Document: {
|
||||||
@ -55,23 +56,49 @@ export const updateField = async ({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: {
|
|
||||||
recipientId,
|
|
||||||
type,
|
|
||||||
page: pageNumber,
|
|
||||||
positionX: pageX,
|
|
||||||
positionY: pageY,
|
|
||||||
width: pageWidth,
|
|
||||||
height: pageHeight,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
Recipient: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!field) {
|
const field = prisma.$transaction(async (tx) => {
|
||||||
throw new Error('Field not found');
|
const updatedField = await tx.field.update({
|
||||||
}
|
where: {
|
||||||
|
id: fieldId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
recipientId,
|
||||||
|
type,
|
||||||
|
page: pageNumber,
|
||||||
|
positionX: pageX,
|
||||||
|
positionY: pageY,
|
||||||
|
width: pageWidth,
|
||||||
|
height: pageHeight,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Recipient: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
|
||||||
|
documentId,
|
||||||
|
user: {
|
||||||
|
id: team?.id ?? user.id,
|
||||||
|
email: team?.name ?? user.email,
|
||||||
|
name: team ? '' : user.name,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
fieldId: updatedField.secondaryId,
|
||||||
|
fieldRecipientEmail: updatedField.Recipient?.email ?? '',
|
||||||
|
fieldRecipientId: recipientId ?? -1,
|
||||||
|
fieldType: updatedField.type,
|
||||||
|
changes: diffFieldChanges(oldField, updatedField),
|
||||||
|
},
|
||||||
|
requestMetadata,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedField;
|
||||||
|
});
|
||||||
|
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@ -99,24 +126,5 @@ export const updateField = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.documentAuditLog.create({
|
|
||||||
data: createDocumentAuditLogData({
|
|
||||||
type: 'FIELD_UPDATED',
|
|
||||||
documentId,
|
|
||||||
user: {
|
|
||||||
id: team?.id ?? user.id,
|
|
||||||
email: team?.name ?? user.email,
|
|
||||||
name: team ? '' : user.name,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
fieldId: field.secondaryId,
|
|
||||||
fieldRecipientEmail: field.Recipient?.email ?? '',
|
|
||||||
fieldRecipientId: recipientId ?? -1,
|
|
||||||
fieldType: field.type,
|
|
||||||
},
|
|
||||||
requestMetadata,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return field;
|
return field;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import {
|
||||||
|
type TRecipientActionAuthTypes,
|
||||||
|
ZRecipientAuthOptionsSchema,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import {
|
import {
|
||||||
createDocumentAuditLogData,
|
createDocumentAuditLogData,
|
||||||
diffRecipientChanges,
|
diffRecipientChanges,
|
||||||
} from '@documenso/lib/utils/document-audit-logs';
|
} from '@documenso/lib/utils/document-audit-logs';
|
||||||
|
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { RecipientRole } from '@documenso/prisma/client';
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
@ -18,6 +23,7 @@ export interface SetRecipientsForDocumentOptions {
|
|||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: RecipientRole;
|
role: RecipientRole;
|
||||||
|
actionAuth?: TRecipientActionAuthTypes | null;
|
||||||
}[];
|
}[];
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
}
|
}
|
||||||
@ -111,6 +117,15 @@ export const setRecipientsForDocument = async ({
|
|||||||
const persistedRecipients = await prisma.$transaction(async (tx) => {
|
const persistedRecipients = await prisma.$transaction(async (tx) => {
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
linkedRecipients.map(async (recipient) => {
|
linkedRecipients.map(async (recipient) => {
|
||||||
|
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions);
|
||||||
|
|
||||||
|
if (recipient.actionAuth !== undefined) {
|
||||||
|
authOptions = createRecipientAuthOptions({
|
||||||
|
accessAuth: authOptions.accessAuth,
|
||||||
|
actionAuth: recipient.actionAuth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const upsertedRecipient = await tx.recipient.upsert({
|
const upsertedRecipient = await tx.recipient.upsert({
|
||||||
where: {
|
where: {
|
||||||
id: recipient._persisted?.id ?? -1,
|
id: recipient._persisted?.id ?? -1,
|
||||||
@ -124,6 +139,7 @@ export const setRecipientsForDocument = async ({
|
|||||||
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||||
signingStatus:
|
signingStatus:
|
||||||
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||||
|
authOptions,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
name: recipient.name,
|
name: recipient.name,
|
||||||
@ -134,6 +150,7 @@ export const setRecipientsForDocument = async ({
|
|||||||
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||||
signingStatus:
|
signingStatus:
|
||||||
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||||
|
authOptions,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -187,7 +204,10 @@ export const setRecipientsForDocument = async ({
|
|||||||
documentId: documentId,
|
documentId: documentId,
|
||||||
user,
|
user,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
data: baseAuditLog,
|
data: {
|
||||||
|
...baseAuditLog,
|
||||||
|
actionAuth: recipient.actionAuth || undefined,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { ZRecipientActionAuthTypesSchema } from './document-auth';
|
||||||
|
|
||||||
export const ZDocumentAuditLogTypeSchema = z.enum([
|
export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||||
// Document actions.
|
// Document actions.
|
||||||
'EMAIL_SENT',
|
'EMAIL_SENT',
|
||||||
@ -26,6 +28,8 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
|||||||
'DOCUMENT_DELETED', // When the document is soft deleted.
|
'DOCUMENT_DELETED', // When the document is soft deleted.
|
||||||
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
|
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
|
||||||
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
|
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
|
||||||
|
'DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED', // When the global access authentication is updated.
|
||||||
|
'DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED', // When the global action authentication is updated.
|
||||||
'DOCUMENT_META_UPDATED', // When the document meta data is updated.
|
'DOCUMENT_META_UPDATED', // When the document meta data is updated.
|
||||||
'DOCUMENT_OPENED', // When the document is opened by a recipient.
|
'DOCUMENT_OPENED', // When the document is opened by a recipient.
|
||||||
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
|
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
|
||||||
@ -51,7 +55,13 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']);
|
export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']);
|
||||||
export const ZRecipientDiffTypeSchema = z.enum(['NAME', 'ROLE', 'EMAIL']);
|
export const ZRecipientDiffTypeSchema = z.enum([
|
||||||
|
'NAME',
|
||||||
|
'ROLE',
|
||||||
|
'EMAIL',
|
||||||
|
'ACCESS_AUTH',
|
||||||
|
'ACTION_AUTH',
|
||||||
|
]);
|
||||||
|
|
||||||
export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum;
|
export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum;
|
||||||
export const DOCUMENT_EMAIL_TYPE = ZDocumentAuditLogEmailTypeSchema.Enum;
|
export const DOCUMENT_EMAIL_TYPE = ZDocumentAuditLogEmailTypeSchema.Enum;
|
||||||
@ -107,25 +117,34 @@ export const ZDocumentAuditLogFieldDiffSchema = z.union([
|
|||||||
ZFieldDiffPositionSchema,
|
ZFieldDiffPositionSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ZRecipientDiffNameSchema = z.object({
|
export const ZGenericFromToSchema = z.object({
|
||||||
|
from: z.string().nullable(),
|
||||||
|
to: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZRecipientDiffActionAuthSchema = ZGenericFromToSchema.extend({
|
||||||
|
type: z.literal(RECIPIENT_DIFF_TYPE.ACCESS_AUTH),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZRecipientDiffAccessAuthSchema = ZGenericFromToSchema.extend({
|
||||||
|
type: z.literal(RECIPIENT_DIFF_TYPE.ACTION_AUTH),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZRecipientDiffNameSchema = ZGenericFromToSchema.extend({
|
||||||
type: z.literal(RECIPIENT_DIFF_TYPE.NAME),
|
type: z.literal(RECIPIENT_DIFF_TYPE.NAME),
|
||||||
from: z.string(),
|
|
||||||
to: z.string(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZRecipientDiffRoleSchema = z.object({
|
export const ZRecipientDiffRoleSchema = ZGenericFromToSchema.extend({
|
||||||
type: z.literal(RECIPIENT_DIFF_TYPE.ROLE),
|
type: z.literal(RECIPIENT_DIFF_TYPE.ROLE),
|
||||||
from: z.string(),
|
|
||||||
to: z.string(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZRecipientDiffEmailSchema = z.object({
|
export const ZRecipientDiffEmailSchema = ZGenericFromToSchema.extend({
|
||||||
type: z.literal(RECIPIENT_DIFF_TYPE.EMAIL),
|
type: z.literal(RECIPIENT_DIFF_TYPE.EMAIL),
|
||||||
from: z.string(),
|
|
||||||
to: z.string(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZDocumentAuditLogRecipientDiffSchema = z.union([
|
export const ZDocumentAuditLogRecipientDiffSchema = z.discriminatedUnion('type', [
|
||||||
|
ZRecipientDiffActionAuthSchema,
|
||||||
|
ZRecipientDiffAccessAuthSchema,
|
||||||
ZRecipientDiffNameSchema,
|
ZRecipientDiffNameSchema,
|
||||||
ZRecipientDiffRoleSchema,
|
ZRecipientDiffRoleSchema,
|
||||||
ZRecipientDiffEmailSchema,
|
ZRecipientDiffEmailSchema,
|
||||||
@ -217,11 +236,11 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
|
|||||||
data: z.string(),
|
data: z.string(),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
|
fieldSecurity: z
|
||||||
// Todo: Replace with union once we have more field security types.
|
.object({
|
||||||
fieldSecurity: z.object({
|
type: ZRecipientActionAuthTypesSchema,
|
||||||
type: z.literal('NONE'),
|
})
|
||||||
}),
|
.optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -236,6 +255,22 @@ export const ZDocumentAuditLogEventDocumentFieldUninsertedSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Document global authentication access updated.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED),
|
||||||
|
data: ZGenericFromToSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Document global authentication action updated.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED),
|
||||||
|
data: ZGenericFromToSchema,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event: Document meta updated.
|
* Event: Document meta updated.
|
||||||
*/
|
*/
|
||||||
@ -251,7 +286,9 @@ export const ZDocumentAuditLogEventDocumentMetaUpdatedSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
|
export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
|
||||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED),
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED),
|
||||||
data: ZBaseRecipientDataSchema,
|
data: ZBaseRecipientDataSchema.extend({
|
||||||
|
accessAuth: z.string().optional(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -259,7 +296,9 @@ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
|
export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
|
||||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED),
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED),
|
||||||
data: ZBaseRecipientDataSchema,
|
data: ZBaseRecipientDataSchema.extend({
|
||||||
|
actionAuth: z.string().optional(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -303,7 +342,9 @@ export const ZDocumentAuditLogEventFieldRemovedSchema = z.object({
|
|||||||
export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({
|
export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({
|
||||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED),
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED),
|
||||||
data: ZBaseFieldEventDataSchema.extend({
|
data: ZBaseFieldEventDataSchema.extend({
|
||||||
changes: z.array(ZDocumentAuditLogFieldDiffSchema),
|
// Provide an empty array as a migration workaround due to a mistake where we were
|
||||||
|
// not passing through any changes via API/v1 due to a type error.
|
||||||
|
changes: z.preprocess((x) => x || [], z.array(ZDocumentAuditLogFieldDiffSchema)),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -312,7 +353,9 @@ export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({
|
export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({
|
||||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED),
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED),
|
||||||
data: ZBaseRecipientDataSchema,
|
data: ZBaseRecipientDataSchema.extend({
|
||||||
|
actionAuth: ZRecipientActionAuthTypesSchema.optional(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -352,6 +395,8 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
|||||||
ZDocumentAuditLogEventDocumentDeletedSchema,
|
ZDocumentAuditLogEventDocumentDeletedSchema,
|
||||||
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
|
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
|
||||||
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
|
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema,
|
||||||
ZDocumentAuditLogEventDocumentMetaUpdatedSchema,
|
ZDocumentAuditLogEventDocumentMetaUpdatedSchema,
|
||||||
ZDocumentAuditLogEventDocumentOpenedSchema,
|
ZDocumentAuditLogEventDocumentOpenedSchema,
|
||||||
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
|
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
|
||||||
|
|||||||
135
packages/lib/types/document-auth.ts
Normal file
135
packages/lib/types/document-auth.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZAuthenticationResponseJSONSchema } from './webauthn';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All the available types of document authentication options for both access and action.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'PASSKEY', 'EXPLICIT_NONE']);
|
||||||
|
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
|
||||||
|
|
||||||
|
const ZDocumentAuthAccountSchema = z.object({
|
||||||
|
type: z.literal(DocumentAuth.ACCOUNT),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZDocumentAuthExplicitNoneSchema = z.object({
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
|
||||||
|
ZDocumentAuthAccountSchema,
|
||||||
|
ZDocumentAuthExplicitNoneSchema,
|
||||||
|
ZDocumentAuthPasskeySchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The global document access auth methods.
|
||||||
|
*
|
||||||
|
* Must keep these two in sync.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]);
|
||||||
|
export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The global document action auth methods.
|
||||||
|
*
|
||||||
|
* Must keep these two in sync.
|
||||||
|
*/
|
||||||
|
export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
|
||||||
|
ZDocumentAuthAccountSchema,
|
||||||
|
ZDocumentAuthPasskeySchema,
|
||||||
|
]);
|
||||||
|
export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recipient access auth methods.
|
||||||
|
*
|
||||||
|
* Must keep these two in sync.
|
||||||
|
*/
|
||||||
|
export const ZRecipientAccessAuthSchema = z.discriminatedUnion('type', [
|
||||||
|
ZDocumentAuthAccountSchema,
|
||||||
|
]);
|
||||||
|
export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The recipient action auth methods.
|
||||||
|
*
|
||||||
|
* Must keep these two in sync.
|
||||||
|
*/
|
||||||
|
export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
|
||||||
|
ZDocumentAuthAccountSchema,
|
||||||
|
ZDocumentAuthPasskeySchema,
|
||||||
|
ZDocumentAuthExplicitNoneSchema,
|
||||||
|
]);
|
||||||
|
export const ZRecipientActionAuthTypesSchema = z.enum([
|
||||||
|
DocumentAuth.ACCOUNT,
|
||||||
|
DocumentAuth.PASSKEY,
|
||||||
|
DocumentAuth.EXPLICIT_NONE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const DocumentAccessAuth = ZDocumentAccessAuthTypesSchema.Enum;
|
||||||
|
export const DocumentActionAuth = ZDocumentActionAuthTypesSchema.Enum;
|
||||||
|
export const RecipientAccessAuth = ZRecipientAccessAuthTypesSchema.Enum;
|
||||||
|
export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication options attached to the document.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuthOptionsSchema = z.preprocess(
|
||||||
|
(unknownValue) => {
|
||||||
|
if (unknownValue) {
|
||||||
|
return unknownValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
globalAccessAuth: null,
|
||||||
|
globalActionAuth: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
z.object({
|
||||||
|
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable(),
|
||||||
|
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication options attached to the recipient.
|
||||||
|
*/
|
||||||
|
export const ZRecipientAuthOptionsSchema = z.preprocess(
|
||||||
|
(unknownValue) => {
|
||||||
|
if (unknownValue) {
|
||||||
|
return unknownValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessAuth: null,
|
||||||
|
actionAuth: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
z.object({
|
||||||
|
accessAuth: ZRecipientAccessAuthTypesSchema.nullable(),
|
||||||
|
actionAuth: ZRecipientActionAuthTypesSchema.nullable(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TDocumentAuth = z.infer<typeof ZDocumentAuthTypesSchema>;
|
||||||
|
export type TDocumentAuthMethods = z.infer<typeof ZDocumentAuthMethodsSchema>;
|
||||||
|
export type TDocumentAuthOptions = z.infer<typeof ZDocumentAuthOptionsSchema>;
|
||||||
|
export type TDocumentAccessAuth = z.infer<typeof ZDocumentAccessAuthSchema>;
|
||||||
|
export type TDocumentAccessAuthTypes = z.infer<typeof ZDocumentAccessAuthTypesSchema>;
|
||||||
|
export type TDocumentActionAuth = z.infer<typeof ZDocumentActionAuthSchema>;
|
||||||
|
export type TDocumentActionAuthTypes = z.infer<typeof ZDocumentActionAuthTypesSchema>;
|
||||||
|
export type TRecipientAccessAuth = z.infer<typeof ZRecipientAccessAuthSchema>;
|
||||||
|
export type TRecipientAccessAuthTypes = z.infer<typeof ZRecipientAccessAuthTypesSchema>;
|
||||||
|
export type TRecipientActionAuth = z.infer<typeof ZRecipientActionAuthSchema>;
|
||||||
|
export type TRecipientActionAuthTypes = z.infer<typeof ZRecipientActionAuthTypesSchema>;
|
||||||
|
export type TRecipientAuthOptions = z.infer<typeof ZRecipientAuthOptionsSchema>;
|
||||||
44
packages/lib/types/webauthn.ts
Normal file
44
packages/lib/types/webauthn.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const ZClientExtensionResults = z.object({
|
||||||
|
appid: z.boolean().optional(),
|
||||||
|
credProps: z
|
||||||
|
.object({
|
||||||
|
rk: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
hmacCreateSecret: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZAuthenticationResponseJSONSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
rawId: z.string(),
|
||||||
|
response: z.object({
|
||||||
|
clientDataJSON: z.string(),
|
||||||
|
authenticatorData: z.string(),
|
||||||
|
signature: z.string(),
|
||||||
|
userHandle: z.string().optional(),
|
||||||
|
}),
|
||||||
|
authenticatorAttachment: z.union([z.literal('cross-platform'), z.literal('platform')]).optional(),
|
||||||
|
clientExtensionResults: ZClientExtensionResults,
|
||||||
|
type: z.literal('public-key'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZRegistrationResponseJSONSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
rawId: z.string(),
|
||||||
|
response: z.object({
|
||||||
|
clientDataJSON: z.string(),
|
||||||
|
attestationObject: z.string(),
|
||||||
|
authenticatorData: z.string().optional(),
|
||||||
|
transports: z.array(z.string()).optional(),
|
||||||
|
publicKeyAlgorithm: z.number().optional(),
|
||||||
|
publicKey: z.string().optional(),
|
||||||
|
}),
|
||||||
|
authenticatorAttachment: z.string().optional(),
|
||||||
|
clientExtensionResults: ZClientExtensionResults.optional(),
|
||||||
|
type: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAuthenticationResponseJSONSchema = z.infer<typeof ZAuthenticationResponseJSONSchema>;
|
||||||
|
export type TRegistrationResponseJSONSchema = z.infer<typeof ZRegistrationResponseJSONSchema>;
|
||||||
17
packages/lib/utils/authenticator.ts
Normal file
17
packages/lib/utils/authenticator.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { WEBAPP_BASE_URL } from '../constants/app';
|
||||||
|
import { PASSKEY_TIMEOUT } from '../constants/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts common fields to identify the RP (relying party)
|
||||||
|
*/
|
||||||
|
export const getAuthenticatorOptions = () => {
|
||||||
|
const webAppBaseUrl = new URL(WEBAPP_BASE_URL);
|
||||||
|
const rpId = webAppBaseUrl.hostname;
|
||||||
|
|
||||||
|
return {
|
||||||
|
rpName: 'Documenso',
|
||||||
|
rpId,
|
||||||
|
origin: WEBAPP_BASE_URL,
|
||||||
|
timeout: PASSKEY_TIMEOUT,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -22,6 +22,7 @@ import {
|
|||||||
RECIPIENT_DIFF_TYPE,
|
RECIPIENT_DIFF_TYPE,
|
||||||
ZDocumentAuditLogSchema,
|
ZDocumentAuditLogSchema,
|
||||||
} from '../types/document-audit-logs';
|
} from '../types/document-audit-logs';
|
||||||
|
import { ZRecipientAuthOptionsSchema } from '../types/document-auth';
|
||||||
import type { RequestMetadata } from '../universal/extract-request-metadata';
|
import type { RequestMetadata } from '../universal/extract-request-metadata';
|
||||||
|
|
||||||
type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
|
type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
|
||||||
@ -32,20 +33,20 @@ type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
|
|||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CreateDocumentAuditLogDataResponse = Pick<
|
export type CreateDocumentAuditLogDataResponse = Pick<
|
||||||
DocumentAuditLog,
|
DocumentAuditLog,
|
||||||
'type' | 'ipAddress' | 'userAgent' | 'email' | 'userId' | 'name' | 'documentId'
|
'type' | 'ipAddress' | 'userAgent' | 'email' | 'userId' | 'name' | 'documentId'
|
||||||
> & {
|
> & {
|
||||||
data: TDocumentAuditLog['data'];
|
data: TDocumentAuditLog['data'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createDocumentAuditLogData = ({
|
export const createDocumentAuditLogData = <T extends TDocumentAuditLog['type']>({
|
||||||
documentId,
|
documentId,
|
||||||
type,
|
type,
|
||||||
data,
|
data,
|
||||||
user,
|
user,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: CreateDocumentAuditLogDataOptions): CreateDocumentAuditLogDataResponse => {
|
}: CreateDocumentAuditLogDataOptions<T>): CreateDocumentAuditLogDataResponse => {
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
data,
|
data,
|
||||||
@ -68,6 +69,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument
|
|||||||
|
|
||||||
// Handle any required migrations here.
|
// Handle any required migrations here.
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
|
// Todo: Alert us.
|
||||||
console.error(data.error);
|
console.error(data.error);
|
||||||
throw new Error('Migration required');
|
throw new Error('Migration required');
|
||||||
}
|
}
|
||||||
@ -75,7 +77,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument
|
|||||||
return data.data;
|
return data.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role'>;
|
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role' | 'authOptions'>;
|
||||||
|
|
||||||
export const diffRecipientChanges = (
|
export const diffRecipientChanges = (
|
||||||
oldRecipient: PartialRecipient,
|
oldRecipient: PartialRecipient,
|
||||||
@ -83,6 +85,32 @@ export const diffRecipientChanges = (
|
|||||||
): TDocumentAuditLogRecipientDiffSchema[] => {
|
): TDocumentAuditLogRecipientDiffSchema[] => {
|
||||||
const diffs: TDocumentAuditLogRecipientDiffSchema[] = [];
|
const diffs: TDocumentAuditLogRecipientDiffSchema[] = [];
|
||||||
|
|
||||||
|
const oldAuthOptions = ZRecipientAuthOptionsSchema.parse(oldRecipient.authOptions);
|
||||||
|
const oldAccessAuth = oldAuthOptions.accessAuth;
|
||||||
|
const oldActionAuth = oldAuthOptions.actionAuth;
|
||||||
|
|
||||||
|
const newAuthOptions = ZRecipientAuthOptionsSchema.parse(newRecipient.authOptions);
|
||||||
|
const newAccessAuth =
|
||||||
|
newAuthOptions?.accessAuth === undefined ? oldAccessAuth : newAuthOptions.accessAuth;
|
||||||
|
const newActionAuth =
|
||||||
|
newAuthOptions?.actionAuth === undefined ? oldActionAuth : newAuthOptions.actionAuth;
|
||||||
|
|
||||||
|
if (oldAccessAuth !== newAccessAuth) {
|
||||||
|
diffs.push({
|
||||||
|
type: RECIPIENT_DIFF_TYPE.ACCESS_AUTH,
|
||||||
|
from: oldAccessAuth ?? '',
|
||||||
|
to: newAccessAuth ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldActionAuth !== newActionAuth) {
|
||||||
|
diffs.push({
|
||||||
|
type: RECIPIENT_DIFF_TYPE.ACTION_AUTH,
|
||||||
|
from: oldActionAuth ?? '',
|
||||||
|
to: newActionAuth ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (oldRecipient.email !== newRecipient.email) {
|
if (oldRecipient.email !== newRecipient.email) {
|
||||||
diffs.push({
|
diffs.push({
|
||||||
type: RECIPIENT_DIFF_TYPE.EMAIL,
|
type: RECIPIENT_DIFF_TYPE.EMAIL,
|
||||||
@ -166,7 +194,13 @@ export const diffDocumentMetaChanges = (
|
|||||||
const oldPassword = oldData?.password ?? null;
|
const oldPassword = oldData?.password ?? null;
|
||||||
const oldRedirectUrl = oldData?.redirectUrl ?? '';
|
const oldRedirectUrl = oldData?.redirectUrl ?? '';
|
||||||
|
|
||||||
if (oldDateFormat !== newData.dateFormat) {
|
const newDateFormat = newData?.dateFormat ?? '';
|
||||||
|
const newMessage = newData?.message ?? '';
|
||||||
|
const newSubject = newData?.subject ?? '';
|
||||||
|
const newTimezone = newData?.timezone ?? '';
|
||||||
|
const newRedirectUrl = newData?.redirectUrl ?? '';
|
||||||
|
|
||||||
|
if (oldDateFormat !== newDateFormat) {
|
||||||
diffs.push({
|
diffs.push({
|
||||||
type: DOCUMENT_META_DIFF_TYPE.DATE_FORMAT,
|
type: DOCUMENT_META_DIFF_TYPE.DATE_FORMAT,
|
||||||
from: oldData?.dateFormat ?? '',
|
from: oldData?.dateFormat ?? '',
|
||||||
@ -174,35 +208,35 @@ export const diffDocumentMetaChanges = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldMessage !== newData.message) {
|
if (oldMessage !== newMessage) {
|
||||||
diffs.push({
|
diffs.push({
|
||||||
type: DOCUMENT_META_DIFF_TYPE.MESSAGE,
|
type: DOCUMENT_META_DIFF_TYPE.MESSAGE,
|
||||||
from: oldMessage,
|
from: oldMessage,
|
||||||
to: newData.message,
|
to: newMessage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldSubject !== newData.subject) {
|
if (oldSubject !== newSubject) {
|
||||||
diffs.push({
|
diffs.push({
|
||||||
type: DOCUMENT_META_DIFF_TYPE.SUBJECT,
|
type: DOCUMENT_META_DIFF_TYPE.SUBJECT,
|
||||||
from: oldSubject,
|
from: oldSubject,
|
||||||
to: newData.subject,
|
to: newSubject,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldTimezone !== newData.timezone) {
|
if (oldTimezone !== newTimezone) {
|
||||||
diffs.push({
|
diffs.push({
|
||||||
type: DOCUMENT_META_DIFF_TYPE.TIMEZONE,
|
type: DOCUMENT_META_DIFF_TYPE.TIMEZONE,
|
||||||
from: oldTimezone,
|
from: oldTimezone,
|
||||||
to: newData.timezone,
|
to: newTimezone,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldRedirectUrl !== newData.redirectUrl) {
|
if (oldRedirectUrl !== newRedirectUrl) {
|
||||||
diffs.push({
|
diffs.push({
|
||||||
type: DOCUMENT_META_DIFF_TYPE.REDIRECT_URL,
|
type: DOCUMENT_META_DIFF_TYPE.REDIRECT_URL,
|
||||||
from: oldRedirectUrl,
|
from: oldRedirectUrl,
|
||||||
to: newData.redirectUrl,
|
to: newRedirectUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -278,6 +312,14 @@ export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId
|
|||||||
anonymous: 'Field unsigned',
|
anonymous: 'Field unsigned',
|
||||||
identified: 'unsigned a field',
|
identified: 'unsigned a field',
|
||||||
}))
|
}))
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({
|
||||||
|
anonymous: 'Document access auth updated',
|
||||||
|
identified: 'updated the document access auth requirements',
|
||||||
|
}))
|
||||||
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, () => ({
|
||||||
|
anonymous: 'Document signing auth updated',
|
||||||
|
identified: 'updated the document signing auth requirements',
|
||||||
|
}))
|
||||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
|
||||||
anonymous: 'Document updated',
|
anonymous: 'Document updated',
|
||||||
identified: 'updated the document',
|
identified: 'updated the document',
|
||||||
|
|||||||
72
packages/lib/utils/document-auth.ts
Normal file
72
packages/lib/utils/document-auth.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
TDocumentAuthOptions,
|
||||||
|
TRecipientAccessAuthTypes,
|
||||||
|
TRecipientActionAuthTypes,
|
||||||
|
TRecipientAuthOptions,
|
||||||
|
} from '../types/document-auth';
|
||||||
|
import { DocumentAuth } from '../types/document-auth';
|
||||||
|
import { ZDocumentAuthOptionsSchema, ZRecipientAuthOptionsSchema } from '../types/document-auth';
|
||||||
|
|
||||||
|
type ExtractDocumentAuthMethodsOptions = {
|
||||||
|
documentAuth: Document['authOptions'];
|
||||||
|
recipientAuth?: Recipient['authOptions'];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and extracts the document and recipient authentication values.
|
||||||
|
*
|
||||||
|
* Will combine the recipient and document auth values to derive the final
|
||||||
|
* auth values for a recipient if possible.
|
||||||
|
*/
|
||||||
|
export const extractDocumentAuthMethods = ({
|
||||||
|
documentAuth,
|
||||||
|
recipientAuth,
|
||||||
|
}: ExtractDocumentAuthMethodsOptions) => {
|
||||||
|
const documentAuthOption = ZDocumentAuthOptionsSchema.parse(documentAuth);
|
||||||
|
const recipientAuthOption = ZRecipientAuthOptionsSchema.parse(recipientAuth);
|
||||||
|
|
||||||
|
const derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null =
|
||||||
|
recipientAuthOption.accessAuth || documentAuthOption.globalAccessAuth;
|
||||||
|
|
||||||
|
const derivedRecipientActionAuth: TRecipientActionAuthTypes | null =
|
||||||
|
recipientAuthOption.actionAuth || documentAuthOption.globalActionAuth;
|
||||||
|
|
||||||
|
const recipientAccessAuthRequired = derivedRecipientAccessAuth !== null;
|
||||||
|
|
||||||
|
const recipientActionAuthRequired =
|
||||||
|
derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE &&
|
||||||
|
derivedRecipientActionAuth !== null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
derivedRecipientAccessAuth,
|
||||||
|
derivedRecipientActionAuth,
|
||||||
|
recipientAccessAuthRequired,
|
||||||
|
recipientActionAuthRequired,
|
||||||
|
documentAuthOption,
|
||||||
|
recipientAuthOption,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create document auth options in a type safe way.
|
||||||
|
*/
|
||||||
|
export const createDocumentAuthOptions = (options: TDocumentAuthOptions): TDocumentAuthOptions => {
|
||||||
|
return {
|
||||||
|
globalAccessAuth: options?.globalAccessAuth ?? null,
|
||||||
|
globalActionAuth: options?.globalActionAuth ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create recipient auth options in a type safe way.
|
||||||
|
*/
|
||||||
|
export const createRecipientAuthOptions = (
|
||||||
|
options: TRecipientAuthOptions,
|
||||||
|
): TRecipientAuthOptions => {
|
||||||
|
return {
|
||||||
|
accessAuth: options?.accessAuth ?? null,
|
||||||
|
actionAuth: options?.actionAuth ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
-- This migration adds more than one value to an enum.
|
||||||
|
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||||
|
-- in a single migration. This can be worked around by creating
|
||||||
|
-- multiple migrations, each migration adding only one value to
|
||||||
|
-- the enum.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'PASSKEY_CREATED';
|
||||||
|
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'PASSKEY_DELETED';
|
||||||
|
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'PASSKEY_UPDATED';
|
||||||
|
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'SIGN_IN_PASSKEY_FAIL';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Passkey" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lastUsedAt" TIMESTAMP(3),
|
||||||
|
"credentialId" BYTEA NOT NULL,
|
||||||
|
"credentialPublicKey" BYTEA NOT NULL,
|
||||||
|
"counter" BIGINT NOT NULL,
|
||||||
|
"credentialDeviceType" TEXT NOT NULL,
|
||||||
|
"credentialBackedUp" BOOLEAN NOT NULL,
|
||||||
|
"transports" TEXT[],
|
||||||
|
|
||||||
|
CONSTRAINT "Passkey_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AnonymousVerificationToken" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AnonymousVerificationToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AnonymousVerificationToken_id_key" ON "AnonymousVerificationToken"("id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AnonymousVerificationToken_token_key" ON "AnonymousVerificationToken"("token");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Passkey" ADD CONSTRAINT "Passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Document" ADD COLUMN "authOptions" JSONB;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Recipient" ADD COLUMN "authOptions" JSONB;
|
||||||
@ -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");
|
||||||
@ -52,6 +52,7 @@ model User {
|
|||||||
securityAuditLogs UserSecurityAuditLog[]
|
securityAuditLogs UserSecurityAuditLog[]
|
||||||
Webhooks Webhook[]
|
Webhooks Webhook[]
|
||||||
siteSettings SiteSettings[]
|
siteSettings SiteSettings[]
|
||||||
|
passkeys Passkey[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
}
|
}
|
||||||
@ -68,12 +69,16 @@ enum UserSecurityAuditLogType {
|
|||||||
ACCOUNT_SSO_LINK
|
ACCOUNT_SSO_LINK
|
||||||
AUTH_2FA_DISABLE
|
AUTH_2FA_DISABLE
|
||||||
AUTH_2FA_ENABLE
|
AUTH_2FA_ENABLE
|
||||||
|
PASSKEY_CREATED
|
||||||
|
PASSKEY_DELETED
|
||||||
|
PASSKEY_UPDATED
|
||||||
PASSWORD_RESET
|
PASSWORD_RESET
|
||||||
PASSWORD_UPDATE
|
PASSWORD_UPDATE
|
||||||
SIGN_OUT
|
SIGN_OUT
|
||||||
SIGN_IN
|
SIGN_IN
|
||||||
SIGN_IN_FAIL
|
SIGN_IN_FAIL
|
||||||
SIGN_IN_2FA_FAIL
|
SIGN_IN_2FA_FAIL
|
||||||
|
SIGN_IN_PASSKEY_FAIL
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserSecurityAuditLog {
|
model UserSecurityAuditLog {
|
||||||
@ -96,14 +101,39 @@ model PasswordResetToken {
|
|||||||
User User @relation(fields: [userId], references: [id])
|
User User @relation(fields: [userId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Passkey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId Int
|
||||||
|
name String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now())
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
credentialId Bytes
|
||||||
|
credentialPublicKey Bytes
|
||||||
|
counter BigInt
|
||||||
|
credentialDeviceType String
|
||||||
|
credentialBackedUp Boolean
|
||||||
|
transports String[]
|
||||||
|
|
||||||
|
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model AnonymousVerificationToken {
|
||||||
|
id String @id @unique @default(cuid())
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@ -226,6 +256,7 @@ model Document {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
authOptions Json?
|
||||||
title String
|
title String
|
||||||
status DocumentStatus @default(DRAFT)
|
status DocumentStatus @default(DRAFT)
|
||||||
Recipient Recipient[]
|
Recipient Recipient[]
|
||||||
@ -323,6 +354,7 @@ model Recipient {
|
|||||||
token String
|
token String
|
||||||
expired DateTime?
|
expired DateTime?
|
||||||
signedAt DateTime?
|
signedAt DateTime?
|
||||||
|
authOptions Json?
|
||||||
role RecipientRole @default(SIGNER)
|
role RecipientRole @default(SIGNER)
|
||||||
readStatus ReadStatus @default(NOT_OPENED)
|
readStatus ReadStatus @default(NOT_OPENED)
|
||||||
signingStatus SigningStatus @default(NOT_SIGNED)
|
signingStatus SigningStatus @default(NOT_SIGNED)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { User } from '@prisma/client';
|
import type { Document, User } from '@prisma/client';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@ -33,19 +33,19 @@ export const seedDocuments = async (documents: DocumentToSeed[]) => {
|
|||||||
documents.map(async (document, i) =>
|
documents.map(async (document, i) =>
|
||||||
match(document.type)
|
match(document.type)
|
||||||
.with(DocumentStatus.DRAFT, async () =>
|
.with(DocumentStatus.DRAFT, async () =>
|
||||||
createDraftDocument(document.sender, document.recipients, {
|
seedDraftDocument(document.sender, document.recipients, {
|
||||||
key: i,
|
key: i,
|
||||||
createDocumentOptions: document.documentOptions,
|
createDocumentOptions: document.documentOptions,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.with(DocumentStatus.PENDING, async () =>
|
.with(DocumentStatus.PENDING, async () =>
|
||||||
createPendingDocument(document.sender, document.recipients, {
|
seedPendingDocument(document.sender, document.recipients, {
|
||||||
key: i,
|
key: i,
|
||||||
createDocumentOptions: document.documentOptions,
|
createDocumentOptions: document.documentOptions,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.with(DocumentStatus.COMPLETED, async () =>
|
.with(DocumentStatus.COMPLETED, async () =>
|
||||||
createCompletedDocument(document.sender, document.recipients, {
|
seedCompletedDocument(document.sender, document.recipients, {
|
||||||
key: i,
|
key: i,
|
||||||
createDocumentOptions: document.documentOptions,
|
createDocumentOptions: document.documentOptions,
|
||||||
}),
|
}),
|
||||||
@ -55,7 +55,37 @@ export const seedDocuments = async (documents: DocumentToSeed[]) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createDraftDocument = async (
|
export const seedBlankDocument = async (owner: User, options: CreateDocumentOptions = {}) => {
|
||||||
|
const { key, createDocumentOptions = {} } = options;
|
||||||
|
|
||||||
|
const documentData = await prisma.documentData.create({
|
||||||
|
data: {
|
||||||
|
type: DocumentDataType.BYTES_64,
|
||||||
|
data: examplePdf,
|
||||||
|
initialData: examplePdf,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await prisma.document.create({
|
||||||
|
data: {
|
||||||
|
title: `[TEST] Document ${key} - Draft`,
|
||||||
|
status: DocumentStatus.DRAFT,
|
||||||
|
documentDataId: documentData.id,
|
||||||
|
userId: owner.id,
|
||||||
|
...createDocumentOptions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unseedDocument = async (documentId: number) => {
|
||||||
|
await prisma.document.delete({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seedDraftDocument = async (
|
||||||
sender: User,
|
sender: User,
|
||||||
recipients: (User | string)[],
|
recipients: (User | string)[],
|
||||||
options: CreateDocumentOptions = {},
|
options: CreateDocumentOptions = {},
|
||||||
@ -114,6 +144,8 @@ const createDraftDocument = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return document;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CreateDocumentOptions = {
|
type CreateDocumentOptions = {
|
||||||
@ -121,7 +153,7 @@ type CreateDocumentOptions = {
|
|||||||
createDocumentOptions?: Partial<Prisma.DocumentUncheckedCreateInput>;
|
createDocumentOptions?: Partial<Prisma.DocumentUncheckedCreateInput>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createPendingDocument = async (
|
export const seedPendingDocument = async (
|
||||||
sender: User,
|
sender: User,
|
||||||
recipients: (User | string)[],
|
recipients: (User | string)[],
|
||||||
options: CreateDocumentOptions = {},
|
options: CreateDocumentOptions = {},
|
||||||
@ -180,9 +212,145 @@ const createPendingDocument = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return document;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createCompletedDocument = async (
|
export const seedPendingDocumentNoFields = async ({
|
||||||
|
owner,
|
||||||
|
recipients,
|
||||||
|
updateDocumentOptions,
|
||||||
|
}: {
|
||||||
|
owner: User;
|
||||||
|
recipients: (User | string)[];
|
||||||
|
updateDocumentOptions?: Partial<Prisma.DocumentUncheckedUpdateInput>;
|
||||||
|
}) => {
|
||||||
|
const document: Document = await seedBlankDocument(owner);
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const email = typeof recipient === 'string' ? recipient : recipient.email;
|
||||||
|
const name = typeof recipient === 'string' ? recipient : recipient.name ?? '';
|
||||||
|
|
||||||
|
await prisma.recipient.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
token: nanoid(),
|
||||||
|
readStatus: ReadStatus.OPENED,
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
signingStatus: SigningStatus.NOT_SIGNED,
|
||||||
|
signedAt: new Date(),
|
||||||
|
Document: {
|
||||||
|
connect: {
|
||||||
|
id: document.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdRecipients = await prisma.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Field: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestDocument = updateDocumentOptions
|
||||||
|
? await prisma.document.update({
|
||||||
|
where: {
|
||||||
|
id: document.id,
|
||||||
|
},
|
||||||
|
data: updateDocumentOptions,
|
||||||
|
})
|
||||||
|
: document;
|
||||||
|
|
||||||
|
return {
|
||||||
|
document: latestDocument,
|
||||||
|
recipients: createdRecipients,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seedPendingDocumentWithFullFields = async ({
|
||||||
|
owner,
|
||||||
|
recipients,
|
||||||
|
recipientsCreateOptions,
|
||||||
|
updateDocumentOptions,
|
||||||
|
fields = [FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.SIGNATURE, FieldType.TEXT],
|
||||||
|
}: {
|
||||||
|
owner: User;
|
||||||
|
recipients: (User | string)[];
|
||||||
|
recipientsCreateOptions?: Partial<Prisma.RecipientCreateInput>[];
|
||||||
|
updateDocumentOptions?: Partial<Prisma.DocumentUncheckedUpdateInput>;
|
||||||
|
fields?: FieldType[];
|
||||||
|
}) => {
|
||||||
|
const document: Document = await seedBlankDocument(owner);
|
||||||
|
|
||||||
|
for (const [recipientIndex, recipient] of recipients.entries()) {
|
||||||
|
const email = typeof recipient === 'string' ? recipient : recipient.email;
|
||||||
|
const name = typeof recipient === 'string' ? recipient : recipient.name ?? '';
|
||||||
|
|
||||||
|
await prisma.recipient.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
token: nanoid(),
|
||||||
|
readStatus: ReadStatus.OPENED,
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
signingStatus: SigningStatus.NOT_SIGNED,
|
||||||
|
signedAt: new Date(),
|
||||||
|
Document: {
|
||||||
|
connect: {
|
||||||
|
id: document.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Field: {
|
||||||
|
createMany: {
|
||||||
|
data: fields.map((fieldType, fieldIndex) => ({
|
||||||
|
page: 1,
|
||||||
|
type: fieldType,
|
||||||
|
inserted: false,
|
||||||
|
customText: name,
|
||||||
|
positionX: new Prisma.Decimal((recipientIndex + 1) * 5),
|
||||||
|
positionY: new Prisma.Decimal((fieldIndex + 1) * 5),
|
||||||
|
width: new Prisma.Decimal(5),
|
||||||
|
height: new Prisma.Decimal(5),
|
||||||
|
documentId: document.id,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(recipientsCreateOptions?.[recipientIndex] ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdRecipients = await prisma.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Field: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestDocument = updateDocumentOptions
|
||||||
|
? await prisma.document.update({
|
||||||
|
where: {
|
||||||
|
id: document.id,
|
||||||
|
},
|
||||||
|
data: updateDocumentOptions,
|
||||||
|
})
|
||||||
|
: document;
|
||||||
|
|
||||||
|
return {
|
||||||
|
document: latestDocument,
|
||||||
|
recipients: createdRecipients,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seedCompletedDocument = async (
|
||||||
sender: User,
|
sender: User,
|
||||||
recipients: (User | string)[],
|
recipients: (User | string)[],
|
||||||
options: CreateDocumentOptions = {},
|
options: CreateDocumentOptions = {},
|
||||||
@ -241,6 +409,8 @@ const createCompletedDocument = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return document;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash';
|
|||||||
|
|
||||||
import { prisma } from '..';
|
import { prisma } from '..';
|
||||||
|
|
||||||
|
export const seedTestEmail = () => `user-${Date.now()}@test.documenso.com`;
|
||||||
|
|
||||||
type SeedUserOptions = {
|
type SeedUserOptions = {
|
||||||
name?: string;
|
name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|||||||
@ -1,15 +1,32 @@
|
|||||||
|
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { env } from 'next-runtime-env';
|
import { env } from 'next-runtime-env';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
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 { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-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 { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey';
|
||||||
|
import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
|
||||||
import { compareSync } from '@documenso/lib/server-only/auth/hash';
|
import { compareSync } from '@documenso/lib/server-only/auth/hash';
|
||||||
|
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
|
||||||
import { createUser } from '@documenso/lib/server-only/user/create-user';
|
import { createUser } from '@documenso/lib/server-only/user/create-user';
|
||||||
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
|
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
|
||||||
|
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
|
||||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema';
|
import {
|
||||||
|
ZCreatePasskeyAuthenticationOptionsMutationSchema,
|
||||||
|
ZCreatePasskeyMutationSchema,
|
||||||
|
ZDeletePasskeyMutationSchema,
|
||||||
|
ZFindPasskeysQuerySchema,
|
||||||
|
ZSignUpMutationSchema,
|
||||||
|
ZUpdatePasskeyMutationSchema,
|
||||||
|
ZVerifyPasswordMutationSchema,
|
||||||
|
} from './schema';
|
||||||
|
|
||||||
const NEXT_PUBLIC_DISABLE_SIGNUP = () => env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
const NEXT_PUBLIC_DISABLE_SIGNUP = () => env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||||
|
|
||||||
@ -78,4 +95,147 @@ export const authRouter = router({
|
|||||||
|
|
||||||
return valid;
|
return valid;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
createPasskey: authenticatedProcedure
|
||||||
|
.input(ZCreatePasskeyMutationSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const verificationResponse = input.verificationResponse as RegistrationResponseJSON;
|
||||||
|
|
||||||
|
return await createPasskey({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
verificationResponse,
|
||||||
|
passkeyName: input.passkeyName,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw AppError.parseErrorToTRPCError(err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
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 }) => {
|
||||||
|
try {
|
||||||
|
return await createPasskeyRegistrationOptions({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message:
|
||||||
|
'We were unable to create the registration options for the passkey. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
createPasskeySigninOptions: procedure.mutation(async ({ ctx }) => {
|
||||||
|
const cookie = ctx.req.headers.cookie ?? '';
|
||||||
|
|
||||||
|
const sessionIdToken = cookie?.split(';').find((c) => c.includes('next-auth.csrf-token'));
|
||||||
|
|
||||||
|
if (!sessionIdToken) {
|
||||||
|
throw new Error('Missing CSRF token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = decodeURI(sessionIdToken.split('=')[1]).split('|')[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await createPasskeySigninOptions({ sessionId });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to create the options for passkey signin. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
deletePasskey: authenticatedProcedure
|
||||||
|
.input(ZDeletePasskeyMutationSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
try {
|
||||||
|
const { passkeyId } = input;
|
||||||
|
|
||||||
|
await deletePasskey({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
passkeyId,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to delete this passkey. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
findPasskeys: authenticatedProcedure
|
||||||
|
.input(ZFindPasskeysQuerySchema)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { page, perPage, orderBy } = input;
|
||||||
|
|
||||||
|
return await findPasskeys({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
orderBy,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to find passkeys. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
updatePasskey: authenticatedProcedure
|
||||||
|
.input(ZUpdatePasskeyMutationSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
try {
|
||||||
|
const { passkeyId, name } = input;
|
||||||
|
|
||||||
|
await updatePasskey({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
passkeyId,
|
||||||
|
name,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to update this passkey. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn';
|
||||||
|
|
||||||
export const ZCurrentPasswordSchema = z
|
export const ZCurrentPasswordSchema = z
|
||||||
.string()
|
.string()
|
||||||
.min(6, { message: 'Must be at least 6 characters in length' })
|
.min(6, { message: 'Must be at least 6 characters in length' })
|
||||||
@ -32,6 +35,35 @@ export const ZSignUpMutationSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ZCreatePasskeyMutationSchema = z.object({
|
||||||
|
passkeyName: z.string().trim().min(1),
|
||||||
|
verificationResponse: ZRegistrationResponseJSONSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZCreatePasskeyAuthenticationOptionsMutationSchema = z
|
||||||
|
.object({
|
||||||
|
preferredPasskeyId: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
export const ZDeletePasskeyMutationSchema = z.object({
|
||||||
|
passkeyId: z.string().trim().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZUpdatePasskeyMutationSchema = z.object({
|
||||||
|
passkeyId: z.string().trim().min(1),
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZFindPasskeysQuerySchema = ZBaseTableSearchParamsSchema.extend({
|
||||||
|
orderBy: z
|
||||||
|
.object({
|
||||||
|
column: z.enum(['createdAt', 'updatedAt', 'name']),
|
||||||
|
direction: z.enum(['asc', 'desc']),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
|
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
|
||||||
|
|
||||||
export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true });
|
export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true });
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
|
|||||||
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
|
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
|
||||||
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
|
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
|
||||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||||
|
import { updateDocumentSettings } from '@documenso/lib/server-only/document/update-document-settings';
|
||||||
import { updateTitle } from '@documenso/lib/server-only/document/update-title';
|
import { updateTitle } from '@documenso/lib/server-only/document/update-title';
|
||||||
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
@ -27,6 +28,7 @@ import {
|
|||||||
ZSearchDocumentsMutationSchema,
|
ZSearchDocumentsMutationSchema,
|
||||||
ZSendDocumentMutationSchema,
|
ZSendDocumentMutationSchema,
|
||||||
ZSetPasswordForDocumentMutationSchema,
|
ZSetPasswordForDocumentMutationSchema,
|
||||||
|
ZSetSettingsForDocumentMutationSchema,
|
||||||
ZSetTitleForDocumentMutationSchema,
|
ZSetTitleForDocumentMutationSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
@ -49,22 +51,25 @@ export const documentRouter = router({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getDocumentByToken: procedure.input(ZGetDocumentByTokenQuerySchema).query(async ({ input }) => {
|
getDocumentByToken: procedure
|
||||||
try {
|
.input(ZGetDocumentByTokenQuerySchema)
|
||||||
const { token } = input;
|
.query(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { token } = input;
|
||||||
|
|
||||||
return await getDocumentAndSenderByToken({
|
return await getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
});
|
userId: ctx.user?.id,
|
||||||
} catch (err) {
|
});
|
||||||
console.error(err);
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'We were unable to find this document. Please try again later.',
|
message: 'We were unable to find this document. Please try again later.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createDocument: authenticatedProcedure
|
createDocument: authenticatedProcedure
|
||||||
.input(ZCreateDocumentMutationSchema)
|
.input(ZCreateDocumentMutationSchema)
|
||||||
@ -150,6 +155,46 @@ export const documentRouter = router({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Todo: Add API
|
||||||
|
setSettingsForDocument: authenticatedProcedure
|
||||||
|
.input(ZSetSettingsForDocumentMutationSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { documentId, teamId, data, meta } = input;
|
||||||
|
|
||||||
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
|
const requestMetadata = extractNextApiRequestMetadata(ctx.req);
|
||||||
|
|
||||||
|
if (meta.timezone || meta.dateFormat || meta.redirectUrl) {
|
||||||
|
await upsertDocumentMeta({
|
||||||
|
documentId,
|
||||||
|
dateFormat: meta.dateFormat,
|
||||||
|
timezone: meta.timezone,
|
||||||
|
redirectUrl: meta.redirectUrl,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
requestMetadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await updateDocumentSettings({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
documentId,
|
||||||
|
data,
|
||||||
|
requestMetadata,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message:
|
||||||
|
'We were unable to update the settings for this document. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
setTitleForDocument: authenticatedProcedure
|
setTitleForDocument: authenticatedProcedure
|
||||||
.input(ZSetTitleForDocumentMutationSchema)
|
.input(ZSetTitleForDocumentMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
|
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
|
||||||
|
import {
|
||||||
|
ZDocumentAccessAuthTypesSchema,
|
||||||
|
ZDocumentActionAuthTypesSchema,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
@ -37,6 +41,30 @@ export const ZCreateDocumentMutationSchema = z.object({
|
|||||||
|
|
||||||
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
|
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
|
||||||
|
|
||||||
|
export const ZSetSettingsForDocumentMutationSchema = z.object({
|
||||||
|
documentId: z.number(),
|
||||||
|
teamId: z.number().min(1).optional(),
|
||||||
|
data: z.object({
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
|
||||||
|
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
|
||||||
|
}),
|
||||||
|
meta: z.object({
|
||||||
|
timezone: z.string(),
|
||||||
|
dateFormat: z.string(),
|
||||||
|
redirectUrl: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
|
||||||
|
message: 'Please enter a valid URL',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSetGeneralSettingsForDocumentMutationSchema = z.infer<
|
||||||
|
typeof ZSetSettingsForDocumentMutationSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export const ZSetTitleForDocumentMutationSchema = z.object({
|
export const ZSetTitleForDocumentMutationSchema = z.object({
|
||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
teamId: z.number().min(1).optional(),
|
teamId: z.number().min(1).optional(),
|
||||||
@ -88,8 +116,8 @@ export const ZSendDocumentMutationSchema = z.object({
|
|||||||
meta: z.object({
|
meta: z.object({
|
||||||
subject: z.string(),
|
subject: z.string(),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
timezone: z.string(),
|
timezone: z.string().optional(),
|
||||||
dateFormat: z.string(),
|
dateFormat: z.string().optional(),
|
||||||
redirectUrl: z
|
redirectUrl: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token';
|
import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token';
|
||||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||||
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
|
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
|
||||||
@ -71,22 +72,21 @@ export const fieldRouter = router({
|
|||||||
.input(ZSignFieldWithTokenMutationSchema)
|
.input(ZSignFieldWithTokenMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const { token, fieldId, value, isBase64 } = input;
|
const { token, fieldId, value, isBase64, authOptions } = input;
|
||||||
|
|
||||||
return await signFieldWithToken({
|
return await signFieldWithToken({
|
||||||
token,
|
token,
|
||||||
fieldId,
|
fieldId,
|
||||||
value,
|
value,
|
||||||
isBase64,
|
isBase64,
|
||||||
|
userId: ctx.user?.id,
|
||||||
|
authOptions,
|
||||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
throw new TRPCError({
|
throw AppError.parseErrorToTRPCError(err);
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: 'We were unable to sign this field. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZRecipientActionAuthSchema } from '@documenso/lib/types/document-auth';
|
||||||
import { FieldType } from '@documenso/prisma/client';
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const ZAddFieldsMutationSchema = z.object({
|
export const ZAddFieldsMutationSchema = z.object({
|
||||||
@ -45,6 +46,7 @@ export const ZSignFieldWithTokenMutationSchema = z.object({
|
|||||||
fieldId: z.number(),
|
fieldId: z.number(),
|
||||||
value: z.string().trim(),
|
value: z.string().trim(),
|
||||||
isBase64: z.boolean().optional(),
|
isBase64: z.boolean().optional(),
|
||||||
|
authOptions: ZRecipientActionAuthSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSignFieldWithTokenMutationSchema = z.infer<typeof ZSignFieldWithTokenMutationSchema>;
|
export type TSignFieldWithTokenMutationSchema = z.infer<typeof ZSignFieldWithTokenMutationSchema>;
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export const recipientRouter = router({
|
|||||||
email: signer.email,
|
email: signer.email,
|
||||||
name: signer.name,
|
name: signer.name,
|
||||||
role: signer.role,
|
role: signer.role,
|
||||||
|
actionAuth: signer.actionAuth,
|
||||||
})),
|
})),
|
||||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
@ -71,11 +72,13 @@ export const recipientRouter = router({
|
|||||||
.input(ZCompleteDocumentWithTokenMutationSchema)
|
.input(ZCompleteDocumentWithTokenMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const { token, documentId } = input;
|
const { token, documentId, authOptions } = input;
|
||||||
|
|
||||||
return await completeDocumentWithToken({
|
return await completeDocumentWithToken({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
|
authOptions,
|
||||||
|
userId: ctx.user?.id,
|
||||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ZRecipientActionAuthSchema,
|
||||||
|
ZRecipientActionAuthTypesSchema,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
import { RecipientRole } from '@documenso/prisma/client';
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const ZAddSignersMutationSchema = z
|
export const ZAddSignersMutationSchema = z
|
||||||
@ -12,6 +16,7 @@ export const ZAddSignersMutationSchema = z
|
|||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole),
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@ -54,6 +59,7 @@ export type TAddTemplateSignersMutationSchema = z.infer<typeof ZAddTemplateSigne
|
|||||||
export const ZCompleteDocumentWithTokenMutationSchema = z.object({
|
export const ZCompleteDocumentWithTokenMutationSchema = z.object({
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
documentId: z.number(),
|
documentId: z.number(),
|
||||||
|
authOptions: ZRecipientActionAuthSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TCompleteDocumentWithTokenMutationSchema = z.infer<
|
export type TCompleteDocumentWithTokenMutationSchema = z.infer<
|
||||||
|
|||||||
@ -23,7 +23,6 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next }) => {
|
|||||||
return await next({
|
return await next({
|
||||||
ctx: {
|
ctx: {
|
||||||
...ctx,
|
...ctx,
|
||||||
|
|
||||||
user: ctx.user,
|
user: ctx.user,
|
||||||
session: ctx.session,
|
session: ctx.session,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -5,11 +5,17 @@ import { motion } from 'framer-motion';
|
|||||||
type AnimateGenericFadeInOutProps = {
|
type AnimateGenericFadeInOutProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
motionKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AnimateGenericFadeInOut = ({ children, className }: AnimateGenericFadeInOutProps) => {
|
export const AnimateGenericFadeInOut = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
motionKey,
|
||||||
|
}: AnimateGenericFadeInOutProps) => {
|
||||||
return (
|
return (
|
||||||
<motion.section
|
<motion.section
|
||||||
|
key={motionKey}
|
||||||
initial={{
|
initial={{
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -60,7 +60,7 @@ export const DocumentDownloadButton = ({
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Download className="mr-2 h-5 w-5" />
|
{!isLoading && <Download className="mr-2 h-5 w-5" />}
|
||||||
Download
|
Download
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -16,7 +16,7 @@ const Checkbox = React.forwardRef<
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-input ring-offset-background focus-visible:ring-ring data-[state=checked]:border-primary peer h-4 w-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
'border-input bg-background ring-offset-background focus-visible:ring-ring data-[state=checked]:border-primary peer h-4 w-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
364
packages/ui/primitives/document-flow/add-settings.tsx
Normal file
364
packages/ui/primitives/document-flow/add-settings.tsx
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { InfoIcon } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
|
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||||
|
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
|
import { DocumentAccessAuth, DocumentActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
|
||||||
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from '@documenso/ui/primitives/accordion';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
|
||||||
|
import { Combobox } from '../combobox';
|
||||||
|
import { Input } from '../input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
|
||||||
|
import { useStep } from '../stepper';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
|
||||||
|
import type { TAddSettingsFormSchema } from './add-settings.types';
|
||||||
|
import { ZAddSettingsFormSchema } from './add-settings.types';
|
||||||
|
import {
|
||||||
|
DocumentFlowFormContainerActions,
|
||||||
|
DocumentFlowFormContainerContent,
|
||||||
|
DocumentFlowFormContainerFooter,
|
||||||
|
DocumentFlowFormContainerHeader,
|
||||||
|
DocumentFlowFormContainerStep,
|
||||||
|
} from './document-flow-root';
|
||||||
|
import { ShowFieldItem } from './show-field-item';
|
||||||
|
import type { DocumentFlowStep } from './types';
|
||||||
|
|
||||||
|
export type AddSettingsFormProps = {
|
||||||
|
documentFlow: DocumentFlowStep;
|
||||||
|
recipients: Recipient[];
|
||||||
|
fields: Field[];
|
||||||
|
document: DocumentWithData;
|
||||||
|
onSubmit: (_data: TAddSettingsFormSchema) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddSettingsFormPartial = ({
|
||||||
|
documentFlow,
|
||||||
|
recipients,
|
||||||
|
fields,
|
||||||
|
document,
|
||||||
|
onSubmit,
|
||||||
|
}: AddSettingsFormProps) => {
|
||||||
|
const { documentAuthOption } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<TAddSettingsFormSchema>({
|
||||||
|
resolver: zodResolver(ZAddSettingsFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: document.title,
|
||||||
|
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
|
||||||
|
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
|
||||||
|
meta: {
|
||||||
|
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
||||||
|
dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
|
redirectUrl: document.documentMeta?.redirectUrl ?? '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
|
||||||
|
|
||||||
|
const documentHasBeenSent = recipients.some(
|
||||||
|
(recipient) => recipient.sendStatus === SendStatus.SENT,
|
||||||
|
);
|
||||||
|
|
||||||
|
// We almost always want to set the timezone to the user's local timezone to avoid confusion
|
||||||
|
// when the document is signed.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!form.formState.touchedFields.meta?.timezone && !documentHasBeenSent) {
|
||||||
|
form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||||
|
}
|
||||||
|
}, [documentHasBeenSent, form, form.setValue, form.formState.touchedFields.meta?.timezone]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DocumentFlowFormContainerHeader
|
||||||
|
title={documentFlow.title}
|
||||||
|
description={documentFlow.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DocumentFlowFormContainerContent>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<ShowFieldItem key={index} field={field} recipients={recipients} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-6"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Title</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="bg-background"
|
||||||
|
{...field}
|
||||||
|
disabled={document.status !== DocumentStatus.DRAFT || field.disabled}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="globalAccessAuth"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex flex-row items-center">
|
||||||
|
Document access
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||||
|
<h2>
|
||||||
|
<strong>Document access</strong>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p>The authentication required for recipients to view the document.</p>
|
||||||
|
|
||||||
|
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
|
||||||
|
<li>
|
||||||
|
<strong>Require account</strong> - The recipient must be signed in to
|
||||||
|
view the document
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>None</strong> - The document can be accessed directly by the URL
|
||||||
|
sent to the recipient
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="bg-background text-muted-foreground">
|
||||||
|
<SelectValue data-testid="documentAccessSelectValue" placeholder="None" />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{Object.values(DocumentAccessAuth).map((authType) => (
|
||||||
|
<SelectItem key={authType} value={authType}>
|
||||||
|
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
||||||
|
<SelectItem value={'-1'}>None</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="globalActionAuth"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex flex-row items-center">
|
||||||
|
Recipient action authentication
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||||
|
<h2>
|
||||||
|
<strong>Global recipient action authentication</strong>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The authentication required for recipients to sign fields and complete the
|
||||||
|
document.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This can be overriden by setting the authentication requirements directly
|
||||||
|
on each recipient in the next step.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
|
||||||
|
<li>
|
||||||
|
<strong>Require account</strong> - The recipient must be signed in
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Require passkey</strong> - The recipient must have an account
|
||||||
|
and passkey configured via their settings
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>None</strong> - No authentication required
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="bg-background text-muted-foreground">
|
||||||
|
<SelectValue data-testid="documentActionSelectValue" placeholder="None" />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{Object.values(DocumentActionAuth).map((authType) => (
|
||||||
|
<SelectItem key={authType} value={authType}>
|
||||||
|
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
||||||
|
<SelectItem value={'-1'}>None</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Accordion type="multiple" className="mt-6">
|
||||||
|
<AccordionItem value="advanced-options" className="border-none">
|
||||||
|
<AccordionTrigger className="text-foreground mb-2 rounded border px-3 py-2 text-left hover:bg-neutral-200/30 hover:no-underline">
|
||||||
|
Advanced Options
|
||||||
|
</AccordionTrigger>
|
||||||
|
|
||||||
|
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-2 text-sm leading-relaxed">
|
||||||
|
<div className="flex flex-col space-y-6 ">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.dateFormat"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Date Format</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
disabled={documentHasBeenSent}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{DATE_FORMATS.map((format) => (
|
||||||
|
<SelectItem key={format.key} value={format.value}>
|
||||||
|
{format.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.timezone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Time Zone</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Combobox
|
||||||
|
className="bg-background"
|
||||||
|
options={TIME_ZONES}
|
||||||
|
{...field}
|
||||||
|
onChange={(value) => value && field.onChange(value)}
|
||||||
|
disabled={documentHasBeenSent}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.redirectUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex flex-row items-center">
|
||||||
|
Redirect URL{' '}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||||
|
Add a URL to redirect the user to once the document is signed
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</fieldset>
|
||||||
|
</Form>
|
||||||
|
</DocumentFlowFormContainerContent>
|
||||||
|
|
||||||
|
<DocumentFlowFormContainerFooter>
|
||||||
|
<DocumentFlowFormContainerStep
|
||||||
|
title={documentFlow.title}
|
||||||
|
step={currentStep}
|
||||||
|
maxStep={totalSteps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DocumentFlowFormContainerActions
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
canGoBack={stepIndex !== 0}
|
||||||
|
onGoBackClick={previousStep}
|
||||||
|
onGoNextClick={form.handleSubmit(onSubmit)}
|
||||||
|
/>
|
||||||
|
</DocumentFlowFormContainerFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
42
packages/ui/primitives/document-flow/add-settings.types.ts
Normal file
42
packages/ui/primitives/document-flow/add-settings.types.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
|
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
|
||||||
|
import {
|
||||||
|
ZDocumentAccessAuthTypesSchema,
|
||||||
|
ZDocumentActionAuthTypesSchema,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
|
|
||||||
|
export const ZMapNegativeOneToUndefinedSchema = z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((val) => {
|
||||||
|
if (val === '-1') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZAddSettingsFormSchema = z.object({
|
||||||
|
title: z.string().trim().min(1, { message: "Title can't be empty" }),
|
||||||
|
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
|
||||||
|
ZDocumentAccessAuthTypesSchema.optional(),
|
||||||
|
),
|
||||||
|
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
|
||||||
|
ZDocumentActionAuthTypesSchema.optional(),
|
||||||
|
),
|
||||||
|
meta: z.object({
|
||||||
|
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
|
||||||
|
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
|
||||||
|
redirectUrl: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
|
||||||
|
message: 'Please enter a valid URL',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAddSettingsFormSchema = z.infer<typeof ZAddSettingsFormSchema>;
|
||||||
@ -1,25 +1,33 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useId } from 'react';
|
import React, { useId, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Plus, Trash } from 'lucide-react';
|
import { InfoIcon, Plus, Trash } from 'lucide-react';
|
||||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
|
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||||
|
import {
|
||||||
|
RecipientActionAuth,
|
||||||
|
ZRecipientAuthOptionsSchema,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
import { RecipientRole, SendStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
import { Button } from '../button';
|
import { Button } from '../button';
|
||||||
|
import { Checkbox } from '../checkbox';
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
import { FormErrorMessage } from '../form/form-error-message';
|
||||||
import { Input } from '../input';
|
import { Input } from '../input';
|
||||||
import { Label } from '../label';
|
|
||||||
import { ROLE_ICONS } from '../recipient-role-icons';
|
import { ROLE_ICONS } from '../recipient-role-icons';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
|
||||||
import { useStep } from '../stepper';
|
import { useStep } from '../stepper';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
|
||||||
import { useToast } from '../use-toast';
|
import { useToast } from '../use-toast';
|
||||||
import type { TAddSignersFormSchema } from './add-signers.types';
|
import type { TAddSignersFormSchema } from './add-signers.types';
|
||||||
import { ZAddSignersFormSchema } from './add-signers.types';
|
import { ZAddSignersFormSchema } from './add-signers.types';
|
||||||
@ -37,14 +45,12 @@ export type AddSignersFormProps = {
|
|||||||
documentFlow: DocumentFlowStep;
|
documentFlow: DocumentFlowStep;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
document: DocumentWithData;
|
|
||||||
onSubmit: (_data: TAddSignersFormSchema) => void;
|
onSubmit: (_data: TAddSignersFormSchema) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddSignersFormPartial = ({
|
export const AddSignersFormPartial = ({
|
||||||
documentFlow,
|
documentFlow,
|
||||||
recipients,
|
recipients,
|
||||||
document,
|
|
||||||
fields,
|
fields,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: AddSignersFormProps) => {
|
}: AddSignersFormProps) => {
|
||||||
@ -55,11 +61,7 @@ export const AddSignersFormPartial = ({
|
|||||||
|
|
||||||
const { currentStep, totalSteps, previousStep } = useStep();
|
const { currentStep, totalSteps, previousStep } = useStep();
|
||||||
|
|
||||||
const {
|
const form = useForm<TAddSignersFormSchema>({
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm<TAddSignersFormSchema>({
|
|
||||||
resolver: zodResolver(ZAddSignersFormSchema),
|
resolver: zodResolver(ZAddSignersFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
signers:
|
signers:
|
||||||
@ -70,6 +72,8 @@ export const AddSignersFormPartial = ({
|
|||||||
name: recipient.name,
|
name: recipient.name,
|
||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
role: recipient.role,
|
role: recipient.role,
|
||||||
|
actionAuth:
|
||||||
|
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
@ -77,12 +81,33 @@ export const AddSignersFormPartial = ({
|
|||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
role: RecipientRole.SIGNER,
|
role: RecipientRole.SIGNER,
|
||||||
|
actionAuth: undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onFormSubmit = handleSubmit(onSubmit);
|
// Always show advanced settings if any recipient has auth options.
|
||||||
|
const alwaysShowAdvancedSettings = useMemo(() => {
|
||||||
|
const recipientHasAuthOptions = recipients.find((recipient) => {
|
||||||
|
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
||||||
|
|
||||||
|
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth);
|
||||||
|
|
||||||
|
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
|
||||||
|
}, [recipients, form]);
|
||||||
|
|
||||||
|
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
|
||||||
|
|
||||||
|
const {
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
control,
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const onFormSubmit = form.handleSubmit(onSubmit);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
append: appendSigner,
|
append: appendSigner,
|
||||||
@ -112,6 +137,7 @@ export const AddSignersFormPartial = ({
|
|||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
role: RecipientRole.SIGNER,
|
role: RecipientRole.SIGNER,
|
||||||
|
actionAuth: undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -144,105 +170,198 @@ export const AddSignersFormPartial = ({
|
|||||||
description={documentFlow.description}
|
description={documentFlow.description}
|
||||||
/>
|
/>
|
||||||
<DocumentFlowFormContainerContent>
|
<DocumentFlowFormContainerContent>
|
||||||
<div className="flex w-full flex-col gap-y-4">
|
{fields.map((field, index) => (
|
||||||
{fields.map((field, index) => (
|
<ShowFieldItem key={index} field={field} recipients={recipients} />
|
||||||
<ShowFieldItem key={index} field={field} recipients={recipients} />
|
))}
|
||||||
))}
|
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
||||||
{signers.map((signer, index) => (
|
<Form {...form}>
|
||||||
<motion.div
|
<div className="flex w-full flex-col gap-y-2">
|
||||||
key={signer.id}
|
{signers.map((signer, index) => (
|
||||||
data-native-id={signer.nativeId}
|
<motion.div
|
||||||
className="flex flex-wrap items-end gap-x-4"
|
key={signer.id}
|
||||||
>
|
data-native-id={signer.nativeId}
|
||||||
<div className="flex-1">
|
className={cn('grid grid-cols-8 gap-4 pb-4', {
|
||||||
<Label htmlFor={`signer-${signer.id}-email`}>
|
'border-b pt-2': showAdvancedSettings,
|
||||||
Email
|
})}
|
||||||
<span className="text-destructive ml-1 inline-block font-medium">*</span>
|
>
|
||||||
</Label>
|
<FormField
|
||||||
|
control={form.control}
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name={`signers.${index}.email`}
|
name={`signers.${index}.email`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Input
|
<FormItem
|
||||||
id={`signer-${signer.id}-email`}
|
className={cn('relative', {
|
||||||
type="email"
|
'col-span-3': !showAdvancedSettings,
|
||||||
className="bg-background mt-2"
|
'col-span-4': showAdvancedSettings,
|
||||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
})}
|
||||||
onKeyDown={onKeyDown}
|
>
|
||||||
{...field}
|
{!showAdvancedSettings && index === 0 && (
|
||||||
/>
|
<FormLabel required>Email</FormLabel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||||
|
{...field}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
<FormField
|
||||||
<Label htmlFor={`signer-${signer.id}-name`}>Name</Label>
|
control={form.control}
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name={`signers.${index}.name`}
|
name={`signers.${index}.name`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Input
|
<FormItem
|
||||||
id={`signer-${signer.id}-name`}
|
className={cn({
|
||||||
type="text"
|
'col-span-3': !showAdvancedSettings,
|
||||||
className="bg-background mt-2"
|
'col-span-4': showAdvancedSettings,
|
||||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
})}
|
||||||
onKeyDown={onKeyDown}
|
>
|
||||||
{...field}
|
{!showAdvancedSettings && index === 0 && (
|
||||||
/>
|
<FormLabel required>Name</FormLabel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Name"
|
||||||
|
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||||
|
{...field}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-[60px]">
|
{showAdvancedSettings && (
|
||||||
<Controller
|
<FormField
|
||||||
control={control}
|
control={form.control}
|
||||||
|
name={`signers.${index}.actionAuth`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-6">
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="bg-background text-muted-foreground">
|
||||||
|
<SelectValue placeholder="Inherit authentication method" />
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger className="-mr-1 ml-auto">
|
||||||
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent className="text-foreground max-w-md p-4">
|
||||||
|
<h2>
|
||||||
|
<strong>Recipient action authentication</strong>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The authentication required for recipients to sign fields and
|
||||||
|
complete the document.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-2">This will override any global settings.</p>
|
||||||
|
|
||||||
|
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
|
||||||
|
<li>
|
||||||
|
<strong>Inherit authentication method</strong> - Use the
|
||||||
|
global action signing authentication method configured in
|
||||||
|
the "General Settings" step
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Require account</strong> - The recipient must be
|
||||||
|
signed in
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Require passkey</strong> - The recipient must have
|
||||||
|
an account and passkey configured via their settings
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>None</strong> - No authentication required
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
||||||
|
<SelectItem value="-1">Inherit authentication method</SelectItem>
|
||||||
|
|
||||||
|
{Object.values(RecipientActionAuth).map((authType) => (
|
||||||
|
<SelectItem key={authType} value={authType}>
|
||||||
|
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
name={`signers.${index}.role`}
|
name={`signers.${index}.role`}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field }) => (
|
||||||
<Select value={value} onValueChange={(x) => onChange(x)}>
|
<FormItem className="col-span-1 mt-auto">
|
||||||
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="bg-background w-[60px]">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
|
||||||
|
{ROLE_ICONS[field.value as RecipientRole]}
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
<SelectContent className="" align="end">
|
<SelectContent align="end">
|
||||||
<SelectItem value={RecipientRole.SIGNER}>
|
<SelectItem value={RecipientRole.SIGNER}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
|
||||||
Signer
|
Signer
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.CC}>
|
<SelectItem value={RecipientRole.CC}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
|
||||||
Receives copy
|
Receives copy
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.APPROVER}>
|
<SelectItem value={RecipientRole.APPROVER}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
|
||||||
Approver
|
Approver
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.VIEWER}>
|
<SelectItem value={RecipientRole.VIEWER}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
|
||||||
Viewer
|
Viewer
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={
|
disabled={
|
||||||
isSubmitting ||
|
isSubmitting ||
|
||||||
hasBeenSentToRecipientId(signer.nativeId) ||
|
hasBeenSentToRecipientId(signer.nativeId) ||
|
||||||
@ -252,33 +371,51 @@ export const AddSignersFormPartial = ({
|
|||||||
>
|
>
|
||||||
<Trash className="h-5 w-5" />
|
<Trash className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormErrorMessage
|
||||||
|
className="mt-2"
|
||||||
|
// Dirty hack to handle errors when .root is populated for an array type
|
||||||
|
error={'signers__root' in errors && errors['signers__root']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn('mt-2 flex flex-row items-center space-x-4', {
|
||||||
|
'mt-4': showAdvancedSettings,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={isSubmitting || signers.length >= remaining.recipients}
|
||||||
|
onClick={() => onAddSigner()}
|
||||||
|
>
|
||||||
|
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||||
|
Add Signer
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!alwaysShowAdvancedSettings && (
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Checkbox
|
||||||
|
id="showAdvancedRecipientSettings"
|
||||||
|
className="h-5 w-5"
|
||||||
|
checkClassName="dark:text-white text-primary"
|
||||||
|
checked={showAdvancedSettings}
|
||||||
|
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className="text-muted-foreground ml-2 text-sm"
|
||||||
|
htmlFor="showAdvancedRecipientSettings"
|
||||||
|
>
|
||||||
|
Show advanced settings
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="w-full">
|
</div>
|
||||||
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.email} />
|
</Form>
|
||||||
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.name} />
|
</AnimateGenericFadeInOut>
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormErrorMessage
|
|
||||||
className="mt-2"
|
|
||||||
// Dirty hack to handle errors when .root is populated for an array type
|
|
||||||
error={'signers__root' in errors && errors['signers__root']}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={isSubmitting || signers.length >= remaining.recipients}
|
|
||||||
onClick={() => onAddSigner()}
|
|
||||||
>
|
|
||||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
|
||||||
Add Signer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DocumentFlowFormContainerContent>
|
</DocumentFlowFormContainerContent>
|
||||||
|
|
||||||
<DocumentFlowFormContainerFooter>
|
<DocumentFlowFormContainerFooter>
|
||||||
@ -289,7 +426,6 @@ export const AddSignersFormPartial = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DocumentFlowFormContainerActions
|
<DocumentFlowFormContainerActions
|
||||||
canGoBack={document.status === DocumentStatus.DRAFT}
|
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onGoBackClick={previousStep}
|
onGoBackClick={previousStep}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||||
|
|
||||||
|
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
|
||||||
import { RecipientRole } from '.prisma/client';
|
import { RecipientRole } from '.prisma/client';
|
||||||
|
|
||||||
export const ZAddSignersFormSchema = z
|
export const ZAddSignersFormSchema = z
|
||||||
@ -11,6 +14,9 @@ export const ZAddSignersFormSchema = z
|
|||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole),
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
|
||||||
|
ZRecipientActionAuthTypesSchema.optional(),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,33 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Info } from 'lucide-react';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
|
||||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import { SendStatus } from '@documenso/prisma/client';
|
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from '@documenso/ui/primitives/accordion';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
|
||||||
|
|
||||||
import { Combobox } from '../combobox';
|
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
import { FormErrorMessage } from '../form/form-error-message';
|
||||||
import { Input } from '../input';
|
import { Input } from '../input';
|
||||||
import { Label } from '../label';
|
import { Label } from '../label';
|
||||||
@ -60,19 +39,14 @@ export const AddSubjectFormPartial = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
}: AddSubjectFormProps) => {
|
}: AddSubjectFormProps) => {
|
||||||
const {
|
const {
|
||||||
control,
|
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors, isSubmitting, touchedFields },
|
formState: { errors, isSubmitting },
|
||||||
setValue,
|
|
||||||
} = useForm<TAddSubjectFormSchema>({
|
} = useForm<TAddSubjectFormSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
meta: {
|
meta: {
|
||||||
subject: document.documentMeta?.subject ?? '',
|
subject: document.documentMeta?.subject ?? '',
|
||||||
message: document.documentMeta?.message ?? '',
|
message: document.documentMeta?.message ?? '',
|
||||||
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
|
||||||
dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
|
||||||
redirectUrl: document.documentMeta?.redirectUrl ?? '',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZAddSubjectFormSchema),
|
resolver: zodResolver(ZAddSubjectFormSchema),
|
||||||
@ -81,20 +55,6 @@ export const AddSubjectFormPartial = ({
|
|||||||
const onFormSubmit = handleSubmit(onSubmit);
|
const onFormSubmit = handleSubmit(onSubmit);
|
||||||
const { currentStep, totalSteps, previousStep } = useStep();
|
const { currentStep, totalSteps, previousStep } = useStep();
|
||||||
|
|
||||||
const hasDateField = fields.find((field) => field.type === 'DATE');
|
|
||||||
|
|
||||||
const documentHasBeenSent = recipients.some(
|
|
||||||
(recipient) => recipient.sendStatus === SendStatus.SENT,
|
|
||||||
);
|
|
||||||
|
|
||||||
// We almost always want to set the timezone to the user's local timezone to avoid confusion
|
|
||||||
// when the document is signed.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!touchedFields.meta?.timezone && !documentHasBeenSent) {
|
|
||||||
setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
||||||
}
|
|
||||||
}, [documentHasBeenSent, setValue, touchedFields.meta?.timezone]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DocumentFlowFormContainerHeader
|
<DocumentFlowFormContainerHeader
|
||||||
@ -167,95 +127,6 @@ export const AddSubjectFormPartial = ({
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Accordion type="multiple" className="mt-8 border-none">
|
|
||||||
<AccordionItem value="advanced-options" className="border-none">
|
|
||||||
<AccordionTrigger className="mb-2 border-b text-left hover:no-underline">
|
|
||||||
Advanced Options
|
|
||||||
</AccordionTrigger>
|
|
||||||
|
|
||||||
<AccordionContent className="text-muted-foreground -mx-1 flex max-w-prose flex-col px-1 pt-2 text-sm leading-relaxed">
|
|
||||||
{hasDateField && (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<Label htmlFor="date-format">
|
|
||||||
Date Format <span className="text-muted-foreground">(Optional)</span>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name={`meta.dateFormat`}
|
|
||||||
disabled={documentHasBeenSent}
|
|
||||||
render={({ field: { value, onChange, disabled } }) => (
|
|
||||||
<Select value={value} onValueChange={onChange} disabled={disabled}>
|
|
||||||
<SelectTrigger className="bg-background mt-2">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
{DATE_FORMATS.map((format) => (
|
|
||||||
<SelectItem key={format.key} value={format.value}>
|
|
||||||
{format.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col">
|
|
||||||
<Label htmlFor="time-zone">
|
|
||||||
Time Zone <span className="text-muted-foreground">(Optional)</span>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name={`meta.timezone`}
|
|
||||||
render={({ field: { value, onChange } }) => (
|
|
||||||
<Combobox
|
|
||||||
className="bg-background"
|
|
||||||
options={TIME_ZONES}
|
|
||||||
value={value}
|
|
||||||
onChange={(value) => value && onChange(value)}
|
|
||||||
disabled={documentHasBeenSent}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-2 flex flex-col">
|
|
||||||
<div className="flex flex-col gap-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="redirectUrl" className="flex items-center">
|
|
||||||
Redirect URL{' '}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Info className="mx-2 h-4 w-4" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
|
|
||||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
|
||||||
Add a URL to redirect the user to once the document is signed
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="redirectUrl"
|
|
||||||
type="url"
|
|
||||||
className="bg-background my-2"
|
|
||||||
{...register('meta.redirectUrl')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-2" error={errors.meta?.redirectUrl} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DocumentFlowFormContainerContent>
|
</DocumentFlowFormContainerContent>
|
||||||
|
|||||||
@ -1,21 +1,9 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
|
||||||
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
|
|
||||||
|
|
||||||
export const ZAddSubjectFormSchema = z.object({
|
export const ZAddSubjectFormSchema = z.object({
|
||||||
meta: z.object({
|
meta: z.object({
|
||||||
subject: z.string(),
|
subject: z.string(),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
|
|
||||||
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
|
|
||||||
redirectUrl: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
|
|
||||||
message: 'Please enter a valid URL',
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,103 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
|
|
||||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
|
||||||
|
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
|
||||||
import { Input } from '../input';
|
|
||||||
import { Label } from '../label';
|
|
||||||
import { useStep } from '../stepper';
|
|
||||||
import type { TAddTitleFormSchema } from './add-title.types';
|
|
||||||
import { ZAddTitleFormSchema } from './add-title.types';
|
|
||||||
import {
|
|
||||||
DocumentFlowFormContainerActions,
|
|
||||||
DocumentFlowFormContainerContent,
|
|
||||||
DocumentFlowFormContainerFooter,
|
|
||||||
DocumentFlowFormContainerHeader,
|
|
||||||
DocumentFlowFormContainerStep,
|
|
||||||
} from './document-flow-root';
|
|
||||||
import { ShowFieldItem } from './show-field-item';
|
|
||||||
import type { DocumentFlowStep } from './types';
|
|
||||||
|
|
||||||
export type AddTitleFormProps = {
|
|
||||||
documentFlow: DocumentFlowStep;
|
|
||||||
recipients: Recipient[];
|
|
||||||
fields: Field[];
|
|
||||||
document: DocumentWithData;
|
|
||||||
onSubmit: (_data: TAddTitleFormSchema) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AddTitleFormPartial = ({
|
|
||||||
documentFlow,
|
|
||||||
recipients,
|
|
||||||
fields,
|
|
||||||
document,
|
|
||||||
onSubmit,
|
|
||||||
}: AddTitleFormProps) => {
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm<TAddTitleFormSchema>({
|
|
||||||
resolver: zodResolver(ZAddTitleFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
title: document.title,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = handleSubmit(onSubmit);
|
|
||||||
|
|
||||||
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DocumentFlowFormContainerHeader
|
|
||||||
title={documentFlow.title}
|
|
||||||
description={documentFlow.description}
|
|
||||||
/>
|
|
||||||
<DocumentFlowFormContainerContent>
|
|
||||||
{fields.map((field, index) => (
|
|
||||||
<ShowFieldItem key={index} field={field} recipients={recipients} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex flex-col gap-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="title">
|
|
||||||
Title<span className="text-destructive ml-1 inline-block font-medium">*</span>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
className="bg-background my-2"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
{...register('title')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-2" error={errors.title} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DocumentFlowFormContainerContent>
|
|
||||||
|
|
||||||
<DocumentFlowFormContainerFooter>
|
|
||||||
<DocumentFlowFormContainerStep
|
|
||||||
title={documentFlow.title}
|
|
||||||
step={currentStep}
|
|
||||||
maxStep={totalSteps}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DocumentFlowFormContainerActions
|
|
||||||
loading={isSubmitting}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
canGoBack={stepIndex !== 0}
|
|
||||||
onGoBackClick={previousStep}
|
|
||||||
onGoNextClick={() => void onFormSubmit()}
|
|
||||||
/>
|
|
||||||
</DocumentFlowFormContainerFooter>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const ZAddTitleFormSchema = z.object({
|
|
||||||
title: z.string().trim().min(1, { message: "Title can't be empty" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TAddTitleFormSchema = z.infer<typeof ZAddTitleFormSchema>;
|
|
||||||
@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-background border-input ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
'bg-background border-input ring-offset-background placeholder:text-muted-foreground/40 focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
{
|
{
|
||||||
'ring-2 !ring-red-500 transition-all': props['aria-invalid'],
|
'ring-2 !ring-red-500 transition-all': props['aria-invalid'],
|
||||||
|
|||||||
Reference in New Issue
Block a user