mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: show an organisation template library from the admin
This commit is contained in:
@@ -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' 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'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's templates and these. A template changed here replaces Everyone'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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: '',
|
||||
}));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user