From 695ed418e2c6da29601fdded8237f9bbaa4d16f6 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 4 Jun 2025 23:29:36 +1000 Subject: [PATCH 01/30] fix: documents failing to seal (#1821) During our field rework that makes fields appear more accurately between signing and the completed pdf we swapped to using text fields. Unfortunately as part of that we dropped using the Noto font for the text field causing ANSI encoding issues when encountering certain characters. This change restores the font and handles a nasty issue we had with our form flattening reverting our selected font. --- docker/development/compose.yml | 37 ------------------ .../lib/server-only/document/seal-document.ts | 4 +- packages/lib/server-only/pdf/flatten-form.ts | 15 ++++++- .../server-only/pdf/insert-field-in-pdf.ts | 39 ++++++++++++++++--- 4 files changed, 48 insertions(+), 47 deletions(-) diff --git a/docker/development/compose.yml b/docker/development/compose.yml index 6077668ac..3922ce614 100644 --- a/docker/development/compose.yml +++ b/docker/development/compose.yml @@ -40,43 +40,6 @@ services: entrypoint: sh command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"' - triggerdotdev: - image: ghcr.io/triggerdotdev/trigger.dev:latest - container_name: triggerdotdev - environment: - - LOGIN_ORIGIN=http://localhost:3030 - - APP_ORIGIN=http://localhost:3030 - - PORT=3030 - - REMIX_APP_PORT=3030 - - MAGIC_LINK_SECRET=secret - - SESSION_SECRET=secret - - ENCRYPTION_KEY=deadbeefcafefeed - - DATABASE_URL=postgresql://trigger:password@triggerdotdev_database:5432/trigger - - DIRECT_URL=postgresql://trigger:password@triggerdotdev_database:5432/trigger - - RUNTIME_PLATFORM=docker-compose - ports: - - 3030:3030 - depends_on: - - triggerdotdev_database - - triggerdotdev_database: - container_name: triggerdotdev_database - image: postgres:15 - volumes: - - triggerdotdev_database:/var/lib/postgresql/data - healthcheck: - test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}'] - interval: 10s - timeout: 5s - retries: 5 - environment: - - POSTGRES_USER=trigger - - POSTGRES_PASSWORD=password - - POSTGRES_DB=trigger - ports: - - 54321:5432 - volumes: minio: documenso_database: - triggerdotdev_database: diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index 507f6ec19..f28274e6d 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -128,7 +128,7 @@ export const sealDocument = async ({ // Normalize and flatten layers that could cause issues with the signature normalizeSignatureAppearances(doc); - flattenForm(doc); + await flattenForm(doc); flattenAnnotations(doc); // Add rejection stamp if the document is rejected @@ -153,7 +153,7 @@ export const sealDocument = async ({ } // Re-flatten post-insertion to handle fields that create arcoFields - flattenForm(doc); + await flattenForm(doc); const pdfBytes = await doc.save(); diff --git a/packages/lib/server-only/pdf/flatten-form.ts b/packages/lib/server-only/pdf/flatten-form.ts index 0970309e8..ab5b17a1c 100644 --- a/packages/lib/server-only/pdf/flatten-form.ts +++ b/packages/lib/server-only/pdf/flatten-form.ts @@ -1,3 +1,4 @@ +import fontkit from '@pdf-lib/fontkit'; import type { PDFField, PDFWidgetAnnotation } from 'pdf-lib'; import { PDFCheckBox, @@ -13,6 +14,8 @@ import { translate, } from 'pdf-lib'; +import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; + export const removeOptionalContentGroups = (document: PDFDocument) => { const context = document.context; const catalog = context.lookup(context.trailerInfo.Root); @@ -21,12 +24,20 @@ export const removeOptionalContentGroups = (document: PDFDocument) => { } }; -export const flattenForm = (document: PDFDocument) => { +export const flattenForm = async (document: PDFDocument) => { removeOptionalContentGroups(document); const form = document.getForm(); - form.updateFieldAppearances(); + const fontNoto = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then( + async (res) => res.arrayBuffer(), + ); + + document.registerFontkit(fontkit); + + const font = await document.embedFont(fontNoto); + + form.updateFieldAppearances(font); for (const field of form.getFields()) { for (const widget of field.acroField.getWidgets()) { diff --git a/packages/lib/server-only/pdf/insert-field-in-pdf.ts b/packages/lib/server-only/pdf/insert-field-in-pdf.ts index 8db5abcfe..a89a3c3f9 100644 --- a/packages/lib/server-only/pdf/insert-field-in-pdf.ts +++ b/packages/lib/server-only/pdf/insert-field-in-pdf.ts @@ -1,8 +1,15 @@ // https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821 import fontkit from '@pdf-lib/fontkit'; import { FieldType } from '@prisma/client'; -import type { PDFDocument, PDFFont } from 'pdf-lib'; -import { RotationTypes, TextAlignment, degrees, radiansToDegrees, rgb } from 'pdf-lib'; +import type { PDFDocument, PDFFont, PDFTextField } from 'pdf-lib'; +import { + RotationTypes, + TextAlignment, + degrees, + radiansToDegrees, + rgb, + setFontAndSize, +} from 'pdf-lib'; import { P, match } from 'ts-pattern'; import { @@ -442,6 +449,10 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu adjustedFieldY = adjustedPosition.yPos; } + // Set properties for the text field + setTextFieldFontSize(textField, font, fontSize); + textField.setText(textToInsert); + // Set the position and size of the text field textField.addToPage(page, { x: adjustedFieldX, @@ -450,6 +461,8 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu height: adjustedFieldHeight, rotate: degrees(pageRotationInDegrees), + font, + // Hide borders. borderWidth: 0, borderColor: undefined, @@ -457,10 +470,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu ...(isDebugMode ? { borderWidth: 1, borderColor: rgb(0, 0, 1) } : {}), }); - - // Set properties for the text field - textField.setFontSize(fontSize); - textField.setText(textToInsert); }); return pdf; @@ -629,3 +638,21 @@ function breakLongString(text: string, maxWidth: number, font: PDFFont, fontSize return lines.join('\n'); } + +const setTextFieldFontSize = (textField: PDFTextField, font: PDFFont, fontSize: number) => { + textField.defaultUpdateAppearances(font); + textField.updateAppearances(font); + + try { + textField.setFontSize(fontSize); + } catch (err) { + let da = textField.acroField.getDefaultAppearance() ?? ''; + + da += `\n ${setFontAndSize(font.name, fontSize)}`; + + textField.acroField.setDefaultAppearance(da); + } + + textField.defaultUpdateAppearances(font); + textField.updateAppearances(font); +}; From ce66da0055ac36994df92c593ef79387ea68eae0 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 5 Jun 2025 12:58:52 +1000 Subject: [PATCH 02/30] feat: multisign embedding (#1823) Adds the ability to use a multisign embedding for cases where multiple documents need to be signed in a convenient manner. --- .../embed-direct-template-client-page.tsx | 2 +- .../embed/embed-document-signing-page.tsx | 2 +- .../multisign/multi-sign-document-list.tsx | 182 ++++++++ .../multi-sign-document-signing-view.tsx | 394 ++++++++++++++++++ .../routes/embed+/v1+/multisign+/_index.tsx | 327 +++++++++++++++ .../types/embed-multisign-document-schema.ts | 17 + .../client-only/hooks/use-element-bounds.ts | 75 ++++ .../trpc/server/embedding-router/_router.ts | 4 + .../apply-multi-sign-signature.ts | 102 +++++ .../apply-multi-sign-signature.types.ts | 18 + .../get-multi-sign-document.ts | 62 +++ .../get-multi-sign-document.types.ts | 50 +++ packages/ui/components/field/field.tsx | 33 +- packages/ui/primitives/alert.tsx | 2 +- 14 files changed, 1257 insertions(+), 13 deletions(-) create mode 100644 apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx create mode 100644 apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx create mode 100644 apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx create mode 100644 apps/remix/app/types/embed-multisign-document-schema.ts create mode 100644 packages/lib/client-only/hooks/use-element-bounds.ts create mode 100644 packages/trpc/server/embedding-router/apply-multi-sign-signature.ts create mode 100644 packages/trpc/server/embedding-router/apply-multi-sign-signature.types.ts create mode 100644 packages/trpc/server/embedding-router/get-multi-sign-document.ts create mode 100644 packages/trpc/server/embedding-router/get-multi-sign-document.types.ts diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index aa780385c..09f6a91d2 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -332,7 +332,7 @@ export const EmbedDirectTemplateClientPage = ({ {/* Widget */}
diff --git a/apps/remix/app/components/embed/embed-document-signing-page.tsx b/apps/remix/app/components/embed/embed-document-signing-page.tsx index e125b2b4a..ef2eedc1c 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page.tsx @@ -290,7 +290,7 @@ export const EmbedSignDocumentClientPage = ({ {/* Widget */}
diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx new file mode 100644 index 000000000..01476a5ab --- /dev/null +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx @@ -0,0 +1,182 @@ +import { Trans } from '@lingui/react/macro'; +import { ReadStatus, RecipientRole, SigningStatus } from '@prisma/client'; +import { ArrowRight, EyeIcon, XCircle } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; +import type { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import { Progress } from '@documenso/ui/primitives/progress'; + +// Get the return type from getRecipientByToken +type RecipientWithFields = Awaited>; + +interface DocumentEnvelope { + document: DocumentAndSender; + recipient: RecipientWithFields; +} + +interface MultiSignDocumentListProps { + envelopes: DocumentEnvelope[]; + onDocumentSelect: (document: DocumentEnvelope['document']) => void; +} + +export function MultiSignDocumentList({ envelopes, onDocumentSelect }: MultiSignDocumentListProps) { + // Calculate progress + const completedDocuments = envelopes.filter( + (envelope) => envelope.recipient.signingStatus === SigningStatus.SIGNED, + ); + const totalDocuments = envelopes.length; + const progressPercentage = (completedDocuments.length / totalDocuments) * 100; + + // Find next document to sign (first one that's not signed and not rejected) + const nextDocumentToSign = envelopes.find( + (envelope) => + envelope.recipient.signingStatus !== SigningStatus.SIGNED && + envelope.recipient.signingStatus !== SigningStatus.REJECTED, + ); + + const allDocumentsCompleted = completedDocuments.length === totalDocuments; + + const hasAssistantOrCcRecipient = envelopes.some( + (envelope) => + envelope.recipient.role === RecipientRole.ASSISTANT || + envelope.recipient.role === RecipientRole.CC, + ); + + function handleView(doc: DocumentEnvelope['document']) { + onDocumentSelect(doc); + } + + function handleNextDocument() { + if (nextDocumentToSign) { + onDocumentSelect(nextDocumentToSign.document); + } + } + + if (hasAssistantOrCcRecipient) { + return ( +
+
+ +
+ +

+ It looks like we ran into an issue! +

+ +

+ + One of the documents in the current bundle has a signing role that is not compatible + with the current signing experience. + +

+ +

+ + Assistants and Copy roles are currently not compatible with the multi-sign experience. + +

+ +

+ Please contact the site owner for further assistance. +

+
+ ); + } + + return ( +
+

+ Sign Documents +

+ +

+ + You have been requested to sign the following documents. Review each document carefully + and complete the signing process. + +

+ + {/* Progress Section */} +
+
+ + Progress + + + {completedDocuments.length} of {totalDocuments} completed + +
+ +
+ +
+
+ +
+ {envelopes.map((envelope) => ( +
+ + {envelope.document.title} + + + {match(envelope.recipient) + .with({ signingStatus: SigningStatus.SIGNED }, () => ( + + Completed + + )) + .with({ signingStatus: SigningStatus.REJECTED }, () => ( + + Rejected + + )) + .with({ readStatus: ReadStatus.OPENED }, () => ( + + Viewed + + )) + .otherwise(() => null)} + + +
+ ))} +
+ + {/* Next Document Button */} + {!allDocumentsCompleted && nextDocumentToSign && ( +
+ +
+ )} + + {allDocumentsCompleted && ( + + + All documents have been completed! + + + Thank you for completing the signing process. + + + )} +
+ ); +} diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx new file mode 100644 index 000000000..f3fc4e4cd --- /dev/null +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -0,0 +1,394 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client'; +import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react'; +import { P, match } from 'ts-pattern'; + +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; +import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { ElementVisible } from '@documenso/ui/primitives/element-visible'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import PDFViewer from '@documenso/ui/primitives/pdf-viewer'; +import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider'; +import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog'; +import { EmbedDocumentFields } from '../embed-document-fields'; + +interface MultiSignDocumentSigningViewProps { + token: string; + recipientId: number; + onBack: () => void; + onDocumentCompleted?: (data: { token: string; documentId: number; recipientId: number }) => void; + onDocumentRejected?: (data: { + token: string; + documentId: number; + recipientId: number; + reason: string; + }) => void; + onDocumentError?: () => void; + onDocumentReady?: () => void; + isNameLocked?: boolean; + allowDocumentRejection?: boolean; +} + +export const MultiSignDocumentSigningView = ({ + token, + recipientId, + onBack, + onDocumentCompleted, + onDocumentRejected, + onDocumentError, + onDocumentReady, + isNameLocked = false, + allowDocumentRejection = false, +}: MultiSignDocumentSigningViewProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + + const { fullName, email, signature, setFullName, setSignature } = + useRequiredDocumentSigningContext(); + + const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); + + const [isExpanded, setIsExpanded] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); + + const { data: document, isLoading } = trpc.embeddingPresign.getMultiSignDocument.useQuery( + { token }, + { + staleTime: 0, + }, + ); + + const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation(); + const { mutateAsync: removeSignedFieldWithToken } = + trpc.field.removeSignedFieldWithToken.useMutation(); + + const { mutateAsync: completeDocumentWithToken } = + trpc.recipient.completeDocumentWithToken.useMutation(); + + const hasSignatureField = document?.fields.some((field) => field.type === FieldType.SIGNATURE); + + const [pendingFields, completedFields] = [ + document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ?? + [], + document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ?? + [], + ]; + + const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => { + try { + await signFieldWithToken(payload); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while signing the document.`), + variant: 'destructive', + }); + } + }; + + const onUnsignField = async (payload: TRemovedSignedFieldWithTokenMutationSchema) => { + try { + await removeSignedFieldWithToken(payload); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + } + }; + + const onDocumentComplete = async () => { + try { + setIsSubmitting(true); + + await completeDocumentWithToken({ + documentId: document!.id, + token, + }); + + onBack(); + + onDocumentCompleted?.({ + token, + documentId: document!.id, + recipientId, + }); + } catch (err) { + onDocumentError?.(); + + toast({ + title: 'Error', + description: 'Failed to complete the document. Please try again.', + variant: 'destructive', + }); + } finally { + setIsSubmitting(false); + } + }; + + const onNextFieldClick = () => { + setShowPendingFieldTooltip(true); + + setIsExpanded(false); + }; + + const onRejected = (reason: string) => { + if (onDocumentRejected && document) { + onDocumentRejected({ + token, + documentId: document.id, + recipientId, + reason, + }); + } + }; + + return ( +
+
+ {match({ isLoading, document }) + .with({ isLoading: true }, () => ( +
+
+ +

+ Loading document... +

+
+
+ )) + .with({ isLoading: false, document: undefined }, () => ( +
+

+ Failed to load document +

+
+ )) + .with({ document: P.nonNullable }, ({ document }) => ( + <> +
+
+

{document.title}

+
+ + +
+ + {allowDocumentRejection && ( +
+ +
+ )} + +
+
+ { + setHasDocumentLoaded(true); + onDocumentReady?.(); + }} + /> +
+ + {/* Widget */} + {document.status !== DocumentStatus.COMPLETED && ( +
+
+ {/* Header */} +
+
+

+ Sign document +

+ + +
+
+ +
+

+ Sign the document to complete the process. +

+ +
+
+ + {/* Form */} +
+
+ { + <> +
+ + + !isNameLocked && setFullName(e.target.value)} + /> +
+ +
+ + + +
+ + {hasSignatureField && ( +
+ + + setSignature(v ?? '')} + typedSignatureEnabled={ + document.documentMeta?.typedSignatureEnabled + } + uploadSignatureEnabled={ + document.documentMeta?.uploadSignatureEnabled + } + drawSignatureEnabled={ + document.documentMeta?.drawSignatureEnabled + } + /> +
+ )} + + } +
+
+ +
+ +
+ {pendingFields.length > 0 ? ( + + ) : ( + + )} +
+
+
+ )} +
+ + {hasDocumentLoaded && ( + + {showPendingFieldTooltip && pendingFields.length > 0 && ( + + Click to insert field + + )} + + )} + + {/* Fields */} + {hasDocumentLoaded && ( + + )} + + {/* Completed fields */} + {document.status !== DocumentStatus.COMPLETED && ( + + )} + + )) + .otherwise(() => null)} +
+
+ ); +}; diff --git a/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx b/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx new file mode 100644 index 000000000..16ea5448d --- /dev/null +++ b/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx @@ -0,0 +1,327 @@ +import { useEffect, useLayoutEffect, useState } from 'react'; + +import { SigningStatus } from '@prisma/client'; +import { useRevalidator } from 'react-router'; + +import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; +import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan'; +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; +import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform'; +import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; +import { getTeamById } from '@documenso/lib/server-only/team/get-team'; + +import { BrandingLogo } from '~/components/general/branding-logo'; +import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider'; +import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider'; +import { DocumentSigningRecipientProvider } from '~/components/general/document-signing/document-signing-recipient-provider'; +import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema'; +import { injectCss } from '~/utils/css-vars'; +import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader'; + +import { MultiSignDocumentList } from '../../../../components/embed/multisign/multi-sign-document-list'; +import { MultiSignDocumentSigningView } from '../../../../components/embed/multisign/multi-sign-document-signing-view'; +import type { Route } from './+types/_index'; + +export async function loader({ request }: Route.LoaderArgs) { + const { user } = await getOptionalSession(request); + + const url = new URL(request.url); + + const tokens = url.searchParams.getAll('token'); + + const envelopes = await Promise.all( + tokens.map(async (token) => { + const document = await getDocumentAndSenderByToken({ + token, + }); + + const recipient = await getRecipientByToken({ token }); + + console.log('document', document.id); + + return { document, recipient }; + }), + ); + + // Check the first envelope for whitelabelling settings (assuming all docs are from same team) + const firstDocument = envelopes[0]?.document; + + if (!firstDocument) { + return superLoaderJson({ + envelopes, + user, + hidePoweredBy: false, + allowWhitelabelling: false, + }); + } + + const team = firstDocument.teamId + ? await getTeamById({ teamId: firstDocument.teamId, userId: firstDocument.userId }).catch( + () => null, + ) + : null; + + const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([ + isDocumentPlatform(firstDocument), + isUserEnterprise({ + userId: firstDocument.userId, + teamId: firstDocument.teamId ?? undefined, + }), + isUserCommunityPlan({ + userId: firstDocument.userId, + teamId: firstDocument.teamId ?? undefined, + }), + ]); + + const hidePoweredBy = team?.teamGlobalSettings?.brandingHidePoweredBy ?? false; + const allowWhitelabelling = isCommunityPlan || isPlatformDocument || isEnterpriseDocument; + + return superLoaderJson({ + envelopes, + user, + hidePoweredBy, + allowWhitelabelling, + }); +} + +export default function MultisignPage() { + const { envelopes, user, hidePoweredBy, allowWhitelabelling } = + useSuperLoaderData(); + const revalidator = useRevalidator(); + + const [selectedDocument, setSelectedDocument] = useState< + (typeof envelopes)[number]['document'] | null + >(null); + + // Additional state for embed functionality + const [hasFinishedInit, setHasFinishedInit] = useState(false); + const [isNameLocked, setIsNameLocked] = useState(false); + const [allowDocumentRejection, setAllowDocumentRejection] = useState(false); + const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] = + useState(false); + const [embedFullName, setEmbedFullName] = useState(''); + + // Check if all documents are completed + const isCompleted = envelopes.every( + (envelope) => envelope.recipient.signingStatus === SigningStatus.SIGNED, + ); + + const selectedRecipient = selectedDocument + ? envelopes.find((e) => e.document.id === selectedDocument.id)?.recipient + : null; + + const onSelectDocument = (document: (typeof envelopes)[number]['document']) => { + setSelectedDocument(document); + }; + + const onBackToDocumentList = () => { + setSelectedDocument(null); + // Revalidate to fetch fresh data when returning to document list + void revalidator.revalidate(); + }; + + const onDocumentCompleted = (data: { + token: string; + documentId: number; + recipientId: number; + }) => { + // Send postMessage for individual document completion + if (window.parent) { + window.parent.postMessage( + { + action: 'document-completed', + data: { + token: data.token, + documentId: data.documentId, + recipientId: data.recipientId, + }, + }, + '*', + ); + } + }; + + const onDocumentRejected = (data: { + token: string; + documentId: number; + recipientId: number; + reason: string; + }) => { + // Send postMessage for document rejection + if (window.parent) { + window.parent.postMessage( + { + action: 'document-rejected', + data: { + token: data.token, + documentId: data.documentId, + recipientId: data.recipientId, + reason: data.reason, + }, + }, + '*', + ); + } + }; + + const onDocumentError = () => { + // Send postMessage for document error + if (window.parent) { + window.parent.postMessage( + { + action: 'document-error', + data: null, + }, + '*', + ); + } + }; + + const onDocumentReady = () => { + // Send postMessage when document is ready + if (window.parent) { + window.parent.postMessage( + { + action: 'document-ready', + data: null, + }, + '*', + ); + } + }; + + const onAllDocumentsCompleted = () => { + // Send postMessage for all documents completion + if (window.parent) { + window.parent.postMessage( + { + action: 'all-documents-completed', + data: { + documents: envelopes.map((envelope) => ({ + token: envelope.recipient.token, + documentId: envelope.document.id, + recipientId: envelope.recipient.id, + action: + envelope.recipient.signingStatus === SigningStatus.SIGNED + ? 'document-completed' + : 'document-rejected', + reason: + envelope.recipient.signingStatus === SigningStatus.REJECTED + ? envelope.recipient.rejectionReason + : undefined, + })), + }, + }, + '*', + ); + } + }; + + useEffect(() => { + if ( + envelopes.every((envelope) => envelope.recipient.signingStatus !== SigningStatus.NOT_SIGNED) + ) { + onAllDocumentsCompleted(); + } + }, [envelopes]); + + useLayoutEffect(() => { + const hash = window.location.hash.slice(1); + + try { + const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash)))); + + if (!isCompleted && data.name) { + setEmbedFullName(data.name); + } + + // Since a recipient can be provided a name we can lock it without requiring + // a to be provided by the parent application, unlike direct templates. + setIsNameLocked(!!data.lockName); + setAllowDocumentRejection(!!data.allowDocumentRejection); + setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields); + + if (data.darkModeDisabled) { + document.documentElement.classList.add('dark-mode-disabled'); + } + + if (allowWhitelabelling) { + injectCss({ + css: data.css, + cssVars: data.cssVars, + }); + } + } catch (err) { + console.error(err); + } + + setHasFinishedInit(true); + + // !: While the two setters are stable we still want to ensure we're avoiding + // !: re-renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // If a document is selected, show the signing view + if (selectedDocument && selectedRecipient) { + // Determine the full name to use - prioritize embed data, then user name, then recipient name + const fullNameToUse = + embedFullName || + (user?.email === selectedRecipient.email ? user?.name : selectedRecipient.name) || + ''; + + return ( +
+ + + + + + + + + {!hidePoweredBy && ( +
+ Powered by + +
+ )} +
+ ); + } + + // Otherwise, show the document list + return ( +
+ + + {!hidePoweredBy && ( +
+ Powered by + +
+ )} +
+ ); +} diff --git a/apps/remix/app/types/embed-multisign-document-schema.ts b/apps/remix/app/types/embed-multisign-document-schema.ts new file mode 100644 index 000000000..43369b688 --- /dev/null +++ b/apps/remix/app/types/embed-multisign-document-schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { ZBaseEmbedDataSchema } from './embed-base-schemas'; + +export const ZEmbedMultiSignDocumentSchema = ZBaseEmbedDataSchema.extend({ + email: z + .union([z.literal(''), z.string().email()]) + .optional() + .transform((value) => value || undefined), + lockEmail: z.boolean().optional().default(false), + name: z + .string() + .optional() + .transform((value) => value || undefined), + lockName: z.boolean().optional().default(false), + allowDocumentRejection: z.boolean().optional(), +}); diff --git a/packages/lib/client-only/hooks/use-element-bounds.ts b/packages/lib/client-only/hooks/use-element-bounds.ts new file mode 100644 index 000000000..c2f453b51 --- /dev/null +++ b/packages/lib/client-only/hooks/use-element-bounds.ts @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react'; + +import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; + +export const useElementBounds = (elementOrSelector: HTMLElement | string, withScroll = false) => { + const [bounds, setBounds] = useState({ + top: 0, + left: 0, + height: 0, + width: 0, + }); + + const calculateBounds = () => { + const $el = + typeof elementOrSelector === 'string' + ? document.querySelector(elementOrSelector) + : elementOrSelector; + + if (!$el) { + throw new Error('Element not found'); + } + + if (withScroll) { + return getBoundingClientRect($el); + } + + const { top, left, width, height } = $el.getBoundingClientRect(); + + return { + top, + left, + width, + height, + }; + }; + + useEffect(() => { + setBounds(calculateBounds()); + }, [calculateBounds]); + + useEffect(() => { + const onResize = () => { + setBounds(calculateBounds()); + }; + + window.addEventListener('resize', onResize); + + return () => { + window.removeEventListener('resize', onResize); + }; + }, [calculateBounds]); + + useEffect(() => { + const $el = + typeof elementOrSelector === 'string' + ? document.querySelector(elementOrSelector) + : elementOrSelector; + + if (!$el) { + return; + } + + const observer = new ResizeObserver(() => { + setBounds(calculateBounds()); + }); + + observer.observe($el); + + return () => { + observer.disconnect(); + }; + }, [calculateBounds]); + + return bounds; +}; diff --git a/packages/trpc/server/embedding-router/_router.ts b/packages/trpc/server/embedding-router/_router.ts index 030132f99..3699e6712 100644 --- a/packages/trpc/server/embedding-router/_router.ts +++ b/packages/trpc/server/embedding-router/_router.ts @@ -1,7 +1,9 @@ import { router } from '../trpc'; +import { applyMultiSignSignatureRoute } from './apply-multi-sign-signature'; import { createEmbeddingDocumentRoute } from './create-embedding-document'; import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token'; import { createEmbeddingTemplateRoute } from './create-embedding-template'; +import { getMultiSignDocumentRoute } from './get-multi-sign-document'; import { updateEmbeddingDocumentRoute } from './update-embedding-document'; import { updateEmbeddingTemplateRoute } from './update-embedding-template'; import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token'; @@ -13,4 +15,6 @@ export const embeddingPresignRouter = router({ createEmbeddingTemplate: createEmbeddingTemplateRoute, updateEmbeddingDocument: updateEmbeddingDocumentRoute, updateEmbeddingTemplate: updateEmbeddingTemplateRoute, + applyMultiSignSignature: applyMultiSignSignatureRoute, + getMultiSignDocument: getMultiSignDocumentRoute, }); diff --git a/packages/trpc/server/embedding-router/apply-multi-sign-signature.ts b/packages/trpc/server/embedding-router/apply-multi-sign-signature.ts new file mode 100644 index 000000000..34d4b082d --- /dev/null +++ b/packages/trpc/server/embedding-router/apply-multi-sign-signature.ts @@ -0,0 +1,102 @@ +import { FieldType, ReadStatus, SigningStatus } from '@prisma/client'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token'; +import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; +import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { prisma } from '@documenso/prisma'; + +import { procedure } from '../trpc'; +import { + ZApplyMultiSignSignatureRequestSchema, + ZApplyMultiSignSignatureResponseSchema, +} from './apply-multi-sign-signature.types'; + +export const applyMultiSignSignatureRoute = procedure + .input(ZApplyMultiSignSignatureRequestSchema) + .output(ZApplyMultiSignSignatureResponseSchema) + .mutation(async ({ input, ctx: { metadata } }) => { + try { + const { tokens, signature, isBase64 } = input; + + // Get all documents and recipients for the tokens + const envelopes = await Promise.all( + tokens.map(async (token) => { + const document = await getDocumentByToken({ token }); + const recipient = await getRecipientByToken({ token }); + + return { document, recipient }; + }), + ); + + // Check if all documents have been viewed + const hasUnviewedDocuments = envelopes.some( + (envelope) => envelope.recipient.readStatus !== ReadStatus.OPENED, + ); + + if (hasUnviewedDocuments) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'All documents must be viewed before signing', + }); + } + + // If we require action auth we should abort here for now + for (const envelope of envelopes) { + const derivedRecipientActionAuth = extractDocumentAuthMethods({ + documentAuth: envelope.document.authOptions, + recipientAuth: envelope.recipient.authOptions, + }); + + if ( + derivedRecipientActionAuth.recipientAccessAuthRequired || + derivedRecipientActionAuth.recipientActionAuthRequired + ) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: + 'Documents that require additional authentication cannot be multi signed at the moment', + }); + } + } + + // Sign all signature fields for each document + await Promise.all( + envelopes.map(async (envelope) => { + if (envelope.recipient.signingStatus === SigningStatus.REJECTED) { + return; + } + + const signatureFields = await prisma.field.findMany({ + where: { + documentId: envelope.document.id, + recipientId: envelope.recipient.id, + type: FieldType.SIGNATURE, + inserted: false, + }, + }); + + await Promise.all( + signatureFields.map(async (field) => + signFieldWithToken({ + token: envelope.recipient.token, + fieldId: field.id, + value: signature, + isBase64, + requestMetadata: metadata.requestMetadata, + }), + ), + ); + }), + ); + + return { success: true }; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Failed to apply multi-sign signature', + }); + } + }); diff --git a/packages/trpc/server/embedding-router/apply-multi-sign-signature.types.ts b/packages/trpc/server/embedding-router/apply-multi-sign-signature.types.ts new file mode 100644 index 000000000..b3ff4d890 --- /dev/null +++ b/packages/trpc/server/embedding-router/apply-multi-sign-signature.types.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const ZApplyMultiSignSignatureRequestSchema = z.object({ + tokens: z.array(z.string()).min(1, { message: 'At least one token is required' }), + signature: z.string().min(1, { message: 'Signature is required' }), + isBase64: z.boolean().optional().default(false), +}); + +export const ZApplyMultiSignSignatureResponseSchema = z.object({ + success: z.boolean(), +}); + +export type TApplyMultiSignSignatureRequestSchema = z.infer< + typeof ZApplyMultiSignSignatureRequestSchema +>; +export type TApplyMultiSignSignatureResponseSchema = z.infer< + typeof ZApplyMultiSignSignatureResponseSchema +>; diff --git a/packages/trpc/server/embedding-router/get-multi-sign-document.ts b/packages/trpc/server/embedding-router/get-multi-sign-document.ts new file mode 100644 index 000000000..60a6813c0 --- /dev/null +++ b/packages/trpc/server/embedding-router/get-multi-sign-document.ts @@ -0,0 +1,62 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; +import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token'; +import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; +import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; + +import { procedure } from '../trpc'; +import { + ZGetMultiSignDocumentRequestSchema, + ZGetMultiSignDocumentResponseSchema, +} from './get-multi-sign-document.types'; + +export const getMultiSignDocumentRoute = procedure + .input(ZGetMultiSignDocumentRequestSchema) + .output(ZGetMultiSignDocumentResponseSchema) + .query(async ({ input, ctx: { metadata } }) => { + try { + const { token } = input; + + const [document, fields, recipient] = await Promise.all([ + getDocumentAndSenderByToken({ + token, + requireAccessAuth: false, + }).catch(() => null), + getFieldsForToken({ token }), + getRecipientByToken({ token }).catch(() => null), + getCompletedFieldsForToken({ token }).catch(() => []), + ]); + + if (!document || !recipient) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document or recipient not found', + }); + } + + await viewedDocument({ + token, + requestMetadata: metadata.requestMetadata, + }); + + // Transform fields to match our schema + const transformedFields = fields.map((field) => ({ + ...field, + recipient, + })); + + return { + ...document, + folder: null, + fields: transformedFields, + }; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Failed to get document details', + }); + } + }); diff --git a/packages/trpc/server/embedding-router/get-multi-sign-document.types.ts b/packages/trpc/server/embedding-router/get-multi-sign-document.types.ts new file mode 100644 index 000000000..2ab73db72 --- /dev/null +++ b/packages/trpc/server/embedding-router/get-multi-sign-document.types.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +import { ZDocumentLiteSchema } from '@documenso/lib/types/document'; +import { ZRecipientLiteSchema } from '@documenso/lib/types/recipient'; +import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema'; +import DocumentMetaSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema'; +import FieldSchema from '@documenso/prisma/generated/zod/modelSchema/FieldSchema'; +import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema'; + +export const ZGetMultiSignDocumentRequestSchema = z.object({ + token: z.string().min(1, { message: 'Token is required' }), +}); + +export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({ + documentData: DocumentDataSchema.pick({ + type: true, + id: true, + data: true, + initialData: true, + }), + documentMeta: DocumentMetaSchema.pick({ + signingOrder: true, + distributionMethod: true, + id: true, + subject: true, + message: true, + timezone: true, + password: true, + dateFormat: true, + documentId: true, + redirectUrl: true, + typedSignatureEnabled: true, + uploadSignatureEnabled: true, + drawSignatureEnabled: true, + allowDictateNextSigner: true, + language: true, + emailSettings: true, + }).nullable(), + fields: z.array( + FieldSchema.extend({ + recipient: ZRecipientLiteSchema, + signature: SignatureSchema.nullable(), + }), + ), +}); + +export type TGetMultiSignDocumentRequestSchema = z.infer; +export type TGetMultiSignDocumentResponseSchema = z.infer< + typeof ZGetMultiSignDocumentResponseSchema +>; diff --git a/packages/ui/components/field/field.tsx b/packages/ui/components/field/field.tsx index e3acf8005..5c2c3f300 100644 --- a/packages/ui/components/field/field.tsx +++ b/packages/ui/components/field/field.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { type Field, FieldType } from '@prisma/client'; import { createPortal } from 'react-dom'; @@ -20,24 +20,37 @@ export function FieldContainerPortal({ children, className = '', }: FieldContainerPortalProps) { + const alternativePortalRoot = document.getElementById('document-field-portal-root'); + const coords = useFieldPageCoords(field); const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO'; - const style = { - top: `${coords.y}px`, - left: `${coords.x}px`, - ...(!isCheckboxOrRadioField && { - height: `${coords.height}px`, - width: `${coords.width}px`, - }), - }; + const style = useMemo(() => { + const portalBounds = alternativePortalRoot?.getBoundingClientRect(); + + const bounds = { + top: `${coords.y}px`, + left: `${coords.x}px`, + ...(!isCheckboxOrRadioField && { + height: `${coords.height}px`, + width: `${coords.width}px`, + }), + }; + + if (portalBounds) { + bounds.top = `${coords.y - portalBounds.top}px`; + bounds.left = `${coords.x - portalBounds.left}px`; + } + + return bounds; + }, [coords, isCheckboxOrRadioField]); return createPortal(
{children}
, - document.body, + alternativePortalRoot ?? document.body, ); } diff --git a/packages/ui/primitives/alert.tsx b/packages/ui/primitives/alert.tsx index 092fbb2b4..5b5fa770a 100644 --- a/packages/ui/primitives/alert.tsx +++ b/packages/ui/primitives/alert.tsx @@ -58,7 +58,7 @@ const AlertDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
)); AlertDescription.displayName = 'AlertDescription'; From 55c863262059879c62963a55bd08717555134201 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Sat, 7 Jun 2025 00:27:19 +1000 Subject: [PATCH 03/30] feat: password reauthentication for documents and recipients (#1827) Adds password reauthentication to our existing reauth providers, additionally swaps from an exclusive provider to an inclusive type where multiple methods can be selected to offer a this or that experience. --- .../direct-template-configure-form.tsx | 2 +- .../document-signing-auth-dialog.tsx | 159 ++++++++++++--- .../document-signing-auth-password.tsx | 148 ++++++++++++++ .../document-signing-auth-provider.tsx | 43 +++-- .../document-signing-auto-sign.tsx | 11 +- .../document-signing-complete-dialog.tsx | 2 - .../general/document/document-edit-form.tsx | 6 +- .../document/document-history-sheet.tsx | 18 +- .../general/template/template-edit-form.tsx | 4 +- .../_internal+/[__htmltopdf]+/certificate.tsx | 21 +- .../routes/_recipient+/d.$token+/_index.tsx | 4 +- .../app/routes/embed+/_v0+/direct.$url.tsx | 6 +- .../app/routes/embed+/_v0+/sign.$url.tsx | 6 +- .../routes/embed+/v1+/multisign+/_index.tsx | 2 - packages/api/v1/implementation.ts | 4 +- packages/api/v1/schema.ts | 14 +- .../e2e/document-auth/access-auth.spec.ts | 4 +- .../e2e/document-auth/action-auth.spec.ts | 44 ++--- .../e2e/document-flow/settings-step.spec.ts | 6 +- .../template-settings-step.spec.ts | 6 +- .../template-signers-step.spec.ts | 4 +- .../create-document-from-template.spec.ts | 48 ++--- .../e2e/templates/direct-templates.spec.ts | 4 +- packages/lib/constants/document-auth.ts | 4 + .../lib/server-only/2fa/verify-password.ts | 20 ++ .../document/complete-document-with-token.ts | 7 + .../document/create-document-v2.ts | 18 +- .../get-document-certificate-audit-logs.ts | 4 + .../document/is-recipient-authorized.ts | 23 ++- .../server-only/document/update-document.ts | 7 +- .../document/validate-field-auth.ts | 10 +- .../server-only/document/viewed-document.ts | 4 +- .../create-embedding-presign-token.ts | 1 - .../recipient/create-document-recipients.ts | 16 +- .../recipient/create-template-recipients.ts | 12 +- .../recipient/set-document-recipients.ts | 21 +- .../recipient/set-template-recipients.ts | 6 +- .../recipient/update-document-recipients.ts | 13 +- .../server-only/recipient/update-recipient.ts | 6 +- .../recipient/update-template-recipients.ts | 8 +- .../create-document-from-direct-template.ts | 9 +- .../server-only/template/update-template.ts | 6 +- packages/lib/types/document-audit-logs.ts | 40 +++- packages/lib/types/document-auth.ts | 75 ++++++-- packages/lib/utils/document-audit-logs.ts | 5 +- packages/lib/utils/document-auth.ts | 26 +-- .../trpc/server/document-router/schema.ts | 4 +- .../document-router/update-document.types.ts | 4 +- .../trpc/server/recipient-router/schema.ts | 12 +- .../trpc/server/template-router/schema.ts | 4 +- .../document-global-auth-access-select.tsx | 100 ++++++---- .../document-global-auth-action-select.tsx | 118 +++++++----- .../recipient-action-auth-select.tsx | 181 ++++++++++-------- .../primitives/document-flow/add-settings.tsx | 34 +++- .../document-flow/add-settings.types.ts | 19 +- .../primitives/document-flow/add-signers.tsx | 14 +- .../document-flow/add-signers.types.ts | 6 +- packages/ui/primitives/multiselect.tsx | 4 + .../add-template-placeholder-recipients.tsx | 12 +- ...d-template-placeholder-recipients.types.ts | 6 +- .../template-flow/add-template-settings.tsx | 16 +- .../add-template-settings.types.tsx | 10 +- 62 files changed, 985 insertions(+), 466 deletions(-) create mode 100644 apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx create mode 100644 packages/lib/server-only/2fa/verify-password.ts diff --git a/apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx index 39272b3b1..47e983003 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-configure-form.tsx @@ -130,7 +130,7 @@ export const DirectTemplateConfigureForm = ({ {...field} disabled={ field.disabled || - derivedRecipientAccessAuth !== null || + derivedRecipientAccessAuth.length > 0 || user?.email !== undefined } placeholder="recipient@documenso.com" diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx index de4cf3f68..706daf686 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx @@ -1,5 +1,8 @@ +import { useState } from 'react'; + import { Trans } from '@lingui/react/macro'; import type { FieldType } from '@prisma/client'; +import { ChevronLeftIcon } from 'lucide-react'; import { P, match } from 'ts-pattern'; import { @@ -7,6 +10,7 @@ import { type TRecipientActionAuth, type TRecipientActionAuthTypes, } from '@documenso/lib/types/document-auth'; +import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, @@ -18,11 +22,12 @@ import { import { DocumentSigningAuth2FA } from './document-signing-auth-2fa'; import { DocumentSigningAuthAccount } from './document-signing-auth-account'; import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey'; +import { DocumentSigningAuthPassword } from './document-signing-auth-password'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; export type DocumentSigningAuthDialogProps = { title?: string; - documentAuthType: TRecipientActionAuthTypes; + availableAuthTypes: TRecipientActionAuthTypes[]; description?: string; actionTarget: FieldType | 'DOCUMENT'; open: boolean; @@ -37,54 +42,158 @@ export type DocumentSigningAuthDialogProps = { export const DocumentSigningAuthDialog = ({ title, description, - documentAuthType, + availableAuthTypes, open, onOpenChange, onReauthFormSubmit, }: DocumentSigningAuthDialogProps) => { const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext(); + // Filter out EXPLICIT_NONE from available auth types for the chooser + const validAuthTypes = availableAuthTypes.filter( + (authType) => authType !== DocumentAuth.EXPLICIT_NONE, + ); + + const [selectedAuthType, setSelectedAuthType] = useState(() => { + // Auto-select if there's only one valid option + if (validAuthTypes.length === 1) { + return validAuthTypes[0]; + } + // Return null if multiple options - show chooser + return null; + }); + const handleOnOpenChange = (value: boolean) => { if (isCurrentlyAuthenticating) { return; } + // Reset selected auth type when dialog closes + if (!value) { + setSelectedAuthType(() => { + if (validAuthTypes.length === 1) { + return validAuthTypes[0]; + } + + return null; + }); + } + onOpenChange(value); }; + const handleBackToChooser = () => { + setSelectedAuthType(null); + }; + + // If no valid auth types available, don't render anything + if (validAuthTypes.length === 0) { + return null; + } + return ( - {title || Sign field} + + {selectedAuthType && validAuthTypes.length > 1 && ( +
+ + {title || Sign field} +
+ )} + {(!selectedAuthType || validAuthTypes.length === 1) && + (title || Sign field)} +
{description || Reauthentication is required to sign this field}
- {match({ documentAuthType, user }) - .with( - { documentAuthType: DocumentAuth.ACCOUNT }, - { user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in. - () => , - ) - .with({ documentAuthType: DocumentAuth.PASSKEY }, () => ( - - )) - .with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => ( - - )) - .with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null) - .exhaustive()} + {/* Show chooser if no auth type is selected and there are multiple options */} + {!selectedAuthType && validAuthTypes.length > 1 && ( +
+

+ Choose your preferred authentication method: +

+
+ {validAuthTypes.map((authType) => ( + + ))} +
+
+ )} + + {/* Show the selected auth component */} + {selectedAuthType && + match({ documentAuthType: selectedAuthType, user }) + .with( + { documentAuthType: DocumentAuth.ACCOUNT }, + { user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in. + () => , + ) + .with({ documentAuthType: DocumentAuth.PASSKEY }, () => ( + + )) + .with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => ( + + )) + .with({ documentAuthType: DocumentAuth.PASSWORD }, () => ( + + )) + .with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null) + .exhaustive()}
); diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx new file mode 100644 index 000000000..6446a1e59 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans } from '@lingui/react/macro'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { DialogFooter } from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; + +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; + +export type DocumentSigningAuthPasswordProps = { + actionTarget?: 'FIELD' | 'DOCUMENT'; + actionVerb?: string; + open: boolean; + onOpenChange: (value: boolean) => void; + onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise | void; +}; + +const ZPasswordAuthFormSchema = z.object({ + password: z + .string() + .min(1, { message: 'Password is required' }) + .max(72, { message: 'Password must be at most 72 characters long' }), +}); + +type TPasswordAuthFormSchema = z.infer; + +export const DocumentSigningAuthPassword = ({ + actionTarget = 'FIELD', + actionVerb = 'sign', + onReauthFormSubmit, + open, + onOpenChange, +}: DocumentSigningAuthPasswordProps) => { + const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = + useRequiredDocumentSigningAuthContext(); + + const form = useForm({ + resolver: zodResolver(ZPasswordAuthFormSchema), + defaultValues: { + password: '', + }, + }); + + const [formErrorCode, setFormErrorCode] = useState(null); + + const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => { + try { + setIsCurrentlyAuthenticating(true); + + await onReauthFormSubmit({ + type: DocumentAuth.PASSWORD, + password, + }); + + setIsCurrentlyAuthenticating(false); + + onOpenChange(false); + } catch (err) { + setIsCurrentlyAuthenticating(false); + + const error = AppError.parseError(err); + setFormErrorCode(error.code); + + // Todo: Alert. + } + }; + + useEffect(() => { + form.reset({ + password: '', + }); + + setFormErrorCode(null); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + return ( +
+ +
+
+ {formErrorCode && ( + + + Unauthorized + + + + We were unable to verify your details. Please try again or contact support + + + + )} + + ( + + + Password + + + + + + + + + )} + /> + + + + + + +
+
+
+ + ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx index 8f0af0d30..42e5ffd5b 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx @@ -1,7 +1,6 @@ import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { type Document, FieldType, type Passkey, type Recipient } from '@prisma/client'; -import { match } from 'ts-pattern'; import type { SessionUser } from '@documenso/auth/server/lib/session/session'; import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth'; @@ -33,8 +32,8 @@ export type DocumentSigningAuthContextValue = { recipient: Recipient; recipientAuthOption: TRecipientAuthOptions; setRecipient: (_value: Recipient) => void; - derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null; - derivedRecipientActionAuth: TRecipientActionAuthTypes | null; + derivedRecipientAccessAuth: TRecipientAccessAuthTypes[]; + derivedRecipientActionAuth: TRecipientActionAuthTypes[]; isAuthRedirectRequired: boolean; isCurrentlyAuthenticating: boolean; setIsCurrentlyAuthenticating: (_value: boolean) => void; @@ -100,7 +99,7 @@ export const DocumentSigningAuthProvider = ({ }, { placeholderData: (previousData) => previousData, - enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY, + enabled: derivedRecipientActionAuth?.includes(DocumentAuth.PASSKEY) ?? false, }, ); @@ -121,21 +120,28 @@ export const DocumentSigningAuthProvider = ({ * Will be `null` if the user still requires authentication, or if they don't need * authentication. */ - const preCalculatedActionAuthOptions = match(derivedRecipientActionAuth) - .with(DocumentAuth.ACCOUNT, () => { - if (recipient.email !== user?.email) { - return null; - } + const preCalculatedActionAuthOptions = useMemo(() => { + if ( + !derivedRecipientActionAuth || + derivedRecipientActionAuth.length === 0 || + derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) + ) { + return { + type: DocumentAuth.EXPLICIT_NONE, + }; + } + if ( + derivedRecipientActionAuth.includes(DocumentAuth.ACCOUNT) && + user?.email == recipient.email + ) { return { type: DocumentAuth.ACCOUNT, }; - }) - .with(DocumentAuth.EXPLICIT_NONE, () => ({ - type: DocumentAuth.EXPLICIT_NONE, - })) - .with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null) - .exhaustive(); + } + + return null; + }, [derivedRecipientActionAuth, user, recipient]); const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { // Directly run callback if no auth required. @@ -170,7 +176,8 @@ export const DocumentSigningAuthProvider = ({ // Assume that a user must be logged in for any auth requirements. const isAuthRedirectRequired = Boolean( derivedRecipientActionAuth && - derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE && + derivedRecipientActionAuth.length > 0 && + !derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) && user?.email !== recipient.email, ); @@ -208,7 +215,7 @@ export const DocumentSigningAuthProvider = ({ onOpenChange={() => setDocumentAuthDialogPayload(null)} onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit} actionTarget={documentAuthDialogPayload.actionTarget} - documentAuthType={derivedRecipientActionAuth} + availableAuthTypes={derivedRecipientActionAuth} /> )} @@ -217,7 +224,7 @@ export const DocumentSigningAuthProvider = ({ type ExecuteActionAuthProcedureOptions = Omit< DocumentSigningAuthDialogProps, - 'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' + 'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole' | 'availableAuthTypes' >; DocumentSigningAuthProvider.displayName = 'DocumentSigningAuthProvider'; diff --git a/apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx b/apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx index cb90525a7..4a900a230 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx @@ -44,6 +44,7 @@ const AUTO_SIGNABLE_FIELD_TYPES: string[] = [ // other field types. const NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES: string[] = [ DocumentAuth.PASSKEY, + DocumentAuth.PASSWORD, DocumentAuth.TWO_FACTOR_AUTH, ]; @@ -96,8 +97,8 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu return true; }); - const actionAuthAllowsAutoSign = !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes( - derivedRecipientActionAuth ?? '', + const actionAuthAllowsAutoSign = derivedRecipientActionAuth.every( + (actionAuth) => !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(actionAuth), ); const onSubmit = async () => { @@ -110,16 +111,16 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu .with(FieldType.DATE, () => new Date().toISOString()) .otherwise(() => ''); - const authOptions = match(derivedRecipientActionAuth) + const authOptions = match(derivedRecipientActionAuth.at(0)) .with(DocumentAuth.ACCOUNT, () => ({ type: DocumentAuth.ACCOUNT, })) .with(DocumentAuth.EXPLICIT_NONE, () => ({ type: DocumentAuth.EXPLICIT_NONE, })) - .with(null, () => undefined) + .with(undefined, () => undefined) .with( - P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH), + P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD), // This is a bit dirty, but the sentinel value used here is incredibly short-lived. () => 'NOT_SUPPORTED' as const, ) diff --git a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx index a14f9719c..65503b965 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -92,8 +92,6 @@ export const DocumentSigningCompleteDialog = ({ }; const onFormSubmit = async (data: TNextSignerFormSchema) => { - console.log('data', data); - console.log('form.formState.errors', form.formState.errors); try { if (allowDictateNextSigner && data.name && data.email) { await onSignatureComplete({ name: data.name, email: data.email }); diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index e2b03f053..7a0d0c875 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -183,8 +183,8 @@ export const DocumentEditForm = ({ title: data.title, externalId: data.externalId || null, visibility: data.visibility, - globalAccessAuth: data.globalAccessAuth ?? null, - globalActionAuth: data.globalActionAuth ?? null, + globalAccessAuth: data.globalAccessAuth ?? [], + globalActionAuth: data.globalActionAuth ?? [], }, meta: { timezone, @@ -229,7 +229,7 @@ export const DocumentEditForm = ({ recipients: data.signers.map((signer) => ({ ...signer, // Explicitly set to null to indicate we want to remove auth if required. - actionAuth: signer.actionAuth || null, + actionAuth: signer.actionAuth ?? [], })), }), ]); diff --git a/apps/remix/app/components/general/document/document-history-sheet.tsx b/apps/remix/app/components/general/document/document-history-sheet.tsx index ef73c1c8f..f7c70bc84 100644 --- a/apps/remix/app/components/general/document/document-history-sheet.tsx +++ b/apps/remix/app/components/general/document/document-history-sheet.tsx @@ -81,11 +81,15 @@ export const DocumentHistorySheet = ({ * @param text The text to format * @returns The formatted text */ - const formatGenericText = (text?: string | null) => { + const formatGenericText = (text?: string | string[] | null): string => { if (!text) { return ''; } + if (Array.isArray(text)) { + return text.map((t) => formatGenericText(t)).join(', '); + } + return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' '); }; @@ -245,11 +249,19 @@ export const DocumentHistorySheet = ({ values={[ { key: 'Old', - value: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None', + value: Array.isArray(data.from) + ? data.from + .map((f) => DOCUMENT_AUTH_TYPES[f]?.value || 'None') + .join(', ') + : DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None', }, { key: 'New', - value: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None', + value: Array.isArray(data.to) + ? data.to + .map((f) => DOCUMENT_AUTH_TYPES[f]?.value || 'None') + .join(', ') + : DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None', }, ]} /> diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx index 98449958f..fd120fc87 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -134,8 +134,8 @@ export const TemplateEditForm = ({ title: data.title, externalId: data.externalId || null, visibility: data.visibility, - globalAccessAuth: data.globalAccessAuth ?? null, - globalActionAuth: data.globalActionAuth ?? null, + globalAccessAuth: data.globalAccessAuth ?? [], + globalActionAuth: data.globalActionAuth ?? [], }, meta: { ...data.meta, diff --git a/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx index a8b58bd78..2c9bbd80b 100644 --- a/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx +++ b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx @@ -3,6 +3,7 @@ import { useLingui } from '@lingui/react'; import { FieldType, SigningStatus } from '@prisma/client'; import { DateTime } from 'luxon'; import { redirect } from 'react-router'; +import { prop, sortBy } from 'remeda'; import { match } from 'ts-pattern'; import { UAParser } from 'ua-parser-js'; import { renderSVG } from 'uqr'; @@ -133,18 +134,30 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps) recipientAuth: recipient.authOptions, }); - let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth) + const insertedAuditLogsWithFieldAuth = sortBy( + auditLogs.DOCUMENT_FIELD_INSERTED.filter( + (log) => log.data.recipientId === recipient.id && log.data.fieldSecurity, + ), + [prop('createdAt'), 'desc'], + ); + + const actionAuthMethod = insertedAuditLogsWithFieldAuth.at(0)?.data?.fieldSecurity?.type; + + let authLevel = match(actionAuthMethod) .with('ACCOUNT', () => _(msg`Account Re-Authentication`)) .with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`)) + .with('PASSWORD', () => _(msg`Password Re-Authentication`)) .with('PASSKEY', () => _(msg`Passkey Re-Authentication`)) .with('EXPLICIT_NONE', () => _(msg`Email`)) - .with(null, () => null) + .with(undefined, () => null) .exhaustive(); if (!authLevel) { - authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth) + const accessAuthMethod = extractedAuthMethods.derivedRecipientAccessAuth.at(0); + + authLevel = match(accessAuthMethod) .with('ACCOUNT', () => _(msg`Account Authentication`)) - .with(null, () => _(msg`Email`)) + .with(undefined, () => _(msg`Email`)) .exhaustive(); } diff --git a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx index 21bfab597..ee37e5d0b 100644 --- a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx @@ -47,9 +47,9 @@ export async function loader({ params, request }: Route.LoaderArgs) { }); // Ensure typesafety when we add more options. - const isAccessAuthValid = match(derivedRecipientAccessAuth) + const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0)) .with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user)) - .with(null, () => true) + .with(undefined, () => true) .exhaustive(); if (!isAccessAuthValid) { diff --git a/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx b/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx index d8290f852..45df8a8a8 100644 --- a/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx +++ b/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx @@ -68,9 +68,9 @@ export async function loader({ params, request }: Route.LoaderArgs) { }), ]); - const isAccessAuthValid = match(derivedRecipientAccessAuth) - .with(DocumentAccessAuth.ACCOUNT, () => user !== null) - .with(null, () => true) + const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0)) + .with(DocumentAccessAuth.ACCOUNT, () => !!user) + .with(undefined, () => true) .exhaustive(); if (!isAccessAuthValid) { diff --git a/apps/remix/app/routes/embed+/_v0+/sign.$url.tsx b/apps/remix/app/routes/embed+/_v0+/sign.$url.tsx index 7c16e3beb..5f51c1686 100644 --- a/apps/remix/app/routes/embed+/_v0+/sign.$url.tsx +++ b/apps/remix/app/routes/embed+/_v0+/sign.$url.tsx @@ -81,9 +81,9 @@ export async function loader({ params, request }: Route.LoaderArgs) { documentAuth: document.authOptions, }); - const isAccessAuthValid = match(derivedRecipientAccessAuth) - .with(DocumentAccessAuth.ACCOUNT, () => user !== null) - .with(null, () => true) + const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0)) + .with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email) + .with(undefined, () => true) .exhaustive(); if (!isAccessAuthValid) { diff --git a/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx b/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx index 16ea5448d..ac2a9c7e1 100644 --- a/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx +++ b/apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx @@ -38,8 +38,6 @@ export async function loader({ request }: Route.LoaderArgs) { const recipient = await getRecipientByToken({ token }); - console.log('document', document.id); - return { document, recipient }; }), ); diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 28c33b3a7..63058a043 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -821,7 +821,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { name, role, signingOrder, - actionAuth: authOptions?.actionAuth ?? null, + actionAuth: authOptions?.actionAuth ?? [], }, ], requestMetadata: metadata, @@ -888,7 +888,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { name, role, signingOrder, - actionAuth: authOptions?.actionAuth, + actionAuth: authOptions?.actionAuth ?? [], requestMetadata: metadata.requestMetadata, }).catch(() => null); diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index be8675808..0b96c55ef 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -177,8 +177,8 @@ export const ZCreateDocumentMutationSchema = z.object({ .default({}), authOptions: z .object({ - globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(), - globalActionAuth: ZDocumentActionAuthTypesSchema.optional(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]), }) .optional() .openapi({ @@ -237,8 +237,8 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({ .optional(), authOptions: z .object({ - globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(), - globalActionAuth: ZDocumentActionAuthTypesSchema.optional(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]), }) .optional(), formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), @@ -310,8 +310,8 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({ .optional(), authOptions: z .object({ - globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(), - globalActionAuth: ZDocumentActionAuthTypesSchema.optional(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]), }) .optional(), formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), @@ -350,7 +350,7 @@ export const ZCreateRecipientMutationSchema = z.object({ signingOrder: z.number().nullish(), authOptions: z .object({ - actionAuth: ZRecipientActionAuthTypesSchema.optional(), + actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }) .optional() .openapi({ diff --git a/packages/app-tests/e2e/document-auth/access-auth.spec.ts b/packages/app-tests/e2e/document-auth/access-auth.spec.ts index a84eacf58..8c96a9c4b 100644 --- a/packages/app-tests/e2e/document-auth/access-auth.spec.ts +++ b/packages/app-tests/e2e/document-auth/access-auth.spec.ts @@ -42,8 +42,8 @@ test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page { createDocumentOptions: { authOptions: createDocumentAuthOptions({ - globalAccessAuth: 'ACCOUNT', - globalActionAuth: null, + globalAccessAuth: ['ACCOUNT'], + globalActionAuth: [], }), }, }, diff --git a/packages/app-tests/e2e/document-auth/action-auth.spec.ts b/packages/app-tests/e2e/document-auth/action-auth.spec.ts index 28a4b1087..c93c3f88e 100644 --- a/packages/app-tests/e2e/document-auth/action-auth.spec.ts +++ b/packages/app-tests/e2e/document-auth/action-auth.spec.ts @@ -65,8 +65,8 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa recipients: [recipientWithAccount], updateDocumentOptions: { authOptions: createDocumentAuthOptions({ - globalAccessAuth: null, - globalActionAuth: 'ACCOUNT', + globalAccessAuth: [], + globalActionAuth: ['ACCOUNT'], }), }, }); @@ -116,8 +116,8 @@ test.skip('[DOCUMENT_AUTH]: should deny signing document when required for globa recipients: [recipientWithAccount], updateDocumentOptions: { authOptions: createDocumentAuthOptions({ - globalAccessAuth: null, - globalActionAuth: 'ACCOUNT', + globalAccessAuth: [], + globalActionAuth: ['ACCOUNT'], }), }, }); @@ -147,8 +147,8 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth' recipients: [recipientWithAccount, seedTestEmail()], updateDocumentOptions: { authOptions: createDocumentAuthOptions({ - globalAccessAuth: null, - globalActionAuth: 'ACCOUNT', + globalAccessAuth: [], + globalActionAuth: ['ACCOUNT'], }), }, }); @@ -193,20 +193,20 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au recipientsCreateOptions: [ { authOptions: createRecipientAuthOptions({ - accessAuth: null, - actionAuth: null, + accessAuth: [], + actionAuth: [], }), }, { authOptions: createRecipientAuthOptions({ - accessAuth: null, - actionAuth: 'EXPLICIT_NONE', + accessAuth: [], + actionAuth: ['EXPLICIT_NONE'], }), }, { authOptions: createRecipientAuthOptions({ - accessAuth: null, - actionAuth: 'ACCOUNT', + accessAuth: [], + actionAuth: ['ACCOUNT'], }), }, ], @@ -218,7 +218,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); // This document has no global action auth, so only account should require auth. - const isAuthRequired = actionAuth === 'ACCOUNT'; + const isAuthRequired = actionAuth.includes('ACCOUNT'); const signUrl = `/sign/${token}`; @@ -292,28 +292,28 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an recipientsCreateOptions: [ { authOptions: createRecipientAuthOptions({ - accessAuth: null, - actionAuth: null, + accessAuth: [], + actionAuth: [], }), }, { authOptions: createRecipientAuthOptions({ - accessAuth: null, - actionAuth: 'EXPLICIT_NONE', + accessAuth: [], + actionAuth: ['EXPLICIT_NONE'], }), }, { authOptions: createRecipientAuthOptions({ - accessAuth: null, - actionAuth: 'ACCOUNT', + accessAuth: [], + actionAuth: ['ACCOUNT'], }), }, ], fields: [FieldType.DATE, FieldType.SIGNATURE], updateDocumentOptions: { authOptions: createDocumentAuthOptions({ - globalAccessAuth: null, - globalActionAuth: 'ACCOUNT', + globalAccessAuth: [], + globalActionAuth: ['ACCOUNT'], }), }, }); @@ -323,7 +323,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); // This document HAS global action auth, so account and inherit should require auth. - const isAuthRequired = actionAuth === 'ACCOUNT' || actionAuth === null; + const isAuthRequired = actionAuth.includes('ACCOUNT') || actionAuth.length === 0; const signUrl = `/sign/${token}`; diff --git a/packages/app-tests/e2e/document-flow/settings-step.spec.ts b/packages/app-tests/e2e/document-flow/settings-step.spec.ts index e375f4d45..d3115e584 100644 --- a/packages/app-tests/e2e/document-flow/settings-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/settings-step.spec.ts @@ -40,7 +40,7 @@ test.describe('[EE_ONLY]', () => { // Set EE action auth. await page.getByTestId('documentActionSelectValue').click(); - await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); // Save the settings by going to the next step. @@ -82,7 +82,7 @@ test.describe('[EE_ONLY]', () => { // Set EE action auth. await page.getByTestId('documentActionSelectValue').click(); - await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); // Save the settings by going to the next step. @@ -143,7 +143,7 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => { // Set access auth. await page.getByTestId('documentAccessSelectValue').click(); - await page.getByLabel('Require account').getByText('Require account').click(); + await page.getByRole('option').filter({ hasText: 'Require account' }).click(); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); // Action auth should NOT be visible. diff --git a/packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts index 6cd714082..3cdf197f8 100644 --- a/packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts +++ b/packages/app-tests/e2e/templates-flow/template-settings-step.spec.ts @@ -37,7 +37,7 @@ test.describe('[EE_ONLY]', () => { // Set EE action auth. await page.getByTestId('documentActionSelectValue').click(); - await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); // Save the settings by going to the next step. @@ -79,7 +79,7 @@ test.describe('[EE_ONLY]', () => { // Set EE action auth. await page.getByTestId('documentActionSelectValue').click(); - await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); // Save the settings by going to the next step. @@ -140,7 +140,7 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => { // Set access auth. await page.getByTestId('documentAccessSelectValue').click(); - await page.getByLabel('Require account').getByText('Require account').click(); + await page.getByRole('option').filter({ hasText: 'Require account' }).click(); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); // Action auth should NOT be visible. diff --git a/packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts index fe3efeb83..338d86e9d 100644 --- a/packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts +++ b/packages/app-tests/e2e/templates-flow/template-signers-step.spec.ts @@ -58,8 +58,8 @@ test.describe('[EE_ONLY]', () => { // Add advanced settings for a single recipient. await page.getByLabel('Show advanced settings').check(); - await page.getByRole('combobox').first().click(); - await page.getByLabel('Require passkey').click(); + await page.getByTestId('documentActionSelectValue').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); // Navigate to the next step and back. await page.getByRole('button', { name: 'Continue' }).click(); diff --git a/packages/app-tests/e2e/templates/create-document-from-template.spec.ts b/packages/app-tests/e2e/templates/create-document-from-template.spec.ts index 8add68d7d..564b57d30 100644 --- a/packages/app-tests/e2e/templates/create-document-from-template.spec.ts +++ b/packages/app-tests/e2e/templates/create-document-from-template.spec.ts @@ -48,13 +48,13 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) => // Set template document access. await page.getByTestId('documentAccessSelectValue').click(); - await page.getByLabel('Require account').getByText('Require account').click(); + await page.getByRole('option').filter({ hasText: 'Require account' }).click(); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); // Set EE action auth. if (isBillingEnabled) { await page.getByTestId('documentActionSelectValue').click(); - await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); } @@ -85,8 +85,8 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) => // Apply require passkey for Recipient 1. if (isBillingEnabled) { await page.getByLabel('Show advanced settings').check(); - await page.getByRole('combobox').first().click(); - await page.getByLabel('Require passkey').click(); + await page.getByTestId('documentActionSelectValue').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); } await page.getByRole('button', { name: 'Continue' }).click(); @@ -119,10 +119,12 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) => }); expect(document.title).toEqual('TEMPLATE_TITLE'); - expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT'); - expect(documentAuth.documentAuthOption.globalActionAuth).toEqual( - isBillingEnabled ? 'PASSKEY' : null, - ); + expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT'); + + if (isBillingEnabled) { + expect(documentAuth.documentAuthOption.globalActionAuth).toContain('PASSKEY'); + } + expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a'); expect(document.documentMeta?.message).toEqual('MESSAGE'); expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com'); @@ -143,11 +145,11 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) => }); if (isBillingEnabled) { - expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY'); + expect(recipientOneAuth.derivedRecipientActionAuth).toContain('PASSKEY'); } - expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); - expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); + expect(recipientOneAuth.derivedRecipientAccessAuth).toContain('ACCOUNT'); + expect(recipientTwoAuth.derivedRecipientAccessAuth).toContain('ACCOUNT'); }); /** @@ -183,13 +185,13 @@ test('[TEMPLATE]: should create a team document from a team template', async ({ // Set template document access. await page.getByTestId('documentAccessSelectValue').click(); - await page.getByLabel('Require account').getByText('Require account').click(); + await page.getByRole('option').filter({ hasText: 'Require account' }).click(); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); // Set EE action auth. if (isBillingEnabled) { await page.getByTestId('documentActionSelectValue').click(); - await page.getByLabel('Require passkey').getByText('Require passkey').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey'); } @@ -220,8 +222,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({ // Apply require passkey for Recipient 1. if (isBillingEnabled) { await page.getByLabel('Show advanced settings').check(); - await page.getByRole('combobox').first().click(); - await page.getByLabel('Require passkey').click(); + await page.getByTestId('documentActionSelectValue').click(); + await page.getByRole('option').filter({ hasText: 'Require passkey' }).click(); } await page.getByRole('button', { name: 'Continue' }).click(); @@ -256,10 +258,12 @@ test('[TEMPLATE]: should create a team document from a team template', async ({ }); expect(document.title).toEqual('TEMPLATE_TITLE'); - expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT'); - expect(documentAuth.documentAuthOption.globalActionAuth).toEqual( - isBillingEnabled ? 'PASSKEY' : null, - ); + expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT'); + + if (isBillingEnabled) { + expect(documentAuth.documentAuthOption.globalActionAuth).toContain('PASSKEY'); + } + expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a'); expect(document.documentMeta?.message).toEqual('MESSAGE'); expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com'); @@ -280,11 +284,11 @@ test('[TEMPLATE]: should create a team document from a team template', async ({ }); if (isBillingEnabled) { - expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY'); + expect(recipientOneAuth.derivedRecipientActionAuth).toContain('PASSKEY'); } - expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); - expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT'); + expect(recipientOneAuth.derivedRecipientAccessAuth).toContain('ACCOUNT'); + expect(recipientTwoAuth.derivedRecipientAccessAuth).toContain('ACCOUNT'); }); /** diff --git a/packages/app-tests/e2e/templates/direct-templates.spec.ts b/packages/app-tests/e2e/templates/direct-templates.spec.ts index aa4730395..de25e2adc 100644 --- a/packages/app-tests/e2e/templates/direct-templates.spec.ts +++ b/packages/app-tests/e2e/templates/direct-templates.spec.ts @@ -172,8 +172,8 @@ test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => userId: user.id, createTemplateOptions: { authOptions: createDocumentAuthOptions({ - globalAccessAuth: 'ACCOUNT', - globalActionAuth: null, + globalAccessAuth: ['ACCOUNT'], + globalActionAuth: [], }), }, }); diff --git a/packages/lib/constants/document-auth.ts b/packages/lib/constants/document-auth.ts index 77c8d7b58..3301a700c 100644 --- a/packages/lib/constants/document-auth.ts +++ b/packages/lib/constants/document-auth.ts @@ -19,6 +19,10 @@ export const DOCUMENT_AUTH_TYPES: Record = { key: DocumentAuth.TWO_FACTOR_AUTH, value: 'Require 2FA', }, + [DocumentAuth.PASSWORD]: { + key: DocumentAuth.PASSWORD, + value: 'Require password', + }, [DocumentAuth.EXPLICIT_NONE]: { key: DocumentAuth.EXPLICIT_NONE, value: 'None (Overrides global settings)', diff --git a/packages/lib/server-only/2fa/verify-password.ts b/packages/lib/server-only/2fa/verify-password.ts new file mode 100644 index 000000000..3624919db --- /dev/null +++ b/packages/lib/server-only/2fa/verify-password.ts @@ -0,0 +1,20 @@ +import { compare } from '@node-rs/bcrypt'; + +import { prisma } from '@documenso/prisma'; + +type VerifyPasswordOptions = { + userId: number; + password: string; +}; + +export const verifyPassword = async ({ userId, password }: VerifyPasswordOptions) => { + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user || !user.password) { + return false; + } + + return await compare(password, user.password); +}; diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index d77daa174..92d304c2f 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -23,6 +23,7 @@ import { ZWebhookDocumentSchema, mapDocumentToWebhookDocumentPayload, } from '../../types/webhook-payload'; +import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { sendPendingEmail } from './send-pending-email'; @@ -140,6 +141,11 @@ export const completeDocumentWithToken = async ({ }, }); + const authOptions = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }); + await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, @@ -154,6 +160,7 @@ export const completeDocumentWithToken = async ({ recipientName: recipient.name, recipientId: recipient.id, recipientRole: recipient.role, + actionAuth: authOptions.derivedRecipientActionAuth, }, }), }); diff --git a/packages/lib/server-only/document/create-document-v2.ts b/packages/lib/server-only/document/create-document-v2.ts index a87860488..19719ca85 100644 --- a/packages/lib/server-only/document/create-document-v2.ts +++ b/packages/lib/server-only/document/create-document-v2.ts @@ -39,8 +39,8 @@ export type CreateDocumentOptions = { title: string; externalId?: string; visibility?: DocumentVisibility; - globalAccessAuth?: TDocumentAccessAuthTypes; - globalActionAuth?: TDocumentActionAuthTypes; + globalAccessAuth?: TDocumentAccessAuthTypes[]; + globalActionAuth?: TDocumentActionAuthTypes[]; formValues?: TDocumentFormValues; recipients: TCreateDocumentV2Request['recipients']; }; @@ -113,14 +113,16 @@ export const createDocumentV2 = async ({ } const authOptions = createDocumentAuthOptions({ - globalAccessAuth: data?.globalAccessAuth || null, - globalActionAuth: data?.globalActionAuth || null, + globalAccessAuth: data?.globalAccessAuth || [], + globalActionAuth: data?.globalActionAuth || [], }); - const recipientsHaveActionAuth = data.recipients?.some((recipient) => recipient.actionAuth); + const recipientsHaveActionAuth = data.recipients?.some( + (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, + ); // Check if user has permission to set the global action auth. - if (authOptions.globalActionAuth || recipientsHaveActionAuth) { + if (authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) { const isDocumentEnterprise = await isUserEnterprise({ userId, teamId, @@ -171,8 +173,8 @@ export const createDocumentV2 = async ({ await Promise.all( (data.recipients || []).map(async (recipient) => { const recipientAuthOptions = createRecipientAuthOptions({ - accessAuth: recipient.accessAuth || null, - actionAuth: recipient.actionAuth || null, + accessAuth: recipient.accessAuth ?? [], + actionAuth: recipient.actionAuth ?? [], }); await tx.recipient.create({ diff --git a/packages/lib/server-only/document/get-document-certificate-audit-logs.ts b/packages/lib/server-only/document/get-document-certificate-audit-logs.ts index 676118506..3ace2687f 100644 --- a/packages/lib/server-only/document/get-document-certificate-audit-logs.ts +++ b/packages/lib/server-only/document/get-document-certificate-audit-logs.ts @@ -17,6 +17,7 @@ export const getDocumentCertificateAuditLogs = async ({ in: [ DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, ], @@ -36,6 +37,9 @@ export const getDocumentCertificateAuditLogs = async ({ [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter( (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, ), + [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED]: auditLogs.filter( + (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED, + ), [DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter( (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && diff --git a/packages/lib/server-only/document/is-recipient-authorized.ts b/packages/lib/server-only/document/is-recipient-authorized.ts index 2426b5d88..873b087e1 100644 --- a/packages/lib/server-only/document/is-recipient-authorized.ts +++ b/packages/lib/server-only/document/is-recipient-authorized.ts @@ -5,6 +5,7 @@ import { match } from 'ts-pattern'; import { prisma } from '@documenso/prisma'; import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token'; +import { verifyPassword } from '../2fa/verify-password'; import { AppError, AppErrorCode } from '../../errors/app-error'; import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth'; import { DocumentAuth } from '../../types/document-auth'; @@ -60,23 +61,26 @@ export const isRecipientAuthorized = async ({ recipientAuth: recipient.authOptions, }); - const authMethod: TDocumentAuth | null = + const authMethods: TDocumentAuth[] = type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth; // Early true return when auth is not required. - if (!authMethod || authMethod === DocumentAuth.EXPLICIT_NONE) { + if ( + authMethods.length === 0 || + authMethods.some((method) => method === DocumentAuth.EXPLICIT_NONE) + ) { return true; } // Create auth options when none are passed for account. - if (!authOptions && authMethod === DocumentAuth.ACCOUNT) { + if (!authOptions && authMethods.some((method) => method === DocumentAuth.ACCOUNT)) { authOptions = { type: DocumentAuth.ACCOUNT, }; } // Authentication required does not match provided method. - if (!authOptions || authOptions.type !== authMethod || !userId) { + if (!authOptions || !authMethods.includes(authOptions.type) || !userId) { return false; } @@ -117,6 +121,15 @@ export const isRecipientAuthorized = async ({ window: 10, // 5 minutes worth of tokens }); }) + .with({ type: DocumentAuth.PASSWORD }, async ({ password }) => { + return await verifyPassword({ + userId, + password, + }); + }) + .with({ type: DocumentAuth.EXPLICIT_NONE }, () => { + return true; + }) .exhaustive(); }; @@ -160,7 +173,7 @@ const verifyPasskey = async ({ }: VerifyPasskeyOptions): Promise => { const passkey = await prisma.passkey.findFirst({ where: { - credentialId: Buffer.from(authenticationResponse.id, 'base64'), + credentialId: new Uint8Array(Buffer.from(authenticationResponse.id, 'base64')), userId, }, }); diff --git a/packages/lib/server-only/document/update-document.ts b/packages/lib/server-only/document/update-document.ts index f93661583..fe2f149a5 100644 --- a/packages/lib/server-only/document/update-document.ts +++ b/packages/lib/server-only/document/update-document.ts @@ -21,8 +21,8 @@ export type UpdateDocumentOptions = { title?: string; externalId?: string | null; visibility?: DocumentVisibility | null; - globalAccessAuth?: TDocumentAccessAuthTypes | null; - globalActionAuth?: TDocumentActionAuthTypes | null; + globalAccessAuth?: TDocumentAccessAuthTypes[]; + globalActionAuth?: TDocumentActionAuthTypes[]; useLegacyFieldInsertion?: boolean; }; requestMetadata: ApiRequestMetadata; @@ -119,7 +119,6 @@ export const updateDocument = async ({ // If no data just return the document since this function is normally chained after a meta update. if (!data || Object.values(data).length === 0) { - console.log('no data'); return document; } @@ -137,7 +136,7 @@ export const updateDocument = async ({ data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth; // Check if user has permission to set the global action auth. - if (newGlobalActionAuth) { + if (newGlobalActionAuth && newGlobalActionAuth.length > 0) { const isDocumentEnterprise = await isUserEnterprise({ userId, teamId, diff --git a/packages/lib/server-only/document/validate-field-auth.ts b/packages/lib/server-only/document/validate-field-auth.ts index b78b8a936..8dfc0ab48 100644 --- a/packages/lib/server-only/document/validate-field-auth.ts +++ b/packages/lib/server-only/document/validate-field-auth.ts @@ -3,7 +3,6 @@ import { FieldType } from '@prisma/client'; import { AppError, AppErrorCode } from '../../errors/app-error'; import type { TRecipientActionAuth } from '../../types/document-auth'; -import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { isRecipientAuthorized } from './is-recipient-authorized'; export type ValidateFieldAuthOptions = { @@ -26,14 +25,9 @@ export const validateFieldAuth = async ({ userId, authOptions, }: ValidateFieldAuthOptions) => { - const { derivedRecipientActionAuth } = extractDocumentAuthMethods({ - documentAuth: documentAuthOptions, - recipientAuth: recipient.authOptions, - }); - // Override all non-signature fields to not require any auth. if (field.type !== FieldType.SIGNATURE) { - return null; + return undefined; } const isValid = await isRecipientAuthorized({ @@ -50,5 +44,5 @@ export const validateFieldAuth = async ({ }); } - return derivedRecipientActionAuth; + return authOptions?.type; }; diff --git a/packages/lib/server-only/document/viewed-document.ts b/packages/lib/server-only/document/viewed-document.ts index 1b3da020e..fbc0aaa2d 100644 --- a/packages/lib/server-only/document/viewed-document.ts +++ b/packages/lib/server-only/document/viewed-document.ts @@ -15,7 +15,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type ViewedDocumentOptions = { token: string; - recipientAccessAuth?: TDocumentAccessAuthTypes | null; + recipientAccessAuth?: TDocumentAccessAuthTypes[]; requestMetadata?: RequestMetadata; }; @@ -63,7 +63,7 @@ export const viewedDocument = async ({ recipientId: recipient.id, recipientName: recipient.name, recipientRole: recipient.role, - accessAuth: recipientAccessAuth || undefined, + accessAuth: recipientAccessAuth ?? [], }, }), }); diff --git a/packages/lib/server-only/embedding-presign/create-embedding-presign-token.ts b/packages/lib/server-only/embedding-presign/create-embedding-presign-token.ts index ecdf95d24..f298fe613 100644 --- a/packages/lib/server-only/embedding-presign/create-embedding-presign-token.ts +++ b/packages/lib/server-only/embedding-presign/create-embedding-presign-token.ts @@ -27,7 +27,6 @@ export const createEmbeddingPresignToken = async ({ // In development mode, allow setting expiresIn to 0 for testing // In production, enforce a minimum expiration time const isDevelopment = env('NODE_ENV') !== 'production'; - console.log('isDevelopment', isDevelopment); const minExpirationMinutes = isDevelopment ? 0 : 5; // Ensure expiresIn is at least the minimum allowed value diff --git a/packages/lib/server-only/recipient/create-document-recipients.ts b/packages/lib/server-only/recipient/create-document-recipients.ts index 8a7059ed8..707011296 100644 --- a/packages/lib/server-only/recipient/create-document-recipients.ts +++ b/packages/lib/server-only/recipient/create-document-recipients.ts @@ -22,8 +22,8 @@ export interface CreateDocumentRecipientsOptions { name: string; role: RecipientRole; signingOrder?: number | null; - accessAuth?: TRecipientAccessAuthTypes | null; - actionAuth?: TRecipientActionAuthTypes | null; + accessAuth?: TRecipientAccessAuthTypes[]; + actionAuth?: TRecipientActionAuthTypes[]; }[]; requestMetadata: ApiRequestMetadata; } @@ -71,7 +71,9 @@ export const createDocumentRecipients = async ({ }); } - const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth); + const recipientsHaveActionAuth = recipientsToCreate.some( + (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, + ); // Check if user has permission to set the global action auth. if (recipientsHaveActionAuth) { @@ -110,8 +112,8 @@ export const createDocumentRecipients = async ({ return await Promise.all( normalizedRecipients.map(async (recipient) => { const authOptions = createRecipientAuthOptions({ - accessAuth: recipient.accessAuth || null, - actionAuth: recipient.actionAuth || null, + accessAuth: recipient.accessAuth ?? [], + actionAuth: recipient.actionAuth ?? [], }); const createdRecipient = await tx.recipient.create({ @@ -140,8 +142,8 @@ export const createDocumentRecipients = async ({ recipientName: createdRecipient.name, recipientId: createdRecipient.id, recipientRole: createdRecipient.role, - accessAuth: recipient.accessAuth || undefined, - actionAuth: recipient.actionAuth || undefined, + accessAuth: recipient.accessAuth ?? [], + actionAuth: recipient.actionAuth ?? [], }, }), }); diff --git a/packages/lib/server-only/recipient/create-template-recipients.ts b/packages/lib/server-only/recipient/create-template-recipients.ts index e53ba784b..8768172f4 100644 --- a/packages/lib/server-only/recipient/create-template-recipients.ts +++ b/packages/lib/server-only/recipient/create-template-recipients.ts @@ -19,8 +19,8 @@ export interface CreateTemplateRecipientsOptions { name: string; role: RecipientRole; signingOrder?: number | null; - accessAuth?: TRecipientAccessAuthTypes | null; - actionAuth?: TRecipientActionAuthTypes | null; + accessAuth?: TRecipientAccessAuthTypes[]; + actionAuth?: TRecipientActionAuthTypes[]; }[]; } @@ -60,7 +60,9 @@ export const createTemplateRecipients = async ({ }); } - const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth); + const recipientsHaveActionAuth = recipientsToCreate.some( + (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, + ); // Check if user has permission to set the global action auth. if (recipientsHaveActionAuth) { @@ -99,8 +101,8 @@ export const createTemplateRecipients = async ({ return await Promise.all( normalizedRecipients.map(async (recipient) => { const authOptions = createRecipientAuthOptions({ - accessAuth: recipient.accessAuth || null, - actionAuth: recipient.actionAuth || null, + accessAuth: recipient.accessAuth ?? [], + actionAuth: recipient.actionAuth ?? [], }); const createdRecipient = await tx.recipient.create({ diff --git a/packages/lib/server-only/recipient/set-document-recipients.ts b/packages/lib/server-only/recipient/set-document-recipients.ts index 33a14a28f..b81ca2848 100644 --- a/packages/lib/server-only/recipient/set-document-recipients.ts +++ b/packages/lib/server-only/recipient/set-document-recipients.ts @@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro'; import type { Recipient } from '@prisma/client'; import { RecipientRole } from '@prisma/client'; import { SendStatus, SigningStatus } from '@prisma/client'; +import { isDeepEqual } from 'remeda'; import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { mailer } from '@documenso/email/mailer'; @@ -96,7 +97,9 @@ export const setDocumentRecipients = async ({ throw new Error('Document already complete'); } - const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); + const recipientsHaveActionAuth = recipients.some( + (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, + ); // Check if user has permission to set the global action auth. if (recipientsHaveActionAuth) { @@ -245,8 +248,8 @@ export const setDocumentRecipients = async ({ metadata: requestMetadata, data: { ...baseAuditLog, - accessAuth: recipient.accessAuth || undefined, - actionAuth: recipient.actionAuth || undefined, + accessAuth: recipient.accessAuth || [], + actionAuth: recipient.actionAuth || [], }, }), }); @@ -361,8 +364,8 @@ type RecipientData = { name: string; role: RecipientRole; signingOrder?: number | null; - accessAuth?: TRecipientAccessAuthTypes | null; - actionAuth?: TRecipientActionAuthTypes | null; + accessAuth?: TRecipientAccessAuthTypes[]; + actionAuth?: TRecipientActionAuthTypes[]; }; type RecipientDataWithClientId = Recipient & { @@ -372,15 +375,15 @@ type RecipientDataWithClientId = Recipient & { const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => { const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); - const newRecipientAccessAuth = newRecipientData.accessAuth || null; - const newRecipientActionAuth = newRecipientData.actionAuth || null; + const newRecipientAccessAuth = newRecipientData.accessAuth || []; + const newRecipientActionAuth = newRecipientData.actionAuth || []; return ( recipient.email !== newRecipientData.email || recipient.name !== newRecipientData.name || recipient.role !== newRecipientData.role || recipient.signingOrder !== newRecipientData.signingOrder || - authOptions.accessAuth !== newRecipientAccessAuth || - authOptions.actionAuth !== newRecipientActionAuth + !isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) || + !isDeepEqual(authOptions.actionAuth, newRecipientActionAuth) ); }; diff --git a/packages/lib/server-only/recipient/set-template-recipients.ts b/packages/lib/server-only/recipient/set-template-recipients.ts index 5d878d90b..c1a7fc25b 100644 --- a/packages/lib/server-only/recipient/set-template-recipients.ts +++ b/packages/lib/server-only/recipient/set-template-recipients.ts @@ -26,7 +26,7 @@ export type SetTemplateRecipientsOptions = { name: string; role: RecipientRole; signingOrder?: number | null; - actionAuth?: TRecipientActionAuthTypes | null; + actionAuth?: TRecipientActionAuthTypes[]; }[]; }; @@ -64,7 +64,9 @@ export const setTemplateRecipients = async ({ throw new Error('Template not found'); } - const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); + const recipientsHaveActionAuth = recipients.some( + (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, + ); // Check if user has permission to set the global action auth. if (recipientsHaveActionAuth) { diff --git a/packages/lib/server-only/recipient/update-document-recipients.ts b/packages/lib/server-only/recipient/update-document-recipients.ts index 577915068..43e6c9c37 100644 --- a/packages/lib/server-only/recipient/update-document-recipients.ts +++ b/packages/lib/server-only/recipient/update-document-recipients.ts @@ -1,6 +1,7 @@ import type { Recipient } from '@prisma/client'; import { RecipientRole } from '@prisma/client'; import { SendStatus, SigningStatus } from '@prisma/client'; +import { isDeepEqual } from 'remeda'; import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; @@ -72,7 +73,9 @@ export const updateDocumentRecipients = async ({ }); } - const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); + const recipientsHaveActionAuth = recipients.some( + (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, + ); // Check if user has permission to set the global action auth. if (recipientsHaveActionAuth) { @@ -218,8 +221,8 @@ type RecipientData = { name?: string; role?: RecipientRole; signingOrder?: number | null; - accessAuth?: TRecipientAccessAuthTypes | null; - actionAuth?: TRecipientActionAuthTypes | null; + accessAuth?: TRecipientAccessAuthTypes[]; + actionAuth?: TRecipientActionAuthTypes[]; }; const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => { @@ -233,7 +236,7 @@ const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: Recipie recipient.name !== newRecipientData.name || recipient.role !== newRecipientData.role || recipient.signingOrder !== newRecipientData.signingOrder || - authOptions.accessAuth !== newRecipientAccessAuth || - authOptions.actionAuth !== newRecipientActionAuth + !isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) || + !isDeepEqual(authOptions.actionAuth, newRecipientActionAuth) ); }; diff --git a/packages/lib/server-only/recipient/update-recipient.ts b/packages/lib/server-only/recipient/update-recipient.ts index 67077c6ab..a352ca781 100644 --- a/packages/lib/server-only/recipient/update-recipient.ts +++ b/packages/lib/server-only/recipient/update-recipient.ts @@ -20,7 +20,7 @@ export type UpdateRecipientOptions = { name?: string; role?: RecipientRole; signingOrder?: number | null; - actionAuth?: TRecipientActionAuthTypes | null; + actionAuth?: TRecipientActionAuthTypes[]; userId: number; teamId?: number; requestMetadata?: RequestMetadata; @@ -90,7 +90,7 @@ export const updateRecipient = async ({ throw new Error('Recipient not found'); } - if (actionAuth) { + if (actionAuth && actionAuth.length > 0) { const isDocumentEnterprise = await isUserEnterprise({ userId, teamId, @@ -117,7 +117,7 @@ export const updateRecipient = async ({ signingOrder, authOptions: createRecipientAuthOptions({ accessAuth: recipientAuthOptions.accessAuth, - actionAuth: actionAuth ?? null, + actionAuth: actionAuth ?? [], }), }, }); diff --git a/packages/lib/server-only/recipient/update-template-recipients.ts b/packages/lib/server-only/recipient/update-template-recipients.ts index b69b9dc2b..a9d723064 100644 --- a/packages/lib/server-only/recipient/update-template-recipients.ts +++ b/packages/lib/server-only/recipient/update-template-recipients.ts @@ -22,8 +22,8 @@ export interface UpdateTemplateRecipientsOptions { name?: string; role?: RecipientRole; signingOrder?: number | null; - accessAuth?: TRecipientAccessAuthTypes | null; - actionAuth?: TRecipientActionAuthTypes | null; + accessAuth?: TRecipientAccessAuthTypes[]; + actionAuth?: TRecipientActionAuthTypes[]; }[]; } @@ -63,7 +63,9 @@ export const updateTemplateRecipients = async ({ }); } - const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); + const recipientsHaveActionAuth = recipients.some( + (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, + ); // Check if user has permission to set the global action auth. if (recipientsHaveActionAuth) { diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index 9d71f3297..4441b9460 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -70,7 +70,7 @@ export type CreateDocumentFromDirectTemplateOptions = { type CreatedDirectRecipientField = { field: Field & { signature?: Signature | null }; - derivedRecipientActionAuth: TRecipientActionAuthTypes | null; + derivedRecipientActionAuth?: TRecipientActionAuthTypes; }; export const ZCreateDocumentFromDirectTemplateResponseSchema = z.object({ @@ -151,9 +151,9 @@ export const createDocumentFromDirectTemplate = async ({ const directRecipientName = user?.name || initialDirectRecipientName; // Ensure typesafety when we add more options. - const isAccessAuthValid = match(derivedRecipientAccessAuth) + const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0)) .with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail) - .with(null, () => true) + .with(undefined, () => true) .exhaustive(); if (!isAccessAuthValid) { @@ -460,7 +460,7 @@ export const createDocumentFromDirectTemplate = async ({ const createdDirectRecipientFields: CreatedDirectRecipientField[] = [ ...createdDirectRecipient.fields.map((field) => ({ field, - derivedRecipientActionAuth: null, + derivedRecipientActionAuth: undefined, })), ...createdDirectRecipientSignatureFields, ]; @@ -567,6 +567,7 @@ export const createDocumentFromDirectTemplate = async ({ recipientId: createdDirectRecipient.id, recipientName: createdDirectRecipient.name, recipientRole: createdDirectRecipient.role, + actionAuth: createdDirectRecipient.authOptions?.actionAuth ?? [], }, }), ]; diff --git a/packages/lib/server-only/template/update-template.ts b/packages/lib/server-only/template/update-template.ts index dee71d678..54f7864d1 100644 --- a/packages/lib/server-only/template/update-template.ts +++ b/packages/lib/server-only/template/update-template.ts @@ -15,8 +15,8 @@ export type UpdateTemplateOptions = { title?: string; externalId?: string | null; visibility?: DocumentVisibility; - globalAccessAuth?: TDocumentAccessAuthTypes | null; - globalActionAuth?: TDocumentActionAuthTypes | null; + globalAccessAuth?: TDocumentAccessAuthTypes[]; + globalActionAuth?: TDocumentActionAuthTypes[]; publicTitle?: string; publicDescription?: string; type?: Template['type']; @@ -74,7 +74,7 @@ export const updateTemplate = async ({ data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth; // Check if user has permission to set the global action auth. - if (newGlobalActionAuth) { + if (newGlobalActionAuth && newGlobalActionAuth.length > 0) { const isDocumentEnterprise = await isUserEnterprise({ userId, teamId, diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index cb7873834..25679287a 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -123,8 +123,8 @@ export const ZDocumentAuditLogFieldDiffSchema = z.union([ ]); export const ZGenericFromToSchema = z.object({ - from: z.string().nullable(), - to: z.string().nullable(), + from: z.union([z.string(), z.array(z.string())]).nullable(), + to: z.union([z.string(), z.array(z.string())]).nullable(), }); export const ZRecipientDiffActionAuthSchema = ZGenericFromToSchema.extend({ @@ -296,7 +296,7 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({ }, z .object({ - type: ZRecipientActionAuthTypesSchema, + type: ZRecipientActionAuthTypesSchema.optional(), }) .optional(), ), @@ -384,7 +384,7 @@ export const ZDocumentAuditLogEventDocumentFieldPrefilledSchema = z.object({ }, z .object({ - type: ZRecipientActionAuthTypesSchema, + type: ZRecipientActionAuthTypesSchema.optional(), }) .optional(), ), @@ -428,7 +428,13 @@ export const ZDocumentAuditLogEventDocumentMetaUpdatedSchema = z.object({ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED), data: ZBaseRecipientDataSchema.extend({ - accessAuth: z.string().optional(), + accessAuth: z.preprocess((unknownValue) => { + if (!unknownValue) { + return []; + } + + return Array.isArray(unknownValue) ? unknownValue : [unknownValue]; + }, z.array(ZRecipientAccessAuthTypesSchema)), }), }); @@ -438,7 +444,13 @@ export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED), data: ZBaseRecipientDataSchema.extend({ - actionAuth: z.string().optional(), + actionAuth: z.preprocess((unknownValue) => { + if (!unknownValue) { + return []; + } + + return Array.isArray(unknownValue) ? unknownValue : [unknownValue]; + }, z.array(ZRecipientActionAuthTypesSchema)), }), }); @@ -516,8 +528,20 @@ export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({ export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED), data: ZBaseRecipientDataSchema.extend({ - accessAuth: ZRecipientAccessAuthTypesSchema.optional(), - actionAuth: ZRecipientActionAuthTypesSchema.optional(), + accessAuth: z.preprocess((unknownValue) => { + if (!unknownValue) { + return []; + } + + return Array.isArray(unknownValue) ? unknownValue : [unknownValue]; + }, z.array(ZRecipientAccessAuthTypesSchema)), + actionAuth: z.preprocess((unknownValue) => { + if (!unknownValue) { + return []; + } + + return Array.isArray(unknownValue) ? unknownValue : [unknownValue]; + }, z.array(ZRecipientActionAuthTypesSchema)), }), }); diff --git a/packages/lib/types/document-auth.ts b/packages/lib/types/document-auth.ts index f0979754d..493e14374 100644 --- a/packages/lib/types/document-auth.ts +++ b/packages/lib/types/document-auth.ts @@ -9,8 +9,10 @@ export const ZDocumentAuthTypesSchema = z.enum([ 'ACCOUNT', 'PASSKEY', 'TWO_FACTOR_AUTH', + 'PASSWORD', 'EXPLICIT_NONE', ]); + export const DocumentAuth = ZDocumentAuthTypesSchema.Enum; const ZDocumentAuthAccountSchema = z.object({ @@ -27,6 +29,11 @@ const ZDocumentAuthPasskeySchema = z.object({ tokenReference: z.string().min(1), }); +const ZDocumentAuthPasswordSchema = z.object({ + type: z.literal(DocumentAuth.PASSWORD), + password: z.string().min(1), +}); + const ZDocumentAuth2FASchema = z.object({ type: z.literal(DocumentAuth.TWO_FACTOR_AUTH), token: z.string().min(4).max(10), @@ -40,6 +47,7 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [ ZDocumentAuthExplicitNoneSchema, ZDocumentAuthPasskeySchema, ZDocumentAuth2FASchema, + ZDocumentAuthPasswordSchema, ]); /** @@ -61,9 +69,15 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ ZDocumentAuthAccountSchema, ZDocumentAuthPasskeySchema, ZDocumentAuth2FASchema, + ZDocumentAuthPasswordSchema, ]); export const ZDocumentActionAuthTypesSchema = z - .enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH]) + .enum([ + DocumentAuth.ACCOUNT, + DocumentAuth.PASSKEY, + DocumentAuth.TWO_FACTOR_AUTH, + DocumentAuth.PASSWORD, + ]) .describe( 'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.', ); @@ -89,6 +103,7 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [ ZDocumentAuthAccountSchema, ZDocumentAuthPasskeySchema, ZDocumentAuth2FASchema, + ZDocumentAuthPasswordSchema, ZDocumentAuthExplicitNoneSchema, ]); export const ZRecipientActionAuthTypesSchema = z @@ -96,6 +111,7 @@ export const ZRecipientActionAuthTypesSchema = z DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, + DocumentAuth.PASSWORD, DocumentAuth.EXPLICIT_NONE, ]) .describe('The type of authentication required for the recipient to sign the document.'); @@ -110,18 +126,26 @@ export const RecipientActionAuth = ZRecipientActionAuthTypesSchema.Enum; */ export const ZDocumentAuthOptionsSchema = z.preprocess( (unknownValue) => { - if (unknownValue) { - return unknownValue; + if (!unknownValue || typeof unknownValue !== 'object') { + return { + globalAccessAuth: [], + globalActionAuth: [], + }; } + const globalAccessAuth = + 'globalAccessAuth' in unknownValue ? processAuthValue(unknownValue.globalAccessAuth) : []; + const globalActionAuth = + 'globalActionAuth' in unknownValue ? processAuthValue(unknownValue.globalActionAuth) : []; + return { - globalAccessAuth: null, - globalActionAuth: null, + globalAccessAuth, + globalActionAuth, }; }, z.object({ - globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable(), - globalActionAuth: ZDocumentActionAuthTypesSchema.nullable(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema), }), ); @@ -130,21 +154,46 @@ export const ZDocumentAuthOptionsSchema = z.preprocess( */ export const ZRecipientAuthOptionsSchema = z.preprocess( (unknownValue) => { - if (unknownValue) { - return unknownValue; + if (!unknownValue || typeof unknownValue !== 'object') { + return { + accessAuth: [], + actionAuth: [], + }; } + const accessAuth = + 'accessAuth' in unknownValue ? processAuthValue(unknownValue.accessAuth) : []; + const actionAuth = + 'actionAuth' in unknownValue ? processAuthValue(unknownValue.actionAuth) : []; + return { - accessAuth: null, - actionAuth: null, + accessAuth, + actionAuth, }; }, z.object({ - accessAuth: ZRecipientAccessAuthTypesSchema.nullable(), - actionAuth: ZRecipientActionAuthTypesSchema.nullable(), + accessAuth: z.array(ZRecipientAccessAuthTypesSchema), + actionAuth: z.array(ZRecipientActionAuthTypesSchema), }), ); +/** + * Utility function to process the auth value. + * + * Converts the old singular auth value to an array of auth values. + */ +const processAuthValue = (value: unknown) => { + if (value === null || value === undefined) { + return []; + } + + if (Array.isArray(value)) { + return value; + } + + return [value]; +}; + export type TDocumentAuth = z.infer; export type TDocumentAuthMethods = z.infer; export type TDocumentAuthOptions = z.infer; diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index bc564370b..287d4a823 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -2,6 +2,7 @@ import type { I18n } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@prisma/client'; import { RecipientRole } from '@prisma/client'; +import { isDeepEqual } from 'remeda'; import { match } from 'ts-pattern'; import type { @@ -106,7 +107,7 @@ export const diffRecipientChanges = ( const newActionAuth = newAuthOptions?.actionAuth === undefined ? oldActionAuth : newAuthOptions.actionAuth; - if (oldAccessAuth !== newAccessAuth) { + if (!isDeepEqual(oldAccessAuth, newAccessAuth)) { diffs.push({ type: RECIPIENT_DIFF_TYPE.ACCESS_AUTH, from: oldAccessAuth ?? '', @@ -114,7 +115,7 @@ export const diffRecipientChanges = ( }); } - if (oldActionAuth !== newActionAuth) { + if (!isDeepEqual(oldActionAuth, newActionAuth)) { diffs.push({ type: RECIPIENT_DIFF_TYPE.ACTION_AUTH, from: oldActionAuth ?? '', diff --git a/packages/lib/utils/document-auth.ts b/packages/lib/utils/document-auth.ts index dcf8ccc9e..716e3fa11 100644 --- a/packages/lib/utils/document-auth.ts +++ b/packages/lib/utils/document-auth.ts @@ -27,17 +27,21 @@ export const extractDocumentAuthMethods = ({ const documentAuthOption = ZDocumentAuthOptionsSchema.parse(documentAuth); const recipientAuthOption = ZRecipientAuthOptionsSchema.parse(recipientAuth); - const derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null = - recipientAuthOption.accessAuth || documentAuthOption.globalAccessAuth; + const derivedRecipientAccessAuth: TRecipientAccessAuthTypes[] = + recipientAuthOption.accessAuth.length > 0 + ? recipientAuthOption.accessAuth + : documentAuthOption.globalAccessAuth; - const derivedRecipientActionAuth: TRecipientActionAuthTypes | null = - recipientAuthOption.actionAuth || documentAuthOption.globalActionAuth; + const derivedRecipientActionAuth: TRecipientActionAuthTypes[] = + recipientAuthOption.actionAuth.length > 0 + ? recipientAuthOption.actionAuth + : documentAuthOption.globalActionAuth; - const recipientAccessAuthRequired = derivedRecipientAccessAuth !== null; + const recipientAccessAuthRequired = derivedRecipientAccessAuth.length > 0; const recipientActionAuthRequired = - derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE && - derivedRecipientActionAuth !== null; + derivedRecipientActionAuth.length > 0 && + !derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE); return { derivedRecipientAccessAuth, @@ -54,8 +58,8 @@ export const extractDocumentAuthMethods = ({ */ export const createDocumentAuthOptions = (options: TDocumentAuthOptions): TDocumentAuthOptions => { return { - globalAccessAuth: options?.globalAccessAuth ?? null, - globalActionAuth: options?.globalActionAuth ?? null, + globalAccessAuth: options?.globalAccessAuth ?? [], + globalActionAuth: options?.globalActionAuth ?? [], }; }; @@ -66,7 +70,7 @@ export const createRecipientAuthOptions = ( options: TRecipientAuthOptions, ): TRecipientAuthOptions => { return { - accessAuth: options?.accessAuth ?? null, - actionAuth: options?.actionAuth ?? null, + accessAuth: options?.accessAuth ?? [], + actionAuth: options?.actionAuth ?? [], }; }; diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index ac977de28..239106a8b 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -206,8 +206,8 @@ export const ZCreateDocumentV2RequestSchema = z.object({ title: ZDocumentTitleSchema, externalId: ZDocumentExternalIdSchema.optional(), visibility: ZDocumentVisibilitySchema.optional(), - globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(), - globalActionAuth: ZDocumentActionAuthTypesSchema.optional(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(), formValues: ZDocumentFormValuesSchema.optional(), recipients: z .array( diff --git a/packages/trpc/server/document-router/update-document.types.ts b/packages/trpc/server/document-router/update-document.types.ts index e0f91b482..291eb82d8 100644 --- a/packages/trpc/server/document-router/update-document.types.ts +++ b/packages/trpc/server/document-router/update-document.types.ts @@ -42,8 +42,8 @@ export const ZUpdateDocumentRequestSchema = z.object({ title: ZDocumentTitleSchema.optional(), externalId: ZDocumentExternalIdSchema.nullish(), visibility: ZDocumentVisibilitySchema.optional(), - globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullish(), - globalActionAuth: ZDocumentActionAuthTypesSchema.nullish(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(), useLegacyFieldInsertion: z.boolean().optional(), }) .optional(), diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index 0c43838a2..060b3a031 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -26,8 +26,8 @@ export const ZCreateRecipientSchema = z.object({ name: z.string(), role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(), - accessAuth: ZRecipientAccessAuthTypesSchema.optional().nullable(), - actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), + accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]), + actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }); export const ZUpdateRecipientSchema = z.object({ @@ -36,8 +36,8 @@ export const ZUpdateRecipientSchema = z.object({ name: z.string().optional(), role: z.nativeEnum(RecipientRole).optional(), signingOrder: z.number().optional(), - accessAuth: ZRecipientAccessAuthTypesSchema.optional().nullable(), - actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), + accessAuth: z.array(ZRecipientAccessAuthTypesSchema).optional().default([]), + actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }); export const ZCreateDocumentRecipientRequestSchema = z.object({ @@ -106,7 +106,7 @@ export const ZSetDocumentRecipientsRequestSchema = z name: z.string(), role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(), - actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), + actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }), ), }) @@ -190,7 +190,7 @@ export const ZSetTemplateRecipientsRequestSchema = z name: z.string(), role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(), - actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), + actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]), }), ), }) diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index fc193abd7..ebaf70249 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -133,8 +133,8 @@ export const ZUpdateTemplateRequestSchema = z.object({ title: z.string().min(1).optional(), externalId: z.string().nullish(), visibility: z.nativeEnum(DocumentVisibility).optional(), - globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(), - globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional().default([]), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional().default([]), publicTitle: z .string() .trim() diff --git a/packages/ui/components/document/document-global-auth-access-select.tsx b/packages/ui/components/document/document-global-auth-access-select.tsx index 1901d9b62..227f31d04 100644 --- a/packages/ui/components/document/document-global-auth-access-select.tsx +++ b/packages/ui/components/document/document-global-auth-access-select.tsx @@ -1,51 +1,75 @@ -import { forwardRef } from 'react'; +import React from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { SelectProps } from '@radix-ui/react-select'; import { InfoIcon } from 'lucide-react'; import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; +import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; -export const DocumentGlobalAuthAccessSelect = forwardRef( - (props, ref) => { - const { _ } = useLingui(); +export interface DocumentGlobalAuthAccessSelectProps { + value?: string[]; + defaultValue?: string[]; + onValueChange?: (value: string[]) => void; + disabled?: boolean; + placeholder?: string; +} - return ( - - ); - }, -); + // Convert string array to Option array for MultiSelect + const selectedOptions = + (value + ?.map((val) => authOptions.find((option) => option.value === val)) + .filter(Boolean) as Option[]) || []; + + // Convert default value to Option array + const defaultOptions = + (defaultValue + ?.map((val) => authOptions.find((option) => option.value === val)) + .filter(Boolean) as Option[]) || []; + + const handleChange = (options: Option[]) => { + const values = options.map((option) => option.value); + onValueChange?.(values); + }; + + return ( + + ); +}; DocumentGlobalAuthAccessSelect.displayName = 'DocumentGlobalAuthAccessSelect'; @@ -63,7 +87,11 @@ export const DocumentGlobalAuthAccessTooltip = () => (

