Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings: * 38 client components / 56 toast.error sites converted to toastError(err) so the new admin error inspector becomes usable from user-reported issues — every failed inline-edit, save, send, archive, upload, etc. now carries the request-id + error-code (Copy ID action). * 26 service files / 62 bare-Error throws converted to CodedError or the existing AppError subclasses. Adds new error codes: DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502), DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502), IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502), UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for post-insert returning-empty guards. * Five vitest assertions updated to match the new user-facing wording (client-merge "already been merged", expense/interest "couldn't find that …", documenso "signing service didn't respond"). Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1) + MED §11 (auditor-G Issue 1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
155 lines
4.5 KiB
TypeScript
155 lines
4.5 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { toastError } from '@/lib/api/toast-error';
|
|
|
|
interface Account {
|
|
id: string;
|
|
emailAddress: string;
|
|
isActive: boolean;
|
|
}
|
|
|
|
interface ComposeDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
defaultTo?: string;
|
|
defaultSubject?: string;
|
|
}
|
|
|
|
export function ComposeDialog({
|
|
open,
|
|
onOpenChange,
|
|
defaultTo = '',
|
|
defaultSubject = '',
|
|
}: ComposeDialogProps) {
|
|
const qc = useQueryClient();
|
|
const [accountId, setAccountId] = useState('');
|
|
const [to, setTo] = useState(defaultTo);
|
|
const [cc, setCc] = useState('');
|
|
const [subject, setSubject] = useState(defaultSubject);
|
|
const [body, setBody] = useState('');
|
|
|
|
const { data: accounts = [] } = useQuery<Account[]>({
|
|
queryKey: ['email', 'accounts'],
|
|
queryFn: () => apiFetch<{ data: Account[] }>('/api/v1/email/accounts').then((r) => r.data),
|
|
});
|
|
|
|
const activeAccounts = accounts.filter((a) => a.isActive);
|
|
|
|
const sendMutation = useMutation({
|
|
mutationFn: () =>
|
|
apiFetch('/api/v1/email/compose', {
|
|
method: 'POST',
|
|
body: {
|
|
accountId,
|
|
to: to
|
|
.split(',')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean),
|
|
cc: cc
|
|
? cc
|
|
.split(',')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
: undefined,
|
|
subject,
|
|
bodyHtml: body.replace(/\n/g, '<br>'),
|
|
},
|
|
}),
|
|
onSuccess: () => {
|
|
toast.success('Email sent');
|
|
qc.invalidateQueries({ queryKey: ['email', 'threads'] });
|
|
onOpenChange(false);
|
|
setTo('');
|
|
setCc('');
|
|
setSubject('');
|
|
setBody('');
|
|
},
|
|
onError: (err) => toastError(err),
|
|
});
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Compose email</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="space-y-1">
|
|
<Label>From</Label>
|
|
<Select value={accountId} onValueChange={setAccountId}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Choose account" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{activeAccounts.length === 0 ? (
|
|
<div className="px-2 py-1.5 text-sm text-muted-foreground">
|
|
No active accounts
|
|
</div>
|
|
) : (
|
|
activeAccounts.map((a) => (
|
|
<SelectItem key={a.id} value={a.id}>
|
|
{a.emailAddress}
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>To (comma-separated)</Label>
|
|
<Input value={to} onChange={(e) => setTo(e.target.value)} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>CC</Label>
|
|
<Input value={cc} onChange={(e) => setCc(e.target.value)} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Subject</Label>
|
|
<Input value={subject} onChange={(e) => setSubject(e.target.value)} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Message</Label>
|
|
<Textarea rows={10} value={body} onChange={(e) => setBody(e.target.value)} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => sendMutation.mutate()}
|
|
disabled={
|
|
sendMutation.isPending || !accountId || !to.trim() || !subject.trim() || !body.trim()
|
|
}
|
|
>
|
|
{sendMutation.isPending ? 'Sending…' : 'Send'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|