feat: show an organisation template library from the admin

This commit is contained in:
Linus Rath
2026-10-28 05:53:50 +01:00
parent 6c3ec30a7e
commit 57065d7ef2
18 changed files with 1152 additions and 202 deletions
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
// Organisation > Templates: the admin library per scope (a scope adds to
// Everyone's by id) and the restrictions on users' own and team templates,
// saved in one PUT.
const puts: Array<Record<string, unknown>> = [];
let stored: Record<string, unknown> = {};
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'PUT') {
const body = JSON.parse(String(init.body));
puts.push(body);
stored = body;
return new Response('{"ok":true}');
}
return new Response(JSON.stringify(stored));
}),
withBasePath: (p: string) => p,
getPathPrefix: () => '',
}));
import { OrgTemplatesPage } from '../_pages/org-templates';
import { EVERYONE, useOrgScopeStore } from '@/components/admin/org/use-org-policy-draft';
const WELCOME = { id: 'welcome', name: 'Welcome', subject: 'Hello', body: '<p>Hi <b>there</b></p>', isHTML: true, category: 'Sales' };
const POLICY = {
restrictions: {},
features: {}, defaults: {}, themePolicy: {},
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
organisation: { templates: { library: [WELCOME] } },
scopes: [{ id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} }],
};
beforeEach(() => {
puts.length = 0;
stored = JSON.parse(JSON.stringify(POLICY));
useOrgScopeStore.setState({ scopeId: EVERYONE });
});
describe('Organisation > Templates', () => {
it('edits Everyone\'s library with a preview', async () => {
render(<OrgTemplatesPage />);
const items = await screen.findAllByTestId('org-template-item');
expect(items).toHaveLength(1);
fireEvent.click(within(items[0]).getByRole('button', { name: /^Welcome/ }));
expect(screen.getByTestId('org-template-preview').innerHTML).toContain('<b>there</b>');
fireEvent.click(screen.getByTestId('org-template-add'));
const names = screen.getAllByTestId('org-template-name');
fireEvent.change(names[1], { target: { value: 'Offer' } });
fireEvent.change(screen.getAllByTestId('org-template-body')[1], { target: { value: 'Our offer' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(puts).toHaveLength(1));
const library = (puts[0].organisation as { templates: { library: Array<{ name: string; body: string }> } }).templates.library;
expect(library.map((t) => t.name)).toEqual(['Welcome', 'Offer']);
expect(library[1].body).toBe('Our offer');
});
it('adds templates for a scope without copying Everyone\'s, and changes one of Everyone\'s for that scope only', async () => {
render(<OrgTemplatesPage />);
await screen.findAllByTestId('org-template-item');
fireEvent.click(screen.getByRole('button', { name: 'Org two' }));
// Everyone's template is shown, not editable, and the scope starts empty.
expect(within(screen.getByTestId('org-template-inherited')).getByText('Welcome')).toBeInTheDocument();
expect(screen.getByTestId('org-template-empty')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('org-template-add'));
fireEvent.change(screen.getByTestId('org-template-name'), { target: { value: 'Two only' } });
fireEvent.click(screen.getByRole('button', { name: /Change for this scope/ }));
const subjects = screen.getAllByTestId('org-template-subject');
fireEvent.click(within(screen.getAllByTestId('org-template-item')[1]).getByRole('button', { name: /^Welcome/ }));
fireEvent.change(screen.getAllByTestId('org-template-subject')[1], { target: { value: 'Hello from two' } });
expect(subjects).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(puts).toHaveLength(1));
const scope = (puts[0].scopes as Array<{ policy: { organisation: { templates: { library: Array<{ id: string; name: string; subject: string }> } } } }>)[0];
const library = scope.policy.organisation.templates.library;
expect(library.map((t) => t.name)).toEqual(['Two only', 'Welcome']);
expect(library[1]).toMatchObject({ id: 'welcome', subject: 'Hello from two' });
// Everyone keeps its own.
expect((puts[0].organisation as { templates: { library: Array<{ subject: string }> } }).templates.library[0].subject).toBe('Hello');
});
it('removing the last template of a scope inherits Everyone\'s library again', async () => {
stored = JSON.parse(JSON.stringify({
...POLICY,
scopes: [{ ...POLICY.scopes[0], policy: { organisation: { templates: { library: [{ ...WELCOME, id: 'x', name: 'X' }] } } } }],
}));
render(<OrgTemplatesPage />);
await screen.findAllByTestId('org-template-item');
fireEvent.click(screen.getByRole('button', { name: 'Org two' }));
fireEvent.click(screen.getByRole('button', { name: 'Remove X' }));
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(puts).toHaveLength(1));
const policy = (puts[0].scopes as Array<{ policy: { organisation?: Record<string, unknown> } }>)[0].policy;
expect(policy.organisation?.templates).toBeUndefined();
});
it('says that a template without a name is not saved', async () => {
render(<OrgTemplatesPage />);
await screen.findAllByTestId('org-template-item');
fireEvent.click(screen.getByTestId('org-template-add'));
expect(screen.getByText(/A template without a name is not saved/)).toBeInTheDocument();
});
it('hides personal templates through the restriction row', async () => {
render(<OrgTemplatesPage />);
await screen.findAllByTestId('org-template-item');
fireEvent.click(screen.getByLabelText('Hide'));
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(puts).toHaveLength(1));
expect((puts[0].restrictions as Record<string, unknown>).personalTemplates).toEqual({ hidden: true });
});
});
@@ -1,14 +1,191 @@
'use client';
import { useId, useState } from 'react';
import { ChevronDown, ChevronRight, Plus, RotateCcw, Trash2 } from '@/components/icons';
import { SettingsSection } from '@/components/settings/settings-section';
import { TemplateBodyPreview } from '@/components/templates/template-body-preview';
import { Button } from '@/components/ui/button';
import type { OrgTemplate } from '@/lib/admin/organisation-policy';
import { restrictionKeysForPage } from '@/lib/admin/restriction-keys';
import { cn, generateUUID } from '@/lib/utils';
import { RestrictionRow } from '../org-page';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/** Organisation feature 3; filled in by its own step (local-data/org-features-plan.md). */
export function TemplatesSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
const inputClass =
'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' +
'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 ' +
'focus-visible:ring-ring focus-visible:border-ring';
const labelClass = 'mb-1 block text-xs font-medium text-foreground';
/** Same cap as the sanitiser (organisation-policy.ts): more are not saved. */
const MAX_LIBRARY = 200;
function TemplateEditor({ template, onChange, onRemove }: {
template: OrgTemplate;
onChange: (template: OrgTemplate) => void;
onRemove: () => void;
}) {
const id = useId();
const [open, setOpen] = useState(!template.name);
const set = (patch: Partial<OrgTemplate>) => onChange({ ...template, ...patch });
return (
<SettingsSection id="library" title="Organisation library" description="Templates every user can pick in the composer.">
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
</SettingsSection>
<li className="rounded-md border border-border" data-testid="org-template-item">
<div className="flex items-center gap-2 p-2">
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
className="flex min-w-0 flex-1 items-center gap-2 rounded px-1 py-1 text-start text-sm hover:bg-muted"
>
{open ? <ChevronDown className="h-4 w-4 shrink-0" /> : <ChevronRight className="h-4 w-4 shrink-0" />}
<span className={cn('truncate font-medium', !template.name && 'text-muted-foreground')}>{template.name || 'New template'}</span>
{template.category && <span className="max-w-[40%] shrink-0 truncate rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">{template.category}</span>}
</button>
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="h-8 shrink-0 px-2 hover:text-red-600" aria-label={`Remove ${template.name || 'template'}`}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{!template.name.trim() && (
<p className="px-3 pb-2 text-xs text-red-600 dark:text-red-400">A template without a name is not saved. Give it one.</p>
)}
{open && (
<div className="space-y-3 border-t border-border p-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label htmlFor={`${id}-name`} className={labelClass}>Name</label>
<input id={`${id}-name`} data-testid="org-template-name" className={inputClass} value={template.name} maxLength={200} onChange={(e) => set({ name: e.target.value })} />
</div>
<div>
<label htmlFor={`${id}-category`} className={labelClass}>Category</label>
<input id={`${id}-category`} className={inputClass} value={template.category} maxLength={100} onChange={(e) => set({ category: e.target.value })} />
</div>
</div>
<div>
<label htmlFor={`${id}-subject`} className={labelClass}>Subject</label>
<input id={`${id}-subject`} data-testid="org-template-subject" className={inputClass} value={template.subject} onChange={(e) => set({ subject: e.target.value })} />
</div>
<div>
<label htmlFor={`${id}-body`} className={labelClass}>Text</label>
<textarea
id={`${id}-body`}
data-testid="org-template-body"
rows={6}
className={cn(inputClass, template.isHTML && 'font-mono text-xs')}
value={template.body}
onChange={(e) => set({ body: e.target.value })}
/>
<p className="mt-1 text-xs text-muted-foreground">
Placeholders such as {'{{recipient_name}}'} work as in users&apos; own templates: users fill them in when they pick the template.
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm text-foreground">
<input type="checkbox" className="rounded border-input" checked={!!template.isHTML} onChange={(e) => set({ isHTML: e.target.checked })} />
The text is HTML
</label>
<div>
<p className={labelClass}>Preview</p>
<div className="rounded-md border border-border bg-background p-3" data-testid="org-template-preview">
{template.subject && <p className="mb-2 text-sm font-medium">{template.subject}</p>}
{template.body ? <TemplateBodyPreview template={template} /> : <p className="text-sm text-muted-foreground">No text</p>}
</div>
</div>
</div>
)}
</li>
);
}
/**
* Organisation > Templates (feature 3): the admin's template library
* (policy.json `organisation.templates.library`) at the selected scope, and
* the restrictions on users' own and team templates. A scope's library adds
* to Everyone's by id; the same id replaces Everyone's template for that
* scope (mergeOrganisation).
*/
export function TemplatesSection({ draft }: { draft: OrgPolicyDraft }) {
const library = draft.orgValue('templates', 'library');
const inScope = !!draft.scope;
const own: OrgTemplate[] = inScope ? (library.own ? library.value : []) : library.value;
const everyone = library.everyone;
const ownIds = new Set(own.map((t) => t.id));
const setOwn = (next: OrgTemplate[]) => {
if (inScope && next.length === 0) draft.clearOrgValue('templates', 'library');
else draft.setOrgValue('templates', 'library', next);
};
const add = () => setOwn([...own, { id: generateUUID(), name: '', subject: '', body: '', isHTML: false, category: '' }]);
const change = (index: number, template: OrgTemplate) => setOwn(own.map((t, i) => (i === index ? template : t)));
const remove = (index: number) => setOwn(own.filter((_, i) => i !== index));
const replaceInScope = (template: OrgTemplate) => setOwn([...own, { ...template }]);
const total = new Set([...everyone.map((t) => t.id), ...own.map((t) => t.id)]).size;
return (
<>
<SettingsSection
id="library"
title="Organisation library"
description="Users see these templates under “Organisation” in the template picker and in Settings > Templates. They can use and copy them, not change them."
>
<div className="space-y-4">
{inScope && (
<div>
<p className="mb-2 text-sm font-medium text-foreground">From Everyone</p>
{everyone.length === 0 ? (
<p className="text-sm text-muted-foreground">Everyone&apos;s library is empty.</p>
) : (
<ul className="space-y-1.5" data-testid="org-template-inherited">
{everyone.map((template) => (
<li key={template.id} className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-dashed border-border px-3 py-2 text-sm">
<span className="min-w-0 truncate">{template.name}</span>
{ownIds.has(template.id) ? (
<span className="text-xs text-muted-foreground">Changed for this scope</span>
) : (
<button type="button" onClick={() => replaceInScope(template)} className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:underline">
<RotateCcw className="h-3 w-3" /> Change for this scope
</button>
)}
</li>
))}
</ul>
)}
<p className="mt-4 mb-1 text-sm font-medium text-foreground">For this scope</p>
<p className="mb-2 text-xs text-muted-foreground">
Users in this scope get Everyone&apos;s templates and these. A template changed here replaces Everyone&apos;s for this scope only.
</p>
</div>
)}
{own.length === 0 ? (
<p className="text-sm text-muted-foreground" data-testid="org-template-empty">
{inScope ? 'Nothing added for this scope.' : 'No templates yet. Users only have their own.'}
</p>
) : (
<ul className="space-y-2" data-testid="org-template-list">
{own.map((template, index) => (
<TemplateEditor key={template.id} template={template} onChange={(t) => change(index, t)} onRemove={() => remove(index)} />
))}
</ul>
)}
<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="outline" size="sm" onClick={add} disabled={total >= MAX_LIBRARY} data-testid="org-template-add">
<Plus className="me-1 h-4 w-4" /> Add template
</Button>
{total >= MAX_LIBRARY && <span className="text-xs text-muted-foreground">A library holds at most {MAX_LIBRARY} templates.</span>}
</div>
</div>
</SettingsSection>
<SettingsSection
id="user-templates"
title="Personal and team templates"
description="On Stalwart 1.0 and later, users' own templates are kept in their account, and the members of a group mailbox share its team templates. On older servers, users keep their own templates in the browser and there are no team templates."
>
<div>
{restrictionKeysForPage('org-templates').map((def) => <RestrictionRow key={def.key} draft={draft} def={def} />)}
</div>
</SettingsSection>
</>
);
}
+9 -3
View File
@@ -84,6 +84,7 @@ import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { FileNode } from "@/lib/jmap/types";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { useTemplateStore, renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store";
import { useTemplateSources } from "@/hooks/use-template-sources";
import { notifyIdentitySelected } from "@/stores/identity-store";
import { usePluginSignature } from "@/hooks/use-plugin-signature";
import { useOrgSignature, useOrgSignatureIdentity } from "@/hooks/use-org-signature";
@@ -1554,6 +1555,8 @@ export function EmailComposer({
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const addTemplate = useTemplateStore((s) => s.addTemplate);
// The admin may leave users only organisation and team templates.
const personalTemplatesHidden = useTemplateSources(composingAccountId).personalHidden;
// Sign/encrypt is provided by crypto plugins (S/MIME, PGP) via the
// composer-toolbar slot + the onComposeSend hook — the host stays
// crypto-agnostic.
@@ -4674,9 +4677,11 @@ export function EmailComposer({
<ComposerToolbarMenuItem icon={FileText} onSelect={() => { close(); setShowTemplatePicker(true); }}>
{t('insert_template')}
</ComposerToolbarMenuItem>
<ComposerToolbarMenuItem icon={BookmarkPlus} onSelect={() => { close(); setShowSaveAsTemplate(true); }}>
{t('save_as_template')}
</ComposerToolbarMenuItem>
{!personalTemplatesHidden && (
<ComposerToolbarMenuItem icon={BookmarkPlus} onSelect={() => { close(); setShowSaveAsTemplate(true); }}>
{t('save_as_template')}
</ComposerToolbarMenuItem>
)}
</>}
</ComposerToolbarMenu>
)}
@@ -4831,6 +4836,7 @@ export function EmailComposer({
isOpen={showTemplatePicker}
onClose={() => setShowTemplatePicker(false)}
onSelect={handleTemplateSelect}
accountId={composingAccountId}
/>
)}
+6 -3
View File
@@ -6,6 +6,7 @@ import { SettingsSection } from './settings-section';
import { Button } from '@/components/ui/button';
import { TemplateManagerModal } from '@/components/templates/template-manager-modal';
import { useTemplateStore } from '@/stores/template-store';
import { useTemplateSources } from '@/hooks/use-template-sources';
import { toast } from '@/stores/toast-store';
import { debug } from '@/lib/debug';
import {
@@ -21,6 +22,8 @@ export function TemplateSettings() {
const tNotif = useTranslations('notifications');
const { templates, exportAllTemplates, importTemplates: storeImport } = useTemplateStore();
const { sources, personalHidden } = useTemplateSources();
const total = sources.reduce((n, source) => n + source.templates.length, 0);
const [showManager, setShowManager] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -83,7 +86,7 @@ export function TemplateSettings() {
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="w-4 h-4" />
<span>
{t('count', { count: templates.length })}
{t('count', { count: total })}
</span>
</div>
<Button
@@ -96,7 +99,7 @@ export function TemplateSettings() {
</div>
</SettingsSection>
<SettingsSection id="templates-export" advanced title={t('export_import')} description={t('export_import_description')}>
{!personalHidden && <SettingsSection id="templates-export" advanced title={t('export_import')} description={t('export_import_description')}>
<div className="flex items-center gap-3 py-3">
<Button
variant="outline"
@@ -125,7 +128,7 @@ export function TemplateSettings() {
</Button>
</div>
</div>
</SettingsSection>
</SettingsSection>}
{showManager && (
<TemplateManagerModal
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { TemplateManagerModal } from '../template-manager-modal';
import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { resolvePolicy } from '@/lib/admin/policy-scopes';
import { resetAccountSubjects } from '@/lib/org/account-subjects';
import { usePolicyStore } from '@/stores/policy-store';
import { useTemplateStore } from '@/stores/template-store';
import type { EmailTemplate } from '@/lib/template-types';
// The template manager lists the organisation library read-only next to
// the user's own templates; a template from it can be copied into the
// user's own.
const own = (id: string, name: string): EmailTemplate => ({
id, name, subject: '', body: '', category: '', isFavorite: false,
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
});
const RAW: SettingsPolicy = {
...DEFAULT_POLICY,
organisation: {
...DEFAULT_ORGANISATION_POLICY,
templates: { library: [{ id: 'welcome', name: 'Welcome', subject: 'Hello', body: 'Welcome text', isHTML: false, category: '' }] },
},
};
const ALICE = { email: 'alice@example.org', groups: [] };
function setPolicy(raw: SettingsPolicy) {
usePolicyStore.setState({ rawPolicy: raw, policy: resolvePolicy(raw, ALICE).policy, subject: ALICE });
}
beforeEach(() => {
resetAccountSubjects();
setPolicy(RAW);
useTemplateStore.setState({ templates: [own('mine', 'Mine')], recentTemplateIds: [], deletedTemplateIds: {} });
});
afterEach(() => resetAccountSubjects());
describe('template manager sources', () => {
it('shows the organisation library read-only, with a preview and a copy into the user\'s own', () => {
render(<TemplateManagerModal isOpen onClose={() => {}} />);
expect(screen.getByText('Mine')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('template-source-tab-organisation'));
expect(screen.getByTestId('template-source-note')).toHaveTextContent('organisation_note');
const row = screen.getByTestId('template-row');
expect(within(row).getByText('Welcome')).toBeInTheDocument();
expect(within(row).queryByRole('button', { name: 'edit' })).toBeNull();
expect(within(row).queryByRole('button', { name: 'confirm_delete' })).toBeNull();
expect(screen.queryByRole('button', { name: 'add' })).toBeNull();
fireEvent.click(within(row).getByRole('button', { name: 'preview' }));
expect(screen.getByTestId('template-preview')).toHaveTextContent('Welcome text');
fireEvent.click(within(row).getByRole('button', { name: 'copy_to_personal' }));
const names = useTemplateStore.getState().templates.map((t) => t.name);
expect(names).toEqual(['Mine', 'Welcome']);
expect(useTemplateStore.getState().templates[1].id).not.toBe('welcome');
});
it('offers no personal templates and no copy when the admin hid them', () => {
setPolicy({ ...RAW, restrictions: { personalTemplates: { hidden: true } } });
render(<TemplateManagerModal isOpen onClose={() => {}} />);
expect(screen.queryByTestId('template-source-tab-personal')).toBeNull();
expect(screen.queryByText('Mine')).toBeNull();
const row = screen.getByTestId('template-row');
expect(within(row).getByText('Welcome')).toBeInTheDocument();
expect(within(row).queryByRole('button', { name: 'copy_to_personal' })).toBeNull();
});
});
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { TemplatePicker } from '../template-picker';
import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { resolvePolicy } from '@/lib/admin/policy-scopes';
import { resetAccountSubjects } from '@/lib/org/account-subjects';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import { useTemplateStore } from '@/stores/template-store';
import type { EmailTemplate } from '@/lib/template-types';
// The picker shows the user's own templates, then the admin library under
// "Organisation", from the policy of the account the message is written
// from. The global next-intl mock returns keys.
const own = (id: string, name: string, extra: Partial<EmailTemplate> = {}): EmailTemplate => ({
id, name, subject: '', body: `${name} body`, category: '', isFavorite: false,
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', ...extra,
});
const lib = (id: string, name: string) => ({ id, name, subject: `${name} subject`, body: `<p>${name}</p><script>x()</script>`, isHTML: true, category: '' });
const RAW: SettingsPolicy = {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, templates: { library: [lib('welcome', 'Welcome')] } },
scopes: [
{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: { organisation: { templates: { library: [lib('two', 'Two only')] } } } },
],
};
const ALICE = { email: 'alice@example.org', groups: [] };
const DAVE_CLIENT = {
getUsername: () => 'dave@org-two.test',
getSharedAccounts: () => [],
getAccountInfo: () => null,
};
const original = useAuthStore.getState();
function setPolicy(raw: SettingsPolicy) {
usePolicyStore.setState({ rawPolicy: raw, policy: resolvePolicy(raw, ALICE).policy, subject: ALICE });
}
beforeEach(() => {
resetAccountSubjects();
useAuthStore.setState({
activeAccountId: 'a1',
getClientForAccount: (id: string) => (id === 'a2' ? DAVE_CLIENT : undefined),
} as never);
setPolicy(RAW);
useTemplateStore.setState({ templates: [own('mine', 'Mine')], recentTemplateIds: [], deletedTemplateIds: {} });
});
afterEach(() => {
resetAccountSubjects();
useAuthStore.setState({ activeAccountId: original.activeAccountId, getClientForAccount: original.getClientForAccount } as never);
});
const headings = () => screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent).slice(1);
describe('template picker sources', () => {
it('heads the user\'s own templates and the organisation library', () => {
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
expect(headings()).toEqual(['section_personal', 'section_organisation']);
const organisation = screen.getByTestId('template-source-organisation');
expect(within(organisation).getByText('Welcome')).toBeInTheDocument();
expect(within(organisation).queryByText('Two only')).toBeNull();
expect(within(screen.getByTestId('template-source-personal')).getByText('Mine')).toBeInTheDocument();
});
it('looks as before when there is no organisation library', () => {
setPolicy({ ...RAW, organisation: DEFAULT_ORGANISATION_POLICY, scopes: [] });
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
expect(headings()).toEqual([]);
expect(screen.queryByTestId('template-source-personal')).toBeNull();
expect(screen.getByText('Mine')).toBeInTheDocument();
});
it('shows the library of the account the message is sent from', () => {
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} accountId="a2" />);
const organisation = screen.getByTestId('template-source-organisation');
expect(within(organisation).getByText('Welcome')).toBeInTheDocument();
expect(within(organisation).getByText('Two only')).toBeInTheDocument();
});
it('hands an organisation template to the composer sanitised', () => {
const onSelect = vi.fn();
render(<TemplatePicker isOpen onClose={() => {}} onSelect={onSelect} />);
fireEvent.click(screen.getByText('Welcome'));
expect(onSelect).toHaveBeenCalledTimes(1);
const template = onSelect.mock.calls[0][0] as EmailTemplate;
expect(template.subject).toBe('Welcome subject');
expect(template.body).toContain('<p>Welcome</p>');
expect(template.body).not.toContain('script');
// Recently used is for the user's own templates.
expect(useTemplateStore.getState().recentTemplateIds).toEqual([]);
});
it('leaves out personal templates the admin hid', () => {
setPolicy({ ...RAW, restrictions: { personalTemplates: { hidden: true } } });
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
expect(screen.queryByText('Mine')).toBeNull();
expect(screen.getByText('Welcome')).toBeInTheDocument();
});
it('searches every source under its heading', () => {
useTemplateStore.setState({ templates: [own('mine', 'Welcome back')] });
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'welc' } });
expect(within(screen.getByTestId('template-source-personal')).getByText('Welcome back')).toBeInTheDocument();
expect(within(screen.getByTestId('template-source-organisation')).getByText('Welcome')).toBeInTheDocument();
});
});
@@ -0,0 +1,17 @@
'use client';
import { sanitizeEmailHtml } from '@/lib/email-sanitization';
import type { EmailTemplate } from '@/lib/template-types';
/** A template's body as it goes into the composer, for templates the user can't open in the editor. */
export function TemplateBodyPreview({ template }: { template: Pick<EmailTemplate, 'body' | 'isHTML'> }) {
if (template.isHTML) {
return (
<div
className="max-h-60 overflow-auto text-sm break-words text-foreground"
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(template.body || '') }}
/>
);
}
return <pre className="max-h-60 overflow-auto whitespace-pre-wrap break-words font-sans text-sm text-foreground">{template.body}</pre>;
}
+22 -13
View File
@@ -24,9 +24,16 @@ interface TemplateFormProps {
};
onSave: (data: Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void;
/**
* A template others use too (a team's): no sending identity, since
* identity ids are each member's own, and no favourite.
*/
shared?: boolean;
/** A save is under way: the save button waits. */
busy?: boolean;
}
export function TemplateForm({ template, initialData, onSave, onCancel }: TemplateFormProps) {
export function TemplateForm({ template, initialData, onSave, onCancel, shared = false, busy = false }: TemplateFormProps) {
const t = useTranslations('templates');
const tSettings = useTranslations('settings.templates');
const tComposer = useTranslations('email_composer');
@@ -82,8 +89,8 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
defaultRecipients: to.length || cc.length || bcc.length
? { to: to.length ? to : undefined, cc: cc.length ? cc : undefined, bcc: bcc.length ? bcc : undefined }
: undefined,
identityId: identityId || undefined,
isFavorite,
identityId: shared ? undefined : identityId || undefined,
isFavorite: shared ? false : isFavorite,
});
};
@@ -232,7 +239,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
</div>
</div>
{identities.length > 1 && (
{!shared && identities.length > 1 && (
<div>
<label className="text-sm font-medium text-foreground">{tSettings('identity')}</label>
<select
@@ -251,20 +258,22 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
)}
<div className="flex items-center justify-between pt-2">
<button
type="button"
onClick={() => setIsFavorite(!isFavorite)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Star className={cn('w-4 h-4', isFavorite && 'fill-amber-400 text-amber-400')} />
{tSettings('favorite')}
</button>
{shared ? <span /> : (
<button
type="button"
onClick={() => setIsFavorite(!isFavorite)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Star className={cn('w-4 h-4', isFavorite && 'fill-amber-400 text-amber-400')} />
{tSettings('favorite')}
</button>
)}
<div className="flex gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
{tSettings('cancel')}
</Button>
<Button type="submit" size="sm">
<Button type="submit" size="sm" disabled={busy}>
{template ? tSettings('update') : tSettings('create')}
</Button>
</div>
+274 -129
View File
@@ -2,71 +2,123 @@
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { X, FileText, Pencil, Trash2, Star, Copy, Search } from '@/components/icons';
import { X, FileText, Pencil, Trash2, Star, Copy, Search, Eye } from '@/components/icons';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { TemplateForm } from './template-form';
import { useTemplateStore } from '@/stores/template-store';
import { TemplateBodyPreview } from './template-body-preview';
import { useFocusTrap } from '@/hooks/use-focus-trap';
import { useTemplateSources } from '@/hooks/use-template-sources';
import { useTemplateSourceActions, type TemplateSaveResult } from '@/hooks/use-template-source-actions';
import { OrgLockedNote } from '@/components/settings/org-locked-note';
import { filterTemplates } from '@/lib/template-utils';
import { PERSONAL_SOURCE, type TemplateSource } from '@/lib/templates/template-sources';
import type { EmailTemplate } from '@/lib/template-types';
interface TemplateManagerModalProps {
isOpen: boolean;
onClose: () => void;
/** The signed-in account whose templates are managed; the active one when left out. */
accountId?: string | null;
}
export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalProps) {
type TemplateData = Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>;
export function TemplateManagerModal({ isOpen, onClose, accountId }: TemplateManagerModalProps) {
const t = useTranslations('templates');
const tSettings = useTranslations('settings.templates');
const tOrg = useTranslations('org_templates');
const {
templates,
addTemplate,
updateTemplate,
deleteTemplate,
duplicateTemplate,
toggleFavorite,
searchTemplates,
} = useTemplateStore();
const { sources, personalHidden } = useTemplateSources(accountId);
const actions = useTemplateSourceActions(accountId);
const [selectedKey, setSelectedKey] = useState<string>(PERSONAL_SOURCE);
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [previewId, setPreviewId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const source: TemplateSource | undefined = sources.find((s) => s.key === selectedKey) ?? sources[0];
const personal = sources.find((s) => s.kind === 'personal');
const closeForm = () => {
setIsCreating(false);
setEditingId(null);
setError(null);
};
const modalRef = useFocusTrap({
isActive: isOpen,
onEscape: () => {
if (isCreating || editingId) {
setIsCreating(false);
setEditingId(null);
} else {
onClose();
}
if (isCreating || editingId) closeForm();
else onClose();
},
restoreFocus: true,
});
const filtered = searchQuery ? searchTemplates(searchQuery) : templates;
const selectSource = (key: string) => {
setSelectedKey(key);
closeForm();
setDeleteConfirmId(null);
setPreviewId(null);
};
const handleSave = (data: Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>) => {
if (editingId) {
updateTemplate(editingId, data);
setEditingId(null);
} else {
addTemplate(data);
setIsCreating(false);
const sourceTitle = (s: TemplateSource) =>
s.kind === 'organisation' ? tOrg('section_organisation')
: s.kind === 'team' ? (s.name ?? '')
: tOrg('section_personal');
const errorText = (result: TemplateSaveResult) =>
result === 'too-large' ? tOrg('save_failed_too_large')
: result === 'no-room' ? tOrg('save_failed_no_room')
: result === 'forbidden' ? tOrg('save_failed_forbidden')
: tOrg('save_failed');
// A failed save keeps the form open with what the user typed.
const run = async (step: () => Promise<TemplateSaveResult>, after: () => void) => {
setBusy(true);
setError(null);
try {
const result = await step();
if (result === 'ok') after();
else setError(errorText(result));
} finally {
setBusy(false);
}
};
const handleSave = (data: TemplateData) => {
if (!source?.editable) return;
if (editingId) void run(() => actions.update(source, editingId, data), closeForm);
else void run(() => actions.add(source, data), closeForm);
};
const handleDelete = (id: string) => {
deleteTemplate(id);
setDeleteConfirmId(null);
if (!source?.editable) return;
void run(() => actions.remove(source, id), () => setDeleteConfirmId(null));
};
if (!isOpen) return null;
const templates = source?.templates ?? [];
const filtered = searchQuery ? filterTemplates(templates, searchQuery) : templates;
const editable = !!source?.editable;
const isPersonal = source?.kind === 'personal';
const showTabs = sources.length > 1 || personalHidden;
let note: React.ReactNode = null;
if (source?.kind === 'organisation') {
note = <p className="text-xs text-muted-foreground">{tOrg('organisation_note')}</p>;
} else if (source?.kind === 'team') {
note = source.locked
? <OrgLockedNote />
: <p className="text-xs text-muted-foreground">{source.editable ? tOrg('team_note', { name: source.name ?? '' }) : tOrg('team_read_only')}</p>;
}
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div
@@ -74,20 +126,23 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
role="dialog"
aria-modal="true"
aria-labelledby="template-manager-title"
// The mail shortcuts behind the dialog must not see its keys.
onKeyDown={(e) => e.stopPropagation()}
className={cn(
'bg-background border border-border rounded-lg shadow-xl',
'w-full max-w-3xl max-h-[90vh] overflow-hidden',
'animate-in zoom-in-95 duration-200'
)}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-muted-foreground" />
<h2 id="template-manager-title" className="text-lg font-semibold text-foreground">
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
<FileText className="w-5 h-5 text-muted-foreground flex-shrink-0" />
<h2 id="template-manager-title" className="text-lg font-semibold text-foreground truncate">
{tSettings('title')}
</h2>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
>
@@ -95,8 +150,40 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
</button>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
{!isCreating && !editingId && (
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
{showTabs && (
<div role="tablist" aria-label={tOrg('sources_label')} className="flex flex-wrap gap-2 mb-3" data-testid="template-sources">
{sources.map((s) => (
<button
key={s.key}
type="button"
role="tab"
aria-selected={s.key === source?.key}
data-testid={`template-source-tab-${s.kind}`}
onClick={() => selectSource(s.key)}
className={cn(
'max-w-full truncate rounded-full border px-3 py-1 text-sm transition-colors',
s.key === source?.key
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
{sourceTitle(s)} <span className="text-xs opacity-70">{s.templates.length}</span>
</button>
))}
</div>
)}
{note && <div className="mb-4" data-testid="template-source-note">{note}</div>}
{!source && (
<div className="flex flex-col items-center py-8 text-muted-foreground">
<FileText className="w-10 h-10 mb-3 opacity-40" />
<p className="text-sm">{tSettings('no_templates')}</p>
</div>
)}
{source && !isCreating && !editingId && (
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
@@ -107,129 +194,183 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
className="ps-9"
/>
</div>
<Button
onClick={() => setIsCreating(true)}
size="sm"
>
{tSettings('add')}
</Button>
{editable && (
<Button onClick={() => { setIsCreating(true); setError(null); }} size="sm">
{tSettings('add')}
</Button>
)}
</div>
)}
{isCreating && (
{error && (
<p role="alert" className="mb-3 text-sm text-red-600 dark:text-red-400" data-testid="template-save-error">{error}</p>
)}
{editable && isCreating && (
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
<h3 className="text-sm font-semibold mb-4">{tSettings('add')}</h3>
<TemplateForm
shared={!isPersonal}
busy={busy}
onSave={handleSave}
onCancel={() => setIsCreating(false)}
onCancel={closeForm}
/>
</div>
)}
{editingId && (
{editable && editingId && (
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
<h3 className="text-sm font-semibold mb-4">{tSettings('edit')}</h3>
<TemplateForm
template={templates.find((t) => t.id === editingId)}
template={templates.find((tpl) => tpl.id === editingId)}
shared={!isPersonal}
busy={busy}
onSave={handleSave}
onCancel={() => setEditingId(null)}
onCancel={closeForm}
/>
</div>
)}
<div className="space-y-2">
{filtered.map((template) => (
<div
key={template.id}
className="flex items-center gap-3 p-3 rounded-md border border-border hover:bg-muted/50 transition-colors"
>
<button
type="button"
onClick={() => toggleFavorite(template.id)}
className="flex-shrink-0"
>
<Star
className={cn(
'w-4 h-4 transition-colors',
template.isFavorite
? 'fill-amber-400 text-amber-400'
: 'text-muted-foreground hover:text-amber-400'
)}
/>
</button>
{source && filtered.map((template) => (
<div key={template.id} className="rounded-md border border-border hover:bg-muted/50 transition-colors" data-testid="template-row">
<div className="flex items-center gap-2 sm:gap-3 p-3">
{isPersonal && (
<button
type="button"
onClick={() => void actions.toggleFavorite(source, template.id)}
aria-label={tSettings('favorite')}
aria-pressed={template.isFavorite}
className="flex-shrink-0"
>
<Star
className={cn(
'w-4 h-4 transition-colors',
template.isFavorite
? 'fill-amber-400 text-amber-400'
: 'text-muted-foreground hover:text-amber-400'
)}
/>
</button>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground truncate">
{template.name}
</p>
{template.category && (
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0">
{template.category}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{template.name}
</p>
{template.category && (
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0 max-w-[40%] truncate">
{template.category}
</span>
)}
</div>
{template.subject && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{template.subject}
</p>
)}
{isPersonal && actions.localOnly[template.id] && (
<p className="text-xs text-amber-700 dark:text-amber-400 mt-1" data-testid="template-local-only">
{actions.localOnly[template.id] === 'too-large' ? tOrg('local_only_too_large') : tOrg('local_only_no_room')}
</p>
)}
</div>
{template.subject && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{template.subject}
</p>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => { setEditingId(template.id); setIsCreating(false); }}
disabled={!!editingId || isCreating}
className="h-8 w-8 p-0"
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => duplicateTemplate(template.id, t('copy_suffix'))}
disabled={!!editingId || isCreating}
className="h-8 w-8 p-0"
>
<Copy className="w-3.5 h-3.5" />
</Button>
{deleteConfirmId === template.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(template.id)}
className="h-7 text-xs"
>
{tSettings('confirm_delete')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(null)}
className="h-7 text-xs"
>
{tSettings('cancel')}
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(template.id)}
disabled={!!editingId || isCreating}
className="h-8 w-8 p-0 hover:text-red-600 dark:hover:text-red-400"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
<div className="flex items-center gap-1 flex-shrink-0">
{editable ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => { setEditingId(template.id); setIsCreating(false); setError(null); }}
disabled={!!editingId || isCreating}
aria-label={tSettings('edit')}
className="h-8 w-8 p-0"
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void run(() => actions.duplicate(source, template.id, t('copy_suffix')), () => {})}
disabled={!!editingId || isCreating || busy}
aria-label={tOrg('duplicate')}
className="h-8 w-8 p-0"
>
<Copy className="w-3.5 h-3.5" />
</Button>
{deleteConfirmId === template.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(template.id)}
disabled={busy}
className="h-7 text-xs"
>
{tSettings('confirm_delete')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(null)}
className="h-7 text-xs"
>
{tSettings('cancel')}
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(template.id)}
disabled={!!editingId || isCreating}
aria-label={tSettings('confirm_delete')}
className="h-8 w-8 p-0 hover:text-red-600 dark:hover:text-red-400"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</>
) : (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setPreviewId(previewId === template.id ? null : template.id)}
aria-label={t('preview')}
aria-expanded={previewId === template.id}
className="h-8 w-8 p-0"
>
<Eye className="w-3.5 h-3.5" />
</Button>
{personal?.editable && (
<Button
variant="ghost"
size="sm"
onClick={() => void run(() => actions.copyToPersonal(template), () => {})}
disabled={busy}
aria-label={tOrg('copy_to_personal')}
title={tOrg('copy_to_personal')}
className="h-8 w-8 p-0"
>
<Copy className="w-3.5 h-3.5" />
</Button>
)}
</>
)}
</div>
</div>
{previewId === template.id && (
<div className="border-t border-border px-3 py-2" data-testid="template-preview">
<TemplateBodyPreview template={template} />
</div>
)}
</div>
))}
{filtered.length === 0 && !isCreating && (
{source && filtered.length === 0 && !isCreating && (
<div className="flex flex-col items-center py-8 text-muted-foreground">
<FileText className="w-10 h-10 mb-3 opacity-40" />
<p className="text-sm">
@@ -238,6 +379,10 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
</div>
)}
</div>
{isPersonal && actions.storedInAccount && (
<p className="mt-4 text-xs text-muted-foreground" data-testid="template-storage-note">{tOrg('personal_in_account')}</p>
)}
</div>
</div>
</div>
+101 -48
View File
@@ -8,10 +8,13 @@ import { Input } from '@/components/ui/input';
import { useTemplateStore } from '@/stores/template-store';
import { useAuthStore } from '@/stores/auth-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
import { useTemplateSources } from '@/hooks/use-template-sources';
import {
filterTemplates,
getPlaceholdersFromTemplate,
getAutoFilledPlaceholders,
} from '@/lib/template-utils';
import type { TemplateSource } from '@/lib/templates/template-sources';
import { PlaceholderFillModal } from './placeholder-fill-modal';
import type { EmailTemplate } from '@/lib/template-types';
@@ -19,15 +22,23 @@ interface TemplatePickerProps {
isOpen: boolean;
onClose: () => void;
onSelect: (template: EmailTemplate, filledValues: Record<string, string>) => void;
/**
* The signed-in account the message is written from: its own templates,
* its team templates and its organisation library. The active one when
* left out.
*/
accountId?: string | null;
}
export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProps) {
export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: TemplatePickerProps) {
const t = useTranslations('templates');
const tOrg = useTranslations('org_templates');
const locale = useLocale();
const { templates, getFavorites, getRecent, getTemplatesByCategory, searchTemplates, recordUsage } =
useTemplateStore();
const recordUsage = useTemplateStore((s) => s.recordUsage);
const recentTemplateIds = useTemplateStore((s) => s.recentTemplateIds);
const { primaryIdentity } = useAuthStore();
const { sources } = useTemplateSources(accountId);
const [searchQuery, setSearchQuery] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<EmailTemplate | null>(null);
@@ -39,14 +50,10 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
restoreFocus: true,
});
const favorites = getFavorites();
const recent = getRecent();
const byCategory = getTemplatesByCategory();
const filtered = searchQuery ? searchTemplates(searchQuery) : null;
const handleSelectTemplate = (template: EmailTemplate) => {
const handleSelectTemplate = (template: EmailTemplate, source: TemplateSource) => {
const placeholders = getPlaceholdersFromTemplate(template);
recordUsage(template.id);
// Recently used lists the user's own templates only.
if (source.kind === 'personal') recordUsage(template.id);
if (placeholders.length > 0) {
setSelectedTemplate(template);
@@ -71,22 +78,22 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
locale,
});
const renderTemplateItem = (template: EmailTemplate) => (
const renderTemplateItem = (template: EmailTemplate, source: TemplateSource) => (
<button
key={template.id}
key={`${source.key}:${template.id}`}
type="button"
onClick={() => handleSelectTemplate(template)}
onClick={() => handleSelectTemplate(template, source)}
className="w-full text-start p-3 rounded-md hover:bg-muted transition-colors group"
>
<div className="flex items-center gap-2">
{template.isFavorite && (
<div className="flex items-center gap-2 min-w-0">
{source.kind === 'personal' && template.isFavorite && (
<Star className="w-3 h-3 fill-amber-400 text-amber-400 flex-shrink-0" />
)}
<span className="text-sm font-medium text-foreground truncate">
{template.name}
</span>
{template.category && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0">
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0 max-w-[40%] truncate">
{template.category}
</span>
)}
@@ -99,30 +106,77 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
</button>
);
const renderSection = (title: string, items: EmailTemplate[]) => {
const renderSection = (title: string, items: EmailTemplate[], source: TemplateSource) => {
if (items.length === 0) return null;
return (
<div className="mb-4">
<div className="mb-4" key={`${source.key}:${title}`}>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1 px-3">
{title}
</h3>
<div className="space-y-0.5">{items.map(renderTemplateItem)}</div>
<div className="space-y-0.5">{items.map((item) => renderTemplateItem(item, source))}</div>
</div>
);
};
const categorizedEntries = Object.entries(byCategory).filter(
([cat]) => cat !== ''
);
const favoriteIds = new Set(favorites.map((f) => f.id));
const recentFiltered = recent.filter((r) => !favoriteIds.has(r.id));
const shownIds = new Set([
...favoriteIds,
...recentFiltered.map((r) => r.id),
]);
const uncategorizedFiltered = (byCategory[''] || []).filter(
(i) => !shownIds.has(i.id)
);
const sourceTitle = (source: TemplateSource) =>
source.kind === 'organisation' ? tOrg('section_organisation')
: source.kind === 'team' ? (source.name ?? '')
: tOrg('section_personal');
// The user's own templates keep their favourites, recently used and
// categories. With team or organisation templates next to them, the
// whole block gets a heading of its own.
const renderPersonal = (source: TemplateSource, withHeading: boolean) => {
const templates = source.templates;
const favorites = templates.filter((tpl) => tpl.isFavorite);
const favoriteIds = new Set(favorites.map((f) => f.id));
const recent = recentTemplateIds
.map((id) => templates.find((tpl) => tpl.id === id))
.filter((tpl): tpl is EmailTemplate => !!tpl && !favoriteIds.has(tpl.id));
const shownIds = new Set([...favoriteIds, ...recent.map((r) => r.id)]);
const byCategory: Record<string, EmailTemplate[]> = {};
for (const tpl of templates) {
if (shownIds.has(tpl.id)) continue;
(byCategory[tpl.category || ''] ??= []).push(tpl);
}
const body = (
<>
{renderSection(t('section_favorites'), favorites, source)}
{renderSection(t('section_recent'), recent, source)}
{Object.entries(byCategory)
.filter(([cat]) => cat !== '')
.map(([cat, items]) => renderSection(cat, items, source))}
{renderSection(t('section_uncategorized'), byCategory[''] ?? [], source)}
</>
);
if (!withHeading) return <div key={source.key}>{body}</div>;
if (templates.length === 0) return null;
return (
<section key={source.key} data-testid="template-source-personal" aria-label={sourceTitle(source)} className="mb-2">
<h2 className="text-sm font-semibold text-foreground px-3 pt-1 pb-2">{sourceTitle(source)}</h2>
{body}
</section>
);
};
const renderShared = (source: TemplateSource, items: EmailTemplate[]) => {
if (items.length === 0) return null;
return (
<section key={source.key} data-testid={`template-source-${source.kind}`} aria-label={sourceTitle(source)} className="mb-2">
<h2 className="text-sm font-semibold text-foreground px-3 pt-1 pb-2 truncate">{sourceTitle(source)}</h2>
<div className="space-y-0.5 mb-4">{items.map((item) => renderTemplateItem(item, source))}</div>
</section>
);
};
const total = sources.reduce((n, source) => n + source.templates.length, 0);
// Only the user's own templates: the picker looks as it always did.
const onlyPersonal = sources.every((source) => source.kind === 'personal' || source.templates.length === 0);
const results = searchQuery
? sources.map((source) => ({ source, items: filterTemplates(source.templates, searchQuery) }))
: null;
const resultCount = results?.reduce((n, r) => n + r.items.length, 0) ?? 0;
return (
<>
@@ -132,6 +186,8 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
role="dialog"
aria-modal="true"
aria-labelledby="template-picker-title"
// The mail shortcuts behind the dialog must not see its keys.
onKeyDown={(e) => e.stopPropagation()}
className={cn(
'bg-background border border-border rounded-lg shadow-xl',
'w-full max-w-md max-h-[70vh] overflow-hidden',
@@ -141,6 +197,7 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 id="template-picker-title" className="text-sm font-semibold text-foreground">{t('picker_title')}</h2>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
@@ -162,35 +219,31 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
</div>
<div className="overflow-y-auto max-h-[calc(70vh-120px)] p-2">
{templates.length === 0 && (
{total === 0 && (
<div className="flex flex-col items-center py-8 text-muted-foreground">
<FileText className="w-8 h-8 mb-2 opacity-40" />
<p className="text-sm">{t('no_templates')}</p>
</div>
)}
{filtered ? (
filtered.length > 0 ? (
<div className="space-y-0.5">
{filtered.map(renderTemplateItem)}
</div>
{results ? (
resultCount > 0 ? (
onlyPersonal ? (
<div className="space-y-0.5">
{results.flatMap(({ source, items }) => items.map((item) => renderTemplateItem(item, source)))}
</div>
) : (
results.map(({ source, items }) => renderShared(source, items))
)
) : (
<div className="py-6 text-center text-sm text-muted-foreground">
{t('no_results')}
</div>
)
) : (
<>
{renderSection(t('section_favorites'), favorites)}
{renderSection(t('section_recent'), recentFiltered)}
{categorizedEntries.map(([cat, items]) =>
renderSection(
cat,
items.filter((i) => !shownIds.has(i.id))
)
)}
{renderSection(t('section_uncategorized'), uncategorizedFiltered)}
</>
sources.map((source) => source.kind === 'personal'
? renderPersonal(source, !onlyPersonal)
: renderShared(source, source.templates))
)}
</div>
</div>
+24
View File
@@ -0,0 +1,24 @@
import { useMemo } from 'react';
import type { SettingsPolicy } from '@/lib/admin/types';
import { subjectForAccount, useAccountSubjects } from '@/lib/org/account-subjects';
import { usePolicyStore } from '@/stores/policy-store';
/**
* The effective policy of a signed-in account: the active account's from
* the policy store, another account's resolved for its own domain and group
* scopes (a composer sending from that account). Follows the policy and the
* account's subject as they load.
*/
export function useAccountPolicy(accountId: string | null | undefined): SettingsPolicy {
const policy = usePolicyStore((s) => s.policy);
const rawPolicy = usePolicyStore((s) => s.rawPolicy);
const subject = usePolicyStore((s) => s.subject);
const resolveFor = usePolicyStore((s) => s.resolveFor);
// Re-read when another account's subject has been worked out.
const accountSubjects = useAccountSubjects((s) => s.subjects);
return useMemo(() => {
const accountSubject = subjectForAccount(accountId);
return accountSubject === subject ? policy : resolveFor(accountSubject);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [policy, rawPolicy, subject, resolveFor, accountId, accountSubjects]);
}
+71
View File
@@ -0,0 +1,71 @@
import { useMemo } from 'react';
import type { EmailTemplate } from '@/lib/template-types';
import type { TemplateSource } from '@/lib/templates/template-sources';
import { useTemplateStore } from '@/stores/template-store';
/** What a change to a template came to; anything but 'ok' keeps the form open. */
export type TemplateSaveResult = 'ok' | 'too-large' | 'no-room' | 'forbidden' | 'failed';
/** Why a personal template could not go to the server and stays in this browser. */
export type LocalOnlyReason = 'too-large' | 'no-room';
type TemplateData = Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>;
export interface TemplateSourceActions {
add: (source: TemplateSource, data: TemplateData) => Promise<TemplateSaveResult>;
update: (source: TemplateSource, id: string, data: TemplateData) => Promise<TemplateSaveResult>;
remove: (source: TemplateSource, id: string) => Promise<TemplateSaveResult>;
duplicate: (source: TemplateSource, id: string, nameSuffix: string) => Promise<TemplateSaveResult>;
toggleFavorite: (source: TemplateSource, id: string) => Promise<TemplateSaveResult>;
/** A read-only (organisation or team) template copied into the user's own. */
copyToPersonal: (template: EmailTemplate) => Promise<TemplateSaveResult>;
/** Personal templates that stay in this browser, by id. */
localOnly: Record<string, LocalOnlyReason>;
/** Personal templates live in the account (server metadata), not only in this browser. */
storedInAccount: boolean;
}
const readOnly = async (): Promise<TemplateSaveResult> => 'forbidden';
/**
* Changes to the templates of each source the manager shows, for the
* signed-in account `accountId`. Read-only sources refuse every change.
*/
export function useTemplateSourceActions(accountId?: string | null): TemplateSourceActions {
void accountId;
return useMemo(() => {
const store = () => useTemplateStore.getState();
const personalOnly = <A extends unknown[]>(fn: (...args: A) => void) =>
async (source: TemplateSource, ...args: A): Promise<TemplateSaveResult> => {
if (source.kind !== 'personal' || !source.editable) return readOnly();
fn(...args);
return 'ok';
};
return {
add: personalOnly((data: TemplateData) => { store().addTemplate(data); }),
update: personalOnly((id: string, data: TemplateData) => store().updateTemplate(id, data)),
remove: personalOnly((id: string) => store().deleteTemplate(id)),
duplicate: personalOnly((id: string, suffix: string) => { store().duplicateTemplate(id, suffix); }),
toggleFavorite: personalOnly((id: string) => store().toggleFavorite(id)),
copyToPersonal: async (template) => {
store().addTemplate(copyOf(template));
return 'ok';
},
localOnly: {},
storedInAccount: false,
};
}, []);
}
/** The fields a copy of a shared template takes into the user's own list. */
export function copyOf(template: EmailTemplate): TemplateData {
return {
name: template.name,
subject: template.subject,
body: template.body,
isHTML: template.isHTML,
category: template.category,
defaultRecipients: template.defaultRecipients,
isFavorite: false,
};
}
+40
View File
@@ -0,0 +1,40 @@
import { useMemo } from 'react';
import { useAccountPolicy } from '@/hooks/use-account-policy';
import { orgLibraryTemplates } from '@/lib/templates/org-library';
import {
ORGANISATION_SOURCE,
PERSONAL_SOURCE,
byName,
type TemplateSource,
} from '@/lib/templates/template-sources';
import { useTemplateStore } from '@/stores/template-store';
export interface TemplateSources {
/** Personal first, then teams, then the organisation library; empty sources left out except personal. */
sources: TemplateSource[];
/** The admin hid personal templates (`personalTemplates` hidden): only organisation and team templates. */
personalHidden: boolean;
}
/**
* The templates a user can pick when writing from the signed-in account
* `accountId` (the active one when left out): their own, and the admin
* library of that account's policy, so a composer sending from another
* account shows that account's organisation templates.
*/
export function useTemplateSources(accountId?: string | null): TemplateSources {
const policy = useAccountPolicy(accountId);
const personal = useTemplateStore((s) => s.templates);
const personalHidden = policy.restrictions?.personalTemplates?.hidden === true;
return useMemo(() => {
const sources: TemplateSource[] = [];
if (!personalHidden) {
sources.push({ key: PERSONAL_SOURCE, kind: 'personal', templates: personal, editable: true, locked: false });
}
const organisation = orgLibraryTemplates(policy);
if (organisation.length > 0) {
sources.push({ key: ORGANISATION_SOURCE, kind: 'organisation', templates: byName(organisation), editable: false, locked: false });
}
return { sources, personalHidden };
}, [policy, personal, personalHidden]);
}
+9
View File
@@ -51,6 +51,15 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [
page: 'org-signatures',
modes: ['locked', 'hidden'],
},
{
key: 'personalTemplates',
label: 'Personal templates',
description: "Hide: users get only the organisation and team templates and can't save templates of their own. The ones they have are kept, not deleted.",
category: 'Composer',
type: 'boolean',
page: 'org-templates',
modes: ['hidden'],
},
];
export function restrictionKeysForPage(page: AdminPageId): RestrictionKeyDef[] {
+26
View File
@@ -0,0 +1,26 @@
/**
* The admin's template library (policy.json `organisation.templates.library`)
* as templates the picker and the template manager show under
* "Organisation". Users can't change them; scopes add to Everyone's library
* by id (mergeOrganisation), so the effective policy already holds the
* list for the user's domain and groups.
*/
import type { SettingsPolicy } from '@/lib/admin/types';
import type { EmailTemplate } from '@/lib/template-types';
import { parseSyncedTemplates } from '@/lib/template-utils';
export function orgLibraryTemplates(policy: Pick<SettingsPolicy, 'organisation'> | null | undefined): EmailTemplate[] {
const library = policy?.organisation?.templates?.library ?? [];
if (library.length === 0) return [];
// The same validation and HTML sanitising as a synced template of the
// user's own, so an admin's HTML body reaches the editor cleaned too.
return (parseSyncedTemplates(library) ?? []).map((template) => ({
...template,
defaultRecipients: undefined,
identityId: undefined,
isFavorite: false,
createdAt: '',
updatedAt: '',
}));
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Where the templates a user can pick come from: their own (personal), a
* group mailbox they are a member of (team), and the admin's library
* (organisation). The picker shows each under its own heading; the template
* manager edits the ones the user may change.
*/
import type { EmailTemplate } from '@/lib/template-types';
export type TemplateSourceKind = 'personal' | 'team' | 'organisation';
export interface TemplateSource {
/** 'personal', 'organisation' or `team:<JMAP account id>`. */
key: string;
kind: TemplateSourceKind;
/** A team's name (the group's address); personal and organisation are named by the UI. */
name?: string;
templates: EmailTemplate[];
/** The user may add, change and delete templates here. */
editable: boolean;
/** Read-only because the admin locked it: "Set by your organisation". */
locked: boolean;
}
export const PERSONAL_SOURCE = 'personal';
export const ORGANISATION_SOURCE = 'organisation';
export const teamSourceKey = (accountId: string) => `team:${accountId}`;
/** Sorted by name, the way the picker and the manager list a source. */
export function byName(templates: EmailTemplate[]): EmailTemplate[] {
return [...templates].sort((a, b) => a.name.localeCompare(b.name));
}
+17
View File
@@ -2891,6 +2891,23 @@
"sender_name": "Ihr Name"
}
},
"org_templates": {
"section_personal": "Meine Vorlagen",
"section_organisation": "Organisation",
"sources_label": "Vorlagenlisten",
"organisation_note": "Von Ihrer Organisation. Sie können diese Vorlagen nicht ändern, aber in Ihre eigenen kopieren.",
"copy_to_personal": "In meine Vorlagen kopieren",
"duplicate": "Duplizieren",
"save_failed": "Die Vorlage konnte nicht gespeichert werden. Versuchen Sie es erneut.",
"save_failed_forbidden": "Sie können diese Vorlagen nicht ändern.",
"save_failed_too_large": "Diese Vorlage ist größer als die 8 KB, die der Server pro Vorlage speichert. Kürzen Sie sie und speichern Sie erneut.",
"save_failed_no_room": "Im Postfach ist kein Platz für weitere Vorlagen. Löschen Sie eine und versuchen Sie es erneut.",
"local_only_too_large": "Nur in diesem Browser: größer als die 8 KB, die der Server pro Vorlage speichert.",
"local_only_no_room": "Nur in diesem Browser: In Ihrem Konto ist kein Platz für weitere Vorlagen.",
"personal_in_account": "Ihre Vorlagen werden in Ihrem Konto gespeichert, Sie haben sie also auf jedem Gerät, auf dem Sie sich anmelden. Alle, für die Sie Ihren Posteingang freigeben, können sie lesen.",
"team_note": "Alle Mitglieder von {name} sehen diese Vorlagen und können sie ändern.",
"team_read_only": "Sie können diese Vorlagen verwenden, aber nicht ändern."
},
"contacts": {
"directory": {
"title": "Verzeichnis",
+17
View File
@@ -2891,6 +2891,23 @@
"sender_name": "Your name"
}
},
"org_templates": {
"section_personal": "My templates",
"section_organisation": "Organisation",
"sources_label": "Template lists",
"organisation_note": "From your organisation. You can't change these, but you can copy one into your own templates.",
"copy_to_personal": "Copy to my templates",
"duplicate": "Duplicate",
"save_failed": "The template could not be saved. Try again.",
"save_failed_forbidden": "You can't change these templates.",
"save_failed_too_large": "This template is larger than the 8 KB the server keeps per template. Shorten it and save again.",
"save_failed_no_room": "The mailbox has no room for more templates. Delete one and try again.",
"local_only_too_large": "Only in this browser: larger than the 8 KB the server keeps per template.",
"local_only_no_room": "Only in this browser: your account has no room for more templates.",
"personal_in_account": "Your templates are kept in your account, so you have them on every device you sign in on. Anyone you share your Inbox with can read them.",
"team_note": "Everyone in {name} sees these templates and can change them.",
"team_read_only": "You can use these templates but not change them."
},
"contacts": {
"directory": {
"title": "Directory",