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)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
index ea672ff10..3c9e20211 100644
--- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
+++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx
@@ -1,17 +1,14 @@
'use client';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import {
- type DocumentData,
- type DocumentMeta,
- 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 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';
@@ -33,12 +30,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;
isDocumentEnterprise: boolean;
};
@@ -48,12 +40,7 @@ const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields',
export const EditDocumentForm = ({
className,
- document,
- recipients,
- fields,
- documentMeta,
- user: _user,
- documentData,
+ initialDocument,
documentRootPath,
isDocumentEnterprise,
}: EditDocumentFormProps) => {
@@ -63,11 +50,74 @@ export const EditDocumentForm = ({
const searchParams = useSearchParams();
const team = useOptionalCurrentTeam();
- const { mutateAsync: setSettingsForDocument } =
- trpc.document.setSettingsForDocument.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: setSettingsForDocument } = trpc.document.setSettingsForDocument.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();
@@ -130,6 +180,7 @@ export const EditDocumentForm = ({
},
});
+ // Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
setStep('signers');
@@ -146,7 +197,6 @@ export const EditDocumentForm = ({
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try {
- // Custom invocation server action
await addSigners({
documentId: document.id,
teamId: team?.id,
@@ -157,7 +207,9 @@ export const EditDocumentForm = ({
})),
});
+ // Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
+
setStep('fields');
} catch (err) {
console.error(err);
@@ -172,13 +224,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);
@@ -231,6 +284,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 57417667d..cab17c841 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
@@ -6,9 +6,7 @@ import { ChevronLeft, Users2 } from 'lucide-react';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
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';
@@ -43,13 +41,13 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
teamId: team?.id,
});
- 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);
}
@@ -57,7 +55,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;
@@ -76,18 +74,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 (
@@ -115,12 +101,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
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.
+
+
+
+
+
+ )}
+
-
- >
- )}
+ )}
+
+ {isPasskeyEnabled && (
+
+ {!isPasskeyLoading && }
+ Passkey
+
+ )}
+