feat: add EmailConfigPage with SMTP form, registration toggle, and test email
This commit is contained in:
296
ui/src/pages/vendor/EmailConfigPage.tsx
vendored
Normal file
296
ui/src/pages/vendor/EmailConfigPage.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user