diff --git a/CODE_STYLE.md b/CODE_STYLE.md new file mode 100644 index 000000000..c58c7255f --- /dev/null +++ b/CODE_STYLE.md @@ -0,0 +1,692 @@ +# Documenso Code Style Guide + +This document captures the code style, patterns, and conventions used in the Documenso codebase. It covers both enforceable rules and subjective "taste" elements that make our code consistent and maintainable. + +## Table of Contents + +1. [General Principles](#general-principles) +2. [TypeScript Conventions](#typescript-conventions) +3. [Imports & Dependencies](#imports--dependencies) +4. [Functions & Methods](#functions--methods) +5. [React & Components](#react--components) +6. [Error Handling](#error-handling) +7. [Async/Await Patterns](#asyncawait-patterns) +8. [Whitespace & Formatting](#whitespace--formatting) +9. [Naming Conventions](#naming-conventions) +10. [Pattern Matching](#pattern-matching) +11. [Database & Prisma](#database--prisma) +12. [TRPC Patterns](#trpc-patterns) + +--- + +## General Principles + +- **Functional over Object-Oriented**: Prefer functional programming patterns over classes +- **Explicit over Implicit**: Be explicit about types, return values, and error cases +- **Early Returns**: Use guard clauses and early returns to reduce nesting +- **Immutability**: Favor `const` over `let`; avoid mutation where possible + +--- + +## TypeScript Conventions + +### Type Definitions + +```typescript +// ✅ Prefer `type` over `interface` +type CreateDocumentOptions = { + templateId: number; + userId: number; + recipients: Recipient[]; +}; + +// ❌ Avoid interfaces unless absolutely necessary +interface CreateDocumentOptions { + templateId: number; +} +``` + +### Type Imports + +```typescript +// ✅ Use `type` keyword for type-only imports +import type { Document, Recipient } from '@prisma/client'; +import { DocumentStatus } from '@prisma/client'; + +// Types in function signatures +export const findDocuments = async ({ userId, teamId }: FindDocumentsOptions) => { + // ... +}; +``` + +### Inline Types for Function Parameters + +```typescript +// ✅ Extract inline types to named types +type FinalRecipient = Pick & { + templateRecipientId: number; + fields: Field[]; +}; + +const finalRecipients: FinalRecipient[] = []; +``` + +--- + +## Imports & Dependencies + +### Import Organization + +Imports should be organized in the following order with blank lines between groups: + +```typescript +// 1. React imports +import { useCallback, useEffect, useMemo } from 'react'; + +// 2. Third-party library imports (alphabetically) +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans } from '@lingui/react/macro'; +import type { Document, Recipient } from '@prisma/client'; +import { DocumentStatus, RecipientRole } from '@prisma/client'; +import { match } from 'ts-pattern'; + +// 3. Internal package imports (from @documenso/*) +import { AppError } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; +import { Button } from '@documenso/ui/primitives/button'; + +// 4. Relative imports +import { getTeamById } from '../team/get-team'; +import type { FindResultResponse } from './types'; +``` + +### Destructuring Imports + +```typescript +// ✅ Destructure specific exports +// ✅ Use type imports for types +import type { Document } from '@prisma/client'; + +import { Button } from '@documenso/ui/primitives/button'; +import { Input } from '@documenso/ui/primitives/input'; +``` + +--- + +## Functions & Methods + +### Arrow Functions + +```typescript +// ✅ Always use arrow functions for functions +export const createDocument = async ({ + userId, + title, +}: CreateDocumentOptions) => { + // ... +}; + +// ✅ Callbacks and handlers +const onSubmit = useCallback(async () => { + // ... +}, [dependencies]); + +// ❌ Avoid regular function declarations +function createDocument() { + // ... +} +``` + +### Function Parameters + +```typescript +// ✅ Use destructured object parameters for multiple params +export const findDocuments = async ({ + userId, + teamId, + status = ExtendedDocumentStatus.ALL, + page = 1, + perPage = 10, +}: FindDocumentsOptions) => { + // ... +}; + +// ✅ Destructure on separate line when needed +const onFormSubmit = form.handleSubmit(onSubmit); + +// ✅ Deconstruct nested properties explicitly +const { user } = ctx; +const { templateId } = input; +``` + +--- + +## React & Components + +### Component Definition + +```typescript +// ✅ Use const with arrow function +export const AddSignersFormPartial = ({ + documentFlow, + recipients, + fields, + onSubmit, +}: AddSignersFormProps) => { + // ... +}; + +// ❌ Never use classes +class MyComponent extends React.Component { + // ... +} +``` + +### Hooks + +```typescript +// ✅ Group related hooks together with blank line separation +const { _ } = useLingui(); +const { toast } = useToast(); + +const { currentStep, totalSteps, previousStep } = useStep(); + +const form = useForm({ + resolver: zodResolver(ZFormSchema), + defaultValues: { + // ... + }, +}); +``` + +### Event Handlers + +```typescript +// ✅ Use arrow functions with descriptive names +const onFormSubmit = async () => { + await form.trigger(); + // ... +}; + +const onFieldCopy = useCallback( + (event?: KeyboardEvent | null) => { + event?.preventDefault(); + // ... + }, + [dependencies], +); + +// ✅ Inline handlers for simple operations + +``` + +### State Management + +```typescript +// ✅ Descriptive state names with auxiliary verbs +const [isLoading, setIsLoading] = useState(false); +const [hasError, setHasError] = useState(false); +const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + +// ✅ Complex state in single useState when related +const [coords, setCoords] = useState({ + x: 0, + y: 0, +}); +``` + +--- + +## Error Handling + +### Try-Catch Blocks + +```typescript +// ✅ Use try-catch for operations that might fail +try { + const document = await getDocumentById({ + documentId: Number(documentId), + userId: user.id, + }); + + return { + status: 200, + body: document, + }; +} catch (err) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; +} +``` + +### Throwing Errors + +```typescript +// ✅ Use AppError for application errors +throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Template not found', +}); + +// ✅ Use descriptive error messages +if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: `Template with ID ${templateId} not found`, + }); +} +``` + +### Error Parsing on Frontend + +```typescript +// ✅ Parse errors on the frontend +try { + await updateOrganisation({ organisationId, data }); +} catch (err) { + const error = AppError.parseError(err); + console.error(error); + + toast({ + title: t`An error occurred`, + description: error.message, + variant: 'destructive', + }); +} +``` + +--- + +## Async/Await Patterns + +### Async Function Definitions + +```typescript +// ✅ Mark async functions clearly +export const createDocument = async ({ + userId, + title, +}: Options): Promise => { + // ... +}; + +// ✅ Use await for promises +const document = await prisma.document.create({ data }); + +// ✅ Use Promise.all for parallel operations +const [document, recipients] = await Promise.all([ + getDocumentById({ documentId }), + getRecipientsForDocument({ documentId }), +]); +``` + +### Void for Fire-and-Forget + +```typescript +// ✅ Use void for intentionally unwaited promises +void handleAutoSave(); + +// ✅ Or in event handlers +onClick={() => void onFormSubmit()} +``` + +--- + +## Whitespace & Formatting + +### Blank Lines Between Concepts + +```typescript +// ✅ Blank line after imports +import { prisma } from '@documenso/prisma'; + +export const findDocuments = async () => { + // ... +}; + +// ✅ Blank line between logical sections +const user = await prisma.user.findFirst({ where: { id: userId } }); + +let team = null; + +if (teamId !== undefined) { + team = await getTeamById({ userId, teamId }); +} + +// ✅ Blank line before return statements +const result = await someOperation(); + +return result; +``` + +### Function/Method Spacing + +```typescript +// ✅ No blank lines between chained methods in same operation +const documents = await prisma.document + .findMany({ where: { userId } }) + .then((docs) => docs.map(maskTokens)); + +// ✅ Blank line between different operations +const document = await createDocument({ userId }); + +await sendDocument({ documentId: document.id }); + +return document; +``` + +### Object and Array Formatting + +```typescript +// ✅ Multi-line when complex +const options = { + userId, + teamId, + status: ExtendedDocumentStatus.ALL, + page: 1, +}; + +// ✅ Single line when simple +const coords = { x: 0, y: 0 }; + +// ✅ Array items on separate lines when objects +const recipients = [ + { + name: 'John', + email: 'john@example.com', + }, + { + name: 'Jane', + email: 'jane@example.com', + }, +]; +``` + +--- + +## Naming Conventions + +### Variables + +```typescript +// ✅ camelCase for variables and functions +const documentId = 123; +const onSubmit = () => {}; + +// ✅ Descriptive names with auxiliary verbs for booleans +const isLoading = false; +const hasError = false; +const canEdit = true; +const shouldRender = true; + +// ✅ Prefix with $ for DOM elements +const $page = document.querySelector('.page'); +const $inputRef = useRef(null); +``` + +### Types and Schemas + +```typescript +// ✅ PascalCase for types +type CreateDocumentOptions = { + userId: number; +}; + +// ✅ Prefix Zod schemas with Z +const ZCreateDocumentSchema = z.object({ + title: z.string(), +}); + +// ✅ Prefix type from Zod schema with T +type TCreateDocumentSchema = z.infer; +``` + +### Constants + +```typescript +// ✅ UPPER_SNAKE_CASE for true constants +const DEFAULT_DOCUMENT_DATE_FORMAT = 'dd/MM/yyyy'; +const MAX_FILE_SIZE = 1024 * 1024 * 5; + +// ✅ camelCase for const variables that aren't "constants" +const userId = await getUserId(); +``` + +### Functions + +```typescript +// ✅ Verb-based names for functions +const createDocument = async () => {}; +const findDocuments = async () => {}; +const updateDocument = async () => {}; +const deleteDocument = async () => {}; + +// ✅ On prefix for event handlers +const onSubmit = () => {}; +const onClick = () => {}; +const onFieldCopy = () => {}; // 'on' is also acceptable +``` + +### Clarity Over Brevity + +```typescript +// ✅ Prefer descriptive names over abbreviations +const superLongMethodThatIsCorrect = () => {}; +const recipientAuthenticationOptions = {}; +const documentMetadata = {}; + +// ❌ Avoid abbreviations that sacrifice clarity +const supLongMethThatIsCorrect = () => {}; +const recipAuthOpts = {}; +const docMeta = {}; + +// ✅ Common abbreviations that are widely understood are acceptable +const userId = 123; +const htmlElement = document.querySelector('div'); +const apiResponse = await fetch('/api'); +``` + +--- + +## Pattern Matching + +### Using ts-pattern + +```typescript +import { match } from 'ts-pattern'; + +// ✅ Use match for complex conditionals +const result = match(status) + .with(ExtendedDocumentStatus.DRAFT, () => ({ + status: 'draft', + })) + .with(ExtendedDocumentStatus.PENDING, () => ({ + status: 'pending', + })) + .with(ExtendedDocumentStatus.COMPLETED, () => ({ + status: 'completed', + })) + .exhaustive(); + +// ✅ Use .otherwise() for default case when not exhaustive +const value = match(type) + .with('text', () => 'Text field') + .with('number', () => 'Number field') + .otherwise(() => 'Unknown field'); +``` + +--- + +## Database & Prisma + +### Query Structure + +```typescript +// ✅ Destructure commonly used fields +const { id, email, name } = user; + +// ✅ Use select to limit returned fields +const user = await prisma.user.findFirst({ + where: { id: userId }, + select: { + id: true, + email: true, + name: true, + }, +}); + +// ✅ Use include for relations +const document = await prisma.document.findFirst({ + where: { id: documentId }, + include: { + recipients: true, + fields: true, + }, +}); +``` + +### Transactions + +```typescript +// ✅ Use transactions for related operations +return await prisma.$transaction(async (tx) => { + const document = await tx.document.create({ data }); + + await tx.field.createMany({ data: fieldsData }); + + await tx.documentAuditLog.create({ data: auditData }); + + return document; +}); +``` + +### Where Clauses + +```typescript +// ✅ Build complex where clauses separately +const whereClause: Prisma.DocumentWhereInput = { + AND: [ + { userId: user.id }, + { deletedAt: null }, + { status: { in: [DocumentStatus.DRAFT, DocumentStatus.PENDING] } }, + ], +}; + +const documents = await prisma.document.findMany({ + where: whereClause, +}); +``` + +--- + +## TRPC Patterns + +### Router Structure + +```typescript +// ✅ Destructure context and input at start +.query(async ({ input, ctx }) => { + const { teamId } = ctx; + const { templateId } = input; + + ctx.logger.info({ + input: { templateId }, + }); + + return await getTemplateById({ + id: templateId, + userId: ctx.user.id, + teamId, + }); +}); +``` + +### Request/Response Schemas + +```typescript +// ✅ Name schemas clearly +const ZCreateDocumentRequestSchema = z.object({ + title: z.string(), + recipients: z.array(ZRecipientSchema), +}); + +const ZCreateDocumentResponseSchema = z.object({ + documentId: z.number(), + status: z.string(), +}); +``` + +### Error Handling in TRPC + +```typescript +// ✅ Catch and transform errors appropriately +try { + const result = await createDocument({ userId, data }); + + return result; +} catch (err) { + return AppError.toRestAPIError(err); +} + +// ✅ Or throw AppError directly +if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Template not found', + }); +} +``` + +--- + +## Additional Patterns + +### Optional Chaining + +```typescript +// ✅ Use optional chaining for potentially undefined values +const email = user?.email; +const recipientToken = recipient?.token ?? ''; + +// ✅ Use nullish coalescing for defaults +const pageSize = perPage ?? 10; +const status = documentStatus ?? DocumentStatus.DRAFT; +``` + +### Array Operations + +```typescript +// ✅ Use functional array methods +const activeRecipients = recipients.filter((r) => r.signingStatus === 'SIGNED'); +const recipientEmails = recipients.map((r) => r.email); +const hasSignedRecipients = recipients.some((r) => r.signingStatus === 'SIGNED'); + +// ✅ Use find instead of filter + [0] +const recipient = recipients.find((r) => r.id === recipientId); +``` + +### Conditional Rendering + +```typescript +// ✅ Use && for conditional rendering +{isLoading && } + +// ✅ Use ternary for either/or +{isLoading ? : } + +// ✅ Extract complex conditions to variables +const shouldShowAdvanced = isAdmin && hasPermission && !isDisabled; +{shouldShowAdvanced && } +``` + +--- + +## When in Doubt + +- **Consistency**: Follow the patterns you see in similar files +- **Readability**: Favor code that's easy to read over clever one-liners +- **Explicitness**: Be explicit rather than implicit +- **Whitespace**: Use blank lines to separate logical sections +- **Early Returns**: Use guard clauses to reduce nesting +- **Functional**: Prefer functional patterns over imperative ones diff --git a/apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx b/apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx new file mode 100644 index 000000000..1e6be1e3f --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx @@ -0,0 +1,218 @@ +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 { useNavigate } from 'react-router'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations'; +import { trpc } from '@documenso/trpc/react'; +import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types'; +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 AdminOrganisationMemberUpdateDialogProps = { + trigger?: React.ReactNode; + organisationId: string; + organisationMember: TGetAdminOrganisationResponse['members'][number]; + isOwner: boolean; +} & Omit; + +const ZUpdateOrganisationMemberFormSchema = z.object({ + role: z.enum(['OWNER', 'ADMIN', 'MANAGER', 'MEMBER']), +}); + +type ZUpdateOrganisationMemberSchema = z.infer; + +export const AdminOrganisationMemberUpdateDialog = ({ + trigger, + organisationId, + organisationMember, + isOwner, + ...props +}: AdminOrganisationMemberUpdateDialogProps) => { + const [open, setOpen] = useState(false); + + const { t } = useLingui(); + const { toast } = useToast(); + const navigate = useNavigate(); + + // Determine the current role value for the form + const currentRoleValue = isOwner + ? 'OWNER' + : getHighestOrganisationRoleInGroup( + organisationMember.organisationGroupMembers.map((ogm) => ogm.group), + ); + const organisationMemberName = organisationMember.user.name ?? organisationMember.user.email; + + const form = useForm({ + resolver: zodResolver(ZUpdateOrganisationMemberFormSchema), + defaultValues: { + role: currentRoleValue, + }, + }); + + const { mutateAsync: updateOrganisationMemberRole } = + trpc.admin.organisationMember.updateRole.useMutation(); + + const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => { + try { + await updateOrganisationMemberRole({ + organisationId, + userId: organisationMember.userId, + role, + }); + + const roleLabel = match(role) + .with('OWNER', () => t`Owner`) + .with(OrganisationMemberRole.ADMIN, () => t`Admin`) + .with(OrganisationMemberRole.MANAGER, () => t`Manager`) + .with(OrganisationMemberRole.MEMBER, () => t`Member`) + .exhaustive(); + + toast({ + title: t`Success`, + description: + role === 'OWNER' + ? t`Ownership transferred to ${organisationMemberName}.` + : t`Updated ${organisationMemberName} to ${roleLabel}.`, + duration: 5000, + }); + + setOpen(false); + + // Refresh the page to show updated data + await navigate(0); + } catch (err) { + console.error(err); + + toast({ + title: t`An unknown error occurred`, + description: t`We encountered an unknown error while attempting to update this organisation member. Please try again later.`, + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!open) { + return; + } + + form.reset({ + role: currentRoleValue, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, currentRoleValue, form]); + + 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/general/direct-template/direct-template-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-page.tsx index 686074a27..9bddf9c0d 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-page.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-page.tsx @@ -89,7 +89,10 @@ export const DirectTemplatePageView = ({ setStep('sign'); }; - const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => { + const onSignDirectTemplateSubmit = async ( + fields: DirectTemplateLocalField[], + nextSigner?: { name: string; email: string }, + ) => { try { let directTemplateExternalId = searchParams?.get('externalId') || undefined; @@ -98,6 +101,7 @@ export const DirectTemplatePageView = ({ } const { token } = await createDocumentFromDirectTemplate({ + nextSigner, directTemplateToken, directTemplateExternalId, directRecipientName: fullName, diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index 3ab3046ae..932a693fd 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -55,10 +55,13 @@ import { DocumentSigningRecipientProvider } from '../document-signing/document-s export type DirectTemplateSigningFormProps = { flowStep: DocumentFlowStep; - directRecipient: Pick; + directRecipient: Pick; directRecipientFields: Field[]; template: Omit; - onSubmit: (_data: DirectTemplateLocalField[]) => Promise; + onSubmit: ( + _data: DirectTemplateLocalField[], + _nextSigner?: { name: string; email: string }, + ) => Promise; }; export type DirectTemplateLocalField = Field & { @@ -149,7 +152,7 @@ export const DirectTemplateSigningForm = ({ validateFieldsInserted(fieldsRequiringValidation); }; - const handleSubmit = async () => { + const handleSubmit = async (nextSigner?: { name: string; email: string }) => { setValidateUninsertedFields(true); const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation); @@ -161,7 +164,7 @@ export const DirectTemplateSigningForm = ({ setIsSubmitting(true); try { - await onSubmit(localFields); + await onSubmit(localFields, nextSigner); } catch { setIsSubmitting(false); } @@ -218,6 +221,30 @@ export const DirectTemplateSigningForm = ({ setLocalFields(updatedFields); }, []); + const nextRecipient = useMemo(() => { + if ( + !template.templateMeta?.signingOrder || + template.templateMeta.signingOrder !== 'SEQUENTIAL' || + !template.templateMeta.allowDictateNextSigner + ) { + return undefined; + } + + const sortedRecipients = template.recipients.sort((a, b) => { + // Sort by signingOrder first (nulls last), then by id + if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id; + if (a.signingOrder === null) return 1; + if (b.signingOrder === null) return -1; + if (a.signingOrder === b.signingOrder) return a.id - b.id; + return a.signingOrder - b.signingOrder; + }); + + const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id); + return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 + ? sortedRecipients[currentIndex + 1] + : undefined; + }, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]); + return ( @@ -417,11 +444,15 @@ export const DirectTemplateSigningForm = ({ handleSubmit()} + onSignatureComplete={async (nextSigner) => handleSubmit(nextSigner)} documentTitle={template.title} fields={localFields} fieldsValidated={fieldsValidated} recipient={directRecipient} + allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner} + defaultNextSigner={ + nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined + } /> diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx index 21b1be6ca..123baafa5 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx @@ -9,7 +9,7 @@ import { Button } from '@documenso/ui/primitives/button'; import { useToast } from '@documenso/ui/primitives/use-toast'; export type DocumentSigningAuthPageViewProps = { - email: string; + email?: string; emailHasAccount?: boolean; }; @@ -22,12 +22,18 @@ export const DocumentSigningAuthPageView = ({ const [isSigningOut, setIsSigningOut] = useState(false); - const handleChangeAccount = async (email: string) => { + const handleChangeAccount = async (email?: string) => { try { setIsSigningOut(true); + let redirectPath = '/signin'; + + if (email) { + redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`; + } + await authClient.signOut({ - redirectPath: emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`, + redirectPath, }); } catch { toast({ @@ -49,9 +55,13 @@ export const DocumentSigningAuthPageView = ({

