Files
floci-ui/packages/frontend/src/components/SesConsolePanel.tsx
T
Zakaria 91ce11274d
CI / checks (pull_request) Successful in 25m47s
chore: show TheCloud instead of Floci in console text and messages
Labels, empty states, error messages and defaults the console shows now name
TheCloud or its runtimes. Identifiers that must match the emulator (FLOCI_*
settings, the X-Floci-CSRF header, emulator-returned host names) are unchanged.
2026-10-24 14:30:44 -04:00

72 lines
19 KiB
TypeScript

import {useEffect, useState} from 'react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {Loader2, Mail, Plus, RefreshCw, Send, Trash2} from 'lucide-react'
import {createSesConfigurationSet, createSesEventDestination, createSesIdentity, deleteSesConfigurationSet, deleteSesEventDestination, deleteSesIdentity, deleteSesSuppression, deleteSesTemplate, getSesTemplate, listSesConfigurationSets, listSesEventBuses, listSesEventDestinations, listSesIdentities, listSesSuppressions, listSesTemplates, listSesTopics, putSesSuppression, renderSesTemplate, saveSesTemplate, sendSesRaw, sendSesSimple, sendSesTemplate} from '@/api/sesClient'
const SIMULATORS = ['success@simulator.amazonses.com', 'bounce@simulator.amazonses.com', 'complaint@simulator.amazonses.com']
const EVENT_TYPES = ['SEND', 'DELIVERY', 'BOUNCE', 'COMPLAINT', 'REJECT']
export function SesConsolePanel({runtimeReachable}: {runtimeReachable: boolean}) {
const qc = useQueryClient()
const [identity, setIdentity] = useState('')
const [mode, setMode] = useState<'simple' | 'raw' | 'template'>('simple')
const [from, setFrom] = useState('')
const [to, setTo] = useState(SIMULATORS[0])
const [subject, setSubject] = useState('Local SES test')
const [text, setText] = useState('Sent from TheCloud\'s SES console.')
const [html, setHtml] = useState('')
const [raw, setRaw] = useState('From: sender@example.test\r\nTo: success@simulator.amazonses.com\r\nSubject: Raw SES test\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nHello from a raw MIME message.')
const [configurationSet, setConfigurationSet] = useState('')
const [templateName, setTemplateName] = useState('')
const [templateData, setTemplateData] = useState('{"name":"TheCloud"}')
const [editingTemplate, setEditingTemplate] = useState('')
const [templateSubject, setTemplateSubject] = useState('Hello {{name}}')
const [templateText, setTemplateText] = useState('Hello {{name}}')
const [templateHtml, setTemplateHtml] = useState('')
const [rendered, setRendered] = useState('')
const [suppressionAddress, setSuppressionAddress] = useState('')
const [suppressionReason, setSuppressionReason] = useState<'BOUNCE' | 'COMPLAINT'>('BOUNCE')
const [configurationSetName, setConfigurationSetName] = useState('')
const [selectedConfigurationSet, setSelectedConfigurationSet] = useState('')
const [destinationName, setDestinationName] = useState('')
const [destinationKind, setDestinationKind] = useState<'sns' | 'eventbridge' | 'cloudwatch'>('sns')
const [destinationTarget, setDestinationTarget] = useState('')
const [events, setEvents] = useState<string[]>(['SEND', 'BOUNCE', 'DELIVERY'])
const [dimensionName, setDimensionName] = useState('ses:configuration-set')
const identities = useQuery({queryKey: ['ses-identities'], queryFn: listSesIdentities, enabled: runtimeReachable})
const templates = useQuery({queryKey: ['ses-templates'], queryFn: listSesTemplates, enabled: runtimeReachable})
const suppressions = useQuery({queryKey: ['ses-suppressions'], queryFn: listSesSuppressions, enabled: runtimeReachable})
const configurationSets = useQuery({queryKey: ['ses-configuration-sets'], queryFn: listSesConfigurationSets, enabled: runtimeReachable})
const topics = useQuery({queryKey: ['ses-topics'], queryFn: listSesTopics, enabled: runtimeReachable})
const buses = useQuery({queryKey: ['ses-event-buses'], queryFn: listSesEventBuses, enabled: runtimeReachable})
const destinations = useQuery({queryKey: ['ses-event-destinations', selectedConfigurationSet], queryFn: () => listSesEventDestinations(selectedConfigurationSet), enabled: runtimeReachable && Boolean(selectedConfigurationSet)})
const refreshMailbox = () => qc.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'email']})
const send = useMutation({mutationFn: async () => {
const destination = {ToAddresses: to.split(',').map((value) => value.trim()).filter(Boolean)}
const options = configurationSet ? {ConfigurationSetName: configurationSet} : {}
if (mode === 'raw') return sendSesRaw({...options, FromEmailAddress: from || undefined, Destination: destination, Content: {Raw: {Data: btoa(unescape(encodeURIComponent(raw)))}}})
if (mode === 'template') return sendSesTemplate({...options, FromEmailAddress: from, Destination: destination, Content: {Template: {TemplateName: templateName, TemplateData: templateData}}})
return sendSesSimple({...options, FromEmailAddress: from, Destination: destination, Content: {Simple: {Subject: {Data: subject}, Body: {...(text ? {Text: {Data: text}} : {}), ...(html ? {Html: {Data: html}} : {})}}}})
}, onSuccess: () => { refreshMailbox(); setTimeout(refreshMailbox, 300) }})
const createIdentity = useMutation({mutationFn: () => createSesIdentity(identity.trim()), onSuccess: () => {setIdentity(''); void qc.invalidateQueries({queryKey: ['ses-identities']})}})
const saveTemplate = useMutation({mutationFn: () => saveSesTemplate(editingTemplate.trim(), {Subject: templateSubject, ...(templateText ? {Text: templateText} : {}), ...(templateHtml ? {Html: templateHtml} : {})}, templates.data?.some((item) => item.TemplateName === editingTemplate) ?? false), onSuccess: () => void qc.invalidateQueries({queryKey: ['ses-templates']})})
const renderTemplate = useMutation({mutationFn: () => renderSesTemplate(editingTemplate || templateName, templateData), onSuccess: setRendered})
const addSuppression = useMutation({mutationFn: () => putSesSuppression(suppressionAddress.trim(), suppressionReason), onSuccess: () => {setSuppressionAddress(''); void qc.invalidateQueries({queryKey: ['ses-suppressions']})}})
const createConfigurationSet = useMutation({mutationFn: () => createSesConfigurationSet(configurationSetName.trim()), onSuccess: () => {setConfigurationSetName(''); void qc.invalidateQueries({queryKey: ['ses-configuration-sets']})}})
const addDestination = useMutation({mutationFn: () => createSesEventDestination(selectedConfigurationSet, {EventDestinationName: destinationName.trim(), EventDestination: {Enabled: true, MatchingEventTypes: events, ...(destinationKind === 'sns' ? {SnsDestination: {TopicArn: destinationTarget}} : destinationKind === 'eventbridge' ? {EventBridgeDestination: {EventBusArn: destinationTarget}} : {CloudWatchDestination: {DimensionConfigurations: [{DimensionName: dimensionName, DimensionValueSource: 'MESSAGE_TAG', DefaultDimensionValue: 'local'}]}})}}), onSuccess: () => {setDestinationName(''); void qc.invalidateQueries({queryKey: ['ses-event-destinations', selectedConfigurationSet]})}})
const error = send.error ?? createIdentity.error ?? saveTemplate.error ?? renderTemplate.error ?? addSuppression.error ?? createConfigurationSet.error ?? addDestination.error
useEffect(() => { if (!selectedConfigurationSet && configurationSets.data?.[0]?.ConfigurationSetName) setSelectedConfigurationSet(configurationSets.data[0].ConfigurationSetName) }, [configurationSets.data, selectedConfigurationSet])
async function loadTemplate(name: string) { setEditingTemplate(name); const detail = await getSesTemplate(name); setTemplateSubject(detail.TemplateContent?.Subject ?? ''); setTemplateText(detail.TemplateContent?.Text ?? ''); setTemplateHtml(detail.TemplateContent?.Html ?? '') }
const pending = send.isPending || createIdentity.isPending || saveTemplate.isPending || addSuppression.isPending || createConfigurationSet.isPending || addDestination.isPending
return <div className="ses-console">
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">SES identities</p><h3>Verify senders</h3><p className="muted compact-text">TheCloud verifies identities immediately for AWS-compatible workflows. Local sending is not sandbox-gated.</p></div><button className="button compact" type="button" onClick={() => identities.refetch()}><RefreshCw size={13}/>Refresh</button></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); if (identity.trim()) createIdentity.mutate()}}><input className="input" value={identity} onChange={(event) => setIdentity(event.target.value)} placeholder="sender@example.test or example.test" required/><button className="button primary" disabled={!runtimeReachable || createIdentity.isPending}><Plus size={13}/>Verify identity</button></form>{identities.data?.map((item) => <div className="apigateway-route-item" key={item.IdentityName}><div><code>{item.IdentityName}</code><small>{item.IdentityType} · {item.VerifiedForSendingStatus ? 'verified for sending' : 'not verified'}</small></div><button className="icon-btn danger" onClick={() => deleteSesIdentity(item.IdentityName).then(() => qc.invalidateQueries({queryKey: ['ses-identities']}))}><Trash2 size={13}/></button></div>)}</section>
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Compose</p><h3><Mail size={15}/> Send through SES</h3><p className="muted compact-text">Simulator recipients produce documented delivery, bounce, and complaint events locally.</p></div></div><div className="button-row">{SIMULATORS.map((address) => <button className="button compact" type="button" key={address} onClick={() => setTo(address)}>{address.split('@')[0]}</button>)}</div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); send.mutate()}}><label className="dynamic-field"><span>Format</span><select className="input" value={mode} onChange={(event) => setMode(event.target.value as typeof mode)}><option value="simple">Simple</option><option value="raw">Raw MIME</option><option value="template">Template</option></select></label><label className="dynamic-field"><span>From</span><input className="input" value={from} onChange={(event) => setFrom(event.target.value)} placeholder="Verified identity" required={mode !== 'raw'}/></label><label className="dynamic-field"><span>To</span><input className="input" value={to} onChange={(event) => setTo(event.target.value)} required/></label><label className="dynamic-field"><span>Configuration set</span><select className="input" value={configurationSet} onChange={(event) => setConfigurationSet(event.target.value)}><option value="">None</option>{configurationSets.data?.map((item) => <option key={item.ConfigurationSetName} value={item.ConfigurationSetName}>{item.ConfigurationSetName}</option>)}</select></label>{mode === 'simple' && <><label className="dynamic-field dynamic-field--span"><span>Subject</span><input className="input" value={subject} onChange={(event) => setSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text body</span><textarea className="textarea code-textarea small" value={text} onChange={(event) => setText(event.target.value)}/></label><label className="dynamic-field"><span>HTML body</span><textarea className="textarea code-textarea small" value={html} onChange={(event) => setHtml(event.target.value)}/></label></>}{mode === 'raw' && <label className="dynamic-field dynamic-field--span"><span>Raw MIME</span><textarea className="textarea code-textarea" value={raw} onChange={(event) => setRaw(event.target.value)} required/></label>}{mode === 'template' && <><label className="dynamic-field"><span>Template</span><select className="input" value={templateName} onChange={(event) => setTemplateName(event.target.value)} required><option value="">Select template</option>{templates.data?.map((item) => <option key={item.TemplateName} value={item.TemplateName}>{item.TemplateName}</option>)}</select></label><label className="dynamic-field"><span>Template data (JSON)</span><textarea className="textarea code-textarea small" value={templateData} onChange={(event) => setTemplateData(event.target.value)} required/></label></>}<button className="button primary" disabled={!runtimeReachable || pending}>{send.isPending ? <Loader2 className="spin" size={13}/> : <Send size={13}/>}{send.isPending ? 'Sending' : 'Send email'}</button></form>{send.isSuccess && <p className="success-text compact-text">Accepted by SES. Refresh the mailbox above to inspect the captured message.</p>}</section>
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Templates</p><h3>Author and render</h3></div></div><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); saveTemplate.mutate()}}><label className="dynamic-field"><span>Template name</span><input className="input" value={editingTemplate} onChange={(event) => setEditingTemplate(event.target.value)} required/></label><label className="dynamic-field"><span>Subject</span><input className="input" value={templateSubject} onChange={(event) => setTemplateSubject(event.target.value)} required/></label><label className="dynamic-field"><span>Text</span><textarea className="textarea code-textarea small" value={templateText} onChange={(event) => setTemplateText(event.target.value)}/></label><label className="dynamic-field"><span>HTML</span><textarea className="textarea code-textarea small" value={templateHtml} onChange={(event) => setTemplateHtml(event.target.value)}/></label><div className="button-row"><button className="button primary" disabled={!runtimeReachable || !editingTemplate.trim() || saveTemplate.isPending}>Save template</button><button className="button" type="button" disabled={!editingTemplate.trim() || renderTemplate.isPending} onClick={() => renderTemplate.mutate()}>Test render</button></div></form>{rendered && <pre className="metadata-block">{rendered}</pre>}{templates.data?.map((item) => <div className="apigateway-route-item" key={item.TemplateName}><code>{item.TemplateName}</code><div><button className="button compact" onClick={() => void loadTemplate(item.TemplateName)}>Edit</button><button className="icon-btn danger" onClick={() => deleteSesTemplate(item.TemplateName).then(() => qc.invalidateQueries({queryKey: ['ses-templates']}))}><Trash2 size={13}/></button></div></div>)}</section>
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Suppression list</p><h3>Accepted but suppressed</h3><p className="muted compact-text">Suppressed mail is still accepted and captured, but it is not relayed.</p></div></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); addSuppression.mutate()}}><input className="input" type="email" value={suppressionAddress} onChange={(event) => setSuppressionAddress(event.target.value)} placeholder="recipient@example.test" required/><select className="input" value={suppressionReason} onChange={(event) => setSuppressionReason(event.target.value as typeof suppressionReason)}><option value="BOUNCE">Bounce</option><option value="COMPLAINT">Complaint</option></select><button className="button primary" disabled={addSuppression.isPending}>Add</button></form>{suppressions.data?.map((item) => <div className="apigateway-route-item" key={item.EmailAddress}><div><code>{item.EmailAddress}</code><small>{item.Reason}</small></div><button className="icon-btn danger" onClick={() => deleteSesSuppression(item.EmailAddress ?? '').then(() => qc.invalidateQueries({queryKey: ['ses-suppressions']}))}><Trash2 size={13}/></button></div>)}</section>
<section className="table-panel"><div className="dynamic-stage-header"><div><p className="eyebrow">Configuration sets</p><h3>Publish SES events</h3><p className="muted compact-text">Send, delivery, bounce, and complaint events use real SNS, EventBridge, and CloudWatch destinations.</p></div></div><form className="input-row" onSubmit={(event) => {event.preventDefault(); createConfigurationSet.mutate()}}><input className="input" value={configurationSetName} onChange={(event) => setConfigurationSetName(event.target.value)} placeholder="Configuration set name" required/><button className="button primary" disabled={createConfigurationSet.isPending}>Create</button></form><label className="dynamic-field"><span>Active configuration set</span><select className="input" value={selectedConfigurationSet} onChange={(event) => setSelectedConfigurationSet(event.target.value)}><option value="">Select configuration set</option>{configurationSets.data?.map((item) => <option key={item.ConfigurationSetName} value={item.ConfigurationSetName}>{item.ConfigurationSetName}</option>)}</select></label>{selectedConfigurationSet && <><form className="dynamic-form" onSubmit={(event) => {event.preventDefault(); addDestination.mutate()}}><label className="dynamic-field"><span>Destination name</span><input className="input" value={destinationName} onChange={(event) => setDestinationName(event.target.value)} required/></label><label className="dynamic-field"><span>Target type</span><select className="input" value={destinationKind} onChange={(event) => {setDestinationKind(event.target.value as typeof destinationKind); setDestinationTarget('')}}><option value="sns">SNS topic</option><option value="eventbridge">EventBridge bus</option><option value="cloudwatch">CloudWatch metric</option></select></label>{destinationKind !== 'cloudwatch' ? <label className="dynamic-field dynamic-field--span"><span>{destinationKind === 'sns' ? 'SNS topic' : 'Event bus'}</span><select className="input" value={destinationTarget} onChange={(event) => setDestinationTarget(event.target.value)} required><option value="">Select target</option>{(destinationKind === 'sns' ? topics.data?.map((item) => ({value: item.TopicArn, label: item.TopicArn})) : buses.data?.map((item) => ({value: item.Arn, label: item.Name ?? item.Arn})))?.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label> : <label className="dynamic-field dynamic-field--span"><span>CloudWatch dimension</span><input className="input" value={dimensionName} onChange={(event) => setDimensionName(event.target.value)} required/><small>Events publish counts to AWS/SES using this message-tag dimension.</small></label>}<fieldset className="dynamic-field dynamic-field--span"><span>Events</span><div className="button-row">{EVENT_TYPES.map((type) => <label key={type}><input type="checkbox" checked={events.includes(type)} onChange={() => setEvents((current) => current.includes(type) ? current.filter((item) => item !== type) : [...current, type])}/>{type}</label>)}</div></fieldset><button className="button primary" disabled={!destinationName.trim() || events.length === 0 || (destinationKind !== 'cloudwatch' && !destinationTarget) || addDestination.isPending}>Add destination</button></form>{destinations.data?.map((item) => <div className="apigateway-route-item" key={item.EventDestinationName}><div><code>{item.EventDestinationName}</code><small>{item.MatchingEventTypes?.join(', ')} · {item.SnsDestination?.TopicArn ?? item.EventBridgeDestination?.EventBusArn ?? 'CloudWatch'}</small></div><button className="icon-btn danger" onClick={() => deleteSesEventDestination(selectedConfigurationSet, item.EventDestinationName ?? '').then(() => qc.invalidateQueries({queryKey: ['ses-event-destinations', selectedConfigurationSet]}))}><Trash2 size={13}/></button></div>)}</>} {configurationSets.data?.map((item) => <button className="button danger compact" key={`delete-${item.ConfigurationSetName}`} onClick={() => deleteSesConfigurationSet(item.ConfigurationSetName).then(() => qc.invalidateQueries({queryKey: ['ses-configuration-sets']}))}>Delete {item.ConfigurationSetName}</button>)}</section>
{error instanceof Error && <p className="error-text compact-text">{error.message}</p>}
</div>
}