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