From 55c863262059879c62963a55bd08717555134201 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Sat, 7 Jun 2025 00:27:19 +1000 Subject: [PATCH 01/34] 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 +