feat: add EmailConfigPage with SMTP form, registration toggle, and test email

This commit is contained in:
hsiegeln
2026-04-25 18:02:30 +02:00
parent f85b5a3634
commit 9aa535ace8

296
ui/src/pages/vendor/EmailConfigPage.tsx vendored Normal file
View File

@@ -0,0 +1,296 @@
import { useState } from 'react';
import {
Alert,
Button,
Card,
FormField,
Input,
Spinner,
useToast,
} from '@cameleer/design-system';
import { Send, Trash2, Save, Power } from 'lucide-react';
import {
useEmailConnector,
useSaveEmailConnector,
useDeleteEmailConnector,
useTestEmailConnector,
useToggleRegistration,
} from '../../api/email-connector-hooks';
import styles from '../../styles/platform.module.css';
export function EmailConfigPage() {
const { toast } = useToast();
const { data: connector, isLoading, isError } = useEmailConnector();
const saveMutation = useSaveEmailConnector();
const deleteMutation = useDeleteEmailConnector();
const testMutation = useTestEmailConnector();
const toggleMutation = useToggleRegistration();
const [editing, setEditing] = useState(false);
const [host, setHost] = useState('');
const [port, setPort] = useState('587');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [fromEmail, setFromEmail] = useState('');
const [testTo, setTestTo] = useState('');
const [confirmDelete, setConfirmDelete] = useState(false);
const isConfigured = connector != null;
const showForm = !isConfigured || editing;
function startEditing() {
if (connector) {
setHost(connector.host);
setPort(String(connector.port));
setUsername(connector.username);
setPassword('');
setFromEmail(connector.fromEmail);
}
setEditing(true);
}
async function handleSave() {
if (!host || !username || !password || !fromEmail) {
toast({ title: 'All fields are required', variant: 'error' });
return;
}
try {
await saveMutation.mutateAsync({
host,
port: parseInt(port, 10) || 587,
username,
password,
fromEmail,
});
toast({ title: 'Email connector saved', variant: 'success' });
setEditing(false);
setPassword('');
} catch (err) {
toast({ title: 'Failed to save', description: String(err), variant: 'error' });
}
}
async function handleDelete() {
try {
await deleteMutation.mutateAsync();
toast({ title: 'Email connector removed', variant: 'success' });
setConfirmDelete(false);
setEditing(false);
} catch (err) {
toast({ title: 'Failed to delete', description: String(err), variant: 'error' });
}
}
async function handleTest() {
if (!testTo) {
toast({ title: 'Enter a recipient email address', variant: 'error' });
return;
}
try {
const result = await testMutation.mutateAsync(testTo);
if (result.status === 'sent') {
toast({ title: 'Test email sent', description: result.message, variant: 'success' });
} else {
toast({ title: 'Test failed', description: result.message, variant: 'error' });
}
} catch (err) {
toast({ title: 'Test failed', description: String(err), variant: 'error' });
}
}
async function handleToggleRegistration() {
if (!connector) return;
const newValue = !connector.registrationEnabled;
try {
await toggleMutation.mutateAsync(newValue);
toast({
title: newValue ? 'Registration enabled' : 'Registration disabled',
variant: 'success',
});
} catch (err) {
toast({ title: 'Failed to toggle registration', description: String(err), variant: 'error' });
}
}
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
<Spinner />
</div>
);
}
if (isError) {
return (
<div style={{ padding: 24 }}>
<Alert variant="error" title="Failed to load email configuration">
Could not fetch email connector data. Please refresh.
</Alert>
</div>
);
}
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
<h1 className={styles.heading}>Email Connector</h1>
{!isConfigured && (
<Alert variant="info" title="Email delivery not configured">
Self-service registration is disabled. Configure an SMTP connector to enable email
verification for sign-up and password reset.
</Alert>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: 16 }}>
{/* Current config card (when configured and not editing) */}
{isConfigured && !editing && (
<Card title="SMTP Configuration">
<div className={styles.dividerList}>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Host</span>
<span className={styles.kvValue}>{connector.host}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Port</span>
<span className={styles.kvValue}>{connector.port}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Username</span>
<span className={styles.kvValue}>{connector.username}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>Password</span>
<span className={styles.kvValueMono}>{'*'.repeat(8)}</span>
</div>
<div className={styles.kvRow}>
<span className={styles.kvLabel}>From Email</span>
<span className={styles.kvValue}>{connector.fromEmail}</span>
</div>
<div style={{ paddingTop: 8, display: 'flex', gap: 8 }}>
<Button variant="secondary" onClick={startEditing}>
Edit
</Button>
{!confirmDelete ? (
<Button variant="secondary" onClick={() => setConfirmDelete(true)}>
<Trash2 size={14} style={{ marginRight: 6 }} />
Remove
</Button>
) : (
<>
<Button variant="primary" onClick={handleDelete} loading={deleteMutation.isPending}
style={{ background: 'var(--error)' }}>
Confirm removal
</Button>
<Button variant="secondary" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
</>
)}
</div>
</div>
</Card>
)}
{/* SMTP form (when unconfigured or editing) */}
{showForm && (
<Card title={isConfigured ? 'Edit SMTP Configuration' : 'SMTP Configuration'}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<FormField label="SMTP Host *">
<Input
placeholder="smtp.example.com"
value={host}
onChange={(e) => setHost(e.target.value)}
/>
</FormField>
<FormField label="SMTP Port *">
<Input
type="number"
placeholder="587"
value={port}
onChange={(e) => setPort(e.target.value)}
/>
</FormField>
<FormField label="Username *">
<Input
placeholder="user@example.com"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</FormField>
<FormField label="Password *">
<Input
type="password"
placeholder={isConfigured ? 'Enter new password' : 'Password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</FormField>
<FormField label="From Email *">
<Input
type="email"
placeholder="noreply@example.com"
value={fromEmail}
onChange={(e) => setFromEmail(e.target.value)}
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="primary" onClick={handleSave} loading={saveMutation.isPending}>
<Save size={14} style={{ marginRight: 6 }} />
{isConfigured ? 'Update' : 'Save'}
</Button>
{editing && (
<Button variant="secondary" onClick={() => setEditing(false)}>
Cancel
</Button>
)}
</div>
</div>
</Card>
)}
{/* Registration toggle (when configured) */}
{isConfigured && !editing && (
<Card title="Self-Service Registration">
<div className={styles.dividerList}>
<p className={styles.description}>
{connector.registrationEnabled
? 'New users can register with their email address and verify via a code sent to their inbox.'
: 'Registration is disabled. Only admin-invited users can sign in.'}
</p>
<div style={{ paddingTop: 8 }}>
<Button
variant={connector.registrationEnabled ? 'secondary' : 'primary'}
onClick={handleToggleRegistration}
loading={toggleMutation.isPending}
>
<Power size={14} style={{ marginRight: 6 }} />
{connector.registrationEnabled ? 'Disable Registration' : 'Enable Registration'}
</Button>
</div>
</div>
</Card>
)}
{/* Test email (when configured and not editing) */}
{isConfigured && !editing && (
<Card title="Send Test Email">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<FormField label="Recipient">
<Input
type="email"
placeholder="you@example.com"
value={testTo}
onChange={(e) => setTestTo(e.target.value)}
/>
</FormField>
<Button variant="primary" onClick={handleTest} loading={testMutation.isPending}>
<Send size={14} style={{ marginRight: 6 }} />
Send Test Email
</Button>
</div>
</Card>
)}
</div>
</div>
);
}