- The authentication required for recipients to view the document. + The authentication methods required for recipients to view the document. +

+ +

+ Multiple access methods can be selected.

    diff --git a/packages/ui/components/document/document-global-auth-action-select.tsx b/packages/ui/components/document/document-global-auth-action-select.tsx index 956028339..35ab93dc8 100644 --- a/packages/ui/components/document/document-global-auth-action-select.tsx +++ b/packages/ui/components/document/document-global-auth-action-select.tsx @@ -1,54 +1,75 @@ -import { forwardRef } from 'react'; - import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { SelectProps } from '@radix-ui/react-select'; import { InfoIcon } from 'lucide-react'; import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; +import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; -export const DocumentGlobalAuthActionSelect = forwardRef( - (props, ref) => { - const { _ } = useLingui(); +export interface DocumentGlobalAuthActionSelectProps { + value?: string[]; + defaultValue?: string[]; + onValueChange?: (value: string[]) => void; + disabled?: boolean; + placeholder?: string; +} - return ( - - ); - }, -); + // Convert string array to Option array for MultiSelect + const selectedOptions = + (value + ?.map((val) => authOptions.find((option) => option.value === val)) + .filter(Boolean) as Option[]) || []; + + // Convert default value to Option array + const defaultOptions = + (defaultValue + ?.map((val) => authOptions.find((option) => option.value === val)) + .filter(Boolean) as Option[]) || []; + + const handleChange = (options: Option[]) => { + const values = options.map((option) => option.value); + onValueChange?.(values); + }; + + return ( + + ); +}; DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect'; @@ -64,20 +85,19 @@ export const DocumentGlobalAuthActionTooltip = () => (

    - The authentication required for recipients to sign the signature field. + + The authentication methods required for recipients to sign the signature field. +

    - This can be overriden by setting the authentication requirements directly on each - recipient in the next step. + These can be overriden by setting the authentication requirements directly on each + recipient in the next step. Multiple methods can be selected.

      - {/*
    • - Require account - The recipient must be signed in -
    • */}
    • Require passkey - The recipient must have an account and passkey @@ -90,6 +110,14 @@ export const DocumentGlobalAuthActionTooltip = () => ( their settings
    • + +
    • + + Require password - The recipient must have an account and password + configured via their settings, the password will be verified during signing + +
    • +
    • No restrictions - No authentication required diff --git a/packages/ui/components/recipient/recipient-action-auth-select.tsx b/packages/ui/components/recipient/recipient-action-auth-select.tsx index 1ada0fc26..37de3be2f 100644 --- a/packages/ui/components/recipient/recipient-action-auth-select.tsx +++ b/packages/ui/components/recipient/recipient-action-auth-select.tsx @@ -3,97 +3,124 @@ import React from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { SelectProps } from '@radix-ui/react-select'; import { InfoIcon } from 'lucide-react'; import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; import { RecipientActionAuth } from '@documenso/lib/types/document-auth'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; +import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; -export type RecipientActionAuthSelectProps = SelectProps; +export interface RecipientActionAuthSelectProps { + value?: string[]; + defaultValue?: string[]; + onValueChange?: (value: string[]) => void; + disabled?: boolean; + placeholder?: string; +} -export const RecipientActionAuthSelect = (props: RecipientActionAuthSelectProps) => { +export const RecipientActionAuthSelect = ({ + value, + defaultValue, + onValueChange, + disabled, + placeholder, +}: RecipientActionAuthSelectProps) => { const { _ } = useLingui(); + // Convert auth types to MultiSelect options + const authOptions: Option[] = [ + { + value: '-1', + label: _(msg`Inherit authentication method`), + }, + ...Object.values(RecipientActionAuth) + .filter((auth) => auth !== RecipientActionAuth.ACCOUNT) + .map((authType) => ({ + value: authType, + label: DOCUMENT_AUTH_TYPES[authType].value, + })), + ]; + + // Convert string array to Option array for MultiSelect + const selectedOptions = + (value + ?.map((val) => authOptions.find((option) => option.value === val)) + .filter(Boolean) as Option[]) || []; + + // Convert default value to Option array + const defaultOptions = + (defaultValue + ?.map((val) => authOptions.find((option) => option.value === val)) + .filter(Boolean) as Option[]) || []; + + const handleChange = (options: Option[]) => { + const values = options.map((option) => option.value); + onValueChange?.(values); + }; + return ( - +
        +
      • + + Inherit authentication method - Use the global action signing + authentication method configured in the "General Settings" step + +
      • +
      • + + Require passkey - The recipient must have an account and passkey + configured via their settings + +
      • +
      • + + Require 2FA - The recipient must have an account and 2FA enabled + via their settings + +
      • +
      • + + None - No authentication required + +
      • +
      + + +
); }; diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index b1f33517d..16aeb691f 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -98,8 +98,8 @@ export const AddSettingsFormPartial = ({ title: document.title, externalId: document.externalId || '', visibility: document.visibility || '', - globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined, - globalActionAuth: documentAuthOption?.globalActionAuth || undefined, + globalAccessAuth: documentAuthOption?.globalAccessAuth || [], + globalActionAuth: documentAuthOption?.globalActionAuth || [], meta: { timezone: @@ -131,6 +131,12 @@ export const AddSettingsFormPartial = ({ ) .otherwise(() => false); + const onFormSubmit = form.handleSubmit(onSubmit); + + const onGoNextClick = () => { + void onFormSubmit().catch(console.error); + }; + // We almost always want to set the timezone to the user's local timezone to avoid confusion // when the document is signed. useEffect(() => { @@ -214,7 +220,11 @@ export const AddSettingsFormPartial = ({ - @@ -244,7 +254,11 @@ export const AddSettingsFormPartial = ({ - + )} @@ -286,7 +300,11 @@ export const AddSettingsFormPartial = ({ - + )} @@ -370,7 +388,7 @@ export const AddSettingsFormPartial = ({ + + + + )} + /> + + + + + You will need to configure any claims or subscription after creating this + organisation + + + + + {/* ( + + + Default claim ID + + + + + + Leave blank to use the default free claim + + + + )} + /> */} + + + + + + + + + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/claim-create-dialog.tsx b/apps/remix/app/components/dialogs/claim-create-dialog.tsx new file mode 100644 index 000000000..c3564e7e0 --- /dev/null +++ b/apps/remix/app/components/dialogs/claim-create-dialog.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; + +import { Trans, useLingui } from '@lingui/react/macro'; +import type { z } from 'zod'; + +import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims'; +import { trpc } from '@documenso/trpc/react'; +import type { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { SubscriptionClaimForm } from '../forms/subscription-claim-form'; + +export type CreateClaimFormValues = z.infer; + +export const ClaimCreateDialog = () => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: createClaim, isPending } = trpc.admin.claims.create.useMutation({ + onSuccess: () => { + toast({ + title: t`Subscription claim created successfully.`, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: t`Failed to create subscription claim.`, + variant: 'destructive', + }); + }, + }); + + return ( + + e.stopPropagation()} asChild={true}> + + + + + + + Create Subscription Claim + + + Fill in the details to create a new subscription claim. + + + + + + + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/dialogs/claim-delete-dialog.tsx b/apps/remix/app/components/dialogs/claim-delete-dialog.tsx new file mode 100644 index 000000000..61124fa9d --- /dev/null +++ b/apps/remix/app/components/dialogs/claim-delete-dialog.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react'; + +import { Trans, useLingui } from '@lingui/react/macro'; + +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } 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 { useToast } from '@documenso/ui/primitives/use-toast'; + +export type ClaimDeleteDialogProps = { + claimId: string; + claimName: string; + claimLocked: boolean; + trigger: React.ReactNode; +}; + +export const ClaimDeleteDialog = ({ + claimId, + claimName, + claimLocked, + trigger, +}: ClaimDeleteDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: deleteClaim, isPending } = trpc.admin.claims.delete.useMutation({ + onSuccess: () => { + toast({ + title: t`Subscription claim deleted successfully.`, + }); + + setOpen(false); + }, + onError: (err) => { + console.error(err); + + toast({ + title: t`Failed to delete subscription claim.`, + variant: 'destructive', + }); + }, + }); + + return ( + !isPending && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Delete Subscription Claim + + + Are you sure you want to delete the following claim? + + + + + + {claimLocked ? This claim is locked and cannot be deleted. : claimName} + + + + + + + {!claimLocked && ( + + )} + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/claim-update-dialog.tsx b/apps/remix/app/components/dialogs/claim-update-dialog.tsx new file mode 100644 index 000000000..539343d40 --- /dev/null +++ b/apps/remix/app/components/dialogs/claim-update-dialog.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react'; + +import { Trans, useLingui } from '@lingui/react/macro'; + +import { trpc } from '@documenso/trpc/react'; +import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { SubscriptionClaimForm } from '../forms/subscription-claim-form'; + +export type ClaimUpdateDialogProps = { + claim: TFindSubscriptionClaimsResponse['data'][number]; + trigger: React.ReactNode; +}; + +export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: updateClaim, isPending } = trpc.admin.claims.update.useMutation({ + onSuccess: () => { + toast({ + title: t`Subscription claim updated successfully.`, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: t`Failed to update subscription claim.`, + variant: 'destructive', + }); + }, + }); + + return ( + + e.stopPropagation()}> + {trigger} + + + + + + Update Subscription Claim + + + Modify the details of the subscription claim. + + + + + await updateClaim({ + id: claim.id, + data, + }) + } + formSubmitTrigger={ + + + + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/dialogs/document-delete-dialog.tsx b/apps/remix/app/components/dialogs/document-delete-dialog.tsx index c89e346a0..746ef1570 100644 --- a/apps/remix/app/components/dialogs/document-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-delete-dialog.tsx @@ -28,7 +28,6 @@ type DocumentDeleteDialogProps = { onDelete?: () => Promise | void; status: DocumentStatus; documentTitle: string; - teamId?: number; canManageDocument: boolean; }; diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index 754fa2596..bb87f99dc 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -16,7 +16,7 @@ import { import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; type DocumentDuplicateDialogProps = { id: number; @@ -34,7 +34,7 @@ export const DocumentDuplicateDialog = ({ const { toast } = useToast(); const { _ } = useLingui(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery( { @@ -52,7 +52,7 @@ export const DocumentDuplicateDialog = ({ } : undefined; - const documentsPath = formatDocumentsPath(team?.url); + const documentsPath = formatDocumentsPath(team.url); const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } = trpcReact.document.duplicateDocument.useMutation({ diff --git a/apps/remix/app/components/dialogs/document-move-dialog.tsx b/apps/remix/app/components/dialogs/document-move-dialog.tsx deleted file mode 100644 index 1e0632531..000000000 --- a/apps/remix/app/components/dialogs/document-move-dialog.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { useState } from 'react'; - -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; - -import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; -import { trpc } from '@documenso/trpc/react'; -import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -type DocumentMoveDialogProps = { - documentId: number; - open: boolean; - onOpenChange: (_open: boolean) => void; -}; - -export const DocumentMoveDialog = ({ documentId, open, onOpenChange }: DocumentMoveDialogProps) => { - const { _ } = useLingui(); - const { toast } = useToast(); - - const [selectedTeamId, setSelectedTeamId] = useState(null); - - const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery(); - - const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({ - onSuccess: () => { - toast({ - title: _(msg`Document moved`), - description: _(msg`The document has been successfully moved to the selected team.`), - duration: 5000, - }); - - onOpenChange(false); - }, - onError: (error) => { - toast({ - title: _(msg`Error`), - description: error.message || _(msg`An error occurred while moving the document.`), - variant: 'destructive', - duration: 7500, - }); - }, - }); - - const onMove = async () => { - if (!selectedTeamId) { - return; - } - - await moveDocument({ documentId, teamId: selectedTeamId }); - }; - - return ( - - - - - Move Document to Team - - - Select a team to move this document to. This action cannot be undone. - - - - - - - - - - - - ); -}; diff --git a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx index e8aefd0c0..860230b5f 100644 --- a/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx @@ -33,7 +33,7 @@ import { } from '@documenso/ui/primitives/form/form'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; export type DocumentMoveToFolderDialogProps = { documentId: number; @@ -57,8 +57,9 @@ export const DocumentMoveToFolderDialog = ({ }: DocumentMoveToFolderDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const navigate = useNavigate(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const form = useForm({ resolver: zodResolver(ZMoveDocumentFormSchema), @@ -94,6 +95,14 @@ export const DocumentMoveToFolderDialog = ({ folderId: data.folderId ?? null, }); + const documentsPath = formatDocumentsPath(team.url); + + if (data.folderId) { + await navigate(`${documentsPath}/f/${data.folderId}`); + } else { + await navigate(documentsPath); + } + toast({ title: _(msg`Document moved`), description: _(msg`The document has been moved successfully.`), @@ -101,14 +110,6 @@ export const DocumentMoveToFolderDialog = ({ }); onOpenChange(false); - - const documentsPath = formatDocumentsPath(team?.url); - - if (data.folderId) { - void navigate(`${documentsPath}/f/${data.folderId}`); - } else { - void navigate(documentsPath); - } } catch (err) { const error = AppError.parseError(err); diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx index 0847eca0f..b3dc69503 100644 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx @@ -36,7 +36,7 @@ import { } from '@documenso/ui/primitives/form/form'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; import { StackAvatar } from '../general/stack-avatar'; @@ -57,7 +57,7 @@ export type TResendDocumentFormSchema = z.infer { const { user } = useSession(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const { toast } = useToast(); const { _ } = useLingui(); diff --git a/apps/remix/app/components/dialogs/folder-create-dialog.tsx b/apps/remix/app/components/dialogs/folder-create-dialog.tsx index c0544ba3a..9451b57a1 100644 --- a/apps/remix/app/components/dialogs/folder-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-create-dialog.tsx @@ -34,7 +34,7 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; const ZCreateFolderFormSchema = z.object({ name: z.string().min(1, { message: 'Folder name is required' }), @@ -52,7 +52,7 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp const { folderId } = useParams(); const navigate = useNavigate(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); @@ -75,13 +75,13 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp setIsCreateFolderOpen(false); + const documentsPath = formatDocumentsPath(team.url); + + await navigate(`${documentsPath}/f/${newFolder.id}`); + toast({ description: 'Folder created successfully', }); - - const documentsPath = formatDocumentsPath(team?.url); - - void navigate(`${documentsPath}/f/${newFolder.id}`); } catch (err) { const error = AppError.parseError(err); @@ -124,7 +124,7 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp Create New Folder - Enter a name for your new folder. Folders help you organize your documents. + Enter a name for your new folder. Folders help you organise your documents. diff --git a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx new file mode 100644 index 000000000..1e564ccaf --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx @@ -0,0 +1,423 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import type { MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { ExternalLinkIcon } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { Link, useSearchParams } from 'react-router'; +import { match } from 'ts-pattern'; +import type { z } from 'zod'; + +import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans'; +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; +import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types'; +import { cn } from '@documenso/ui/lib/utils'; +import { Badge } from '@documenso/ui/primitives/badge'; +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 { SpinnerBox } from '@documenso/ui/primitives/spinner'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type OrganisationCreateDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +export const ZCreateOrganisationFormSchema = ZCreateOrganisationRequestSchema.pick({ + name: true, +}); + +export type TCreateOrganisationFormSchema = z.infer; + +export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCreateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + const { refreshSession } = useSession(); + + const [searchParams] = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const actionSearchParam = searchParams?.get('action'); + + const [step, setStep] = useState<'billing' | 'create'>( + IS_BILLING_ENABLED() ? 'billing' : 'create', + ); + + const [selectedPriceId, setSelectedPriceId] = useState(''); + + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(ZCreateOrganisationFormSchema), + defaultValues: { + name: '', + }, + }); + + const { mutateAsync: createOrganisation } = trpc.organisation.create.useMutation(); + + const { data: plansData } = trpc.billing.plans.get.useQuery(); + + const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => { + try { + const response = await createOrganisation({ + name, + priceId: selectedPriceId, + }); + + if (response.paymentRequired) { + window.open(response.checkoutUrl, '_blank'); + setOpen(false); + + return; + } + + await refreshSession(); + setOpen(false); + + toast({ + title: t`Success`, + description: t`Your organisation has been created.`, + duration: 5000, + }); + } catch (err) { + const error = AppError.parseError(err); + + console.error(error); + + toast({ + title: t`An unknown error occurred`, + description: t`We encountered an unknown error while attempting to create a organisation. Please try again later.`, + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (actionSearchParam === 'add-organisation') { + setOpen(true); + updateSearchParams({ action: null }); + } + }, [actionSearchParam, open]); + + useEffect(() => { + form.reset(); + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + {match(step) + .with('billing', () => ( + <> + + + Select a plan + + + + Select a plan to continue + + +
+ {plansData ? ( + + ) : ( + + )} + + + + + + +
+ + )) + .with('create', () => ( + <> + + + Create organisation + + + + Create an organisation to collaborate with teams + + + +
+ +
+ ( + + + Organisation Name + + + + + + + )} + /> + + + {IS_BILLING_ENABLED() ? ( + + ) : ( + + )} + + + +
+
+ + + )) + + .exhaustive()} +
+
+ ); +}; + +// This is separated from the internal claims constant because we need to use the msg +// macro which would cause import issues. +const internalClaimsDescription: { + [key in INTERNAL_CLAIM_ID]: MessageDescriptor | string; +} = { + [INTERNAL_CLAIM_ID.FREE]: msg`5 Documents a month`, + [INTERNAL_CLAIM_ID.INDIVIDUAL]: msg`Unlimited documents, API and more`, + [INTERNAL_CLAIM_ID.TEAM]: msg`Embedding, 5 members included and more`, + [INTERNAL_CLAIM_ID.PLATFORM]: msg`Whitelabeling, unlimited members and more`, + [INTERNAL_CLAIM_ID.ENTERPRISE]: '', + [INTERNAL_CLAIM_ID.EARLY_ADOPTER]: '', +}; + +type BillingPlanFormProps = { + value: string; + onChange: (priceId: string) => void; + plans: InternalClaimPlans; + canCreateFreeOrganisation: boolean; +}; + +const BillingPlanForm = ({ + value, + onChange, + plans, + canCreateFreeOrganisation, +}: BillingPlanFormProps) => { + const { t } = useLingui(); + + const [billingPeriod, setBillingPeriod] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice'); + + const dynamicPlans = useMemo(() => { + return [INTERNAL_CLAIM_ID.INDIVIDUAL, INTERNAL_CLAIM_ID.TEAM, INTERNAL_CLAIM_ID.PLATFORM].map( + (planId) => { + const plan = plans[planId]; + + return { + id: planId, + name: plan.name, + description: parseMessageDescriptorMacro(t, internalClaimsDescription[planId]), + monthlyPrice: plan.monthlyPrice, + yearlyPrice: plan.yearlyPrice, + }; + }, + ); + }, [plans]); + + useEffect(() => { + if (value === '' && !canCreateFreeOrganisation) { + onChange(dynamicPlans[0][billingPeriod]?.id ?? ''); + } + }, [value]); + + const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => { + const plan = dynamicPlans.find((plan) => plan[billingPeriod]?.id === value); + + setBillingPeriod(billingPeriod); + + onChange(plan?.[billingPeriod]?.id ?? Object.keys(plans)[0]); + }; + + return ( +
+ onBillingPeriodChange(value as 'monthlyPrice' | 'yearlyPrice')} + > + + + Monthly + + + Yearly + + + + +
+ + + {dynamicPlans.map((plan) => ( + + ))} + + +
+

