Compare commits

..

2 Commits

Author SHA1 Message Date
Catalin Pit 08e5acd0b2 chore: improve document preference form's default settings and reset functionality 2026-06-26 14:25:26 +03:00
Catalin Pit 1b55b3f7ea feat: add DocumentPreferencesResetDialog component 2026-06-26 14:21:44 +03:00
6 changed files with 199 additions and 310 deletions
@@ -0,0 +1,144 @@
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type DocumentPreferencesResetDialogProps = {
disabled?: boolean;
isSubmitting: boolean;
onReset: () => Promise<void>;
showAiFeatures?: boolean;
showDocumentVisibility?: boolean;
showIncludeSenderDetails?: boolean;
trigger?: React.ReactNode;
};
export const DocumentPreferencesResetDialog = ({
disabled = false,
isSubmitting,
onReset,
showAiFeatures = false,
showDocumentVisibility = false,
showIncludeSenderDetails = false,
trigger,
}: DocumentPreferencesResetDialogProps) => {
const [open, setOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const isLoading = isSubmitting || isResetting;
const handleResetToDefaults = async () => {
setIsResetting(true);
try {
await onReset();
setOpen(false);
} finally {
setIsResetting(false);
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive" type="button" disabled={disabled || isLoading}>
<Trans>Reset</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Reset document preferences</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
This will reset all document preferences to their default values and save the changes immediately.
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<AlertDescription>
<p>
<Trans>Once confirmed, the following will be reset:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
{showDocumentVisibility && (
<li>
<Trans>Default document visibility</Trans>
</li>
)}
<li>
<Trans>Default document language</Trans>
</li>
<li>
<Trans>Default date format</Trans>
</li>
<li>
<Trans>Default time zone</Trans>
</li>
<li>
<Trans>Default signature settings</Trans>
</li>
{showIncludeSenderDetails && (
<li>
<Trans>Send on behalf of team</Trans>
</li>
)}
<li>
<Trans>Include the signing certificate in the document</Trans>
</li>
<li>
<Trans>Include the audit logs in the document</Trans>
</li>
<li>
<Trans>Default recipients</Trans>
</li>
<li>
<Trans>Delegate document ownership</Trans>
</li>
<li>
<Trans>Default envelope expiration</Trans>
</li>
<li>
<Trans>Default signing reminders</Trans>
</li>
{showAiFeatures && (
<li>
<Trans>AI features</Trans>
</li>
)}
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isLoading}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
<Trans>Reset to defaults</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -11,10 +11,10 @@ import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } fr
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { type TDocumentMetaDateFormat, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { type TDocumentMetaDateFormat, ZDocumentMetaDateFormatSchema } from '@documenso/lib/types/document-meta';
import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
@@ -38,11 +38,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg, t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client';
import { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
@@ -93,6 +93,26 @@ export type DocumentPreferencesFormProps = {
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
};
const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPreferencesFormSchema => {
const parsedDocumentDateFormat = ZDocumentMetaDateFormatSchema.safeParse(settings.documentDateFormat);
return {
documentVisibility: settings.documentVisibility,
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
documentTimezone: settings.documentTimezone,
documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
};
};
export const DocumentPreferencesForm = ({
settings,
onFormSubmit,
@@ -113,7 +133,7 @@ export const DocumentPreferencesForm = ({
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
documentTimezone: z.string().nullable(),
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
includeSenderDetails: z.boolean().nullable(),
includeSigningCertificate: z.boolean().nullable(),
includeAuditLog: z.boolean().nullable(),
@@ -127,26 +147,27 @@ export const DocumentPreferencesForm = ({
reminderSettings: ZEnvelopeReminderSettings.nullable(),
});
const defaultValues = getDocumentPreferencesFormValues(settings);
const defaultSettings = canInherit ? generateDefaultTeamSettings() : generateDefaultOrganisationSettings();
const baseResetValues = getDocumentPreferencesFormValues(defaultSettings);
const resetValues = {
...baseResetValues,
aiFeaturesEnabled: isAiFeaturesConfigured ? baseResetValues.aiFeaturesEnabled : defaultValues.aiFeaturesEnabled,
};
const form = useForm<TDocumentPreferencesFormSchema>({
defaultValues: {
documentVisibility: settings.documentVisibility,
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
documentTimezone: settings.documentTimezone,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
},
defaultValues,
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
const currentValues = form.watch();
const isResetDisabled = !form.formState.isDirty && JSON.stringify(currentValues) === JSON.stringify(resetValues);
const handleResetToDefaults = async () => {
await onFormSubmit(resetValues);
form.reset(resetValues);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
@@ -760,6 +781,14 @@ export const DocumentPreferencesForm = ({
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
<DocumentPreferencesResetDialog
disabled={isResetDisabled}
isSubmitting={form.formState.isSubmitting}
onReset={handleResetToDefaults}
showAiFeatures={isAiFeaturesConfigured}
showDocumentVisibility={!isPersonalLayoutMode}
showIncludeSenderDetails={!isPersonalLayoutMode && !isPersonalOrganisation}
/>
</div>
</fieldset>
</form>
+2 -2
View File
@@ -146,7 +146,7 @@ export const downloadRoute = new Hono<HonoEnv>()
* Requires API key authentication via Authorization header.
*/
.get(
'/envelope/:envelopeId/audit-log/download',
'/envelope/:envelopeId/audit-log/pdf',
sValidator('param', ZDownloadEnvelopeAuditLogPdfRequestParamsSchema),
async (c) => {
const logger = c.get('logger');
@@ -220,7 +220,7 @@ export const downloadRoute = new Hono<HonoEnv>()
* Requires API key authentication via Authorization header.
*/
.get(
'/envelope/:envelopeId/certificate/download',
'/envelope/:envelopeId/certificate/pdf',
sValidator('param', ZDownloadEnvelopeCertificatePdfRequestParamsSchema),
async (c) => {
const logger = c.get('logger');
@@ -1,284 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { hashString } from '@documenso/lib/server-only/auth/hash';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { alphaid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedCompletedDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
/**
* Create an API token directly, bypassing the role check in `createApiToken`.
*
* This simulates a token that was minted while the user had permission, and which
* survives a later downgrade to a lower team role (e.g. MEMBER). Such a token must
* still respect document visibility at request time.
*/
const seedApiTokenForUser = async ({
userId,
teamId,
tokenName,
}: {
userId: number;
teamId: number;
tokenName: string;
}) => {
const token = `api_${alphaid(16)}`;
await prisma.apiToken.create({
data: {
name: tokenName,
token: hashString(token),
expires: null,
userId,
teamId,
},
});
return { token };
};
test.describe.configure({
mode: 'parallel',
});
const downloadAuditLogPdf = (request: APIRequestContext, envelopeId: string, authToken?: string) => {
return request.get(`${API_BASE_URL}/envelope/${envelopeId}/audit-log/download`, {
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
});
};
const downloadCertificatePdf = (request: APIRequestContext, envelopeId: string, authToken?: string) => {
return request.get(`${API_BASE_URL}/envelope/${envelopeId}/certificate/download`, {
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
});
};
test.describe('Envelope certificate / audit log PDF download API V2 - access control', () => {
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
test.beforeEach(async () => {
({ user: userA, team: teamA } = await seedUser());
({ token: tokenA } = await createApiToken({
userId: userA.id,
teamId: teamA.id,
tokenName: 'userA',
expiresIn: null,
}));
({ user: userB, team: teamB } = await seedUser());
({ token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
}));
});
test('should reject audit log download without an API token', async ({ request }) => {
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
const res = await downloadAuditLogPdf(request, document.id);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('should reject certificate download without an API token', async ({ request }) => {
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
const res = await downloadCertificatePdf(request, document.id);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('should reject audit log download from a user in a different team', async ({ request }) => {
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
const res = await downloadAuditLogPdf(request, document.id, tokenB);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should reject certificate download from a user in a different team', async ({ request }) => {
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
const res = await downloadCertificatePdf(request, document.id, tokenB);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should reject a disabled user downloading the audit log', async ({ request }) => {
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
await prisma.user.update({
where: { id: userA.id },
data: { disabled: true },
});
const res = await downloadAuditLogPdf(request, document.id, tokenA);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('should reject a disabled user downloading the certificate', async ({ request }) => {
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
await prisma.user.update({
where: { id: userA.id },
data: { disabled: true },
});
const res = await downloadCertificatePdf(request, document.id, tokenA);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('should return 404 for a non-existent envelope id', async ({ request }) => {
const auditLogRes = await downloadAuditLogPdf(request, 'envelope_doesnotexist', tokenA);
expect(auditLogRes.status()).toBe(404);
const certificateRes = await downloadCertificatePdf(request, 'envelope_doesnotexist', tokenA);
expect(certificateRes.status()).toBe(404);
});
});
test.describe('Envelope certificate / audit log PDF download API V2 - document visibility', () => {
test.describe.configure({
mode: 'parallel',
});
test('should hide an ADMIN-only document from a downgraded member (audit log)', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { token: memberToken } = await seedApiTokenForUser({
userId: member.id,
teamId: team.id,
tokenName: 'member-audit-log-token',
});
// ADMIN-visibility document owned by the team owner - a member must not see it.
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
const res = await downloadAuditLogPdf(request, document.id, memberToken);
// Visibility failure surfaces as not-found, matching the canonical access checks.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should hide an ADMIN-only document from a downgraded member (certificate)', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { token: memberToken } = await seedApiTokenForUser({
userId: member.id,
teamId: team.id,
tokenName: 'member-certificate-token',
});
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
const res = await downloadCertificatePdf(request, document.id, memberToken);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should hide a MANAGER_AND_ABOVE document from a downgraded member (audit log)', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { token: memberToken } = await seedApiTokenForUser({
userId: member.id,
teamId: team.id,
tokenName: 'member-manager-vis-token',
});
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
createDocumentOptions: { visibility: DocumentVisibility.MANAGER_AND_ABOVE },
});
const res = await downloadAuditLogPdf(request, document.id, memberToken);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should hide a MANAGER_AND_ABOVE document from a downgraded member (certificate)', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { token: memberToken } = await seedApiTokenForUser({
userId: member.id,
teamId: team.id,
tokenName: 'member-manager-vis-cert-token',
});
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
createDocumentOptions: { visibility: DocumentVisibility.MANAGER_AND_ABOVE },
});
const res = await downloadCertificatePdf(request, document.id, memberToken);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should hide an ADMIN-only document from a downgraded manager (certificate)', async ({ request }) => {
const { team, owner } = await seedTeam();
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
const { token: managerToken } = await seedApiTokenForUser({
userId: manager.id,
teamId: team.id,
tokenName: 'manager-admin-vis-cert-token',
});
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
const res = await downloadCertificatePdf(request, document.id, managerToken);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow a member to download an EVERYONE-visibility document (audit log)', async ({ request }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { token: memberToken } = await seedApiTokenForUser({
userId: member.id,
teamId: team.id,
tokenName: 'member-everyone-vis-token',
});
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
createDocumentOptions: { visibility: DocumentVisibility.EVERYONE },
});
const res = await downloadAuditLogPdf(request, document.id, memberToken);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/pdf');
});
});
@@ -5,7 +5,7 @@ import type { TrpcRouteMeta } from '../trpc';
export const downloadEnvelopeAuditLogPdfMeta: TrpcRouteMeta = {
openapi: {
method: 'GET',
path: '/envelope/{envelopeId}/audit-log/download',
path: '/envelope/{envelopeId}/audit-log/pdf',
summary: 'Download envelope audit log PDF',
description: 'Download the audit log for a document as a PDF.',
tags: ['Envelope'],
@@ -5,7 +5,7 @@ import type { TrpcRouteMeta } from '../trpc';
export const downloadEnvelopeCertificatePdfMeta: TrpcRouteMeta = {
openapi: {
method: 'GET',
path: '/envelope/{envelopeId}/certificate/download',
path: '/envelope/{envelopeId}/certificate/pdf',
summary: 'Download envelope certificate PDF',
description: 'Download the signing certificate for a completed document as a PDF.',
tags: ['Envelope'],