'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}
}
); }