727d86614e
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal 8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
225 lines
9.1 KiB
TypeScript
225 lines
9.1 KiB
TypeScript
/**
|
|
* Signature manager — CRUD for mail signatures with rich text editor.
|
|
* Supports placeholder variables for user/tenant data.
|
|
*/
|
|
|
|
import DOMPurify from 'dompurify';
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Input } from '@/components/ui/Input';
|
|
import { Card } from '@/components/ui/Card';
|
|
import { EmptyState } from '@/components/ui/EmptyState';
|
|
import { useToast } from '@/components/ui/Toast';
|
|
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
|
import { RichTextEditor } from './RichTextEditor';
|
|
|
|
import { Loader2 } from 'lucide-react';
|
|
import {
|
|
fetchSignatures,
|
|
createSignature,
|
|
updateSignature,
|
|
deleteSignature,
|
|
type MailSignature,
|
|
} from '@/api/mail';
|
|
|
|
/** Available placeholder variables for signatures. */
|
|
const SIGNATURE_VARIABLES = [
|
|
{ token: '{{user.name}}', label: 'Vollständiger Name', description: 'Max Mustermann' },
|
|
{ token: '{{user.first_name}}', label: 'Vorname', description: 'Max' },
|
|
{ token: '{{user.last_name}}', label: 'Nachname', description: 'Mustermann' },
|
|
{ token: '{{user.email}}', label: 'E-Mail', description: 'max@firma.de' },
|
|
{ token: '{{user.role}}', label: 'Rolle', description: 'Admin' },
|
|
{ token: '{{tenant.name}}', label: 'Firma/Organisation', description: 'HMS Licht & Ton' },
|
|
{ token: '{{date}}', label: 'Aktuelles Datum', description: '16.07.2026' },
|
|
];
|
|
|
|
export function SignatureManager() {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const [signatures, setSignatures] = useState<MailSignature[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [editing, setEditing] = useState<MailSignature | null>(null);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [deleteTarget, setDeleteTarget] = useState<MailSignature | null>(null);
|
|
|
|
// ── Signature form (RHF + Zod) ──
|
|
const sigSchema = z.object({
|
|
name: z.string().min(1, 'required'),
|
|
body_html: z.string().default(''),
|
|
is_default: z.boolean().default(false),
|
|
});
|
|
type SigFormData = z.infer<typeof sigSchema>;
|
|
|
|
const { register: registerSig, handleSubmit: handleSubmitSig, reset: resetSig, watch: watchSig, setValue: setSigValue, formState: { errors: sigErrors } } = useForm<SigFormData>({
|
|
resolver: zodResolver(sigSchema),
|
|
defaultValues: { name: '', body_html: '', is_default: false },
|
|
});
|
|
|
|
const bodyHtmlValue = watchSig('body_html');
|
|
|
|
const load = useCallback(() => {
|
|
setLoading(true);
|
|
fetchSignatures()
|
|
.then((data) => {
|
|
setSignatures(data);
|
|
setError(null);
|
|
})
|
|
.catch((err: any) => {
|
|
setError(err?.message || err?.detail || (typeof err === 'string' ? err : 'Failed to load signatures'));
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const handleNew = () => {
|
|
setEditing(null);
|
|
resetSig({ name: '', body_html: '', is_default: false });
|
|
setShowForm(true);
|
|
};
|
|
|
|
const handleEdit = (sig: MailSignature) => {
|
|
setEditing(sig);
|
|
resetSig({ name: sig.name, body_html: sig.body_html, is_default: sig.is_default });
|
|
setShowForm(true);
|
|
};
|
|
|
|
const handleSave = useCallback(async (data: SigFormData) => {
|
|
setSaving(true);
|
|
try {
|
|
if (editing) {
|
|
const updated = await updateSignature(editing.id, { name: data.name, body_html: data.body_html, is_default: data.is_default });
|
|
setSignatures((prev) => prev.map((s) => (s.id === editing.id ? updated : s)));
|
|
toast.success(t('mail.signatureUpdated'));
|
|
} else {
|
|
const created = await createSignature({ name: data.name, body_html: data.body_html, is_default: data.is_default });
|
|
setSignatures((prev) => [...prev, created]);
|
|
toast.success(t('mail.signatureCreated'));
|
|
}
|
|
setShowForm(false);
|
|
} catch (err: any) {
|
|
toast.error(err?.message || err?.detail || (typeof err === 'string' ? err : 'Save failed'));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [editing, toast, t]);
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
if (!deleteTarget) return;
|
|
try {
|
|
await deleteSignature(deleteTarget.id);
|
|
setSignatures((prev) => prev.filter((s) => s.id !== deleteTarget.id));
|
|
toast.success(t('mail.signatureDeleted'));
|
|
setDeleteTarget(null);
|
|
} catch (err: any) {
|
|
toast.error(err?.message || err?.detail || (typeof err === 'string' ? err : 'Delete failed'));
|
|
}
|
|
}, [deleteTarget, toast, t]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-8" data-testid="signature-manager-loading">
|
|
<Loader2 className="animate-spin h-5 w-5 text-secondary-400" aria-hidden="true" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-4 bg-danger-50 border border-danger-200 rounded-md" role="alert" data-testid="signature-manager-error">
|
|
<p className="text-sm text-danger-700">{error}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div data-testid="signature-manager">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-secondary-900">{t('mail.signatures')}</h3>
|
|
<Button size="sm" onClick={handleNew} data-testid="new-signature-btn">{t('mail.newSignature')}</Button>
|
|
</div>
|
|
|
|
{showForm && (
|
|
<Card className="mb-4" data-testid="signature-form">
|
|
<form onSubmit={handleSubmitSig(handleSave)} className="space-y-3">
|
|
<Input
|
|
label={t('mail.signatureName')}
|
|
{...registerSig('name')}
|
|
error={sigErrors.name?.message === 'required' ? t('validation.required') : undefined}
|
|
placeholder={t('mail.signatureName')}
|
|
required
|
|
/>
|
|
<div>
|
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.signatureBody')}</label>
|
|
{/* Placeholder variable buttons */}
|
|
<div className="flex flex-wrap gap-1 mb-2" data-testid="signature-variables">
|
|
{SIGNATURE_VARIABLES.map((v) => (
|
|
<button
|
|
key={v.token}
|
|
type="button"
|
|
onClick={() => setSigValue('body_html', `${bodyHtmlValue}${v.token}`)}
|
|
className="inline-flex items-center px-2 py-0.5 text-xs rounded border border-secondary-300 bg-secondary-50 hover:bg-secondary-100 text-secondary-700"
|
|
title={v.description}
|
|
>
|
|
{v.label} <code className="ml-1 text-primary-600">{v.token}</code>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<RichTextEditor
|
|
content={bodyHtmlValue}
|
|
onChange={(html: string) => setSigValue('body_html', html)}
|
|
placeholder="<p>Mit freundlichen Grüßen,<br>{{user.name}}</p>"
|
|
/>
|
|
</div>
|
|
<label className="flex items-center gap-2 text-sm text-secondary-700">
|
|
<input type="checkbox" {...registerSig('is_default')} className="rounded" />
|
|
{t('mail.defaultSignature')}
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<Button type="submit" isLoading={saving} size="sm">{t('common.save')}</Button>
|
|
<Button variant="secondary" size="sm" type="button" onClick={() => setShowForm(false)}>{t('common.cancel')}</Button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
)}
|
|
|
|
{signatures.length === 0 && !showForm ? (
|
|
<EmptyState title={t('mail.noSignatures')} description={t('mail.noSignaturesDesc')} />
|
|
) : (
|
|
<div className="space-y-2" data-testid="signature-list">
|
|
{signatures.map((sig) => (
|
|
<Card key={sig.id}>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1">
|
|
<p className="font-medium text-secondary-900">{sig.name}</p>
|
|
{sig.is_default && <span className="text-xs text-primary-600">{t('mail.defaultSignature')}</span>}
|
|
<p className="text-sm text-secondary-500 mt-1 truncate" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(sig.body_html) }} />
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(sig)} data-testid={`edit-signature-${sig.id}`}>{t('common.edit')}</Button>
|
|
<Button variant="danger" size="sm" onClick={() => setDeleteTarget(sig)} data-testid={`delete-signature-${sig.id}`}>{t('common.delete')}</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={!!deleteTarget}
|
|
title={t('mail.deleteSignature')}
|
|
message={t('mail.confirmDeleteSignature')}
|
|
confirmLabel={t('common.delete')}
|
|
variant="danger"
|
|
onConfirm={handleDelete}
|
|
onCancel={() => setDeleteTarget(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
} |