fix: ask for the details every sending address needs and preview the signature that is sent

This commit is contained in:
Linus Rath
2026-10-28 04:35:26 +01:00
parent d128503db5
commit daae45e379
2 changed files with 62 additions and 18 deletions
@@ -72,7 +72,7 @@ describe('identity signature editor and the organisation signature', () => {
describe('Your details', () => {
it('asks only for the fields the template uses, and stores them', () => {
setPolicy();
setPolicy({ locked: true });
useIdentityStore.setState({ identities: [ALICE] });
render(<SignatureDetailsSection />);
expect(screen.queryByTestId('signature-detail-title')).toBeNull();
@@ -81,6 +81,29 @@ describe('Your details', () => {
expect(screen.getByTestId('signature-details-preview').innerHTML).toContain('Alice<br>Phone: +49 30 2');
});
it('previews the signature that is sent: the own one while the organisation one is only the default', () => {
setPolicy();
useIdentityStore.setState({ identities: [ALICE] });
render(<SignatureDetailsSection />);
expect(screen.getByTestId('signature-detail-phone')).toBeInTheDocument();
expect(screen.getByTestId('signature-details-preview').innerHTML).toBe('<p class="text-xs font-medium text-muted-foreground mb-1">preview</p><div class="p-3 border border-border rounded-md bg-muted/40 text-sm break-words min-w-0"><div><b>Own</b></div></div>');
});
it('asks for what the signature of a group address uses too', () => {
const team = { id: 'id-team', name: 'Team', email: 'team@example.org', mayDelete: false } as Identity;
const policy: SettingsPolicy = {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY },
scopes: [{ id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: { organisation: { signature: { text: '{name}\nMobile: {mobile}' } } } }],
};
const subject = { email: 'alice@example.org', groups: ['team@example.org'] };
usePolicyStore.setState({ rawPolicy: policy, policy: { ...policy, scopes: undefined }, subject });
useIdentityStore.setState({ identities: [ALICE, team] });
render(<SignatureDetailsSection />);
expect(screen.getByTestId('signature-detail-mobile')).toBeInTheDocument();
expect(screen.queryByTestId('signature-detail-phone')).toBeNull();
});
it('shows nothing without an organisation signature', () => {
setPolicy(undefined, { html: '', text: '', fields: [] });
const { container } = render(<SignatureDetailsSection />);
@@ -1,10 +1,12 @@
'use client';
import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Input } from '@/components/ui/input';
import { SettingsSection, SettingItem } from './settings-section';
import { DEFAULT_ORGANISATION_POLICY, type SignatureField } from '@/lib/admin/organisation-policy';
import { signatureFieldsToAsk, signatureFieldsUsed } from '@/lib/org-signature';
import { DEFAULT_ORGANISATION_POLICY, SIGNATURE_FIELDS, type SignatureField } from '@/lib/admin/organisation-policy';
import { effectiveSignatureIdentity, signatureFieldsToAsk, signatureFieldsUsed } from '@/lib/org-signature';
import { policyForSender } from '@/lib/org/signature-state';
import { sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
import { getPlainTextSignature } from '@/lib/signature-utils';
import { useOrgSignatureSetup } from '@/hooks/use-org-signature';
@@ -19,33 +21,52 @@ const INPUT_PROPS: Record<SignatureField, { type: string; autoComplete: string }
mobile: { type: 'tel', autoComplete: 'tel' },
};
/** The details the organisation's signatures and disclaimers ask for, over every address the user sends from. */
function useFieldsToAsk(): SignatureField[] {
const identities = useIdentityStore((s) => s.identities);
const policy = usePolicyStore((s) => s.policy);
const rawPolicy = usePolicyStore((s) => s.rawPolicy);
const subject = usePolicyStore((s) => s.subject);
const resolveFor = usePolicyStore((s) => s.resolveFor);
return useMemo(() => {
const view = { policy, rawPolicy, subject, resolveFor };
// Every identity's policy (a group address may have its group's), and
// the account's own for an account without identities yet.
const policies = [policy, ...identities.map((identity) => policyForSender(view, identity.email))];
const used = new Set<SignatureField>();
for (const each of policies) {
const organisation = each.organisation ?? DEFAULT_ORGANISATION_POLICY;
for (const field of [...signatureFieldsToAsk(organisation.signature), ...signatureFieldsUsed(organisation.disclaimer)]) {
used.add(field);
}
}
return SIGNATURE_FIELDS.filter((field) => used.has(field));
}, [identities, policy, rawPolicy, subject, resolveFor]);
}
/**
* "Your details": what the organisation signature asks each user for
* (title, department, phone, mobile), filled in once and used for every
* address. Shows only the fields the admin's templates use, and nothing
* without an organisation signature. Synced with the other settings; in
* Lite it stays in this browser.
* address. Shows the fields the signatures of any of the user's addresses
* use, and nothing when none does. The preview is the signature the
* primary address sends: the user's own where it still applies. Synced
* with the other settings; in Lite it stays in this browser.
*/
export function SignatureDetailsSection() {
const t = useTranslations('org_signature.details');
const organisation = usePolicyStore((s) => s.policy.organisation) ?? DEFAULT_ORGANISATION_POLICY;
const details = useSettingsStore((s) => s.signatureDetails);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const primary = useIdentityStore((s) => s.identities[0]);
const setup = useOrgSignatureSetup(primary);
const used = new Set<SignatureField>([
...signatureFieldsToAsk(organisation.signature),
...signatureFieldsUsed(organisation.disclaimer),
]);
const fields = (['title', 'department', 'phone', 'mobile'] as const).filter((field) => used.has(field));
const fields = useFieldsToAsk();
if (fields.length === 0) return null;
const preview = setup.signature
? setup.signature.html
? { html: sanitizeSignatureHtmlForDisplay(setup.signature.html) }
: { text: getPlainTextSignature({ textSignature: setup.signature.text }) }
: null;
const sent = effectiveSignatureIdentity(primary, primary, setup.signature, setup.restriction);
const preview = sent?.htmlSignature?.trim()
? { html: sanitizeSignatureHtmlForDisplay(sent.htmlSignature) }
: sent?.textSignature?.trim()
? { text: getPlainTextSignature(sent) }
: null;
return (
<SettingsSection id="signature-details" title={t('title')} description={t('description')}>