- - You need to be logged in as {email} to view this page. - + {email ? ( + + You need to be logged in as {email} to view this page. + + ) : ( + You need to be logged in to view this page. + )}

- - ), + cell: ({ row }) => { + const isOwner = row.original.userId === organisation?.ownerUserId; + + return ( +
+ + Update role + + } + organisationId={organisationId} + organisationMember={row.original} + isOwner={isOwner} + /> +
+ ); + }, }, ] satisfies DataTableColumnDef[]; }, [organisation]); diff --git a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx index 6d7d9a31b..dbe4c5ddd 100644 --- a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx @@ -8,7 +8,6 @@ import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/env import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing'; -import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data'; import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; @@ -98,15 +97,12 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => { envelopeForSigning, } as const; }) - .catch(async (e) => { + .catch((e) => { const error = AppError.parseError(e); if (error.code === AppErrorCode.UNAUTHORIZED) { - const requiredAccessData = await getEnvelopeRequiredAccessData({ token }); - return { isDocumentAccessValid: false, - ...requiredAccessData, } as const; } @@ -226,20 +222,21 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited - ); + return ; } const { envelope, recipient } = data.envelopeForSigning; + const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ + documentAuth: envelope.authOptions, + }); + + const isEmailForced = derivedRecipientAccessAuth.includes(DocumentAccessAuth.ACCOUNT); + return ( diff --git a/packages/app-tests/e2e/admin/organisations/promote-member-to-owner.spec.ts b/packages/app-tests/e2e/admin/organisations/update-organisation-member-role.spec.ts similarity index 64% rename from packages/app-tests/e2e/admin/organisations/promote-member-to-owner.spec.ts rename to packages/app-tests/e2e/admin/organisations/update-organisation-member-role.spec.ts index 86edeac2b..7cc3f7c03 100644 --- a/packages/app-tests/e2e/admin/organisations/promote-member-to-owner.spec.ts +++ b/packages/app-tests/e2e/admin/organisations/update-organisation-member-role.spec.ts @@ -68,15 +68,29 @@ test('[ADMIN]: promote member to owner', async ({ page }) => { // Test promoting a MEMBER to owner const memberRow = page.getByRole('row', { name: memberUser.email }); - // Find and click the "Promote to owner" button for the member - const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' }); - await expect(promoteButton).toBeVisible(); - await expect(promoteButton).not.toBeDisabled(); + // Find and click the "Update role" button for the member + const updateRoleButton = memberRow.getByRole('button', { + name: 'Update role', + }); + await expect(updateRoleButton).toBeVisible(); + await expect(updateRoleButton).not.toBeDisabled(); - await promoteButton.click(); + await updateRoleButton.click(); - // Verify success toast appears - await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible(); + // Wait for dialog to open and select Owner role + await expect(page.getByRole('dialog')).toBeVisible(); + + // Find and click the select trigger - it's a button with role="combobox" + await page.getByRole('dialog').locator('button[role="combobox"]').click(); + + // Select "Owner" from the dropdown options + await page.getByRole('option', { name: 'Owner' }).click(); + + // Click Update button + await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click(); + + // Wait for dialog to close (indicates success) + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 }); // Reload the page to see the changes await page.reload(); @@ -89,12 +103,18 @@ test('[ADMIN]: promote member to owner', async ({ page }) => { const previousOwnerRow = page.getByRole('row', { name: ownerUser.email }); await expect(previousOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible(); - // Verify that the promote button is now disabled for the new owner - const newOwnerPromoteButton = newOwnerRow.getByRole('button', { name: 'Promote to owner' }); - await expect(newOwnerPromoteButton).toBeDisabled(); + // Verify that the Update role button exists for the new owner and shows Owner as current role + const newOwnerUpdateButton = newOwnerRow.getByRole('button', { + name: 'Update role', + }); + await expect(newOwnerUpdateButton).toBeVisible(); - // Test that we can't promote the current owner (button should be disabled) - await expect(newOwnerPromoteButton).toHaveAttribute('disabled'); + // Verify clicking it shows the dialog with Owner already selected + await newOwnerUpdateButton.click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Close the dialog without making changes + await page.getByRole('button', { name: 'Cancel' }).click(); }); test('[ADMIN]: promote manager to owner', async ({ page }) => { @@ -130,10 +150,26 @@ test('[ADMIN]: promote manager to owner', async ({ page }) => { // Promote the manager to owner const managerRow = page.getByRole('row', { name: managerUser.email }); - const promoteButton = managerRow.getByRole('button', { name: 'Promote to owner' }); + const updateRoleButton = managerRow.getByRole('button', { + name: 'Update role', + }); - await promoteButton.click(); - await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible(); + await updateRoleButton.click(); + + // Wait for dialog to open and select Owner role + await expect(page.getByRole('dialog')).toBeVisible(); + + // Find and click the select trigger - it's a button with role="combobox" + await page.getByRole('dialog').locator('button[role="combobox"]').click(); + + // Select "Owner" from the dropdown options + await page.getByRole('option', { name: 'Owner' }).click(); + + // Click Update button + await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click(); + + // Wait for dialog to close (indicates success) + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 }); // Reload and verify the change await page.reload(); @@ -173,14 +209,27 @@ test('[ADMIN]: promote admin member to owner', async ({ page }) => { // Promote the admin member to owner const adminMemberRow = page.getByRole('row', { name: adminMemberUser.email }); - const promoteButton = adminMemberRow.getByRole('button', { name: 'Promote to owner' }); - - await promoteButton.click(); - - await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({ - timeout: 10_000, + const updateRoleButton = adminMemberRow.getByRole('button', { + name: 'Update role', }); + await updateRoleButton.click(); + + // Wait for dialog to open and select Owner role + await expect(page.getByRole('dialog')).toBeVisible(); + + // Find and click the select trigger - it's a button with role="combobox" + await page.getByRole('dialog').locator('button[role="combobox"]').click(); + + // Select "Owner" from the dropdown options + await page.getByRole('option', { name: 'Owner' }).click(); + + // Click Update button + await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click(); + + // Wait for dialog to close (indicates success) + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 }); + // Reload and verify the change await page.reload(); await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible(); @@ -249,11 +298,25 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => { await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible(); // Promote member to owner - const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' }); - await promoteButton.click(); - await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({ - timeout: 10_000, + const updateRoleButton = memberRow.getByRole('button', { + name: 'Update role', }); + await updateRoleButton.click(); + + // Wait for dialog to open and select Owner role + await expect(page.getByRole('dialog')).toBeVisible(); + + // Find and click the select trigger - it's a button with role="combobox" + await page.getByRole('dialog').locator('button[role="combobox"]').click(); + + // Select "Owner" from the dropdown options + await page.getByRole('option', { name: 'Owner' }).click(); + + // Click Update button + await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click(); + + // Wait for dialog to close (indicates success) + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 }); // Reload page to see updated state await page.reload(); @@ -262,9 +325,11 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => { memberRow = page.getByRole('row', { name: memberUser.email }); await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible(); - // Verify the promote button is now disabled for the new owner - const newOwnerPromoteButton = memberRow.getByRole('button', { name: 'Promote to owner' }); - await expect(newOwnerPromoteButton).toBeDisabled(); + // Verify the Update role button exists and shows Owner as current role + const newOwnerUpdateButton = memberRow.getByRole('button', { + name: 'Update role', + }); + await expect(newOwnerUpdateButton).toBeVisible(); // Sign in as the newly promoted user to verify they have owner permissions await apiSignin({ @@ -336,28 +401,56 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => { // First promotion: Member 1 becomes owner let member1Row = page.getByRole('row', { name: member1User.email }); - let promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' }); - await promoteButton1.click(); - await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({ - timeout: 10_000, + let updateRoleButton1 = member1Row.getByRole('button', { + name: 'Update role', }); + await updateRoleButton1.click(); + + // Wait for dialog to open and select Owner role + await expect(page.getByRole('dialog')).toBeVisible(); + + // Find and click the select trigger - it's a button with role="combobox" + await page.getByRole('dialog').locator('button[role="combobox"]').click(); + + // Select "Owner" from the dropdown options + await page.getByRole('option', { name: 'Owner' }).click(); + + // Click Update button + await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click(); + + // Wait for dialog to close (indicates success) + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 }); await page.reload(); - // Verify Member 1 is now owner and button is disabled + // Verify Member 1 is now owner member1Row = page.getByRole('row', { name: member1User.email }); await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible(); - promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' }); - await expect(promoteButton1).toBeDisabled(); + updateRoleButton1 = member1Row.getByRole('button', { name: 'Update role' }); + await expect(updateRoleButton1).toBeVisible(); // Second promotion: Member 2 becomes the new owner const member2Row = page.getByRole('row', { name: member2User.email }); - const promoteButton2 = member2Row.getByRole('button', { name: 'Promote to owner' }); - await expect(promoteButton2).not.toBeDisabled(); - await promoteButton2.click(); - await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({ - timeout: 10_000, + const updateRoleButton2 = member2Row.getByRole('button', { + name: 'Update role', }); + await expect(updateRoleButton2).toBeVisible(); + await updateRoleButton2.click(); + + // Wait for dialog to open and select Owner role + await expect(page.getByRole('dialog')).toBeVisible(); + + // Find and click the select trigger - it's a button with role="combobox" + await page.getByRole('dialog').locator('button[role="combobox"]').click(); + + // Select "Owner" from the dropdown options + await page.getByRole('option', { name: 'Owner' }).click(); + + // Click Update button + await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click(); + + // Wait for dialog to close (indicates success) + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 }); await page.reload(); @@ -365,9 +458,11 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => { await expect(member2Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible(); await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible(); - // Verify Member 1's promote button is now enabled again - const newPromoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' }); - await expect(newPromoteButton1).not.toBeDisabled(); + // Verify Member 1's Update role button is still visible + const newUpdateButton1 = member1Row.getByRole('button', { + name: 'Update role', + }); + await expect(newUpdateButton1).toBeVisible(); }); test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => { @@ -402,11 +497,25 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page }); const memberRow = page.getByRole('row', { name: memberUser.email }); - const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' }); - await promoteButton.click(); - await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({ - timeout: 10_000, + const updateRoleButton = memberRow.getByRole('button', { + name: 'Update role', }); + await updateRoleButton.click(); + + // Wait for dialog to open and select Owner role + await expect(page.getByRole('dialog')).toBeVisible(); + + // Find and click the select trigger - it's a button with role="combobox" + await page.getByRole('dialog').locator('button[role="combobox"]').click(); + + // Select "Owner" from the dropdown options + await page.getByRole('option', { name: 'Owner' }).click(); + + // Click Update button + await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click(); + + // Wait for dialog to close (indicates success) + await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 }); // Test that the new owner can access organisation settings await apiSignin({ diff --git a/packages/app-tests/e2e/templates/direct-templates.spec.ts b/packages/app-tests/e2e/templates/direct-templates.spec.ts index cc5eb0051..dbe165cdd 100644 --- a/packages/app-tests/e2e/templates/direct-templates.spec.ts +++ b/packages/app-tests/e2e/templates/direct-templates.spec.ts @@ -1,9 +1,12 @@ import { expect, test } from '@playwright/test'; +import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; import { customAlphabet } from 'nanoid'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth'; +import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; +import { prisma } from '@documenso/prisma'; import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates'; import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users'; @@ -121,7 +124,7 @@ test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => { await expect(page.getByText('404 not found')).toBeVisible(); }); -test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => { +test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page }) => { const { user, team } = await seedUser(); const directTemplateWithAuth = await seedDirectTemplate({ @@ -153,6 +156,53 @@ test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByLabel('Email')).toBeDisabled(); + + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Complete' }).click(); + + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(/\/sign/); + await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible(); +}); + +test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page }) => { + const { user, team } = await seedUser(); + + const directTemplateWithAuth = await seedDirectTemplate({ + title: 'Personal direct template link', + userId: user.id, + teamId: team.id, + internalVersion: 2, + createTemplateOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: ['ACCOUNT'], + globalActionAuth: [], + }), + }, + }); + + const directTemplatePath = formatDirectTemplatePath( + directTemplateWithAuth.directLink?.token || '', + ); + + await page.goto(directTemplatePath); + + await expect(page.getByText('Authentication required')).toBeVisible(); + + await apiSignin({ + page, + email: user.email, + }); + + await page.goto(directTemplatePath); + + await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible(); + await page.getByRole('button', { name: 'Complete' }).click(); + await expect(page.getByLabel('Your Email')).not.toBeVisible(); + + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(/\/sign/); + await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible(); }); test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ page }) => { @@ -175,6 +225,9 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail()); await page.getByRole('button', { name: 'Continue' }).click(); + + await expect(page.getByText('Next Recipient Name')).not.toBeVisible(); + await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Sign' }).click(); await page.waitForURL(/\/sign/); @@ -183,3 +236,173 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p // Add a longer waiting period to ensure document status is updated await page.waitForTimeout(3000); }); + +test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with next signer dictation', async ({ + page, +}) => { + const { team, owner, organisation } = await seedTeam({ + createTeamMembers: 1, + }); + + // Should be visible to team members. + const template = await seedDirectTemplate({ + title: 'Team direct template link 1', + userId: owner.id, + teamId: team.id, + }); + + await prisma.documentMeta.update({ + where: { + id: template.documentMetaId, + }, + data: { + allowDictateNextSigner: true, + signingOrder: DocumentSigningOrder.SEQUENTIAL, + }, + }); + + const originalName = 'Signer 2'; + const originalSecondSignerEmail = seedTestEmail(); + + // Add another signer + await prisma.recipient.create({ + data: { + signingOrder: 2, + envelopeId: template.id, + email: originalSecondSignerEmail, + name: originalName, + token: Math.random().toString().slice(2, 7), + role: RecipientRole.SIGNER, + }, + }); + + // Check that the direct template link is accessible. + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + + await page.waitForTimeout(100); + await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail()); + + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Complete' }).click(); + + await expect(page.getByText('Next Recipient Name')).toBeVisible(); + + const nextRecipientNameInputValue = await page.getByLabel('Next Recipient Name').inputValue(); + expect(nextRecipientNameInputValue).toBe(originalName); + + const nextRecipientEmailInputValue = await page.getByLabel('Next Recipient Email').inputValue(); + expect(nextRecipientEmailInputValue).toBe(originalSecondSignerEmail); + + const newName = 'Hello'; + const newSecondSignerEmail = seedTestEmail(); + + await page.getByLabel('Next Recipient Email').fill(newSecondSignerEmail); + await page.getByLabel('Next Recipient Name').fill(newName); + + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(/\/sign/); + await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible(); + + const createdEnvelopeRecipients = await prisma.recipient.findMany({ + where: { + envelope: { + templateId: mapSecondaryIdToTemplateId(template.secondaryId), + }, + }, + }); + + const updatedSecondRecipient = createdEnvelopeRecipients.find( + (recipient) => recipient.signingOrder === 2, + ); + + expect(updatedSecondRecipient?.name).toBe(newName); + expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail); +}); + +test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with next signer dictation', async ({ + page, +}) => { + const { team, owner, organisation } = await seedTeam({ + createTeamMembers: 1, + }); + + // Should be visible to team members. + const template = await seedDirectTemplate({ + title: 'Team direct template link 1', + userId: owner.id, + teamId: team.id, + internalVersion: 2, + }); + + await prisma.documentMeta.update({ + where: { + id: template.documentMetaId, + }, + data: { + allowDictateNextSigner: true, + signingOrder: DocumentSigningOrder.SEQUENTIAL, + }, + }); + + const originalName = 'Signer 2'; + const originalSecondSignerEmail = seedTestEmail(); + + // Add another signer + await prisma.recipient.create({ + data: { + signingOrder: 2, + envelopeId: template.id, + email: originalSecondSignerEmail, + name: originalName, + token: Math.random().toString().slice(2, 7), + role: RecipientRole.SIGNER, + }, + }); + + // Check that the direct template link is accessible. + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + await expect(page.getByRole('heading', { name: 'Team direct template link 1' })).toBeVisible(); + await page.waitForTimeout(100); + + await page.getByRole('button', { name: 'Complete' }).click(); + + const currentName = 'John Doe'; + const currentEmail = seedTestEmail(); + + await page.getByPlaceholder('Enter Your Name').fill(currentName); + await page.getByPlaceholder('Enter Your Email').fill(currentEmail); + + await expect(page.getByText('Next Recipient Name')).toBeVisible(); + + const nextRecipientNameInputValue = await page.getByLabel('Next Recipient Name').inputValue(); + expect(nextRecipientNameInputValue).toBe(originalName); + + const nextRecipientEmailInputValue = await page.getByLabel('Next Recipient Email').inputValue(); + expect(nextRecipientEmailInputValue).toBe(originalSecondSignerEmail); + + const newName = 'Hello'; + const newSecondSignerEmail = seedTestEmail(); + + await page.getByLabel('Next Recipient Email').fill(newSecondSignerEmail); + await page.getByLabel('Next Recipient Name').fill(newName); + + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(/\/sign/); + await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible(); + + const createdEnvelopeRecipients = await prisma.recipient.findMany({ + where: { + envelope: { + templateId: mapSecondaryIdToTemplateId(template.secondaryId), + }, + }, + }); + + const updatedSecondRecipient = createdEnvelopeRecipients.find( + (recipient) => recipient.signingOrder === 2, + ); + + expect(updatedSecondRecipient?.name).toBe(newName); + expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail); +}); diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index 58612cf7f..4d47b4e48 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -1,7 +1,5 @@ -import { EnvelopeType, TeamMemberRole } from '@prisma/client'; import type { Prisma, User } from '@prisma/client'; -import { SigningStatus } from '@prisma/client'; -import { DocumentVisibility } from '@prisma/client'; +import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client'; import { DateTime } from 'luxon'; import { match } from 'ts-pattern'; @@ -215,13 +213,14 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { ], }; + const rootPageFilter = folderId === undefined ? { folderId: null } : {}; + let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = { type: EnvelopeType.DOCUMENT, userId: userIdWhereClause, createdAt, teamId, deletedAt: null, - folderId, }; let notSignedCountsGroupByArgs = null; @@ -265,8 +264,16 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { ownerCountsWhereInput = { ...ownerCountsWhereInput, - ...visibilityFiltersWhereInput, - ...searchFilter, + AND: [ + ...(Array.isArray(visibilityFiltersWhereInput.AND) + ? visibilityFiltersWhereInput.AND + : visibilityFiltersWhereInput.AND + ? [visibilityFiltersWhereInput.AND] + : []), + searchFilter, + rootPageFilter, + folderId ? { folderId } : {}, + ], }; if (teamEmail) { @@ -285,6 +292,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { }, ], deletedAt: null, + AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], }; notSignedCountsGroupByArgs = { @@ -296,7 +304,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { type: EnvelopeType.DOCUMENT, userId: userIdWhereClause, createdAt, - folderId, status: ExtendedDocumentStatus.PENDING, recipients: { some: { @@ -306,6 +313,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { }, }, deletedAt: null, + AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], }, } satisfies Prisma.EnvelopeGroupByArgs; @@ -318,7 +326,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { type: EnvelopeType.DOCUMENT, userId: userIdWhereClause, createdAt, - folderId, OR: [ { status: ExtendedDocumentStatus.PENDING, @@ -342,6 +349,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { }, }, ], + AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], }, } satisfies Prisma.EnvelopeGroupByArgs; } diff --git a/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts b/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts index 3045aa068..e9547885c 100644 --- a/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts +++ b/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts @@ -1,10 +1,11 @@ import { DocumentStatus, EnvelopeType } from '@prisma/client'; +import { match } from 'ts-pattern'; import { prisma } from '@documenso/prisma'; import { AppError, AppErrorCode } from '../../errors/app-error'; -import type { TDocumentAuthMethods } from '../../types/document-auth'; -import { isRecipientAuthorized } from '../document/is-recipient-authorized'; +import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth'; +import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { getTeamSettings } from '../team/get-team-settings'; import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing'; import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing'; @@ -98,14 +99,28 @@ export const getEnvelopeForDirectTemplateSigning = async ({ }); } - const documentAccessValid = await isRecipientAuthorized({ - type: 'ACCESS', - documentAuthOptions: envelope.authOptions, - recipient, - userId, - authOptions: accessAuth, + // Currently not using this since for direct templates "User" access means they just need to be + // logged in. + // const documentAccessValid = await isRecipientAuthorized({ + // type: 'ACCESS', + // documentAuthOptions: envelope.authOptions, + // recipient, + // userId, + // authOptions: accessAuth, + // }); + + const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ + documentAuth: envelope.authOptions, }); + // Ensure typesafety when we add more options. + const documentAccessValid = derivedRecipientAccessAuth.every((auth) => + match(auth) + .with(DocumentAccessAuth.ACCOUNT, () => Boolean(userId)) + .with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) + .exhaustive(), + ); + if (!documentAccessValid) { throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Invalid access values', diff --git a/packages/lib/server-only/envelope/get-envelope-required-access-data.ts b/packages/lib/server-only/envelope/get-envelope-required-access-data.ts index b543f1042..c5aef69ff 100644 --- a/packages/lib/server-only/envelope/get-envelope-required-access-data.ts +++ b/packages/lib/server-only/envelope/get-envelope-required-access-data.ts @@ -54,54 +54,3 @@ export const getEnvelopeRequiredAccessData = async ({ token }: { token: string } recipientHasAccount: Boolean(recipientUserAccount), } as const; }; - -export const getEnvelopeDirectTemplateRequiredAccessData = async ({ token }: { token: string }) => { - const envelope = await prisma.envelope.findFirst({ - where: { - type: EnvelopeType.TEMPLATE, - directLink: { - enabled: true, - token, - }, - status: DocumentStatus.DRAFT, - }, - include: { - recipients: { - where: { - token, - }, - }, - directLink: true, - }, - }); - - if (!envelope) { - throw new AppError(AppErrorCode.NOT_FOUND, { - message: 'Envelope not found', - }); - } - - const recipient = envelope.recipients.find( - (r) => r.id === envelope.directLink?.directTemplateRecipientId, - ); - - if (!recipient) { - throw new AppError(AppErrorCode.NOT_FOUND, { - message: 'Recipient not found', - }); - } - - const recipientUserAccount = await prisma.user.findFirst({ - where: { - email: recipient.email.toLowerCase(), - }, - select: { - id: true, - }, - }); - - return { - recipientEmail: recipient.email, - recipientHasAccount: Boolean(recipientUserAccount), - } as const; -}; 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 f2274c09f..bda55ee7a 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 @@ -3,6 +3,7 @@ import { createElement } from 'react'; import { msg } from '@lingui/core/macro'; import type { Field, Signature } from '@prisma/client'; import { + DocumentSigningOrder, DocumentSource, DocumentStatus, EnvelopeType, @@ -26,7 +27,7 @@ import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/f import { getI18nInstance } from '../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { AppError, AppErrorCode } from '../../errors/app-error'; -import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs'; import type { TRecipientActionAuthTypes } from '../../types/document-auth'; import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth'; import { ZFieldMetaSchema } from '../../types/field-meta'; @@ -68,6 +69,10 @@ export type CreateDocumentFromDirectTemplateOptions = { name?: string; email: string; }; + nextSigner?: { + email: string; + name: string; + }; }; type CreatedDirectRecipientField = { @@ -92,6 +97,7 @@ export const createDocumentFromDirectTemplate = async ({ directTemplateExternalId, signedFieldValues, templateUpdatedAt, + nextSigner, requestMetadata, user, }: CreateDocumentFromDirectTemplateOptions): Promise => { @@ -128,6 +134,17 @@ export const createDocumentFromDirectTemplate = async ({ throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' }); } + if ( + nextSigner && + (!directTemplateEnvelope.documentMeta?.allowDictateNextSigner || + directTemplateEnvelope.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL) + ) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: + 'You need to enable allowDictateNextSigner and sequential signing to dictate the next signer', + }); + } + const directTemplateEnvelopeLegacyId = mapSecondaryIdToTemplateId( directTemplateEnvelope.secondaryId, ); @@ -630,6 +647,77 @@ export const createDocumentFromDirectTemplate = async ({ }), ]; + if (nextSigner) { + const pendingRecipients = await tx.recipient.findMany({ + select: { + id: true, + signingOrder: true, + name: true, + email: true, + role: true, + }, + where: { + envelopeId: createdEnvelope.id, + signingStatus: { + not: SigningStatus.SIGNED, + }, + role: { + not: RecipientRole.CC, + }, + }, + // Composite sort so our next recipient is always the one with the lowest signing order or id + // if there is a tie. + orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }], + }); + + const nextRecipient = pendingRecipients[0]; + + if (nextRecipient) { + auditLogsToCreate.push( + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED, + envelopeId: createdEnvelope.id, + user: { + name: user?.name || directRecipientName || '', + email: user?.email || directRecipientEmail, + }, + metadata: requestMetadata, + data: { + recipientEmail: nextRecipient.email, + recipientName: nextRecipient.name, + recipientId: nextRecipient.id, + recipientRole: nextRecipient.role, + changes: [ + { + type: RECIPIENT_DIFF_TYPE.NAME, + from: nextRecipient.name, + to: nextSigner.name, + }, + { + type: RECIPIENT_DIFF_TYPE.EMAIL, + from: nextRecipient.email, + to: nextSigner.email, + }, + ], + }, + }), + ); + + await tx.recipient.update({ + where: { id: nextRecipient.id }, + data: { + sendStatus: SendStatus.SENT, + ...(nextSigner && documentMeta?.allowDictateNextSigner + ? { + name: nextSigner.name, + email: nextSigner.email, + } + : {}), + }, + }); + } + } + await tx.documentAuditLog.createMany({ data: auditLogsToCreate, }); diff --git a/packages/prisma/seed/templates.ts b/packages/prisma/seed/templates.ts index 3ea381abc..c83dfa1d9 100644 --- a/packages/prisma/seed/templates.ts +++ b/packages/prisma/seed/templates.ts @@ -28,6 +28,7 @@ type SeedTemplateOptions = { title?: string; userId: number; teamId: number; + internalVersion?: 1 | 2; createTemplateOptions?: Partial; }; @@ -167,7 +168,7 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => { data: { id: prefixedId('envelope'), secondaryId: templateId.formattedTemplateId, - internalVersion: 1, + internalVersion: options.internalVersion ?? 1, type: EnvelopeType.TEMPLATE, title, envelopeItems: { @@ -184,6 +185,7 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => { teamId, recipients: { create: { + signingOrder: 1, email: DIRECT_TEMPLATE_RECIPIENT_EMAIL, name: DIRECT_TEMPLATE_RECIPIENT_NAME, token: Math.random().toString().slice(2, 7), diff --git a/packages/trpc/server/admin-router/get-admin-organisation.ts b/packages/trpc/server/admin-router/get-admin-organisation.ts index 8990e0521..31fc09022 100644 --- a/packages/trpc/server/admin-router/get-admin-organisation.ts +++ b/packages/trpc/server/admin-router/get-admin-organisation.ts @@ -39,6 +39,11 @@ export const getAdminOrganisation = async ({ organisationId }: GetOrganisationOp teams: true, members: { include: { + organisationGroupMembers: { + include: { + group: true, + }, + }, user: { select: { id: true, diff --git a/packages/trpc/server/admin-router/get-admin-organisation.types.ts b/packages/trpc/server/admin-router/get-admin-organisation.types.ts index 8491003f9..442999c83 100644 --- a/packages/trpc/server/admin-router/get-admin-organisation.types.ts +++ b/packages/trpc/server/admin-router/get-admin-organisation.types.ts @@ -3,6 +3,8 @@ import { z } from 'zod'; import { ZOrganisationSchema } from '@documenso/lib/types/organisation'; import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema'; import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema'; +import OrganisationGroupMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupMemberSchema'; +import OrganisationGroupSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupSchema'; import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema'; import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema'; import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema'; @@ -30,6 +32,18 @@ export const ZGetAdminOrganisationResponseSchema = ZOrganisationSchema.extend({ email: true, name: true, }), + organisationGroupMembers: z.array( + OrganisationGroupMemberSchema.pick({ + id: true, + groupId: true, + }).extend({ + group: OrganisationGroupSchema.pick({ + id: true, + type: true, + organisationRole: true, + }), + }), + ), }).array(), subscription: SubscriptionSchema.nullable(), organisationClaim: OrganisationClaimSchema, diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index c3d2c9b81..526f04980 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -17,6 +17,7 @@ import { promoteMemberToOwnerRoute } from './promote-member-to-owner'; import { resealDocumentRoute } from './reseal-document'; import { resetTwoFactorRoute } from './reset-two-factor-authentication'; import { updateAdminOrganisationRoute } from './update-admin-organisation'; +import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role'; import { updateRecipientRoute } from './update-recipient'; import { updateSiteSettingRoute } from './update-site-setting'; import { updateSubscriptionClaimRoute } from './update-subscription-claim'; @@ -31,6 +32,7 @@ export const adminRouter = router({ }, organisationMember: { promoteToOwner: promoteMemberToOwnerRoute, + updateRole: updateOrganisationMemberRoleRoute, }, claims: { find: findSubscriptionClaimsRoute, diff --git a/packages/trpc/server/admin-router/update-organisation-member-role.ts b/packages/trpc/server/admin-router/update-organisation-member-role.ts new file mode 100644 index 000000000..bfb12256b --- /dev/null +++ b/packages/trpc/server/admin-router/update-organisation-member-role.ts @@ -0,0 +1,220 @@ +import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { generateDatabaseId } from '@documenso/lib/universal/id'; +import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; + +import { adminProcedure } from '../trpc'; +import { + ZUpdateOrganisationMemberRoleRequestSchema, + ZUpdateOrganisationMemberRoleResponseSchema, +} from './update-organisation-member-role.types'; + +/** + * Admin mutation to update organisation member role or transfer ownership. + * + * This mutation handles two scenarios: + * 1. When role='OWNER': Transfers organisation ownership and promotes to ADMIN + * 2. When role=ADMIN/MANAGER/MEMBER: Updates group membership + * + * Admin privileges bypass normal hierarchy restrictions. + */ +export const updateOrganisationMemberRoleRoute = adminProcedure + .input(ZUpdateOrganisationMemberRoleRequestSchema) + .output(ZUpdateOrganisationMemberRoleResponseSchema) + .mutation(async ({ input, ctx }) => { + const { organisationId, userId, role } = input; + + ctx.logger.info({ + input: { + organisationId, + userId, + role, + }, + }); + + const organisation = await prisma.organisation.findUnique({ + where: { + id: organisationId, + }, + include: { + groups: { + where: { + type: OrganisationGroupType.INTERNAL_ORGANISATION, + }, + }, + members: { + where: { + userId, + }, + include: { + organisationGroupMembers: { + include: { + group: true, + }, + }, + }, + }, + }, + }); + + if (!organisation) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Organisation not found', + }); + } + + const [member] = organisation.members; + + if (!member) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'User is not a member of this organisation', + }); + } + + const currentOrganisationRole = getHighestOrganisationRoleInGroup( + member.organisationGroupMembers.flatMap((member) => member.group), + ); + + if (role === 'OWNER') { + if (organisation.ownerUserId === userId) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'User is already the owner of this organisation', + }); + } + + const currentMemberGroup = organisation.groups.find( + (group) => group.organisationRole === currentOrganisationRole, + ); + + const adminGroup = organisation.groups.find( + (group) => group.organisationRole === OrganisationMemberRole.ADMIN, + ); + + if (!currentMemberGroup) { + ctx.logger.error({ + message: '[CRITICAL]: Missing internal group', + organisationId, + userId, + role: currentOrganisationRole, + }); + + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Current member group not found', + }); + } + + if (!adminGroup) { + ctx.logger.error({ + message: '[CRITICAL]: Missing internal group', + organisationId, + userId, + targetRole: 'ADMIN', + }); + + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Admin group not found', + }); + } + + await prisma.$transaction(async (tx) => { + await tx.organisation.update({ + where: { + id: organisationId, + }, + data: { + ownerUserId: userId, + }, + }); + + if (currentOrganisationRole !== OrganisationMemberRole.ADMIN) { + await tx.organisationGroupMember.delete({ + where: { + organisationMemberId_groupId: { + organisationMemberId: member.id, + groupId: currentMemberGroup.id, + }, + }, + }); + + await tx.organisationGroupMember.create({ + data: { + id: generateDatabaseId('group_member'), + organisationMemberId: member.id, + groupId: adminGroup.id, + }, + }); + } + }); + + return; + } + + const targetRole = role as OrganisationMemberRole; + + if (currentOrganisationRole === targetRole) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'User already has this role', + }); + } + + if (userId === organisation.ownerUserId && targetRole !== OrganisationMemberRole.ADMIN) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Organisation owner must be an admin. Transfer ownership first.', + }); + } + + const currentMemberGroup = organisation.groups.find( + (group) => group.organisationRole === currentOrganisationRole, + ); + + const newMemberGroup = organisation.groups.find( + (group) => group.organisationRole === targetRole, + ); + + if (!currentMemberGroup) { + ctx.logger.error({ + message: '[CRITICAL]: Missing internal group', + organisationId, + userId, + role: currentOrganisationRole, + }); + + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'Current member group not found', + }); + } + + if (!newMemberGroup) { + ctx.logger.error({ + message: '[CRITICAL]: Missing internal group', + organisationId, + userId, + targetRole, + }); + + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: 'New member group not found', + }); + } + + await prisma.$transaction(async (tx) => { + await tx.organisationGroupMember.delete({ + where: { + organisationMemberId_groupId: { + organisationMemberId: member.id, + groupId: currentMemberGroup.id, + }, + }, + }); + + await tx.organisationGroupMember.create({ + data: { + id: generateDatabaseId('group_member'), + organisationMemberId: member.id, + groupId: newMemberGroup.id, + }, + }); + }); + }); diff --git a/packages/trpc/server/admin-router/update-organisation-member-role.types.ts b/packages/trpc/server/admin-router/update-organisation-member-role.types.ts new file mode 100644 index 000000000..9e8adf7db --- /dev/null +++ b/packages/trpc/server/admin-router/update-organisation-member-role.types.ts @@ -0,0 +1,30 @@ +import { OrganisationMemberRole } from '@prisma/client'; +import { z } from 'zod'; + +/** + * Admin-only role selection that includes OWNER as a special case. + * OWNER is not a database role but triggers ownership transfer. + */ +export const ZAdminRoleSelection = z.enum([ + 'OWNER', + OrganisationMemberRole.ADMIN, + OrganisationMemberRole.MANAGER, + OrganisationMemberRole.MEMBER, +]); + +export type TAdminRoleSelection = z.infer; + +export const ZUpdateOrganisationMemberRoleRequestSchema = z.object({ + organisationId: z.string().min(1), + userId: z.number().min(1), + role: ZAdminRoleSelection, +}); + +export const ZUpdateOrganisationMemberRoleResponseSchema = z.void(); + +export type TUpdateOrganisationMemberRoleRequest = z.infer< + typeof ZUpdateOrganisationMemberRoleRequestSchema +>; +export type TUpdateOrganisationMemberRoleResponse = z.infer< + typeof ZUpdateOrganisationMemberRoleResponseSchema +>; diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 3cefb136f..0ee4669d4 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -519,6 +519,7 @@ export const templateRouter = router({ directTemplateExternalId, signedFieldValues, templateUpdatedAt, + nextSigner, } = input; ctx.logger.info({ @@ -541,6 +542,7 @@ export const templateRouter = router({ email: ctx.user.email, } : undefined, + nextSigner, requestMetadata: ctx.metadata, }); }), diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 0783ef232..a199f1c1a 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -90,6 +90,12 @@ export const ZCreateDocumentFromDirectTemplateRequestSchema = z.object({ directTemplateExternalId: z.string().optional(), signedFieldValues: z.array(ZSignFieldWithTokenMutationSchema), templateUpdatedAt: z.date(), + nextSigner: z + .object({ + email: z.string().email().max(254), + name: z.string().min(1).max(255), + }) + .optional(), }); export const ZCreateDocumentFromTemplateRequestSchema = z.object({