wulf-pulse/components/admin/route53/record-editor-dialog.tsx
lorentz fadfcb8345 feat(24-07): record editor dialog with create, edit, and immediate delete
- RecordEditorDialog: plain-state form (name/type/ttl/values), type
  selector offers exactly the six D-01 writable types, name/type
  read-only in edit mode
- Submit disabled while in flight (T-24-20); 400/409/502 responses
  render the server message inline and keep the dialog open
- RecordDeleteConfirm: single misclick-guard confirmation, deletes
  immediately with no staged/approval state (D-03)
- Wired into the Records tab actions cell from the prior commit
2026-08-05 20:52:34 -04:00

370 lines
12 KiB
TypeScript

'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<Route53WritableType>('A');
const [ttl, setTtl] = useState(DEFAULT_TTL);
const [values, setValues] = useState<string[]>(['']);
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={(next) => !submitting && onOpenChange(next)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{mode === 'create' ? 'New DNS record' : 'Edit DNS record'}</DialogTitle>
<DialogDescription>
{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.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="record-name">Name</Label>
<Input
id="record-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="www.example.com"
disabled={mode === 'edit'}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="record-type">Type</Label>
<Select
value={type}
onValueChange={(v) => setType(v as Route53WritableType)}
disabled={mode === 'edit'}
>
<SelectTrigger id="record-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{RECORD_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="record-ttl">TTL (seconds)</Label>
<Input
id="record-ttl"
type="number"
min={0}
max={2147483647}
value={ttl}
onChange={(e) => setTtl(Number(e.target.value))}
/>
</div>
</div>
<div className="space-y-1.5">
<Label>Values</Label>
<div className="space-y-2">
{values.map((v, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
value={v}
onChange={(e) => updateValue(idx, e.target.value)}
placeholder={type === 'MX' ? '10 mail.example.com' : 'value'}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeValue(idx)}
disabled={values.length <= 1}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addValue}
disabled={values.length >= MAX_RESOURCE_RECORDS}
>
<Plus className="w-3.5 h-3.5 mr-1.5" />
Add value
</Button>
</div>
{formError && (
<div className="text-sm text-destructive bg-destructive/10 rounded p-2">{formError}</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={submitting || !name.trim()}>
{submitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{mode === 'create' ? 'Create record' : 'Save changes'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── 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<string | null>(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 (
<Dialog open={open} onOpenChange={(next) => !submitting && onOpenChange(next)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete DNS record</DialogTitle>
<DialogDescription>
This dialog is a single misclick guard confirming deletes the record from AWS
Route 53 immediately, with no further review step.
</DialogDescription>
</DialogHeader>
<div className="rounded-md border p-3 text-sm space-y-1">
<div>
<span className="text-muted-foreground">Name:</span> {record.name}
</div>
<div>
<span className="text-muted-foreground">Type:</span> {record.type}
</div>
<div>
<span className="text-muted-foreground">TTL:</span> {record.ttl ?? '—'}
</div>
<div>
<span className="text-muted-foreground">Values:</span> {values}
</div>
</div>
{error && <div className="text-sm text-destructive bg-destructive/10 rounded p-2">{error}</div>}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={submitting}>
{submitting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}
Delete record
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}