+ Enterprise +

+

+ Contact sales here + +

+
+ +
+ +
+ + Compare all plans and features in detail + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-delete-dialog.tsx new file mode 100644 index 000000000..10b53833b --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; +import { z } from 'zod'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +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 OrganisationDeleteDialogProps = { + trigger?: React.ReactNode; +}; + +export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogProps) => { + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + const { refreshSession } = useSession(); + + const organisation = useCurrentOrganisation(); + + const deleteMessage = _(msg`delete ${organisation.name}`); + + const ZDeleteOrganisationFormSchema = z.object({ + organisationName: z.literal(deleteMessage, { + errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }), + }), + }); + + const form = useForm({ + resolver: zodResolver(ZDeleteOrganisationFormSchema), + defaultValues: { + organisationName: '', + }, + }); + + const { mutateAsync: deleteOrganisation } = trpc.organisation.delete.useMutation(); + + const onFormSubmit = async () => { + try { + await deleteOrganisation({ organisationId: organisation.id }); + + toast({ + title: _(msg`Success`), + description: _(msg`Your organisation has been successfully deleted.`), + duration: 5000, + }); + + await navigate('/settings/organisations'); + await refreshSession(); + + setOpen(false); + } catch (err) { + const error = AppError.parseError(err); + console.error(error); + + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to delete this organisation. Please try again later.`, + ), + variant: 'destructive', + duration: 10000, + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure you wish to delete this organisation? + + + + + You are about to delete {organisation.name}. + All data related to this organisation such as teams, documents, and all other + resources will be deleted. This action is irreversible. + + + + +
+ +
+ ( + + + + Confirm by typing {deleteMessage} + + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx new file mode 100644 index 000000000..4f80b318a --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx @@ -0,0 +1,251 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { OrganisationMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import type { z } from 'zod'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations'; +import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateOrganisationGroupRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-group.types'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type OrganisationGroupCreateDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +const ZCreateOrganisationGroupFormSchema = ZCreateOrganisationGroupRequestSchema.pick({ + name: true, + memberIds: true, + organisationRole: true, +}); + +type TCreateOrganisationGroupFormSchema = z.infer; + +export const OrganisationGroupCreateDialog = ({ + trigger, + ...props +}: OrganisationGroupCreateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + const organisation = useCurrentOrganisation(); + + const form = useForm({ + resolver: zodResolver(ZCreateOrganisationGroupFormSchema), + defaultValues: { + name: '', + organisationRole: OrganisationMemberRole.MEMBER, + memberIds: [], + }, + }); + + const { mutateAsync: createOrganisationGroup } = trpc.organisation.group.create.useMutation(); + + const { data: membersFindResult, isLoading: isLoadingMembers } = + trpc.organisation.member.find.useQuery({ + organisationId: organisation.id, + }); + + const members = membersFindResult?.data ?? []; + + const onFormSubmit = async ({ + name, + organisationRole, + memberIds, + }: TCreateOrganisationGroupFormSchema) => { + try { + await createOrganisationGroup({ + organisationId: organisation.id, + name, + organisationRole, + memberIds, + }); + + setOpen(false); + + toast({ + title: t`Success`, + description: t`Group has been created.`, + duration: 5000, + }); + } catch (err) { + const error = AppError.parseError(err); + + console.error(error); + + toast({ + title: t`An unknown error occurred`, + description: t`We encountered an unknown error while attempting to create a group. Please try again later.`, + variant: 'destructive', + }); + } + }; + + useEffect(() => { + form.reset(); + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + + Create group + + + + Organise your members into groups which can be assigned to teams + + + +
+ +
+ ( + + + Group Name + + + + + + + )} + /> + + ( + + + Organisation role + + + + + + + + )} + /> + + ( + + + Members + + + + ({ + label: member.name, + value: member.id, + }))} + loading={isLoadingMembers} + selectedValues={field.value} + onChange={field.onChange} + className="bg-background w-full" + emptySelectionPlaceholder={t`Select members`} + /> + + + + Select the members to add to this group + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx new file mode 100644 index 000000000..17a389622 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-group-delete-dialog.tsx @@ -0,0 +1,117 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } 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 { useToast } from '@documenso/ui/primitives/use-toast'; + +export type OrganisationGroupDeleteDialogProps = { + organisationGroupId: string; + organisationGroupName: string; + trigger?: React.ReactNode; +}; + +export const OrganisationGroupDeleteDialog = ({ + trigger, + organisationGroupId, + organisationGroupName, +}: OrganisationGroupDeleteDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const organisation = useCurrentOrganisation(); + + const { mutateAsync: deleteGroup, isPending: isDeleting } = + trpc.organisation.group.delete.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`You have successfully removed this group from the organisation.`), + duration: 5000, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to remove this group. Please try again later.`, + ), + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + !isDeleting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to remove the following group from{' '} + {organisation.name}. + + + + + + + {organisationGroupName} + + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-leave-dialog.tsx b/apps/remix/app/components/dialogs/organisation-leave-dialog.tsx new file mode 100644 index 000000000..fb1c7a914 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-leave-dialog.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; + +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import type { OrganisationMemberRole } from '@prisma/client'; + +import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations'; +import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; +import { trpc } from '@documenso/trpc/react'; +import { Alert } from '@documenso/ui/primitives/alert'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type OrganisationLeaveDialogProps = { + organisationId: string; + organisationName: string; + organisationAvatarImageId?: string | null; + role: OrganisationMemberRole; + trigger?: React.ReactNode; +}; + +export const OrganisationLeaveDialog = ({ + trigger, + organisationId, + organisationName, + organisationAvatarImageId, + role, +}: OrganisationLeaveDialogProps) => { + const [open, setOpen] = useState(false); + + const { t } = useLingui(); + const { toast } = useToast(); + + const { mutateAsync: leaveOrganisation, isPending: isLeavingOrganisation } = + trpc.organisation.leave.useMutation({ + onSuccess: () => { + toast({ + title: t`Success`, + description: t`You have successfully left this organisation.`, + duration: 5000, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: t`An unknown error occurred`, + description: t`We encountered an unknown error while attempting to leave this organisation. Please try again later.`, + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + !isLeavingOrganisation && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + You are about to leave the following organisation. + + + + + + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-member-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-member-delete-dialog.tsx new file mode 100644 index 000000000..a46151d3b --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-member-delete-dialog.tsx @@ -0,0 +1,123 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { trpc } from '@documenso/trpc/react'; +import { Alert } from '@documenso/ui/primitives/alert'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type OrganisationMemberDeleteDialogProps = { + organisationMemberId: string; + organisationMemberName: string; + organisationMemberEmail: string; + trigger?: React.ReactNode; +}; + +export const OrganisationMemberDeleteDialog = ({ + trigger, + organisationMemberId, + organisationMemberName, + organisationMemberEmail, +}: OrganisationMemberDeleteDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const organisation = useCurrentOrganisation(); + + const { mutateAsync: deleteOrganisationMembers, isPending: isDeletingOrganisationMember } = + trpc.organisation.member.delete.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`You have successfully removed this user from the organisation.`), + duration: 5000, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to remove this user. Please try again later.`, + ), + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + !isDeletingOrganisationMember && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to remove the following user from{' '} + {organisation.name}. + + + + + + {organisationMemberName}} + secondaryText={organisationMemberEmail} + /> + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx b/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx new file mode 100644 index 000000000..1009ef4e6 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx @@ -0,0 +1,478 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { OrganisationMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react'; +import Papa, { type ParseResult } from 'papaparse'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { downloadFile } from '@documenso/lib/client-only/download-file'; +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations'; +import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations'; +import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types'; +import { cn } from '@documenso/ui/lib/utils'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { SpinnerBox } from '@documenso/ui/primitives/spinner'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type OrganisationMemberInviteDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +const ZInviteOrganisationMembersFormSchema = z + .object({ + invitations: ZCreateOrganisationMemberInvitesRequestSchema.shape.invitations, + }) + // Display exactly which rows are duplicates. + .superRefine((items, ctx) => { + const uniqueEmails = new Map(); + + for (const [index, invitation] of items.invitations.entries()) { + const email = invitation.email.toLowerCase(); + + const firstFoundIndex = uniqueEmails.get(email); + + if (firstFoundIndex === undefined) { + uniqueEmails.set(email, index); + continue; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Emails must be unique', + path: ['invitations', index, 'email'], + }); + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Emails must be unique', + path: ['invitations', firstFoundIndex, 'email'], + }); + } + }); + +type TInviteOrganisationMembersFormSchema = z.infer; + +type TabTypes = 'INDIVIDUAL' | 'BULK'; + +const ZImportOrganisationMemberSchema = z.array( + z.object({ + email: z.string().email(), + organisationRole: z.nativeEnum(OrganisationMemberRole), + }), +); + +export const OrganisationMemberInviteDialog = ({ + trigger, + ...props +}: OrganisationMemberInviteDialogProps) => { + const [open, setOpen] = useState(false); + const fileInputRef = useRef(null); + const [invitationType, setInvitationType] = useState('INDIVIDUAL'); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const organisation = useCurrentOrganisation(); + + const form = useForm({ + resolver: zodResolver(ZInviteOrganisationMembersFormSchema), + defaultValues: { + invitations: [ + { + email: '', + organisationRole: OrganisationMemberRole.MEMBER, + }, + ], + }, + }); + + const { + append: appendOrganisationMemberInvite, + fields: organisationMemberInvites, + remove: removeOrganisationMemberInvite, + } = useFieldArray({ + control: form.control, + name: 'invitations', + }); + + const { mutateAsync: createOrganisationMemberInvites } = + trpc.organisation.member.invite.createMany.useMutation(); + + const { data: fullOrganisation } = trpc.organisation.get.useQuery({ + organisationReference: organisation.id, + }); + + const onAddOrganisationMemberInvite = () => { + appendOrganisationMemberInvite({ + email: '', + organisationRole: OrganisationMemberRole.MEMBER, + }); + }; + + const onFormSubmit = async ({ invitations }: TInviteOrganisationMembersFormSchema) => { + try { + await createOrganisationMemberInvites({ + organisationId: organisation.id, + invitations, + }); + + toast({ + title: _(msg`Success`), + description: _(msg`Organisation invitations have been sent.`), + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to invite organisation members. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + const dialogState = useMemo(() => { + if (!fullOrganisation) { + return 'loading'; + } + + if (!IS_BILLING_ENABLED()) { + return 'form'; + } + + if (fullOrganisation.organisationClaim.memberCount === 0) { + return 'form'; + } + + // This is probably going to screw us over in the future. + if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) { + return 'alert'; + } + + return 'form'; + }, [fullOrganisation]); + + useEffect(() => { + if (!open) { + form.reset(); + setInvitationType('INDIVIDUAL'); + } + }, [open, form]); + + const onFileInputChange = (e: React.ChangeEvent) => { + if (!e.target.files?.length) { + return; + } + + const csvFile = e.target.files[0]; + + Papa.parse(csvFile, { + skipEmptyLines: true, + comments: 'Work email,Job title', + complete: (results: ParseResult) => { + const members = results.data.map((row) => { + const [email, role] = row; + + return { + email: email.trim(), + organisationRole: role.trim().toUpperCase(), + }; + }); + + // Remove the first row if it contains the headers. + if (members.length > 1 && members[0].organisationRole.toUpperCase() === 'ROLE') { + members.shift(); + } + + try { + const importedInvitations = ZImportOrganisationMemberSchema.parse(members); + + form.setValue('invitations', importedInvitations); + form.clearErrors('invitations'); + + setInvitationType('INDIVIDUAL'); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Something went wrong`), + description: _( + msg`Please check the CSV file and make sure it is according to our format`, + ), + variant: 'destructive', + }); + } + }, + }); + }; + + const downloadTemplate = () => { + const data = [ + { email: 'admin@documenso.com', role: 'Admin' }, + { email: 'manager@documenso.com', role: 'Manager' }, + { email: 'member@documenso.com', role: 'Member' }, + ]; + + const csvContent = + 'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n'); + + const blob = new Blob([csvContent], { + type: 'text/csv', + }); + + downloadFile({ + filename: 'documenso-organisation-member-invites-template.csv', + data: blob, + }); + }; + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Invite organisation members + + + + An email containing an invitation will be sent to each member. + + + + {dialogState === 'loading' && } + + {dialogState === 'alert' && ( + <> + + + + Your plan does not support inviting members. Please upgrade or your plan or + contact sales at support@documenso.com{' '} + if you would like to discuss your options. + + + + + + + + + )} + + {dialogState === 'form' && ( + setInvitationType(value as TabTypes)} + > + + + + Invite Members + + + + Bulk Import + + + + +
+ +
+
+ {organisationMemberInvites.map((organisationMemberInvite, index) => ( +
+ ( + + {index === 0 && ( + + Email address + + )} + + + + + + )} + /> + + ( + + {index === 0 && ( + + Organisation Role + + )} + + + + + + )} + /> + + +
+ ))} +
+ + + + + + + + +
+
+ +
+ + +
+ + fileInputRef.current?.click()} + > + + +

