fix: resolve open risks — invoice race condition, SMTP config, workflow seeds

- billing/service.py: pg_advisory_xact_lock on invoice_number_seq per year
  → prevents duplicate INV-YYYY-NNNN under concurrent requests
- admin.py: SMTP settings in system_settings (smtp_host/port/user/password/
  from_address/enabled) with GET+PUT support; seed-workflows endpoint creates
  4 standard workflow definitions (still-cycles, still-eevee, turntable,
  multi-angle) idempotently
- notifications/service.py: send_email_notification_stub now sends real
  SMTP email via smtplib when smtp_enabled=true in system_settings
- Admin.tsx: SMTP settings panel (host/port/user/password/from + enable
  toggle, save button); Seed Standard Workflows maintenance button
- Upload.tsx: fix TS error — title→aria-label on Lucide icons
- Admin.tsx Settings type: add render_backend/flamenco_* fields (TS fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 18:15:45 +01:00
parent f19a6ccde8
commit c0ea60d984
5 changed files with 289 additions and 10 deletions
+123
View File
@@ -75,6 +75,15 @@ export default function AdminPage() {
blender_max_concurrent_renders: number
render_stall_timeout_minutes: number
product_thumbnail_priority: string // JSON array
render_backend: string
flamenco_manager_url: string
flamenco_worker_count: number
smtp_enabled: boolean
smtp_host: string
smtp_port: number
smtp_user: string
smtp_password: string
smtp_from_address: string
}
const { data: settings } = useQuery({
@@ -138,6 +147,18 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const seedWorkflowsMut = useMutation({
mutationFn: () => api.post('/admin/settings/seed-workflows'),
onSuccess: (res) => {
toast.success(res.data.message || 'Workflows seeded')
qc.invalidateQueries({ queryKey: ['workflows'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const [smtpDraft, setSmtpDraft] = useState<Partial<Settings>>({})
const smtp = { ...settings, ...smtpDraft } as Settings
type FlamencoStatus = {
manager: { available: boolean; version: string | null; name: string | null; error?: string }
workers: any[]
@@ -965,10 +986,112 @@ export default function AdminPage() {
Generates low + high STL files for any completed STEP file that is missing them.
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => seedWorkflowsMut.mutate()}
disabled={seedWorkflowsMut.isPending}
className="btn-secondary text-sm"
title="Create standard workflow definitions (Still Cycles/EEVEE, Turntable, Multi-Angle) if not yet present"
>
<RefreshCw size={14} className={seedWorkflowsMut.isPending ? 'animate-spin' : ''} />
{seedWorkflowsMut.isPending ? 'Seeding…' : 'Seed Standard Workflows'}
</button>
<p className="text-xs text-content-muted">
Creates the 4 standard workflow definitions (still/turntable/multi-angle) if they don't exist.
</p>
</div>
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* E-Mail / SMTP Settings */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
<div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">E-Mail Notifications (SMTP)</h2>
<p className="text-xs text-content-muted mt-0.5">
Configure outbound SMTP for email notifications. Enable only when credentials are set.
</p>
</div>
<div className="p-6 space-y-4">
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={smtp.smtp_enabled ?? false}
onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_enabled: e.target.checked }))}
className="w-4 h-4 rounded"
/>
<span className="text-sm font-medium text-content">Enable email sending</span>
</label>
{smtp.smtp_enabled && (
<span className="badge badge-green text-xs">Active</span>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">SMTP Host</label>
<input
type="text"
value={smtp.smtp_host ?? ''}
onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_host: e.target.value }))}
placeholder="smtp.example.com"
className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Port</label>
<input
type="number"
value={smtp.smtp_port ?? 587}
onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_port: parseInt(e.target.value) || 587 }))}
className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Username</label>
<input
type="text"
value={smtp.smtp_user ?? ''}
onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_user: e.target.value }))}
placeholder="user@example.com"
className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Password</label>
<input
type="password"
value={smtp.smtp_password ?? ''}
onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_password: e.target.value }))}
placeholder="••••••••"
className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div className="col-span-2">
<label className="block text-xs font-medium text-content-secondary mb-1">From Address</label>
<input
type="email"
value={smtp.smtp_from_address ?? ''}
onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_from_address: e.target.value }))}
placeholder="noreply@schaeffler.com"
className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
</div>
<button
onClick={() => { updateSettingsMut.mutate(smtpDraft as any); setSmtpDraft({}) }}
disabled={updateSettingsMut.isPending || Object.keys(smtpDraft).length === 0}
className="btn-primary text-sm"
>
Save SMTP Settings
</button>
</div>
</div>
)}
{/* ------------------------------------------------------------------ */}
{/* Templates */}
{/* ------------------------------------------------------------------ */}