From 5210fe29636523b9821eed326dac0f984cf0966b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 26 Mar 2024 21:11:59 +0800 Subject: [PATCH 1/2] feat: add passkeys (#989) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Add support to login with passkeys. Passkeys can be added via the user security settings page. Note: Currently left out adding the type of authentication method for the 'user security audit logs' because we're using the `signIn` next-auth event which doesn't appear to provide the context. Will look into it at another time. ## Changes Made - Add passkeys to login - Add passkeys feature flag - Add page to manage passkeys - Add audit logs relating to passkeys - Updated prisma schema to support passkeys & anonymous verification tokens ## Testing Performed To be done. MacOS: - Safari ✅ - Chrome ✅ - Firefox ✅ Windows: - Chrome [Untested] - Firefox [Untested] Linux: - Chrome [Untested] - Firefox [Untested] iOS: - Safari ✅ ## Checklist - [X] I have tested these changes locally and they work as expected. ## Summary by CodeRabbit - **New Features** - Introduced Passkey authentication, including creation, sign-in, and management of passkeys. - Added a Passkeys section in Security Settings for managing user passkeys. - Implemented UI updates for Passkey authentication, including a new dialog for creating passkeys and a data table for managing them. - Enhanced security settings with server-side feature flags to conditionally display new security features. - **Bug Fixes** - Improved UI consistency in the Settings Security Activity Page. - Updated button styling in the 2FA Recovery Codes component for better visibility. - **Refactor** - Streamlined authentication options to include WebAuthn credentials provider. - **Chores** - Updated database schema to support passkeys and related functionality. - Added new audit log types for passkey-related activities. - Enhanced server-only authentication utilities for passkey registration and management. --- apps/web/package.json | 4 + .../settings/security/activity/page.tsx | 11 +- .../(dashboard)/settings/security/page.tsx | 24 +- .../passkeys/create-passkey-dialog.tsx | 235 ++++++++++++++++++ .../settings/security/passkeys/page.tsx | 33 +++ .../user-passkeys-data-table-actions.tsx | 200 +++++++++++++++ .../passkeys/user-passkeys-data-table.tsx | 120 +++++++++ .../(dashboard)/settings/layout/header.tsx | 11 +- apps/web/src/components/forms/signin.tsx | 124 +++++++-- package-lock.json | 169 +++++++++++++ packages/lib/constants/auth.ts | 14 ++ packages/lib/constants/feature-flags.ts | 3 +- packages/lib/next-auth/auth-options.ts | 112 +++++++++ .../create-passkey-registration-options.ts | 58 +++++ .../auth/create-passkey-signin-options.ts | 41 +++ .../lib/server-only/auth/create-passkey.ts | 106 ++++++++ .../lib/server-only/auth/delete-passkey.ts | 41 +++ .../lib/server-only/auth/find-passkeys.ts | 71 ++++++ .../lib/server-only/auth/update-passkey.ts | 51 ++++ packages/lib/types/webauthn.ts | 44 ++++ packages/lib/utils/authenticator.ts | 17 ++ .../20240306060259_add_passkeys/migration.sql | 49 ++++ packages/prisma/schema.prisma | 29 +++ packages/trpc/server/auth-router/router.ts | 140 ++++++++++- packages/trpc/server/auth-router/schema.ts | 26 ++ 25 files changed, 1706 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/security/passkeys/page.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/security/passkeys/user-passkeys-data-table-actions.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/security/passkeys/user-passkeys-data-table.tsx create mode 100644 packages/lib/server-only/auth/create-passkey-registration-options.ts create mode 100644 packages/lib/server-only/auth/create-passkey-signin-options.ts create mode 100644 packages/lib/server-only/auth/create-passkey.ts create mode 100644 packages/lib/server-only/auth/delete-passkey.ts create mode 100644 packages/lib/server-only/auth/find-passkeys.ts create mode 100644 packages/lib/server-only/auth/update-passkey.ts create mode 100644 packages/lib/types/webauthn.ts create mode 100644 packages/lib/utils/authenticator.ts create mode 100644 packages/prisma/migrations/20240306060259_add_passkeys/migration.sql diff --git a/apps/web/package.json b/apps/web/package.json index e72f4898a..4f6617d1e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,7 +22,10 @@ "@documenso/trpc": "*", "@documenso/ui": "*", "@hookform/resolvers": "^3.1.0", + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.3", "@tanstack/react-query": "^4.29.5", + "cookie-es": "^1.0.0", "formidable": "^2.1.1", "framer-motion": "^10.12.8", "lucide-react": "^0.279.0", @@ -51,6 +54,7 @@ }, "devDependencies": { "@documenso/tailwind-config": "*", + "@simplewebauthn/types": "^9.0.1", "@types/formidable": "^2.0.6", "@types/luxon": "^3.3.1", "@types/node": "20.1.0", diff --git a/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx b/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx index 2b5906177..a2b850412 100644 --- a/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx @@ -15,15 +15,14 @@ export default function SettingsSecurityActivityPage() { -
- -
+
-
- - +
+ +
); } diff --git a/apps/web/src/app/(dashboard)/settings/security/page.tsx b/apps/web/src/app/(dashboard)/settings/security/page.tsx index ba5d9846c..4bfd37aff 100644 --- a/apps/web/src/app/(dashboard)/settings/security/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/security/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import Link from 'next/link'; 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 { Button } from '@documenso/ui/primitives/button'; @@ -18,6 +19,8 @@ export const metadata: Metadata = { export default async function SecuritySettingsPage() { const { user } = await getRequiredServerComponentSession(); + const isPasskeyEnabled = await getServerComponentFlag('app_passkey'); + return (
)} + {isPasskeyEnabled && ( + +
+ Passkeys + + + Allows authenticating using biometrics, password managers, hardware keys, etc. + +
+ + +
+ )} +
- diff --git a/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx b/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx new file mode 100644 index 000000000..c07d638c0 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/security/passkeys/create-passkey-dialog.tsx @@ -0,0 +1,235 @@ +'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; +} & Omit; + +const ZCreatePasskeyFormSchema = z.object({ + passkeyName: z.string().min(3), +}); + +type TCreatePasskeyFormSchema = z.infer; + +const parser = new UAParser(); + +export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogProps) => { + const [open, setOpen] = useState(false); + const [formError, setFormError] = useState(null); + + const { toast } = useToast(); + + const form = useForm({ + 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, + }); + + 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 ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + Add passkey + + + Passkeys allow you to sign in and authenticate using biometrics, password managers, etc. + + + +
+ +
+ ( + + Passkey name + + + + + + )} + /> + + + + When you click continue, you will be prompted to add the first available + authenticator on your system. + + + + If you do not want to use the authenticator prompted, you can close it, which will + then display the next available authenticator. + + + + {formError && ( + + {match(formError) + .with('ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', () => ( + This passkey has already been registered. + )) + .with('TOO_MANY_PASSKEYS', () => ( + + You cannot have more than {MAXIMUM_PASSKEYS} passkeys. + + )) + .with('InvalidStateError', () => ( + <> + + Passkey creation cancelled due to one of the following reasons: + + +
    +
  • Cancelled by user
  • +
  • Passkey already exists for the provided authenticator
  • +
  • Exceeded timeout
  • +
+
+ + )) + .otherwise(() => ( + + Something went wrong. Please try again or contact support. + + ))} +
+ )} + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/security/passkeys/page.tsx b/apps/web/src/app/(dashboard)/settings/security/passkeys/page.tsx new file mode 100644 index 000000000..724177b39 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/security/passkeys/page.tsx @@ -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 ( +
+ + + + +
+ +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/security/passkeys/user-passkeys-data-table-actions.tsx b/apps/web/src/app/(dashboard)/settings/security/passkeys/user-passkeys-data-table-actions.tsx new file mode 100644 index 000000000..247f130c5 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/security/passkeys/user-passkeys-data-table-actions.tsx @@ -0,0 +1,200 @@ +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; + +export const UserPasskeysDataTableActions = ({ + className, + passkeyId, + passkeyName, +}: UserPasskeysDataTableActionsProps) => { + const { toast } = useToast(); + + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false); + + const form = useForm({ + 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 ( +
+ !isUpdatingPasskey && setIsUpdateDialogOpen(value)} + > + e.stopPropagation()} asChild> + + + + + + Update passkey + + + You are currently updating the {passkeyName} passkey. + + + +
+ + updatePasskey({ + passkeyId, + name, + }), + )} + > +
+ ( + + Name + + + + + + )} + /> + + + + + + + + +
+
+ +
+
+ + !isDeletingPasskey && setIsDeleteDialogOpen(value)} + > + e.stopPropagation()} asChild={true}> + + + + + + Delete passkey + + + Are you sure you want to remove the {passkeyName} passkey. + + + +
+ + + + + + + +
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/security/passkeys/user-passkeys-data-table.tsx b/apps/web/src/app/(dashboard)/settings/security/passkeys/user-passkeys-data-table.tsx new file mode 100644 index 000000000..edc14cb43 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/security/passkeys/user-passkeys-data-table.tsx @@ -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 ( + 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 }) => ( + + ), + }, + ]} + 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: ( + <> + + + + + + + + + + +
+ + +
+
+ + ), + }} + > + {(table) => } +
+ ); +}; diff --git a/apps/web/src/components/(dashboard)/settings/layout/header.tsx b/apps/web/src/components/(dashboard)/settings/layout/header.tsx index 5722d1985..6f5ae28bc 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/header.tsx @@ -5,11 +5,18 @@ import { cn } from '@documenso/ui/lib/utils'; export type SettingsHeaderProps = { title: string; subtitle: string; + hideDivider?: boolean; children?: React.ReactNode; className?: string; }; -export const SettingsHeader = ({ children, title, subtitle, className }: SettingsHeaderProps) => { +export const SettingsHeader = ({ + children, + title, + subtitle, + className, + hideDivider, +}: SettingsHeaderProps) => { return ( <>
@@ -22,7 +29,7 @@ export const SettingsHeader = ({ children, title, subtitle, className }: Setting {children}
-
+ {!hideDivider &&
} ); }; diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 1d6d32f1f..6fa5492ac 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -6,12 +6,18 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; +import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'; +import { KeyRoundIcon } from 'lucide-react'; import { signIn } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { FcGoogle } from 'react-icons/fc'; +import { match } from 'ts-pattern'; 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 { trpc } from '@documenso/trpc/react'; import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -66,14 +72,24 @@ export type SignInFormProps = { export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => { const { toast } = useToast(); + const { getFlag } = useFeatureFlags(); + + const router = useRouter(); + const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] = useState(false); - const router = useRouter(); const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState< 'totp' | 'backup' >('totp'); + const [isPasskeyLoading, setIsPasskeyLoading] = useState(false); + + const isPasskeyEnabled = getFlag('app_passkey'); + + const { mutateAsync: createPasskeySigninOptions } = + trpc.auth.createPasskeySigninOptions.useMutation(); + const form = useForm({ values: { email: initialEmail ?? '', @@ -107,6 +123,63 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign 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) => { try { const credentials: Record = { @@ -189,7 +262,10 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)} > -
+
+ +

- )} /> -
- + - {isGoogleSSOEnabled && ( - <> + {(isGoogleSSOEnabled || isPasskeyEnabled) && (
Or continue with
+ )} + {isGoogleSSOEnabled && ( - - )} + )} + + {isPasskeyEnabled && ( + + )} +
=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": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-2.2.1.tgz", @@ -4611,6 +4625,60 @@ "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": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -6441,6 +6509,38 @@ "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", "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/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -8616,6 +8716,19 @@ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "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/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -9968,6 +10081,11 @@ "node": ">= 0.6" } }, + "node_modules/cookie-es": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.0.0.tgz", + "integrity": "sha512-mWYvfOLrfEc996hlKcdABeIiPHUPC6DM2QYZdGGOvhOTbA3tjm2eBwqlJpoFdjC89NI4Qt6h0Pu06Mp+1Pj5OQ==" + }, "node_modules/copy-anything": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", @@ -10052,6 +10170,33 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "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": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -13579,6 +13724,14 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -17919,6 +18072,22 @@ "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": { "version": "6.11.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index 1918e2db0..137ebe640 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -16,10 +16,24 @@ export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: s [UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated', [UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled', [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_UPDATE]: 'Password updated', [UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out', [UserSecurityAuditLogType.SIGN_IN]: 'Signed In', [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', }; + +/** + * 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; diff --git a/packages/lib/constants/feature-flags.ts b/packages/lib/constants/feature-flags.ts index 3b58e047a..83137217f 100644 --- a/packages/lib/constants/feature-flags.ts +++ b/packages/lib/constants/feature-flags.ts @@ -1,6 +1,6 @@ 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_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY'); @@ -23,6 +23,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000; export const LOCAL_FEATURE_FLAGS: Record = { app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true', 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_profiles_announcement_bar: true, } as const; diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 74a712576..425c7e70a 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -1,6 +1,7 @@ /// import { PrismaAdapter } from '@next-auth/prisma-adapter'; import { compare } from '@node-rs/bcrypt'; +import { verifyAuthenticationResponse } from '@simplewebauthn/server'; import { DateTime } from 'luxon'; import type { AuthOptions, Session, User } from 'next-auth'; import type { JWT } from 'next-auth/jwt'; @@ -12,12 +13,16 @@ import { env } from 'next-runtime-env'; import { prisma } from '@documenso/prisma'; import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client'; +import { AppError, AppErrorCode } from '../errors/app-error'; import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble'; import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa'; 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 { 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 { getAuthenticatorRegistrationOptions } from '../utils/authenticator'; import { ErrorCode } from './error-codes'; export const NEXT_AUTH_OPTIONS: AuthOptions = { @@ -131,6 +136,113 @@ 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 } = getAuthenticatorRegistrationOptions(); + + 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); + + if (!verification?.verified) { + await prisma.userSecurityAuditLog.create({ + data: { + userId: user.id, + ipAddress: requestMetadata.ipAddress, + userAgent: requestMetadata.userAgent, + type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL, + }, + }); + + return null; + } + + 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; + }, + }), ], callbacks: { async jwt({ token, user, trigger, account }) { diff --git a/packages/lib/server-only/auth/create-passkey-registration-options.ts b/packages/lib/server-only/auth/create-passkey-registration-options.ts new file mode 100644 index 000000000..5c9d73b8a --- /dev/null +++ b/packages/lib/server-only/auth/create-passkey-registration-options.ts @@ -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 { getAuthenticatorRegistrationOptions } 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 } = getAuthenticatorRegistrationOptions(); + + 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; +}; diff --git a/packages/lib/server-only/auth/create-passkey-signin-options.ts b/packages/lib/server-only/auth/create-passkey-signin-options.ts new file mode 100644 index 000000000..03241edd0 --- /dev/null +++ b/packages/lib/server-only/auth/create-passkey-signin-options.ts @@ -0,0 +1,41 @@ +import { generateAuthenticationOptions } from '@simplewebauthn/server'; +import { DateTime } from 'luxon'; + +import { prisma } from '@documenso/prisma'; + +import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator'; + +type CreatePasskeySigninOptions = { + sessionId: string; +}; + +export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => { + const { rpId, timeout } = getAuthenticatorRegistrationOptions(); + + 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; +}; diff --git a/packages/lib/server-only/auth/create-passkey.ts b/packages/lib/server-only/auth/create-passkey.ts new file mode 100644 index 000000000..c493d8205 --- /dev/null +++ b/packages/lib/server-only/auth/create-passkey.ts @@ -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 { getAuthenticatorRegistrationOptions } 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 } = getAuthenticatorRegistrationOptions(); + + 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, + }, + }); + }); +}; diff --git a/packages/lib/server-only/auth/delete-passkey.ts b/packages/lib/server-only/auth/delete-passkey.ts new file mode 100644 index 000000000..cb7edc4cf --- /dev/null +++ b/packages/lib/server-only/auth/delete-passkey.ts @@ -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, + }, + }); + }); +}; diff --git a/packages/lib/server-only/auth/find-passkeys.ts b/packages/lib/server-only/auth/find-passkeys.ts new file mode 100644 index 000000000..26eac95c3 --- /dev/null +++ b/packages/lib/server-only/auth/find-passkeys.ts @@ -0,0 +1,71 @@ +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'; + }; +} + +export const findPasskeys = async ({ + userId, + term = '', + page = 1, + perPage = 10, + orderBy, +}: FindPasskeysOptions) => { + const orderByColumn = orderBy?.column ?? 'name'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + 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]: orderByDirection, + }, + 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; +}; diff --git a/packages/lib/server-only/auth/update-passkey.ts b/packages/lib/server-only/auth/update-passkey.ts new file mode 100644 index 000000000..b08eca233 --- /dev/null +++ b/packages/lib/server-only/auth/update-passkey.ts @@ -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, + }, + }); + }); +}; diff --git a/packages/lib/types/webauthn.ts b/packages/lib/types/webauthn.ts new file mode 100644 index 000000000..af409ec89 --- /dev/null +++ b/packages/lib/types/webauthn.ts @@ -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; +export type TRegistrationResponseJSONSchema = z.infer; diff --git a/packages/lib/utils/authenticator.ts b/packages/lib/utils/authenticator.ts new file mode 100644 index 000000000..b5563a4ed --- /dev/null +++ b/packages/lib/utils/authenticator.ts @@ -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 getAuthenticatorRegistrationOptions = () => { + const webAppBaseUrl = new URL(WEBAPP_BASE_URL); + const rpId = webAppBaseUrl.hostname; + + return { + rpName: 'Documenso', + rpId, + origin: WEBAPP_BASE_URL, + timeout: PASSKEY_TIMEOUT, + }; +}; diff --git a/packages/prisma/migrations/20240306060259_add_passkeys/migration.sql b/packages/prisma/migrations/20240306060259_add_passkeys/migration.sql new file mode 100644 index 000000000..e1bd80e69 --- /dev/null +++ b/packages/prisma/migrations/20240306060259_add_passkeys/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index b1bf9f985..aa161fa1f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -52,6 +52,7 @@ model User { securityAuditLogs UserSecurityAuditLog[] Webhooks Webhook[] siteSettings SiteSettings[] + passkeys Passkey[] @@index([email]) } @@ -68,12 +69,16 @@ enum UserSecurityAuditLogType { ACCOUNT_SSO_LINK AUTH_2FA_DISABLE AUTH_2FA_ENABLE + PASSKEY_CREATED + PASSKEY_DELETED + PASSKEY_UPDATED PASSWORD_RESET PASSWORD_UPDATE SIGN_OUT SIGN_IN SIGN_IN_FAIL SIGN_IN_2FA_FAIL + SIGN_IN_PASSKEY_FAIL } model UserSecurityAuditLog { @@ -96,6 +101,30 @@ model PasswordResetToken { 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 { id Int @id @default(autoincrement()) identifier String diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index 16b370b3e..0272e54b9 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -1,15 +1,31 @@ +import type { RegistrationResponseJSON } from '@simplewebauthn/types'; import { TRPCError } from '@trpc/server'; +import { parse } from 'cookie-es'; import { env } from 'next-runtime-env'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; +import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey'; +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 { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey'; import { createUser } from '@documenso/lib/server-only/user/create-user'; 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 { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema'; +import { + ZCreatePasskeyMutationSchema, + ZDeletePasskeyMutationSchema, + ZFindPasskeysQuerySchema, + ZSignUpMutationSchema, + ZUpdatePasskeyMutationSchema, + ZVerifyPasswordMutationSchema, +} from './schema'; const NEXT_PUBLIC_DISABLE_SIGNUP = () => env('NEXT_PUBLIC_DISABLE_SIGNUP'); @@ -78,4 +94,126 @@ export const authRouter = router({ 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); + } + }), + + 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 sessionIdToken = parse(ctx.req.headers.cookie ?? '')['next-auth.csrf-token']; + + if (!sessionIdToken) { + throw new Error('Missing CSRF token'); + } + + const [sessionId] = decodeURI(sessionIdToken).split('|'); + + 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.', + }); + } + }), }); diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index 9a52f7fc2..d78b429fc 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -1,5 +1,8 @@ import { z } from 'zod'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn'; + export const ZCurrentPasswordSchema = z .string() .min(6, { message: 'Must be at least 6 characters in length' }) @@ -32,6 +35,29 @@ export const ZSignUpMutationSchema = z.object({ .optional(), }); +export const ZCreatePasskeyMutationSchema = z.object({ + passkeyName: z.string().trim().min(1), + verificationResponse: ZRegistrationResponseJSONSchema, +}); + +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; export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true }); From 006b732edbfe03cbd3e17116e21c5c9abe6c0507 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 26 Mar 2024 21:12:41 +0800 Subject: [PATCH 2/2] fix: update document flow fetch logic (#1039) ## Description **Fixes issues with mismatching state between document steps.** For example, editing a recipient and proceeding to the next step may not display the updated recipient. And going back will display the old recipient instead of the updated values. **This PR also improves mutation and query speeds by adding logic to bypass query invalidation.** ```ts export const trpc = createTRPCReact({ unstable_overrides: { useMutation: { async onSuccess(opts) { await opts.originalFn(); // This forces mutations to wait for all the queries on the page to reload, and in // this case one of the queries is `searchDocument` for the command overlay, which // on average takes ~500ms. This means that every single mutation must wait for this. await opts.queryClient.invalidateQueries(); }, }, }, }); ``` I've added workarounds to allow us to bypass things such as batching and invalidating queries. But I think we should instead remove this and update all the mutations where a query is required for a more optimised system. ## Example benchmarks Using stg-app vs this preview there's an average 50% speed increase across mutations. **Set signer step:** Average old speed: ~1100ms Average new speed: ~550ms **Set recipient step:** Average old speed: ~1200ms Average new speed: ~600ms **Set fields step:** Average old speed: ~1200ms Average new speed: ~600ms ## Related Issue This will resolve #470 ## Changes Made - Added ability to skip batch queries - Added a state to store the required document data. - Refetch the data between steps if/when required - Optimise mutations and queries ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have followed the project's coding style guidelines. --------- Co-authored-by: Lucas Smith --- .../documents/[id]/edit-document.tsx | 124 +++++++++++++----- .../[id]/edit/document-edit-page-view.tsx | 29 +--- .../app/(signing)/sign/[token]/date-field.tsx | 5 +- .../(signing)/sign/[token]/email-field.tsx | 5 +- .../app/(signing)/sign/[token]/name-field.tsx | 5 +- .../sign/[token]/signature-field.tsx | 5 +- .../app/(signing)/sign/[token]/text-field.tsx | 5 +- .../(dashboard)/common/command-menu.tsx | 8 ++ packages/lib/constants/trpc.ts | 25 ++++ .../get-document-with-details-by-id.ts | 32 +++++ .../field/set-fields-for-document.ts | 16 ++- .../recipient/set-recipients-for-document.ts | 17 ++- packages/prisma/types/document.ts | 15 ++- packages/trpc/client/index.ts | 14 +- packages/trpc/react/index.tsx | 26 +++- .../trpc/server/document-router/router.ts | 20 +++ .../trpc/server/document-router/schema.ts | 9 ++ 17 files changed, 279 insertions(+), 81 deletions(-) create mode 100644 packages/lib/constants/trpc.ts create mode 100644 packages/lib/server-only/document/get-document-with-details-by-id.ts diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 5d9fe78aa..9b051bbad 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -1,18 +1,15 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { - type DocumentData, - type DocumentMeta, - DocumentStatus, - type Field, - type Recipient, - type User, -} from '@documenso/prisma/client'; -import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; + DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + SKIP_QUERY_BATCH_META, +} from '@documenso/lib/constants/trpc'; +import { DocumentStatus } from '@documenso/prisma/client'; +import type { DocumentWithDetails } from '@documenso/prisma/types/document'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -34,12 +31,7 @@ import { useOptionalCurrentTeam } from '~/providers/team'; export type EditDocumentFormProps = { className?: string; - user: User; - document: DocumentWithData; - recipients: Recipient[]; - documentMeta: DocumentMeta | null; - fields: Field[]; - documentData: DocumentData; + initialDocument: DocumentWithDetails; documentRootPath: string; }; @@ -48,12 +40,7 @@ const EditDocumentSteps: EditDocumentStep[] = ['title', 'signers', 'fields', 'su export const EditDocumentForm = ({ className, - document, - recipients, - fields, - documentMeta, - user: _user, - documentData, + initialDocument, documentRootPath, }: EditDocumentFormProps) => { const { toast } = useToast(); @@ -62,10 +49,74 @@ export const EditDocumentForm = ({ const searchParams = useSearchParams(); const team = useOptionalCurrentTeam(); - const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation(); - const { mutateAsync: addFields } = trpc.field.addFields.useMutation(); - const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation(); - const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation(); + const utils = trpc.useUtils(); + + const { data: document, refetch: refetchDocument } = + trpc.document.getDocumentWithDetailsById.useQuery( + { + id: initialDocument.id, + teamId: team?.id, + }, + { + initialData: initialDocument, + ...SKIP_QUERY_BATCH_META, + }, + ); + + const { Recipient: recipients, Field: fields } = document; + + const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: (newData) => { + utils.document.getDocumentWithDetailsById.setData( + { + id: initialDocument.id, + teamId: team?.id, + }, + (oldData) => ({ ...(oldData || initialDocument), ...newData }), + ); + }, + }); + + const { mutateAsync: addFields } = trpc.field.addFields.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: (newFields) => { + utils.document.getDocumentWithDetailsById.setData( + { + id: initialDocument.id, + teamId: team?.id, + }, + (oldData) => ({ ...(oldData || initialDocument), Field: newFields }), + ); + }, + }); + + const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: (newRecipients) => { + utils.document.getDocumentWithDetailsById.setData( + { + id: initialDocument.id, + teamId: team?.id, + }, + (oldData) => ({ ...(oldData || initialDocument), Recipient: newRecipients }), + ); + }, + }); + + const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation({ + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + onSuccess: (newData) => { + utils.document.getDocumentWithDetailsById.setData( + { + id: initialDocument.id, + teamId: team?.id, + }, + (oldData) => ({ ...(oldData || initialDocument), ...newData }), + ); + }, + }); + const { mutateAsync: setPasswordForDocument } = trpc.document.setPasswordForDocument.useMutation(); @@ -112,13 +163,13 @@ export const EditDocumentForm = ({ const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => { try { - // Custom invocation server action await addTitle({ documentId: document.id, teamId: team?.id, title: data.title, }); + // Router refresh is here to clear the router cache for when navigating to /documents. router.refresh(); setStep('signers'); @@ -135,14 +186,15 @@ export const EditDocumentForm = ({ const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => { try { - // Custom invocation server action await addSigners({ documentId: document.id, teamId: team?.id, signers: data.signers, }); + // Router refresh is here to clear the router cache for when navigating to /documents. router.refresh(); + setStep('fields'); } catch (err) { console.error(err); @@ -157,13 +209,14 @@ export const EditDocumentForm = ({ const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => { try { - // Custom invocation server action await addFields({ documentId: document.id, fields: data.fields, }); + // Router refresh is here to clear the router cache for when navigating to /documents. router.refresh(); + setStep('subject'); } catch (err) { console.error(err); @@ -219,6 +272,15 @@ export const EditDocumentForm = ({ const currentDocumentFlow = documentFlow[step]; + /** + * Refresh the data in the background when steps change. + */ + useEffect(() => { + void refetchDocument(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step]); + return (
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx index 69122312e..c18337641 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx @@ -5,9 +5,7 @@ import { ChevronLeft, Users2 } from 'lucide-react'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; -import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; -import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; -import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; +import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import type { Team } from '@documenso/prisma/client'; @@ -37,13 +35,13 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie const { user } = await getRequiredServerComponentSession(); - const document = await getDocumentById({ + const document = await getDocumentWithDetailsById({ id: documentId, userId: user.id, teamId: team?.id, }).catch(() => null); - if (!document || !document.documentData) { + if (!document) { redirect(documentRootPath); } @@ -51,7 +49,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie redirect(`${documentRootPath}/${documentId}`); } - const { documentData, documentMeta } = document; + const { documentMeta, Recipient: recipients } = document; if (documentMeta?.password) { const key = DOCUMENSO_ENCRYPTION_KEY; @@ -70,18 +68,6 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie documentMeta.password = securePassword; } - const [recipients, fields] = await Promise.all([ - getRecipientsForDocument({ - documentId, - userId: user.id, - teamId: team?.id, - }), - getFieldsForDocument({ - documentId, - userId: user.id, - }), - ]); - return (
@@ -109,12 +95,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
diff --git a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx index ce34a55fd..a06e7f2f9 100644 --- a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx @@ -11,6 +11,7 @@ import { convertToLocalSystemFormat, } from '@documenso/lib/constants/date-formats'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; @@ -38,12 +39,12 @@ export const DateField = ({ const [isPending, startTransition] = useTransition(); const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = - trpc.field.signFieldWithToken.useMutation(); + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); const { mutateAsync: removeSignedFieldWithToken, isLoading: isRemoveSignedFieldWithTokenLoading, - } = trpc.field.removeSignedFieldWithToken.useMutation(); + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; diff --git a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx index 4d52ca50a..d81116c21 100644 --- a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; @@ -29,12 +30,12 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => { const [isPending, startTransition] = useTransition(); const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = - trpc.field.signFieldWithToken.useMutation(); + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); const { mutateAsync: removeSignedFieldWithToken, isLoading: isRemoveSignedFieldWithTokenLoading, - } = trpc.field.removeSignedFieldWithToken.useMutation(); + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; diff --git a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx index 44de2fc36..9fd72da2d 100644 --- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; @@ -34,12 +35,12 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { const [isPending, startTransition] = useTransition(); const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = - trpc.field.signFieldWithToken.useMutation(); + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); const { mutateAsync: removeSignedFieldWithToken, isLoading: isRemoveSignedFieldWithTokenLoading, - } = trpc.field.removeSignedFieldWithToken.useMutation(); + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx index 220d3084a..ed0e0adcd 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; @@ -35,12 +36,12 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { const [isPending, startTransition] = useTransition(); const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = - trpc.field.signFieldWithToken.useMutation(); + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); const { mutateAsync: removeSignedFieldWithToken, isLoading: isRemoveSignedFieldWithTokenLoading, - } = trpc.field.removeSignedFieldWithToken.useMutation(); + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); const { Signature: signature } = field; diff --git a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx index 0b91fa283..4e444a09e 100644 --- a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; @@ -30,12 +31,12 @@ export const TextField = ({ field, recipient }: TextFieldProps) => { const [isPending, startTransition] = useTransition(); const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = - trpc.field.signFieldWithToken.useMutation(); + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); const { mutateAsync: removeSignedFieldWithToken, isLoading: isRemoveSignedFieldWithTokenLoading, - } = trpc.field.removeSignedFieldWithToken.useMutation(); + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index 3fe42a4c4..bdc6c2064 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -14,6 +14,10 @@ import { SETTINGS_PAGE_SHORTCUT, TEMPLATES_PAGE_SHORTCUT, } from '@documenso/lib/constants/keyboard-shortcuts'; +import { + DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + SKIP_QUERY_BATCH_META, +} from '@documenso/lib/constants/trpc'; import type { Document, Recipient } from '@documenso/prisma/client'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { @@ -82,6 +86,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { }, { keepPreviousData: true, + // Do not batch this due to relatively long request time compared to + // other queries which are generally batched with this. + ...SKIP_QUERY_BATCH_META, + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, }, ); diff --git a/packages/lib/constants/trpc.ts b/packages/lib/constants/trpc.ts new file mode 100644 index 000000000..0ff76cc21 --- /dev/null +++ b/packages/lib/constants/trpc.ts @@ -0,0 +1,25 @@ +/** + * For TRPC useQueries that should not be batched with other queries. + */ +export const SKIP_QUERY_BATCH_META = { + trpc: { + context: { + skipBatch: true, + }, + }, +}; + +/** + * For TRPC useQueries and useMutations to adjust the logic on when query invalidation + * should occur. + * + * When used in: + * - useQuery: Will not invalidate the given query when a mutation occurs. + * - useMutation: Will not trigger invalidation on all queries when mutation succeeds. + * + */ +export const DO_NOT_INVALIDATE_QUERY_ON_MUTATION = { + meta: { + doNotInvalidateQueryOnMutation: true, + }, +}; diff --git a/packages/lib/server-only/document/get-document-with-details-by-id.ts b/packages/lib/server-only/document/get-document-with-details-by-id.ts new file mode 100644 index 000000000..af30e1f33 --- /dev/null +++ b/packages/lib/server-only/document/get-document-with-details-by-id.ts @@ -0,0 +1,32 @@ +import { prisma } from '@documenso/prisma'; +import type { DocumentWithDetails } from '@documenso/prisma/types/document'; + +import { getDocumentWhereInput } from './get-document-by-id'; + +export type GetDocumentWithDetailsByIdOptions = { + id: number; + userId: number; + teamId?: number; +}; + +export const getDocumentWithDetailsById = async ({ + id, + userId, + teamId, +}: GetDocumentWithDetailsByIdOptions): Promise => { + const documentWhereInput = await getDocumentWhereInput({ + documentId: id, + userId, + teamId, + }); + + return await prisma.document.findFirstOrThrow({ + where: documentWhereInput, + include: { + documentData: true, + documentMeta: true, + Recipient: true, + Field: true, + }, + }); +}; diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts index 7916de554..a5b1cfd8b 100644 --- a/packages/lib/server-only/field/set-fields-for-document.ts +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -5,7 +5,7 @@ import { diffFieldChanges, } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; -import type { FieldType } from '@documenso/prisma/client'; +import type { Field, FieldType } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client'; export interface SetFieldsForDocumentOptions { @@ -29,7 +29,7 @@ export const setFieldsForDocument = async ({ documentId, fields, requestMetadata, -}: SetFieldsForDocumentOptions) => { +}: SetFieldsForDocumentOptions): Promise => { const document = await prisma.document.findFirst({ where: { id: documentId, @@ -99,7 +99,7 @@ export const setFieldsForDocument = async ({ }); const persistedFields = await prisma.$transaction(async (tx) => { - await Promise.all( + return await Promise.all( linkedFields.map(async (field) => { const fieldSignerEmail = field.signerEmail.toLowerCase(); @@ -218,5 +218,13 @@ export const setFieldsForDocument = async ({ }); } - return persistedFields; + // Filter out fields that have been removed or have been updated. + const filteredFields = existingFields.filter((field) => { + const isRemoved = removedFields.find((removedField) => removedField.id === field.id); + const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id); + + return !isRemoved && !isUpdated; + }); + + return [...filteredFields, ...persistedFields]; }; diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-recipients-for-document.ts index 2505e5261..f9f8426bc 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -6,6 +6,7 @@ import { diffRecipientChanges, } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; +import type { Recipient } from '@documenso/prisma/client'; import { RecipientRole } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client'; @@ -28,7 +29,7 @@ export const setRecipientsForDocument = async ({ documentId, recipients, requestMetadata, -}: SetRecipientsForDocumentOptions) => { +}: SetRecipientsForDocumentOptions): Promise => { const document = await prisma.document.findFirst({ where: { id: documentId, @@ -226,5 +227,17 @@ export const setRecipientsForDocument = async ({ }); } - return persistedRecipients; + // Filter out recipients that have been removed or have been updated. + const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => { + const isRemoved = removedRecipients.find( + (removedRecipient) => removedRecipient.id === recipient.id, + ); + const isUpdated = persistedRecipients.find( + (persistedRecipient) => persistedRecipient.id === recipient.id, + ); + + return !isRemoved && !isUpdated; + }); + + return [...filteredRecipients, ...persistedRecipients]; }; diff --git a/packages/prisma/types/document.ts b/packages/prisma/types/document.ts index 35a6a33b5..5bbc53b55 100644 --- a/packages/prisma/types/document.ts +++ b/packages/prisma/types/document.ts @@ -1,4 +1,10 @@ -import { Document, Recipient } from '@documenso/prisma/client'; +import type { + Document, + DocumentData, + DocumentMeta, + Field, + Recipient, +} from '@documenso/prisma/client'; export type DocumentWithRecipientAndSender = Omit & { recipient: Recipient; @@ -10,3 +16,10 @@ export type DocumentWithRecipientAndSender = Omit & { subject: string; description: string; }; + +export type DocumentWithDetails = Document & { + documentData: DocumentData; + documentMeta: DocumentMeta | null; + Recipient: Recipient[]; + Field: Field[]; +}; diff --git a/packages/trpc/client/index.ts b/packages/trpc/client/index.ts index 39e1ed511..a91021f97 100644 --- a/packages/trpc/client/index.ts +++ b/packages/trpc/client/index.ts @@ -1,16 +1,22 @@ -import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; +import { createTRPCProxyClient, httpBatchLink, httpLink, splitLink } from '@trpc/client'; import SuperJSON from 'superjson'; import { getBaseUrl } from '@documenso/lib/universal/get-base-url'; -import { AppRouter } from '../server/router'; +import type { AppRouter } from '../server/router'; export const trpc = createTRPCProxyClient({ transformer: SuperJSON, links: [ - httpBatchLink({ - url: `${getBaseUrl()}/api/trpc`, + splitLink({ + condition: (op) => op.context.skipBatch === true, + true: httpLink({ + url: `${getBaseUrl()}/api/trpc`, + }), + false: httpBatchLink({ + url: `${getBaseUrl()}/api/trpc`, + }), }), ], }); diff --git a/packages/trpc/react/index.tsx b/packages/trpc/react/index.tsx index f1ac7afb9..0a6028b08 100644 --- a/packages/trpc/react/index.tsx +++ b/packages/trpc/react/index.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import type { QueryClientConfig } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { httpBatchLink } from '@trpc/client'; +import { httpBatchLink, httpLink, splitLink } from '@trpc/client'; import { createTRPCReact } from '@trpc/react-query'; import SuperJSON from 'superjson'; @@ -12,12 +12,22 @@ import { getBaseUrl } from '@documenso/lib/universal/get-base-url'; import type { AppRouter } from '../server/router'; +export { getQueryKey } from '@trpc/react-query'; + export const trpc = createTRPCReact({ - unstable_overrides: { + overrides: { useMutation: { async onSuccess(opts) { await opts.originalFn(); - await opts.queryClient.invalidateQueries(); + + if (opts.meta.doNotInvalidateQueryOnMutation) { + return; + } + + // Invalidate all queries besides ones that specify not to in the meta data. + await opts.queryClient.invalidateQueries({ + predicate: (query) => !query?.meta?.doNotInvalidateQueryOnMutation, + }); }, }, }, @@ -55,8 +65,14 @@ export function TrpcProvider({ children }: TrpcProviderProps) { transformer: SuperJSON, links: [ - httpBatchLink({ - url: `${getBaseUrl()}/api/trpc`, + splitLink({ + condition: (op) => op.context.skipBatch === true, + true: httpLink({ + url: `${getBaseUrl()}/api/trpc`, + }), + false: httpBatchLink({ + url: `${getBaseUrl()}/api/trpc`, + }), }), ], }), diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 26b547ac9..70cf15291 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -9,6 +9,7 @@ import { duplicateDocumentById } from '@documenso/lib/server-only/document/dupli import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; @@ -23,6 +24,7 @@ import { ZFindDocumentAuditLogsQuerySchema, ZGetDocumentByIdQuerySchema, ZGetDocumentByTokenQuerySchema, + ZGetDocumentWithDetailsByIdQuerySchema, ZResendDocumentMutationSchema, ZSearchDocumentsMutationSchema, ZSendDocumentMutationSchema, @@ -66,6 +68,24 @@ export const documentRouter = router({ } }), + getDocumentWithDetailsById: authenticatedProcedure + .input(ZGetDocumentWithDetailsByIdQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await getDocumentWithDetailsById({ + ...input, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to find this document. Please try again later.', + }); + } + }), + createDocument: authenticatedProcedure .input(ZCreateDocumentMutationSchema) .mutation(async ({ input, ctx }) => { diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 34ddf1a5c..065552ee2 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -29,6 +29,15 @@ export const ZGetDocumentByTokenQuerySchema = z.object({ export type TGetDocumentByTokenQuerySchema = z.infer; +export const ZGetDocumentWithDetailsByIdQuerySchema = z.object({ + id: z.number().min(1), + teamId: z.number().min(1).optional(), +}); + +export type TGetDocumentWithDetailsByIdQuerySchema = z.infer< + typeof ZGetDocumentWithDetailsByIdQuerySchema +>; + export const ZCreateDocumentMutationSchema = z.object({ title: z.string().min(1), documentDataId: z.string().min(1),