+ Click here to upload +

+ + +
+
+ + + + +
+
+
+ )} +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx b/apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx new file mode 100644 index 000000000..07db162a2 --- /dev/null +++ b/apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -0,0 +1,205 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { OrganisationMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations'; +import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations'; +import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations'; +import { trpc } from '@documenso/trpc/react'; +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type OrganisationMemberUpdateDialogProps = { + currentUserOrganisationRole: OrganisationMemberRole; + trigger?: React.ReactNode; + organisationId: string; + organisationMemberId: string; + organisationMemberName: string; + organisationMemberRole: OrganisationMemberRole; +} & Omit; + +const ZUpdateOrganisationMemberFormSchema = z.object({ + role: z.nativeEnum(OrganisationMemberRole), +}); + +type ZUpdateOrganisationMemberSchema = z.infer; + +export const OrganisationMemberUpdateDialog = ({ + currentUserOrganisationRole, + trigger, + organisationId, + organisationMemberId, + organisationMemberName, + organisationMemberRole, + ...props +}: OrganisationMemberUpdateDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZUpdateOrganisationMemberFormSchema), + defaultValues: { + role: organisationMemberRole, + }, + }); + + const { mutateAsync: updateOrganisationMember } = trpc.organisation.member.update.useMutation(); + + const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => { + try { + await updateOrganisationMember({ + organisationId, + organisationMemberId, + data: { + role, + }, + }); + + toast({ + title: _(msg`Success`), + description: _(msg`You have updated ${organisationMemberName}.`), + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to update this organisation member. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + return; + } + + form.reset(); + + if ( + !isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole) + ) { + setOpen(false); + + toast({ + title: _(msg`You cannot modify a organisation member who has a higher role than you.`), + variant: 'destructive', + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, currentUserOrganisationRole, organisationMemberRole, form, toast]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Update organisation member + + + + + You are currently updating{' '} + {organisationMemberName}. + + + + +
+ +
+ ( + + + Role + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx b/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx index d6a13f456..bcd624290 100644 --- a/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx +++ b/apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx @@ -49,7 +49,7 @@ import { import { Textarea } from '@documenso/ui/primitives/textarea'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; export type ManagePublicTemplateDialogProps = { directTemplates: (Template & { @@ -95,7 +95,7 @@ export const ManagePublicTemplateDialog = ({ const [open, onOpenChange] = useState(isOpen); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const [selectedTemplateId, setSelectedTemplateId] = useState(initialTemplateId); diff --git a/apps/remix/app/components/dialogs/team-checkout-create-dialog.tsx b/apps/remix/app/components/dialogs/team-checkout-create-dialog.tsx deleted file mode 100644 index 038c78504..000000000 --- a/apps/remix/app/components/dialogs/team-checkout-create-dialog.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { useMemo, useState } from 'react'; - -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import type * as DialogPrimitive from '@radix-ui/react-dialog'; -import { AnimatePresence, motion } from 'framer-motion'; -import { Loader, TagIcon } from 'lucide-react'; - -import { trpc } from '@documenso/trpc/react'; -import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -export type TeamCheckoutCreateDialogProps = { - pendingTeamId: number | null; - onClose: () => void; -} & Omit; - -const MotionCard = motion(Card); - -export const TeamCheckoutCreateDialog = ({ - pendingTeamId, - onClose, - ...props -}: TeamCheckoutCreateDialogProps) => { - const { _ } = useLingui(); - const { toast } = useToast(); - - const [interval, setInterval] = useState<'monthly' | 'yearly'>('monthly'); - - const { data, isLoading } = trpc.team.getTeamPrices.useQuery(); - - const { mutateAsync: createCheckout, isPending: isCreatingCheckout } = - trpc.team.createTeamPendingCheckout.useMutation({ - onSuccess: (checkoutUrl) => { - window.open(checkoutUrl, '_blank'); - onClose(); - }, - onError: () => - toast({ - title: _(msg`Something went wrong`), - description: _( - msg`We were unable to create a checkout session. Please try again, or contact support`, - ), - variant: 'destructive', - }), - }); - - const selectedPrice = useMemo(() => { - if (!data) { - return null; - } - - return data[interval]; - }, [data, interval]); - - const handleOnOpenChange = (open: boolean) => { - if (pendingTeamId === null) { - return; - } - - if (!open) { - onClose(); - } - }; - - if (pendingTeamId === null) { - return null; - } - - return ( - - - - - Team checkout - - - - Payment is required to finalise the creation of your team. - - - - {(isLoading || !data) && ( -
- {isLoading ? ( - - ) : ( -

- Something went wrong -

- )} -
- )} - - {data && selectedPrice && !isLoading && ( -
- setInterval(value as 'monthly' | 'yearly')} - value={interval} - className="mb-4" - > - - {[data.monthly, data.yearly].map((price) => ( - - {price.friendlyInterval} - - ))} - - - - - - - {selectedPrice.interval === 'monthly' ? ( -
- $50 USD per month -
- ) : ( -
- - $480 USD per year - -
- - 20% off -
-
- )} - -
-

- This price includes minimum 5 seats. -

- -

- Adding and removing seats will adjust your invoice accordingly. -

-
-
-
-
- - - - - - -
- )} -
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/team-create-dialog.tsx b/apps/remix/app/components/dialogs/team-create-dialog.tsx index 0b49e9b6d..65740ce67 100644 --- a/apps/remix/app/components/dialogs/team-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-create-dialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; @@ -7,15 +7,18 @@ import { Trans } from '@lingui/react/macro'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; import { useForm } from 'react-hook-form'; import { useSearchParams } from 'react-router'; -import { useNavigate } from 'react-router'; import type { z } from 'zod'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { useSession } from '@documenso/lib/client-only/providers/session'; +import { IS_BILLING_ENABLED, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; -import { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema'; +import { ZCreateTeamRequestSchema } from '@documenso/trpc/server/team-router/create-team.types'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Dialog, DialogContent, @@ -34,29 +37,37 @@ import { FormMessage, } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; +import { SpinnerBox } from '@documenso/ui/primitives/spinner'; import { useToast } from '@documenso/ui/primitives/use-toast'; export type TeamCreateDialogProps = { trigger?: React.ReactNode; + onCreated?: () => Promise; } & Omit; -const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({ +const ZCreateTeamFormSchema = ZCreateTeamRequestSchema.pick({ teamName: true, teamUrl: true, + inheritMembers: true, }); type TCreateTeamFormSchema = z.infer; -export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) => { +export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { refreshSession } = useSession(); - const navigate = useNavigate(); const [searchParams] = useSearchParams(); const updateSearchParams = useUpdateSearchParams(); + const organisation = useCurrentOrganisation(); const [open, setOpen] = useState(false); + const { data: fullOrganisation } = trpc.organisation.get.useQuery({ + organisationReference: organisation.id, + }); + const actionSearchParam = searchParams?.get('action'); const form = useForm({ @@ -64,24 +75,25 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) = defaultValues: { teamName: '', teamUrl: '', + inheritMembers: true, }, }); - const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation(); + const { mutateAsync: createTeam } = trpc.team.create.useMutation(); - const onFormSubmit = async ({ teamName, teamUrl }: TCreateTeamFormSchema) => { + const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => { try { - const response = await createTeam({ + await createTeam({ + organisationId: organisation.id, teamName, teamUrl, + inheritMembers, }); setOpen(false); - if (response.paymentRequired) { - await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`); - return; - } + await onCreated?.(); + await refreshSession(); toast({ title: _(msg`Success`), @@ -114,6 +126,26 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) = return text.toLowerCase().replace(/\s+/g, '-'); }; + const dialogState = useMemo(() => { + if (!fullOrganisation) { + return 'loading'; + } + + if (!IS_BILLING_ENABLED()) { + return 'form'; + } + + if (fullOrganisation.organisationClaim.teamCount === 0) { + return 'form'; + } + + if (fullOrganisation.organisationClaim.teamCount <= fullOrganisation.teams.length) { + return 'alert'; + } + + return 'form'; + }, [fullOrganisation]); + useEffect(() => { if (actionSearchParam === 'add-team') { setOpen(true); @@ -145,89 +177,141 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) = Create team - + Create a team to collaborate with your team members. -
- -
} + + {dialogState === 'alert' && ( + <> + - ( - - - Team Name - - - { - const oldGeneratedUrl = mapTextToUrl(field.value); - const newGeneratedUrl = mapTextToUrl(event.target.value); + + + You have reached the maximum number of teams for your plan. Please contact sales + at support@documenso.com if you would + like to adjust your plan. + + + - const urlField = form.getValues('teamUrl'); - if (urlField === oldGeneratedUrl) { - form.setValue('teamUrl', newGeneratedUrl); - } + + + + + )} - field.onChange(event); - }} - /> - - - - )} - /> + {dialogState === 'form' && ( + + +
+ ( + + + Team Name + + + { + const oldGeneratedUrl = mapTextToUrl(field.value); + const newGeneratedUrl = mapTextToUrl(event.target.value); - ( - - - Team URL - - - - - {!form.formState.errors.teamUrl && ( - - {field.value ? ( - `${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}` - ) : ( - A unique URL to identify your team - )} - - )} + const urlField = form.getValues('teamUrl'); + if (urlField === oldGeneratedUrl) { + form.setValue('teamUrl', newGeneratedUrl); + } - - - )} - /> + field.onChange(event); + }} + /> + + + + )} + /> - - + ( + + + Team URL + + + + + {!form.formState.errors.teamUrl && ( + + {field.value ? ( + `${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}` + ) : ( + A unique URL to identify your team + )} + + )} - - -
- - + + + )} + /> + + ( + + +
+ + + +
+
+
+ )} + /> + + + + + + +
+ + + )} ); diff --git a/apps/remix/app/components/dialogs/team-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-delete-dialog.tsx index b297cbb2a..670ba1a2a 100644 --- a/apps/remix/app/components/dialogs/team-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-delete-dialog.tsx @@ -8,6 +8,7 @@ import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { z } from 'zod'; +import { useSession } from '@documenso/lib/client-only/providers/session'; import { AppError } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; @@ -35,15 +36,22 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type TeamDeleteDialogProps = { teamId: number; teamName: string; + redirectTo?: string; trigger?: React.ReactNode; }; -export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialogProps) => { +export const TeamDeleteDialog = ({ + trigger, + teamId, + teamName, + redirectTo, +}: TeamDeleteDialogProps) => { const navigate = useNavigate(); const [open, setOpen] = useState(false); const { _ } = useLingui(); const { toast } = useToast(); + const { refreshSession } = useSession(); const deleteMessage = _(msg`delete ${teamName}`); @@ -60,19 +68,23 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialog }, }); - const { mutateAsync: deleteTeam } = trpc.team.deleteTeam.useMutation(); + const { mutateAsync: deleteTeam } = trpc.team.delete.useMutation(); const onFormSubmit = async () => { try { await deleteTeam({ teamId }); + await refreshSession(); + toast({ title: _(msg`Success`), description: _(msg`Your team has been successfully deleted.`), duration: 5000, }); - await navigate('/settings/teams'); + if (redirectTo) { + await navigate(redirectTo); + } setOpen(false); } catch (err) { @@ -113,7 +125,7 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialog {trigger ?? ( )} diff --git a/apps/remix/app/components/dialogs/team-email-add-dialog.tsx b/apps/remix/app/components/dialogs/team-email-add-dialog.tsx index 161c2c0eb..56e54da72 100644 --- a/apps/remix/app/components/dialogs/team-email-add-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-add-dialog.tsx @@ -61,12 +61,12 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi }, }); - const { mutateAsync: createTeamEmailVerification, isPending } = - trpc.team.createTeamEmailVerification.useMutation(); + const { mutateAsync: sendTeamEmailVerification, isPending } = + trpc.team.email.verification.send.useMutation(); const onFormSubmit = async ({ name, email }: TCreateTeamEmailFormSchema) => { try { - await createTeamEmailVerification({ + await sendTeamEmailVerification({ teamId, name, email, diff --git a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx index ec050961c..d9b780657 100644 --- a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx @@ -48,7 +48,7 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele const { revalidate } = useRevalidator(); const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } = - trpc.team.deleteTeamEmail.useMutation({ + trpc.team.email.delete.useMutation({ onSuccess: () => { toast({ title: _(msg`Success`), @@ -67,7 +67,7 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele }); const { mutateAsync: deleteTeamEmailVerification, isPending: isDeletingTeamEmailVerification } = - trpc.team.deleteTeamEmailVerification.useMutation({ + trpc.team.email.verification.delete.useMutation({ onSuccess: () => { toast({ title: _(msg`Success`), diff --git a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx index bde700949..dd5411b59 100644 --- a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx @@ -61,7 +61,7 @@ export const TeamEmailUpdateDialog = ({ }, }); - const { mutateAsync: updateTeamEmail } = trpc.team.updateTeamEmail.useMutation(); + const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation(); const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => { try { diff --git a/apps/remix/app/components/dialogs/team-group-create-dialog.tsx b/apps/remix/app/components/dialogs/team-group-create-dialog.tsx new file mode 100644 index 000000000..dd79e9e05 --- /dev/null +++ b/apps/remix/app/components/dialogs/team-group-create-dialog.tsx @@ -0,0 +1,305 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { OrganisationGroupType, TeamMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams'; +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +export type TeamGroupCreateDialogProps = Omit; + +const ZAddTeamMembersFormSchema = z.object({ + groups: z.array( + z.object({ + organisationGroupId: z.string(), + teamRole: z.nativeEnum(TeamMemberRole), + }), + ), +}); + +type TAddTeamMembersFormSchema = z.infer; + +export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps) => { + const [open, setOpen] = useState(false); + const [step, setStep] = useState<'SELECT' | 'ROLES'>('SELECT'); + + const { t } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const form = useForm({ + resolver: zodResolver(ZAddTeamMembersFormSchema), + defaultValues: { + groups: [], + }, + }); + + const { mutateAsync: createTeamGroups } = trpc.team.group.createMany.useMutation(); + + const organisationGroupQuery = trpc.organisation.group.find.useQuery({ + organisationId: team.organisationId, + perPage: 100, // Won't really work if they somehow have more than 100 groups. + types: [OrganisationGroupType.CUSTOM], + }); + + const teamGroupQuery = trpc.team.group.find.useQuery({ + teamId: team.id, + perPage: 100, // Won't really work if they somehow have more than 100 groups. + }); + + const avaliableOrganisationGroups = useMemo(() => { + const organisationGroups = organisationGroupQuery.data?.data ?? []; + const teamGroups = teamGroupQuery.data?.data ?? []; + + return organisationGroups.filter( + (group) => !teamGroups.some((teamGroup) => teamGroup.organisationGroupId === group.id), + ); + }, [organisationGroupQuery, teamGroupQuery]); + + const onFormSubmit = async ({ groups }: TAddTeamMembersFormSchema) => { + try { + await createTeamGroups({ + teamId: team.id, + groups, + }); + + toast({ + title: t`Success`, + description: t`Team members have been added.`, + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: t`An unknown error occurred`, + description: t`We encountered an unknown error while attempting to add team members. Please try again later.`, + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + setStep('SELECT'); + } + }, [open, form]); + + return ( + + e.stopPropagation()} asChild> + + + + + {match(step) + .with('SELECT', () => ( + + + Add members + + + + Select members or groups of members to add to the team. + + + )) + .with('ROLES', () => ( + + + Add group roles + + + + Configure the team roles for each group + + + )) + .exhaustive()} + +
+ +
+ {step === 'SELECT' && ( + <> + ( + + + Groups + + + + ({ + label: group.name ?? group.organisationRole, + value: group.id, + }))} + loading={organisationGroupQuery.isLoading || teamGroupQuery.isLoading} + selectedValues={field.value.map( + ({ organisationGroupId }) => organisationGroupId, + )} + onChange={(value) => { + field.onChange( + value.map((organisationGroupId) => ({ + organisationGroupId, + teamRole: + field.value.find( + (value) => value.organisationGroupId === organisationGroupId, + )?.teamRole || TeamMemberRole.MEMBER, + })), + ); + }} + className="bg-background w-full" + emptySelectionPlaceholder={t`Select groups`} + /> + + + + Select groups to add to this team + + + )} + /> + + + + + + + + )} + + {step === 'ROLES' && ( + <> +
+ {form.getValues('groups').map((group, index) => ( +
+
+ {index === 0 && ( + + Group + + )} + id === group.organisationGroupId, + )?.name || t`Untitled Group` + } + /> +
+ + ( + + {index === 0 && ( + + Team Role + + )} + + + + + + )} + /> +
+ ))} +
+ + + + + + + + )} +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-group-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-group-delete-dialog.tsx new file mode 100644 index 000000000..f42fd4630 --- /dev/null +++ b/apps/remix/app/components/dialogs/team-group-delete-dialog.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import type { TeamMemberRole } from '@prisma/client'; + +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } 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 { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +export type TeamGroupDeleteDialogProps = { + trigger?: React.ReactNode; + teamGroupId: string; + teamGroupName: string; + teamGroupRole: TeamMemberRole; +}; + +export const TeamGroupDeleteDialog = ({ + trigger, + teamGroupId, + teamGroupName, + teamGroupRole, +}: TeamGroupDeleteDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const { mutateAsync: deleteGroup, isPending: isDeleting } = trpc.team.group.delete.useMutation({ + onSuccess: () => { + toast({ + title: _(msg`Success`), + description: _(msg`You have successfully removed this group from the team.`), + duration: 5000, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to remove this group. Please try again later.`, + ), + variant: 'destructive', + duration: 10000, + }); + }, + }); + + return ( + !isDeleting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Are you sure? + + + + + You are about to remove the following group from{' '} + {team.name}. + + + + + {isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? ( + <> + + + {teamGroupName} + + + +
+ + + + + +
+ + ) : ( + <> + + + You cannot delete a group which has a higher role than you. + + + + + + + + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-group-update-dialog.tsx b/apps/remix/app/components/dialogs/team-group-update-dialog.tsx new file mode 100644 index 000000000..c57755e87 --- /dev/null +++ b/apps/remix/app/components/dialogs/team-group-update-dialog.tsx @@ -0,0 +1,209 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { TeamMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams'; +import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +export type TeamGroupUpdateDialogProps = { + trigger?: React.ReactNode; + teamGroupId: string; + teamGroupName: string; + teamGroupRole: TeamMemberRole; +} & Omit; + +const ZUpdateTeamGroupFormSchema = z.object({ + role: z.nativeEnum(TeamMemberRole), +}); + +type ZUpdateTeamGroupSchema = z.infer; + +export const TeamGroupUpdateDialog = ({ + trigger, + teamGroupId, + teamGroupName, + teamGroupRole, + ...props +}: TeamGroupUpdateDialogProps) => { + const [open, setOpen] = useState(false); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const form = useForm({ + resolver: zodResolver(ZUpdateTeamGroupFormSchema), + defaultValues: { + role: teamGroupRole, + }, + }); + + const { mutateAsync: updateTeamGroup } = trpc.team.group.update.useMutation(); + + const onFormSubmit = async ({ role }: ZUpdateTeamGroupSchema) => { + try { + await updateTeamGroup({ + id: teamGroupId, + data: { + teamRole: role, + }, + }); + + toast({ + title: _(msg`Success`), + description: _(msg`You have updated the team group.`), + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: _(msg`An unknown error occurred`), + description: _( + msg`We encountered an unknown error while attempting to update this team member. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + return; + } + + form.reset(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, team.currentTeamRole, teamGroupRole, form, toast]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Update team group + + + + + You are currently updating the {teamGroupName} team + group. + + + + + {isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? ( +
+ +
+ ( + + + Role + + + + + + + )} + /> + + + + + + +
+
+ + ) : ( + <> + + + You cannot modify a group which has a higher role than you. + + + + + + + + )} +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-inherit-member-disable-dialog.tsx b/apps/remix/app/components/dialogs/team-inherit-member-disable-dialog.tsx new file mode 100644 index 000000000..c7352b7fe --- /dev/null +++ b/apps/remix/app/components/dialogs/team-inherit-member-disable-dialog.tsx @@ -0,0 +1,93 @@ +import { Trans, useLingui } from '@lingui/react/macro'; +import type { TeamGroup } from '@prisma/client'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +type TeamMemberInheritDisableDialogProps = { + group: TeamGroup; +}; + +export const TeamMemberInheritDisableDialog = ({ group }: TeamMemberInheritDisableDialogProps) => { + const { toast } = useToast(); + const { t } = useLingui(); + + const team = useCurrentTeam(); + + const deleteGroupMutation = trpc.team.group.delete.useMutation({ + onSuccess: () => { + toast({ + title: t`Access disabled`, + duration: 5000, + }); + }, + onError: () => { + toast({ + title: t`Something went wrong`, + description: t`We encountered an unknown error while attempting to disable access.`, + variant: 'destructive', + duration: 5000, + }); + }, + }); + + return ( + + + + + + + + + Are you sure? + + + + + You are about to remove default access to this team for all organisation members. Any + members not explicitly added to this team will no longer have access. + + + + + + + + + + + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx b/apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx new file mode 100644 index 000000000..1abece3fa --- /dev/null +++ b/apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx @@ -0,0 +1,109 @@ +import { Trans, useLingui } from '@lingui/react/macro'; +import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client'; + +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +export const TeamMemberInheritEnableDialog = () => { + const organisation = useCurrentOrganisation(); + const team = useCurrentTeam(); + + const { toast } = useToast(); + const { t } = useLingui(); + + const { mutateAsync: createTeamGroups, isPending } = trpc.team.group.createMany.useMutation({ + onSuccess: () => { + toast({ + title: t`Access enabled`, + duration: 5000, + }); + }, + onError: () => { + toast({ + title: t`Something went wrong`, + description: t`We encountered an unknown error while attempting to enable access.`, + variant: 'destructive', + duration: 5000, + }); + }, + }); + + const organisationGroupQuery = trpc.organisation.group.find.useQuery({ + organisationId: organisation.id, + perPage: 1, + types: [OrganisationGroupType.INTERNAL_ORGANISATION], + organisationRoles: [OrganisationMemberRole.MEMBER], + }); + + const enableAccessGroup = async () => { + if (!organisationGroupQuery.data?.data[0]?.id) { + return; + } + + await createTeamGroups({ + teamId: team.id, + groups: [ + { + organisationGroupId: organisationGroupQuery.data?.data[0]?.id, + teamRole: TeamMemberRole.MEMBER, + }, + ], + }); + }; + + return ( + + + + + + + + + Are you sure? + + + + + You are about to give all organisation members access to this team under their + organisation role. + + + + + + + + + + + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/team-leave-dialog.tsx b/apps/remix/app/components/dialogs/team-leave-dialog.tsx deleted file mode 100644 index a6b6246a6..000000000 --- a/apps/remix/app/components/dialogs/team-leave-dialog.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useState } from 'react'; - -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import type { TeamMemberRole } from '@prisma/client'; - -import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; -import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; -import { trpc } from '@documenso/trpc/react'; -import { Alert } from '@documenso/ui/primitives/alert'; -import { AvatarWithText } from '@documenso/ui/primitives/avatar'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@documenso/ui/primitives/dialog'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -export type TeamLeaveDialogProps = { - teamId: number; - teamName: string; - teamAvatarImageId?: string | null; - role: TeamMemberRole; - trigger?: React.ReactNode; -}; - -export const TeamLeaveDialog = ({ - trigger, - teamId, - teamName, - teamAvatarImageId, - role, -}: TeamLeaveDialogProps) => { - const [open, setOpen] = useState(false); - - const { _ } = useLingui(); - const { toast } = useToast(); - - const { mutateAsync: leaveTeam, isPending: isLeavingTeam } = trpc.team.leaveTeam.useMutation({ - onSuccess: () => { - toast({ - title: _(msg`Success`), - description: _(msg`You have successfully left this team.`), - duration: 5000, - }); - - setOpen(false); - }, - onError: () => { - toast({ - title: _(msg`An unknown error occurred`), - description: _( - msg`We encountered an unknown error while attempting to leave this team. Please try again later.`, - ), - variant: 'destructive', - duration: 10000, - }); - }, - }); - - return ( - !isLeavingTeam && setOpen(value)}> - - {trigger ?? ( - - )} - - - - - - Are you sure? - - - - You are about to leave the following team. - - - - - - - -
- - - - - -
-
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/team-member-create-dialog.tsx b/apps/remix/app/components/dialogs/team-member-create-dialog.tsx new file mode 100644 index 000000000..b691d910c --- /dev/null +++ b/apps/remix/app/components/dialogs/team-member-create-dialog.tsx @@ -0,0 +1,305 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { TeamMemberRole } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams'; +import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useCurrentTeam } from '~/providers/team'; + +export type TeamMemberCreateDialogProps = { + trigger?: React.ReactNode; +} & Omit; + +const ZAddTeamMembersFormSchema = z.object({ + members: z.array( + z.object({ + organisationMemberId: z.string(), + teamRole: z.nativeEnum(TeamMemberRole), + }), + ), +}); + +type TAddTeamMembersFormSchema = z.infer; + +export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => { + const [open, setOpen] = useState(false); + const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT'); + + const { t } = useLingui(); + const { toast } = useToast(); + + const team = useCurrentTeam(); + + const form = useForm({ + resolver: zodResolver(ZAddTeamMembersFormSchema), + defaultValues: { + members: [], + }, + }); + + const { mutateAsync: createTeamMembers } = trpc.team.member.createMany.useMutation(); + + const organisationMemberQuery = trpc.organisation.member.find.useQuery({ + organisationId: team.organisationId, + }); + + const teamMemberQuery = trpc.team.member.find.useQuery({ + teamId: team.id, + }); + + const avaliableOrganisationMembers = useMemo(() => { + const organisationMembers = organisationMemberQuery.data?.data ?? []; + const teamMembers = teamMemberQuery.data?.data ?? []; + + return organisationMembers.filter( + (member) => !teamMembers.some((teamMember) => teamMember.id === member.id), + ); + }, [organisationMemberQuery, teamMemberQuery]); + + const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => { + try { + await createTeamMembers({ + teamId: team.id, + organisationMembers: members, + }); + + toast({ + title: t`Success`, + description: t`Team members have been added.`, + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: t`An unknown error occurred`, + description: t`We encountered an unknown error while attempting to add team members. Please try again later.`, + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + setStep('SELECT'); + } + }, [open, form]); + + return ( + + e.stopPropagation()} asChild> + + + + + {match(step) + .with('SELECT', () => ( + + + Add members + + + + Select members or groups of members to add to the team. + + + )) + .with('MEMBERS', () => ( + + + Add members roles + + + + Configure the team roles for each member + + + )) + .exhaustive()} + +
+ +
+ {step === 'SELECT' && ( + <> + ( + + + Members + + + + ({ + label: member.name, + value: member.id, + }))} + loading={organisationMemberQuery.isLoading} + selectedValues={field.value.map( + (member) => member.organisationMemberId, + )} + onChange={(value) => { + field.onChange( + value.map((organisationMemberId) => ({ + organisationMemberId, + teamRole: + field.value.find( + (member) => + member.organisationMemberId === organisationMemberId, + )?.teamRole || TeamMemberRole.MEMBER, + })), + ); + }} + className="bg-background w-full" + emptySelectionPlaceholder={t`Select members`} + /> + + + + Select members to add to this team + + + )} + /> + + + + + + + + )} + + {step === 'MEMBERS' && ( + <> +
+ {form.getValues('members').map((member, index) => ( +
+
+ {index === 0 && ( + + Member + + )} + id === member.organisationMemberId, + )?.name || '' + } + /> +
+ + ( + + {index === 0 && ( + + Team Role + + )} + + + + + + )} + /> +
+ ))} +
+ + + + + + + + )} +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx index dedda2003..82a4d7a04 100644 --- a/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-member-delete-dialog.tsx @@ -5,7 +5,7 @@ import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { trpc } from '@documenso/trpc/react'; -import { Alert } from '@documenso/ui/primitives/alert'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -22,9 +22,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type TeamMemberDeleteDialogProps = { teamId: number; teamName: string; - teamMemberId: number; - teamMemberName: string; - teamMemberEmail: string; + memberId: string; + memberName: string; + memberEmail: string; + isInheritMemberEnabled: boolean | null; trigger?: React.ReactNode; }; @@ -32,17 +33,18 @@ export const TeamMemberDeleteDialog = ({ trigger, teamId, teamName, - teamMemberId, - teamMemberName, - teamMemberEmail, + memberId, + memberName, + memberEmail, + isInheritMemberEnabled, }: TeamMemberDeleteDialogProps) => { const [open, setOpen] = useState(false); const { _ } = useLingui(); const { toast } = useToast(); - const { mutateAsync: deleteTeamMembers, isPending: isDeletingTeamMember } = - trpc.team.deleteTeamMembers.useMutation({ + const { mutateAsync: deleteTeamMember, isPending: isDeletingTeamMember } = + trpc.team.member.delete.useMutation({ onSuccess: () => { toast({ title: _(msg`Success`), @@ -69,7 +71,7 @@ export const TeamMemberDeleteDialog = ({ {trigger ?? ( )} @@ -88,29 +90,42 @@ export const TeamMemberDeleteDialog = ({
- - {teamMemberName}} - secondaryText={teamMemberEmail} - /> - + {isInheritMemberEnabled ? ( + + + + You cannot remove members from this team if the inherit member feature is enabled. + + + + ) : ( + + {memberName}} + secondaryText={memberEmail} + /> + + )}
- + {!isInheritMemberEnabled && ( + + )}
diff --git a/apps/remix/app/components/dialogs/team-member-invite-dialog.tsx b/apps/remix/app/components/dialogs/team-member-invite-dialog.tsx deleted file mode 100644 index dac4f8fce..000000000 --- a/apps/remix/app/components/dialogs/team-member-invite-dialog.tsx +++ /dev/null @@ -1,415 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { TeamMemberRole } from '@prisma/client'; -import type * as DialogPrimitive from '@radix-ui/react-dialog'; -import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react'; -import Papa, { type ParseResult } from 'papaparse'; -import { useFieldArray, useForm } from 'react-hook-form'; -import { z } from 'zod'; - -import { downloadFile } from '@documenso/lib/client-only/download-file'; -import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; -import { trpc } from '@documenso/trpc/react'; -import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; -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 { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { useCurrentTeam } from '~/providers/team'; - -export type TeamMemberInviteDialogProps = { - trigger?: React.ReactNode; -} & Omit; - -const ZInviteTeamMembersFormSchema = z - .object({ - invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations, - }) - // Display exactly which rows are duplicates. - .superRefine((items, ctx) => { - const uniqueEmails = new Map(); - - for (const [index, invitation] of items.invitations.entries()) { - const email = invitation.email.toLowerCase(); - - const firstFoundIndex = uniqueEmails.get(email); - - if (firstFoundIndex === undefined) { - uniqueEmails.set(email, index); - continue; - } - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Emails must be unique', - path: ['invitations', index, 'email'], - }); - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Emails must be unique', - path: ['invitations', firstFoundIndex, 'email'], - }); - } - }); - -type TInviteTeamMembersFormSchema = z.infer; - -type TabTypes = 'INDIVIDUAL' | 'BULK'; - -const ZImportTeamMemberSchema = z.array( - z.object({ - email: z.string().email(), - role: z.nativeEnum(TeamMemberRole), - }), -); - -export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDialogProps) => { - const [open, setOpen] = useState(false); - const fileInputRef = useRef(null); - const [invitationType, setInvitationType] = useState('INDIVIDUAL'); - - const { _ } = useLingui(); - const { toast } = useToast(); - - const team = useCurrentTeam(); - - const form = useForm({ - resolver: zodResolver(ZInviteTeamMembersFormSchema), - defaultValues: { - invitations: [ - { - email: '', - role: TeamMemberRole.MEMBER, - }, - ], - }, - }); - - const { - append: appendTeamMemberInvite, - fields: teamMemberInvites, - remove: removeTeamMemberInvite, - } = useFieldArray({ - control: form.control, - name: 'invitations', - }); - - const { mutateAsync: createTeamMemberInvites } = trpc.team.createTeamMemberInvites.useMutation(); - - const onAddTeamMemberInvite = () => { - appendTeamMemberInvite({ - email: '', - role: TeamMemberRole.MEMBER, - }); - }; - - const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => { - try { - await createTeamMemberInvites({ - teamId: team.id, - invitations, - }); - - toast({ - title: _(msg`Success`), - description: _(msg`Team invitations have been sent.`), - duration: 5000, - }); - - setOpen(false); - } catch { - toast({ - title: _(msg`An unknown error occurred`), - description: _( - msg`We encountered an unknown error while attempting to invite team members. Please try again later.`, - ), - variant: 'destructive', - }); - } - }; - - useEffect(() => { - if (!open) { - form.reset(); - setInvitationType('INDIVIDUAL'); - } - }, [open, form]); - - const onFileInputChange = (e: React.ChangeEvent) => { - if (!e.target.files?.length) { - return; - } - - const csvFile = e.target.files[0]; - - Papa.parse(csvFile, { - skipEmptyLines: true, - comments: 'Work email,Job title', - complete: (results: ParseResult) => { - const members = results.data.map((row) => { - const [email, role] = row; - - return { - email: email.trim(), - role: role.trim().toUpperCase(), - }; - }); - - // Remove the first row if it contains the headers. - if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') { - members.shift(); - } - - try { - const importedInvitations = ZImportTeamMemberSchema.parse(members); - - form.setValue('invitations', importedInvitations); - form.clearErrors('invitations'); - - setInvitationType('INDIVIDUAL'); - } catch (err) { - console.error(err); - - toast({ - title: _(msg`Something went wrong`), - description: _( - msg`Please check the CSV file and make sure it is according to our format`, - ), - variant: 'destructive', - }); - } - }, - }); - }; - - const downloadTemplate = () => { - const data = [ - { email: 'admin@documenso.com', role: 'Admin' }, - { email: 'manager@documenso.com', role: 'Manager' }, - { email: 'member@documenso.com', role: 'Member' }, - ]; - - const csvContent = - 'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n'); - - const blob = new Blob([csvContent], { - type: 'text/csv', - }); - - downloadFile({ - filename: 'documenso-team-member-invites-template.csv', - data: blob, - }); - }; - - return ( - !form.formState.isSubmitting && setOpen(value)} - > - e.stopPropagation()} asChild> - {trigger ?? ( - - )} - - - - - - Invite team members - - - - An email containing an invitation will be sent to each member. - - - - setInvitationType(value as TabTypes)} - > - - - - Invite Members - - - - Bulk Import - - - - -
- -
-
- {teamMemberInvites.map((teamMemberInvite, index) => ( -
- ( - - {index === 0 && ( - - Email address - - )} - - - - - - )} - /> - - ( - - {index === 0 && ( - - Role - - )} - - - - - - )} - /> - - -
- ))} -
- - - - - - - - -
-
- -
- - -
- - fileInputRef.current?.click()} - > - - -

- Click here to upload -

- - -
-
- - - - -
-
-
-
-
- ); -}; diff --git a/apps/remix/app/components/dialogs/team-member-update-dialog.tsx b/apps/remix/app/components/dialogs/team-member-update-dialog.tsx index e9c3a021b..b7729f734 100644 --- a/apps/remix/app/components/dialogs/team-member-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-member-update-dialog.tsx @@ -9,7 +9,8 @@ import type * as DialogPrimitive from '@radix-ui/react-dialog'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams'; +import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams'; +import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; @@ -43,9 +44,9 @@ export type TeamMemberUpdateDialogProps = { currentUserTeamRole: TeamMemberRole; trigger?: React.ReactNode; teamId: number; - teamMemberId: number; - teamMemberName: string; - teamMemberRole: TeamMemberRole; + memberId: string; + memberName: string; + memberTeamRole: TeamMemberRole; } & Omit; const ZUpdateTeamMemberFormSchema = z.object({ @@ -58,9 +59,9 @@ export const TeamMemberUpdateDialog = ({ currentUserTeamRole, trigger, teamId, - teamMemberId, - teamMemberName, - teamMemberRole, + memberId, + memberName, + memberTeamRole, ...props }: TeamMemberUpdateDialogProps) => { const [open, setOpen] = useState(false); @@ -71,17 +72,17 @@ export const TeamMemberUpdateDialog = ({ const form = useForm({ resolver: zodResolver(ZUpdateTeamMemberFormSchema), defaultValues: { - role: teamMemberRole, + role: memberTeamRole, }, }); - const { mutateAsync: updateTeamMember } = trpc.team.updateTeamMember.useMutation(); + const { mutateAsync: updateTeamMember } = trpc.team.member.update.useMutation(); const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => { try { await updateTeamMember({ teamId, - teamMemberId, + memberId, data: { role, }, @@ -89,7 +90,7 @@ export const TeamMemberUpdateDialog = ({ toast({ title: _(msg`Success`), - description: _(msg`You have updated ${teamMemberName}.`), + description: _(msg`You have updated ${memberName}.`), duration: 5000, }); @@ -112,7 +113,7 @@ export const TeamMemberUpdateDialog = ({ form.reset(); - if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, teamMemberRole)) { + if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) { setOpen(false); toast({ @@ -121,7 +122,7 @@ export const TeamMemberUpdateDialog = ({ }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, currentUserTeamRole, teamMemberRole, form, toast]); + }, [open, currentUserTeamRole, memberTeamRole, form, toast]); return ( Update team member - + - You are currently updating {teamMemberName}. + You are currently updating {memberName}. @@ -170,7 +171,7 @@ export const TeamMemberUpdateDialog = ({ {TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => ( - {_(TEAM_MEMBER_ROLE_MAP[role]) ?? role} + {_(EXTENDED_TEAM_MEMBER_ROLE_MAP[role]) ?? role} ))} diff --git a/apps/remix/app/components/dialogs/team-transfer-dialog.tsx b/apps/remix/app/components/dialogs/team-transfer-dialog.tsx deleted file mode 100644 index 4e46233cc..000000000 --- a/apps/remix/app/components/dialogs/team-transfer-dialog.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { Loader } from 'lucide-react'; -import { useForm } from 'react-hook-form'; -import { useRevalidator } from 'react-router'; -import { z } from 'zod'; - -import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; -import { trpc } from '@documenso/trpc/react'; -import { Alert, AlertDescription } 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 { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -export type TeamTransferDialogProps = { - teamId: number; - teamName: string; - ownerUserId: number; - trigger?: React.ReactNode; -}; - -export const TeamTransferDialog = ({ - trigger, - teamId, - teamName, - ownerUserId, -}: TeamTransferDialogProps) => { - const [open, setOpen] = useState(false); - - const { _ } = useLingui(); - const { toast } = useToast(); - const { revalidate } = useRevalidator(); - - const { mutateAsync: requestTeamOwnershipTransfer } = - trpc.team.requestTeamOwnershipTransfer.useMutation(); - - const { - data, - refetch: refetchTeamMembers, - isPending: loadingTeamMembers, - isLoadingError: loadingTeamMembersError, - } = trpc.team.getTeamMembers.useQuery({ - teamId, - }); - - const confirmTransferMessage = _(msg`transfer ${teamName}`); - - const ZTransferTeamFormSchema = z.object({ - teamName: z.literal(confirmTransferMessage, { - errorMap: () => ({ message: `You must enter '${confirmTransferMessage}' to proceed` }), - }), - newOwnerUserId: z.string(), - clearPaymentMethods: z.boolean(), - }); - - const form = useForm>({ - resolver: zodResolver(ZTransferTeamFormSchema), - defaultValues: { - teamName: '', - clearPaymentMethods: false, - }, - }); - - const onFormSubmit = async ({ - newOwnerUserId, - clearPaymentMethods, - }: z.infer) => { - try { - await requestTeamOwnershipTransfer({ - teamId, - newOwnerUserId: Number.parseInt(newOwnerUserId), - clearPaymentMethods, - }); - - await revalidate(); - - toast({ - title: _(msg`Success`), - description: _(msg`An email requesting the transfer of this team has been sent.`), - duration: 5000, - }); - - setOpen(false); - } catch (err) { - toast({ - title: _(msg`An unknown error occurred`), - description: _( - msg`We encountered an unknown error while attempting to request a transfer of this team. Please try again later.`, - ), - variant: 'destructive', - duration: 10000, - }); - } - }; - - useEffect(() => { - if (!open) { - form.reset(); - } - }, [open, form]); - - useEffect(() => { - if (open && loadingTeamMembersError) { - void refetchTeamMembers(); - } - }, [open, loadingTeamMembersError, refetchTeamMembers]); - - const teamMembers = data - ? data.filter((teamMember) => teamMember.userId !== ownerUserId) - : undefined; - - return ( - !form.formState.isSubmitting && setOpen(value)}> - - {trigger ?? ( - - )} - - - {teamMembers && teamMembers.length > 0 ? ( - - - - Transfer team - - - - Transfer ownership of this team to a selected team member. - - - -
- -
- ( - - - New team owner - - - - - - - )} - /> - - ( - - - - Confirm by typing{' '} - {confirmTransferMessage} - - - - - - - - )} - /> - - - -
    - {IS_BILLING_ENABLED() && ( -
  • - - Any payment methods attached to this team will remain attached to this - team. Please contact us if you need to update this information. - -
  • - )} -
  • - - The selected team member will receive an email which they must accept - before the team is transferred - -
  • -
-
-
- - - - - - -
-
- -
- ) : ( - - {loadingTeamMembers ? ( - - ) : ( -

- {loadingTeamMembersError ? ( - An error occurred while loading team members. Please try again later. - ) : ( - You must have at least one other team member to transfer ownership. - )} -

- )} -
- )} -
- ); -}; diff --git a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx index cd700ac1e..610ce7ac3 100644 --- a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx @@ -21,7 +21,7 @@ import { import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; const ZBulkSendFormSchema = z.object({ file: z.instanceof(File), @@ -46,7 +46,7 @@ export const TemplateBulkSendDialog = ({ const { _ } = useLingui(); const { toast } = useToast(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const form = useForm({ resolver: zodResolver(ZBulkSendFormSchema), diff --git a/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx b/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx index 8142eb848..d7e60b512 100644 --- a/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx @@ -15,6 +15,7 @@ import { P, match } from 'ts-pattern'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; +import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template'; @@ -75,6 +76,8 @@ export const TemplateDirectLinkDialog = ({ token ? 'MANAGE' : 'ONBOARD', ); + const organisation = useCurrentOrganisation(); + const validDirectTemplateRecipients = useMemo( () => template.recipients.filter( @@ -237,7 +240,7 @@ export const TemplateDirectLinkDialog = ({ templates.{' '} Upgrade your account to continue! diff --git a/apps/remix/app/components/dialogs/template-folder-create-dialog.tsx b/apps/remix/app/components/dialogs/template-folder-create-dialog.tsx index aaf0322f4..db1da14de 100644 --- a/apps/remix/app/components/dialogs/template-folder-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-folder-create-dialog.tsx @@ -34,7 +34,7 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; const ZCreateFolderFormSchema = z.object({ name: z.string().min(1, { message: 'Folder name is required' }), @@ -52,10 +52,11 @@ export const TemplateFolderCreateDialog = ({ }: TemplateFolderCreateDialogProps) => { const { toast } = useToast(); const { _ } = useLingui(); - const navigate = useNavigate(); - const team = useOptionalCurrentTeam(); const { folderId } = useParams(); + const navigate = useNavigate(); + const team = useCurrentTeam(); + const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation(); @@ -81,7 +82,7 @@ export const TemplateFolderCreateDialog = ({ description: _(msg`Folder created successfully`), }); - const templatesPath = formatTemplatesPath(team?.url); + const templatesPath = formatTemplatesPath(team.url); void navigate(`${templatesPath}/f/${newFolder.id}`); } catch (err) { @@ -126,7 +127,7 @@ export const TemplateFolderCreateDialog = ({ Create New Folder - Enter a name for your new folder. Folders help you organize your templates. + Enter a name for your new folder. Folders help you organise your templates. diff --git a/apps/remix/app/components/dialogs/template-move-dialog.tsx b/apps/remix/app/components/dialogs/template-move-dialog.tsx deleted file mode 100644 index f113317eb..000000000 --- a/apps/remix/app/components/dialogs/template-move-dialog.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { useState } from 'react'; - -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import { match } from 'ts-pattern'; - -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; -import { trpc } from '@documenso/trpc/react'; -import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; -import { Button } from '@documenso/ui/primitives/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@documenso/ui/primitives/dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@documenso/ui/primitives/select'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -type TemplateMoveDialogProps = { - templateId: number; - open: boolean; - onOpenChange: (_open: boolean) => void; - onMove?: ({ - templateId, - teamUrl, - }: { - templateId: number; - teamUrl: string; - }) => Promise | void; -}; - -export const TemplateMoveDialog = ({ - templateId, - open, - onOpenChange, - onMove, -}: TemplateMoveDialogProps) => { - const { toast } = useToast(); - const { _ } = useLingui(); - - const [selectedTeamId, setSelectedTeamId] = useState(null); - - const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery(); - - const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({ - onSuccess: async () => { - const team = teams?.find((team) => team.id === selectedTeamId); - - if (team) { - await onMove?.({ templateId, teamUrl: team.url }); - } - - toast({ - title: _(msg`Template moved`), - description: _(msg`The template has been successfully moved to the selected team.`), - duration: 5000, - }); - - onOpenChange(false); - }, - onError: (err) => { - const error = AppError.parseError(err); - - const errorMessage = match(error.code) - .with( - AppErrorCode.NOT_FOUND, - () => msg`Template not found or already associated with a team.`, - ) - .with(AppErrorCode.UNAUTHORIZED, () => msg`You are not a member of this team.`) - .otherwise(() => msg`An error occurred while moving the template.`); - - toast({ - title: _(msg`Error`), - description: _(errorMessage), - variant: 'destructive', - duration: 7500, - }); - }, - }); - - const handleOnMove = async () => { - if (!selectedTeamId) { - return; - } - - await moveTemplate({ templateId, teamId: selectedTeamId }); - }; - - return ( - - - - - Move Template to Team - - - Select a team to move this template to. This action cannot be undone. - - - - - - - - - - - - ); -}; diff --git a/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx b/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx index c0d068ac4..4838995b5 100644 --- a/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx @@ -33,7 +33,7 @@ import { } from '@documenso/ui/primitives/form/form'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; export type TemplateMoveToFolderDialogProps = { templateId: number; @@ -60,7 +60,7 @@ export function TemplateMoveToFolderDialog({ const { _ } = useLingui(); const { toast } = useToast(); const navigate = useNavigate(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const form = useForm({ resolver: zodResolver(ZMoveTemplateFormSchema), @@ -104,7 +104,7 @@ export function TemplateMoveToFolderDialog({ onOpenChange(false); - const templatesPath = formatTemplatesPath(team?.url); + const templatesPath = formatTemplatesPath(team.url); if (data.folderId) { void navigate(`${templatesPath}/f/${data.folderId}`); diff --git a/apps/remix/app/components/dialogs/token-delete-dialog.tsx b/apps/remix/app/components/dialogs/token-delete-dialog.tsx index 511ce04db..aa557132b 100644 --- a/apps/remix/app/components/dialogs/token-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/token-delete-dialog.tsx @@ -30,7 +30,7 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; export type TokenDeleteDialogProps = { token: Pick; @@ -42,7 +42,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe const { _ } = useLingui(); const { toast } = useToast(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const [isOpen, setIsOpen] = useState(false); diff --git a/apps/remix/app/components/dialogs/webhook-create-dialog.tsx b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx index f8c5c94d2..ce1109322 100644 --- a/apps/remix/app/components/dialogs/webhook-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx @@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form'; import type { z } from 'zod'; import { trpc } from '@documenso/trpc/react'; -import { ZCreateWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema'; +import { ZCreateWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -34,11 +34,11 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { Switch } from '@documenso/ui/primitives/switch'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox'; -const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true }); +const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema.omit({ teamId: true }); type TCreateWebhookFormSchema = z.infer; @@ -50,7 +50,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr const { _ } = useLingui(); const { toast } = useToast(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const [open, setOpen] = useState(false); @@ -78,7 +78,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr eventTriggers, secret, webhookUrl, - teamId: team?.id, + teamId: team.id, }); setOpen(false); diff --git a/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx index 5842f4fb7..6fb369577 100644 --- a/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx @@ -30,7 +30,7 @@ import { import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; +import { useCurrentTeam } from '~/providers/team'; export type WebhookDeleteDialogProps = { webhook: Pick; @@ -42,7 +42,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr const { _ } = useLingui(); const { toast } = useToast(); - const team = useOptionalCurrentTeam(); + const team = useCurrentTeam(); const [open, setOpen] = useState(false); @@ -67,7 +67,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr const onSubmit = async () => { try { - await deleteWebhook({ id: webhook.id, teamId: team?.id }); + await deleteWebhook({ id: webhook.id, teamId: team.id }); toast({ title: _(msg`Webhook deleted`), diff --git a/apps/remix/app/components/forms/avatar-image.tsx b/apps/remix/app/components/forms/avatar-image.tsx index 852063287..0d6dd4ddf 100644 --- a/apps/remix/app/components/forms/avatar-image.tsx +++ b/apps/remix/app/components/forms/avatar-image.tsx @@ -6,7 +6,6 @@ import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { ErrorCode, useDropzone } from 'react-dropzone'; import { useForm } from 'react-hook-form'; -import { useRevalidator } from 'react-router'; import { match } from 'ts-pattern'; import { z } from 'zod'; @@ -29,8 +28,6 @@ import { } from '@documenso/ui/primitives/form/form'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; - export const ZAvatarImageFormSchema = z.object({ bytes: z.string().nullish(), }); @@ -39,29 +36,44 @@ export type TAvatarImageFormSchema = z.infer; export type AvatarImageFormProps = { className?: string; + team?: { + id: number; + name: string; + avatarImageId: string | null; + }; + organisation?: { + id: string; + name: string; + avatarImageId: string | null; + }; }; -export const AvatarImageForm = ({ className }: AvatarImageFormProps) => { +export const AvatarImageForm = ({ className, team, organisation }: AvatarImageFormProps) => { const { user, refreshSession } = useSession(); const { _ } = useLingui(); const { toast } = useToast(); - const { revalidate } = useRevalidator(); - - const team = useOptionalCurrentTeam(); const { mutateAsync: setProfileImage } = trpc.profile.setProfileImage.useMutation(); - const initials = extractInitials(team?.name || user.name || ''); + const initials = extractInitials(team?.name || organisation?.name || user.name || ''); const hasAvatarImage = useMemo(() => { if (team) { return team.avatarImageId !== null; } - return user.avatarImageId !== null; - }, [team, user.avatarImageId]); + if (organisation) { + return organisation.avatarImageId !== null; + } - const avatarImageId = team ? team.avatarImageId : user.avatarImageId; + return user.avatarImageId !== null; + }, [team, organisation, user.avatarImageId]); + + const avatarImageId = team + ? team.avatarImageId + : organisation + ? organisation.avatarImageId + : user.avatarImageId; const form = useForm({ values: { @@ -100,7 +112,8 @@ export const AvatarImageForm = ({ className }: AvatarImageFormProps) => { try { await setProfileImage({ bytes: data.bytes, - teamId: team?.id, + teamId: team?.id ?? null, + organisationId: organisation?.id ?? null, }); await refreshSession(); diff --git a/apps/remix/app/components/forms/team-branding-preferences-form.tsx b/apps/remix/app/components/forms/branding-preferences-form.tsx similarity index 64% rename from apps/remix/app/components/forms/team-branding-preferences-form.tsx rename to apps/remix/app/components/forms/branding-preferences-form.tsx index 5cc519960..85355a7b1 100644 --- a/apps/remix/app/components/forms/team-branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/branding-preferences-form.tsx @@ -1,17 +1,14 @@ import { useEffect, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; +import { useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro'; -import type { Team, TeamGlobalSettings } from '@prisma/client'; +import type { TeamGlobalSettings } from '@prisma/client'; import { Loader } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { getFile } from '@documenso/lib/universal/upload/get-file'; -import { putFile } from '@documenso/lib/universal/upload/put-file'; -import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -23,15 +20,20 @@ import { FormLabel, } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; -import { Switch } from '@documenso/ui/primitives/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; import { Textarea } from '@documenso/ui/primitives/textarea'; -import { useToast } from '@documenso/ui/primitives/use-toast'; const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; -const ZTeamBrandingPreferencesFormSchema = z.object({ - brandingEnabled: z.boolean(), +const ZBrandingPreferencesFormSchema = z.object({ + brandingEnabled: z.boolean().nullable(), brandingLogo: z .instanceof(File) .refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB') @@ -44,76 +46,45 @@ const ZTeamBrandingPreferencesFormSchema = z.object({ brandingCompanyDetails: z.string().max(500).optional(), }); -type TTeamBrandingPreferencesFormSchema = z.infer; +export type TBrandingPreferencesFormSchema = z.infer; -export type TeamBrandingPreferencesFormProps = { - team: Team; - settings?: TeamGlobalSettings | null; +type SettingsSubset = Pick< + TeamGlobalSettings, + 'brandingEnabled' | 'brandingLogo' | 'brandingUrl' | 'brandingCompanyDetails' +>; + +export type BrandingPreferencesFormProps = { + canInherit?: boolean; + settings: SettingsSubset; + onFormSubmit: (data: TBrandingPreferencesFormSchema) => Promise; + context: 'Team' | 'Organisation'; }; -export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPreferencesFormProps) { - const { _ } = useLingui(); - const { toast } = useToast(); +export function BrandingPreferencesForm({ + canInherit = false, + settings, + onFormSubmit, + context, +}: BrandingPreferencesFormProps) { + const { t } = useLingui(); const [previewUrl, setPreviewUrl] = useState(''); const [hasLoadedPreview, setHasLoadedPreview] = useState(false); - const { mutateAsync: updateTeamBrandingSettings } = - trpc.team.updateTeamBrandingSettings.useMutation(); - - const form = useForm({ + const form = useForm({ defaultValues: { - brandingEnabled: settings?.brandingEnabled ?? false, - brandingUrl: settings?.brandingUrl ?? '', + brandingEnabled: settings.brandingEnabled ?? null, + brandingUrl: settings.brandingUrl ?? '', brandingLogo: undefined, - brandingCompanyDetails: settings?.brandingCompanyDetails ?? '', + brandingCompanyDetails: settings.brandingCompanyDetails ?? '', }, - resolver: zodResolver(ZTeamBrandingPreferencesFormSchema), + resolver: zodResolver(ZBrandingPreferencesFormSchema), }); const isBrandingEnabled = form.watch('brandingEnabled'); - const onSubmit = async (data: TTeamBrandingPreferencesFormSchema) => { - try { - const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data; - - let uploadedBrandingLogo = settings?.brandingLogo; - - if (brandingLogo) { - uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo)); - } - - if (brandingLogo === null) { - uploadedBrandingLogo = ''; - } - - await updateTeamBrandingSettings({ - teamId: team.id, - settings: { - brandingEnabled, - brandingLogo: uploadedBrandingLogo, - brandingUrl, - brandingCompanyDetails, - }, - }); - - toast({ - title: _(msg`Branding preferences updated`), - description: _(msg`Your branding preferences have been updated`), - }); - } catch (err) { - toast({ - title: _(msg`Something went wrong`), - description: _( - msg`We were unable to update your branding preferences at this time, please try again later`, - ), - variant: 'destructive', - }); - } - }; - useEffect(() => { - if (settings?.brandingLogo) { + if (settings.brandingLogo) { const file = JSON.parse(settings.brandingLogo); if ('type' in file && 'data' in file) { @@ -129,7 +100,7 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref } setHasLoadedPreview(true); - }, [settings?.brandingLogo]); + }, [settings.brandingLogo]); // Cleanup ObjectURL on unmount or when previewUrl changes useEffect(() => { @@ -142,45 +113,72 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref return (
- -
+ +
( - Enable Custom Branding + + Enable Custom Branding + -
- - - -
+ + + - Enable custom branding for all documents in this team. + {context === 'Team' ? ( + Enable custom branding for all documents in this team + ) : ( + Enable custom branding for all documents in this organisation + )}
)} />
- {!isBrandingEnabled &&
} + {!isBrandingEnabled &&
} ( - Branding Logo + + Branding Logo +
@@ -192,7 +190,8 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref /> ) : (
- Please upload a logo + Please upload a logo + {!hasLoadedPreview && (
@@ -253,6 +252,13 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref Upload your brand logo (max 5MB, JPG, PNG, or WebP) + + {canInherit && ( + + {'. '} + Leave blank to inherit from the organisation. + + )}
@@ -264,7 +270,9 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref name="brandingUrl" render={({ field }) => ( - Brand Website + + Brand Website + Your brand website URL + + {canInherit && ( + + {'. '} + Leave blank to inherit from the organisation. + + )} )} @@ -287,11 +302,13 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref name="brandingCompanyDetails" render={({ field }) => ( - Brand Details + + Brand Details +