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
This commit is contained in:
parent
b3048be769
commit
fadfcb8345
1 changed files with 370 additions and 0 deletions
370
components/admin/route53/record-editor-dialog.tsx
Normal file
370
components/admin/route53/record-editor-dialog.tsx
Normal file
|
|
@ -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<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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue