diff --git a/components/admin/route53/record-editor-dialog.tsx b/components/admin/route53/record-editor-dialog.tsx new file mode 100644 index 0000000..a8c180c --- /dev/null +++ b/components/admin/route53/record-editor-dialog.tsx @@ -0,0 +1,370 @@ +'use client'; + +/** + * RecordEditorDialog / RecordDeleteConfirm — create, edit, and delete forms + * for AWS Route 53 DNS records, driving the plan 24-05 CRUD routes. + * + * Plain useState form fields — no react-hook-form (CLAUDE.md scopes that to + * admin/auth forms; this matches the surrounding /admin/sync/* pages' plain- + * state style). + * + * D-01: the type selector offers only the six writable record types. NS and + * SOA never appear here — the server-side validator is the real gate + * (lib/services/route53-record-validation.ts), this is UI consistency only. + * + * D-03: RecordDeleteConfirm's single confirmation dialog is a misclick guard + * only. Deletion executes immediately on confirm — do not add a typed-name + * check, a second reviewer step, or any staged/pending state; doing so would + * turn this into exactly the multi-step gate D-03 rules out. + * + * T-24-20: both the save and delete controls disable themselves while their + * own request is in flight, preventing a double-submit against the same + * hosted zone from producing AWS's PriorRequestNotComplete. + */ + +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Loader2, Plus, X, Trash2 } from 'lucide-react'; +import type { Route53Record, Route53WritableType } from '@/lib/types/route53'; + +/** D-01: closed allowlist — must match + * lib/services/route53-record-validation.ts WRITABLE_RECORD_TYPES exactly. + * NS and SOA are zone-delegation records and are never offered here. */ +const RECORD_TYPES: Route53WritableType[] = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV']; +const DEFAULT_TTL = 300; +const MAX_RESOURCE_RECORDS = 100; + +interface WriteResponseBody { + error?: string; + message?: string; + propagationStatus?: 'INSYNC' | 'PENDING'; +} + +// ── RecordEditorDialog (create / edit) ────────────────────────────────────── + +export interface RecordEditorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + zoneId: string; + mode: 'create' | 'edit'; + record?: Route53Record; + onSaved: () => void; +} + +export function RecordEditorDialog({ + open, + onOpenChange, + zoneId, + mode, + record, + onSaved, +}: RecordEditorDialogProps) { + const [name, setName] = useState(''); + const [type, setType] = useState('A'); + const [ttl, setTtl] = useState(DEFAULT_TTL); + const [values, setValues] = useState(['']); + const [submitting, setSubmitting] = useState(false); + const [formError, setFormError] = useState(null); + + useEffect(() => { + if (!open) return; + if (mode === 'edit' && record) { + setName(record.name); + setType((record.type as Route53WritableType) ?? 'A'); + setTtl(record.ttl ?? DEFAULT_TTL); + const existing = (record.resourceRecords ?? []).map((r) => r.value); + setValues(existing.length > 0 ? existing : ['']); + } else { + setName(''); + setType('A'); + setTtl(DEFAULT_TTL); + setValues(['']); + } + setFormError(null); + }, [open, mode, record]); + + const updateValue = (idx: number, v: string) => { + setValues((prev) => prev.map((existing, i) => (i === idx ? v : existing))); + }; + + const addValue = () => { + setValues((prev) => (prev.length >= MAX_RESOURCE_RECORDS ? prev : [...prev, ''])); + }; + + const removeValue = (idx: number) => { + setValues((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== idx))); + }; + + const handleSubmit = async () => { + setSubmitting(true); + setFormError(null); + try { + const resourceRecords = values + .map((v) => v.trim()) + .filter((v) => v.length > 0) + .map((v) => ({ value: v })); + + const payload = { name, type, ttl: Number(ttl), resourceRecords }; + const url = + mode === 'create' + ? `/api/route53/zones/${zoneId}/records` + : `/api/route53/zones/${zoneId}/records/${encodeURIComponent(record!.recordKey)}`; + const method = mode === 'create' ? 'POST' : 'PATCH'; + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const body: WriteResponseBody = await res.json().catch(() => ({})); + + if (res.ok) { + const propagationLabel = body.propagationStatus === 'INSYNC' ? 'Propagated' : 'Submitted — propagating'; + toast.success(`Record ${mode === 'create' ? 'created' : 'updated'} — ${propagationLabel}`); + onSaved(); + onOpenChange(false); + return; + } + + const message = body.message || body.error || 'Failed to save record'; + setFormError(message); + toast.error(message); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to save record'; + setFormError(message); + toast.error(message); + } finally { + setSubmitting(false); + } + }; + + return ( + !submitting && onOpenChange(next)}> + + + {mode === 'create' ? 'New DNS record' : 'Edit DNS record'} + + {mode === 'create' + ? 'Creates a resource record set in AWS Route 53.' + : 'Name and type cannot be changed here — renaming a record set is a delete-plus-create, not an update.'} + + + +
+
+ + setName(e.target.value)} + placeholder="www.example.com" + disabled={mode === 'edit'} + /> +
+ +
+
+ + +
+
+ + setTtl(Number(e.target.value))} + /> +
+
+ +
+ +
+ {values.map((v, idx) => ( +
+ updateValue(idx, e.target.value)} + placeholder={type === 'MX' ? '10 mail.example.com' : 'value'} + /> + +
+ ))} +
+ +
+ + {formError && ( +
{formError}
+ )} +
+ + + + + +
+
+ ); +} + +// ── RecordDeleteConfirm ────────────────────────────────────────────────────── + +export interface RecordDeleteConfirmProps { + open: boolean; + onOpenChange: (open: boolean) => void; + zoneId: string; + record?: Route53Record; + onSaved: () => void; +} + +export function RecordDeleteConfirm({ + open, + onOpenChange, + zoneId, + record, + onSaved, +}: RecordDeleteConfirmProps) { + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (open) setError(null); + }, [open]); + + if (!record) return null; + + const handleDelete = async () => { + setSubmitting(true); + setError(null); + try { + const res = await fetch( + `/api/route53/zones/${zoneId}/records/${encodeURIComponent(record.recordKey)}`, + { method: 'DELETE' } + ); + const body: WriteResponseBody = await res.json().catch(() => ({})); + + if (res.ok) { + toast.success('Record deleted'); + onSaved(); + onOpenChange(false); + return; + } + + const message = body.message || body.error || 'Failed to delete record'; + setError(message); + toast.error(message); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete record'; + setError(message); + toast.error(message); + } finally { + setSubmitting(false); + } + }; + + const values = (record.resourceRecords ?? []).map((r) => r.value).join(', ') || '—'; + + return ( + !submitting && onOpenChange(next)}> + + + Delete DNS record + + This dialog is a single misclick guard — confirming deletes the record from AWS + Route 53 immediately, with no further review step. + + + +
+
+ Name: {record.name} +
+
+ Type: {record.type} +
+
+ TTL: {record.ttl ?? '—'} +
+
+ Values: {values} +
+
+ + {error &&
{error}
} + + + + + +
+
+ ); +}