371 lines
12 KiB
TypeScript
371 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>
|
||
|
|
);
|
||
|
|
}
|