feat: add delegate document ownership option (#2272)

When using an API key created in a team context, the
documents/templates’ owner always defaults to the team API token
creator, rather than the actual uploader.

For example, John creates the API key for the team "Lawyers". Tom and
Maria use the API key to upload documents. All the uploaded documents
are attributed to John.

This makes it impossible to see who actually uploaded a document.

The new feature allows users to enable document ownership delegation
from the organization/team settings.
This commit is contained in:
Catalin Pit
2025-12-23 13:08:54 +02:00
committed by GitHub
parent 1e585e06e6
commit baa2c51123
24 changed files with 693 additions and 484 deletions
@@ -81,6 +81,7 @@ export type CreateEnvelopeOptions = {
globalActionAuth?: TDocumentActionAuthTypes[];
recipients?: CreateEnvelopeRecipientOptions[];
folderId?: string;
delegatedDocumentOwner?: string;
};
attachments?: Array<{
label: string;
@@ -114,6 +115,7 @@ export const createEnvelope = async ({
publicTitle,
publicDescription,
visibility: visibilityOverride,
delegatedDocumentOwner,
} = data;
const team = await prisma.team.findFirst({
@@ -256,6 +258,43 @@ export const createEnvelope = async ({
? await incrementDocumentId().then((v) => v.formattedDocumentId)
: await incrementTemplateId().then((v) => v.formattedTemplateId);
const getValidatedDelegatedOwner = async () => {
if (
!settings.delegateDocumentOwnership ||
!delegatedDocumentOwner ||
requestMetadata.source === 'app'
) {
return null;
}
const delegatedOwner = await prisma.user.findFirst({
where: {
email: delegatedDocumentOwner,
},
});
if (!delegatedOwner) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Delegated document owner must be a member of the team',
});
}
const isTeamMember = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId: delegatedOwner.id }),
});
if (!isTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Delegated document owner must be a member of the team',
});
}
return delegatedOwner;
};
const delegatedOwner = await getValidatedDelegatedOwner();
const envelopeOwnerId = delegatedOwner?.id ?? userId;
return await prisma.$transaction(async (tx) => {
const envelope = await tx.envelope.create({
data: {
@@ -285,7 +324,7 @@ export const createEnvelope = async ({
})),
},
},
userId,
userId: envelopeOwnerId,
teamId,
authOptions,
visibility,
@@ -393,6 +432,9 @@ export const createEnvelope = async ({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
envelopeId: envelope.id,
user: {
id: envelopeOwnerId,
},
metadata: requestMetadata,
data: {
title,
@@ -403,6 +445,25 @@ export const createEnvelope = async ({
}),
});
// Create audit log for delegated owner if validation passed
if (delegatedOwner) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED,
envelopeId: envelope.id,
user: {
id: userId,
},
metadata: requestMetadata,
data: {
delegatedOwnerName: delegatedOwner.name,
delegatedOwnerEmail: delegatedOwner.email,
teamName: team.name,
},
}),
});
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
+14
View File
@@ -44,6 +44,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
'DOCUMENT_DELEGATED_OWNER_CREATED', // When the document delegated owner is created.
// ACCESS AUTH 2FA events.
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
@@ -681,6 +682,18 @@ export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({
}),
});
/**
* Event: Document delegated owner created.
*/
export const ZDocumentAuditLogEventDocumentDelegatedOwnerCreatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED),
data: z.object({
delegatedOwnerName: z.string().nullable(),
delegatedOwnerEmail: z.string(),
teamName: z.string(),
}),
});
export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(),
createdAt: z.date(),
@@ -701,6 +714,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentCreatedSchema,
ZDocumentAuditLogEventDocumentDeletedSchema,
ZDocumentAuditLogEventDocumentMovedToTeamSchema,
ZDocumentAuditLogEventDocumentDelegatedOwnerCreatedSchema,
ZDocumentAuditLogEventDocumentFieldsAutoInsertedSchema,
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
@@ -530,6 +530,13 @@ export const formatDocumentAuditLogAction = (
anonymous: msg`Envelope item deleted`,
identified: msg`${prefix} deleted an envelope item with title ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED }, ({ data }) => ({
anonymous: msg({
message: `Document ownership delegated`,
context: `Audit log format`,
}),
identified: msg`The document ownership was delegated to ${data.delegatedOwnerName || data.delegatedOwnerEmail} on behalf of ${data.teamName}`,
}))
.exhaustive();
return {
+1
View File
@@ -117,6 +117,7 @@ export const generateDefaultOrganisationSettings = (): Omit<
documentLanguage: 'en',
documentTimezone: null, // Null means local timezone.
documentDateFormat: DEFAULT_DOCUMENT_DATE_FORMAT,
delegateDocumentOwnership: false,
includeSenderDetails: true,
includeSigningCertificate: true,
+1
View File
@@ -184,6 +184,7 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
documentLanguage: null,
documentTimezone: null,
documentDateFormat: null,
delegateDocumentOwnership: null,
includeSenderDetails: null,
includeSigningCertificate: null,
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "delegateDocumentOwnership" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "delegateDocumentOwnership" BOOLEAN;
+6 -4
View File
@@ -818,6 +818,7 @@ model OrganisationGlobalSettings {
includeAuditLog Boolean @default(false)
documentTimezone String? // Nullable to allow using local timezones if not set.
documentDateFormat String @default("yyyy-MM-dd hh:mm a")
delegateDocumentOwnership Boolean @default(false)
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
@@ -844,10 +845,11 @@ model TeamGlobalSettings {
id String @id
team Team?
documentVisibility DocumentVisibility?
documentLanguage String?
documentTimezone String?
documentDateFormat String?
documentVisibility DocumentVisibility?
documentLanguage String?
documentTimezone String?
documentDateFormat String?
delegateDocumentOwnership Boolean?
includeSenderDetails Boolean?
includeSigningCertificate Boolean?
@@ -32,6 +32,7 @@ export const createEnvelopeRoute = authenticatedProcedure
folderId,
meta,
attachments,
delegatedDocumentOwner,
} = payload;
ctx.logger.info({
@@ -144,6 +145,7 @@ export const createEnvelopeRoute = authenticatedProcedure
recipients: recipientsToCreate,
folderId,
envelopeItems,
delegatedDocumentOwner,
},
attachments,
meta,
@@ -41,6 +41,11 @@ export const createEnvelopeMeta: TrpcRouteMeta = {
export const ZCreateEnvelopePayloadSchema = z.object({
title: ZDocumentTitleSchema,
type: z.nativeEnum(EnvelopeType),
delegatedDocumentOwner: z
.string()
.email()
.describe('The email of the user who will own the document.')
.optional(),
externalId: ZDocumentExternalIdSchema.optional(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
@@ -36,6 +36,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
delegateDocumentOwnership,
// Branding related settings.
brandingEnabled,
@@ -99,6 +100,10 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
const derivedDrawSignatureEnabled =
drawSignatureEnabled ?? organisation.organisationGlobalSettings.drawSignatureEnabled;
const derivedDelegateDocumentOwnership =
delegateDocumentOwnership ??
organisation.organisationGlobalSettings.delegateDocumentOwnership;
if (
derivedTypedSignatureEnabled === false &&
derivedUploadSignatureEnabled === false &&
@@ -140,6 +145,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
// Branding related settings.
brandingEnabled,
@@ -22,6 +22,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
typedSignatureEnabled: z.boolean().optional(),
uploadSignatureEnabled: z.boolean().optional(),
drawSignatureEnabled: z.boolean().optional(),
delegateDocumentOwnership: z.boolean().nullish(),
// Branding related settings.
brandingEnabled: z.boolean().optional(),
@@ -39,6 +39,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
delegateDocumentOwnership,
// Branding related settings.
brandingEnabled,
@@ -150,6 +151,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
delegateDocumentOwnership,
// Branding related settings.
brandingEnabled,
@@ -26,6 +26,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
typedSignatureEnabled: z.boolean().nullish(),
uploadSignatureEnabled: z.boolean().nullish(),
drawSignatureEnabled: z.boolean().nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
// Branding related settings.
brandingEnabled: z.boolean().nullish(),
@@ -626,7 +626,7 @@ export const AddFieldsFormPartial = ({
{selectedField && (
<div
className={cn(
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white text-muted-foreground ring-2 transition duration-200 [container-type:size]',
selectedSignerStyles?.base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
@@ -728,7 +728,7 @@ export const AddFieldsFormPartial = ({
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground font-signature flex items-center justify-center gap-x-1.5 text-lg font-normal',
'flex items-center justify-center gap-x-1.5 font-signature text-lg font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<Trans>Signature</Trans>
@@ -752,7 +752,7 @@ export const AddFieldsFormPartial = ({
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<Contact className="h-4 w-4" />
@@ -777,7 +777,7 @@ export const AddFieldsFormPartial = ({
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<Mail className="h-4 w-4" />
@@ -802,7 +802,7 @@ export const AddFieldsFormPartial = ({
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<User className="h-4 w-4" />
@@ -827,7 +827,7 @@ export const AddFieldsFormPartial = ({
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<CalendarDays className="h-4 w-4" />
@@ -852,7 +852,7 @@ export const AddFieldsFormPartial = ({
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<Type className="h-4 w-4" />
@@ -877,7 +877,7 @@ export const AddFieldsFormPartial = ({
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<Hash className="h-4 w-4" />
@@ -902,7 +902,7 @@ export const AddFieldsFormPartial = ({
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<Disc className="h-4 w-4" />
@@ -927,7 +927,7 @@ export const AddFieldsFormPartial = ({
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<CheckSquare className="h-4 w-4" />
@@ -953,7 +953,7 @@ export const AddFieldsFormPartial = ({
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<ChevronDown className="h-4 w-4" />
@@ -581,7 +581,7 @@ export const AddTemplateFieldsFormPartial = ({
{selectedField && (
<div
className={cn(
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white text-muted-foreground ring-2 transition duration-200 [container-type:size]',
selectedSignerStyles?.base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
@@ -650,7 +650,7 @@ export const AddTemplateFieldsFormPartial = ({
variant="outline"
role="combobox"
className={cn(
'bg-background text-muted-foreground hover:text-foreground mb-12 mt-2 justify-between font-normal',
'mb-12 mt-2 justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
selectedSignerStyles?.comboxBoxTrigger,
)}
>
@@ -681,7 +681,7 @@ export const AddTemplateFieldsFormPartial = ({
<CommandInput />
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
<span className="inline-block px-4 text-muted-foreground">
<Trans>No recipient matching this description was found.</Trans>
</span>
</CommandEmpty>
@@ -689,14 +689,14 @@ export const AddTemplateFieldsFormPartial = ({
{/* Note: This is duplicated in `add-fields.tsx` */}
{recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
<div className="mb-1 ml-2 mt-2 text-xs font-medium text-muted-foreground">
{_(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
</div>
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
className="px-4 pb-4 pt-2.5 text-center text-xs text-muted-foreground/80"
>
<Trans>No recipients with this role</Trans>
</div>
@@ -720,7 +720,7 @@ export const AddTemplateFieldsFormPartial = ({
}}
>
<span
className={cn('text-foreground/70 truncate', {
className={cn('truncate text-foreground/70', {
'text-foreground/80': recipient === selectedSigner,
})}
>
@@ -768,7 +768,7 @@ export const AddTemplateFieldsFormPartial = ({
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground font-signature flex items-center justify-center gap-x-1.5 text-lg font-normal',
'flex items-center justify-center gap-x-1.5 font-signature text-lg font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<Trans>Signature</Trans>
@@ -793,7 +793,7 @@ export const AddTemplateFieldsFormPartial = ({
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<Contact className="h-4 w-4" />
@@ -819,7 +819,7 @@ export const AddTemplateFieldsFormPartial = ({
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<Mail className="h-4 w-4" />
@@ -845,7 +845,7 @@ export const AddTemplateFieldsFormPartial = ({
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<User className="h-4 w-4" />
@@ -871,7 +871,7 @@ export const AddTemplateFieldsFormPartial = ({
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<CalendarDays className="h-4 w-4" />
@@ -897,7 +897,7 @@ export const AddTemplateFieldsFormPartial = ({
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<Type className="h-4 w-4" />
@@ -923,7 +923,7 @@ export const AddTemplateFieldsFormPartial = ({
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<Hash className="h-4 w-4" />
@@ -949,7 +949,7 @@ export const AddTemplateFieldsFormPartial = ({
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<Disc className="h-4 w-4" />
@@ -975,7 +975,7 @@ export const AddTemplateFieldsFormPartial = ({
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<CheckSquare className="h-4 w-4" />
@@ -1002,7 +1002,7 @@ export const AddTemplateFieldsFormPartial = ({
<CardContent className="p-4">
<p
className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
)}
>
<ChevronDown className="h-4 w-4" />