diff --git a/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-07-SUMMARY.md b/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-07-SUMMARY.md new file mode 100644 index 0000000..039962e --- /dev/null +++ b/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-07-SUMMARY.md @@ -0,0 +1,166 @@ +--- +phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud +plan: 07 +subsystem: aws-route53 +tags: [route53, admin-ui, dns, crud, audit-history] + +requires: + - phase: 24-04 + provides: "isRoute53Configured / health check with D-12 NS-delegation comparison" + - phase: 24-05 + provides: "/api/route53/* read + CRUD write routes, audit lifecycle" + - phase: 24-06 + provides: "route53-incremental/route53-full scheduler entries, /admin/sync tile" +provides: + - "app/admin/sync/route53/page.tsx — Zones / Records / History / Schedule tabbed detail page" + - "components/admin/route53/record-editor-dialog.tsx — RecordEditorDialog (create/edit) + RecordDeleteConfirm (immediate delete)" +affects: [phase-24-verification] + +tech-stack: + added: [] + patterns: + - "Client-side pagination over full-list API responses (zones/records/history endpoints have no offset/limit params) via a local paginate() slice helper" + - "Row-click-to-select-and-switch-tab (Zones row -> Records tab pre-filtered; Records row action -> History tab pre-filtered) instead of a nested drill-down route" + - "DetailModal's generic Fields/Raw-tab fallback used as-is for zone and history-row drill-downs — no bespoke JSON-diff UI (per 24-RESEARCH.md 'Don't Hand-Roll')" + +key-files: + created: + - app/admin/sync/route53/page.tsx + - components/admin/route53/record-editor-dialog.tsx + modified: + - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-VALIDATION.md + +key-decisions: + - "GET /api/route53/zones, /records, and /history return { items: [...] }, not a bare array as the plan's block stated — page.tsx reads .items from each response (ground truth from the actual plan 24-05 route implementations, not the plan's interface doc)." + - "The plan's History tab spec calls for 'a zone-wide view when no record is selected', but the only history endpoint implemented in plan 24-05 is per-record (GET .../records/{recordId}/history) — there is no zone-wide history route and this plan's files_modified does not authorize adding one. Implemented instead: History tab shows an EmptyState prompting the operator to pick a record's History action on the Records tab; once selected, the tab shows that record's full ledger with a Clear control to return to the prompt." + - "GET /api/route53/sync never actually returns 503 for the unconfigured case in the current plan-24-05 implementation (only the POST trigger and the CRUD write routes do) — the page still checks for a 503 on the GET response per the plan's literal acceptance criteria (defensive / forward-compatible), and separately surfaces a 503 from the POST Sync Now action via a toast + the same unconfigured empty-state." + +requirements-completed: [] # SC-2/SC-4/SC-6 not yet marked complete — Task 3 (live checkpoint) is unresolved, see below + +duration: "~55 min, 2 of 3 tasks (Task 3 is a blocking human checkpoint)" +completed: "2026-08-05" +--- + +# Phase 24 Plan 7: Route 53 Admin Detail Page + Record Editor Summary — PARTIAL (checkpoint pending) + +Built the `/admin/sync/route53` four-tab detail page and the create/edit/delete record dialog +that drive the plan 24-05 CRUD routes. Task 3 (the nine-step live AWS end-to-end verification) +is a blocking `checkpoint:human-verify` and has **not** been run — this plan is not complete +until a human runs it against a real AWS account and reports back. + +## Performance + +- **Duration:** ~55 min (Tasks 1-2 only) +- **Tasks:** 2 of 3 completed; Task 3 blocked awaiting human verification +- **Files modified:** 2 created, 1 doc updated + +## Accomplishments + +- `/admin/sync/route53` renders Zones / Records / History / Schedule tabs following the + `veeam`/`pax8` sync-detail-page shape, with a Sync Now trigger (bounded 3s/20-poll loop), + a status line (zone/record/history-row counts + last sync status), and a graceful + unconfigured empty-state. +- Records tab actions cell gates on the D-01 writable-type allowlist (`A`/`AAAA`/`CNAME`/ + `MX`/`TXT`/`SRV`); NS/SOA rows render a muted "Read-only" label with an explanatory + `title`, with the real 400 gate remaining server-side (plan 24-05). +- History tab renders `pulse_crud` vs `sync_detected_drift` with visually distinct + `StatusBadge` tones (D-06), plus change-action (create/update/delete) and actor columns. +- `RecordEditorDialog` (create/edit) and `RecordDeleteConfirm` (single misclick-guard, + immediate delete, D-03) both wired to the plan 24-05 write routes, with the submit/delete + controls disabled while their own request is in flight (T-24-20) and server 400/409/502 + messages rendered inline without closing the dialog. +- Confirmed the dev server is live on port 3100 and `/admin/sync/route53` correctly + redirects an unauthenticated request to `/auth/sign-in` (middleware working as expected) + ahead of handing off the checkpoint. + +## Task Commits + +1. **Task 1: Zones, records, and history page shell** - `b3048be` (feat) +2. **Task 2: Record editor dialog with create, edit, and immediate delete** - `fadfcb8` (feat) +3. **Task 3: End-to-end phase verification** - NOT STARTED (blocking `checkpoint:human-verify`; requires a real AWS account and a running, authenticated browser session — see "Checkpoint Status" below) + +**Plan metadata:** this commit (docs: partial plan summary + validation update) + +## Files Created/Modified + +- `app/admin/sync/route53/page.tsx` — four-tab detail page (789 lines) +- `components/admin/route53/record-editor-dialog.tsx` — `RecordEditorDialog` + `RecordDeleteConfirm` (370 lines) +- `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-VALIDATION.md` — marked 24-07-T1/24-07-T2 rows green; 24-07-T3 (the checkpoint) left pending + +## Decisions Made + +See `key-decisions` in frontmatter: (1) API responses are `{ items: [...] }` envelopes, not +bare arrays as the plan's interfaces block stated — followed the actual plan-24-05 route +implementations; (2) no zone-wide history endpoint exists, so the History tab requires a +record selection first rather than showing an unfiltered zone view; (3) the unconfigured-503 +check is implemented on the sync-status GET per the plan's literal spec even though the +current route never emits it, and is backed up by handling a 503 from the POST trigger. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Grep-checked acceptance criteria false-positived on doc-comment prose** +- **Found during:** Task 1 verification (`grep -c dangerouslySetInnerHTML` / `grep -c swr\|react-query`) +- **Issue:** the file-header doc comment explained the security posture by naming the literal + strings the acceptance grep checks are zero occurrences of (e.g. "no dangerouslySetInnerHTML", + "no SWR/react-query"), which made both grep counts 1 instead of 0 even though no actual + usage exists — the same false-positive pattern documented in 24-05's SUMMARY. +- **Fix:** reworded the two affected doc-comment lines to describe the same guarantee without + the literal grepped substrings ("no raw-HTML injection helper", "no client-side data-fetching + library"). +- **Files modified:** `app/admin/sync/route53/page.tsx` +- **Verification:** `grep -c dangerouslySetInnerHTML` and `grep -c "swr\|react-query\|useSWR"` both return 0; `npx tsc --noEmit --pretty` and `npm run build` both clean after the edit. +- **Committed in:** `b3048be` (part of Task 1 commit — caught before commit, not a follow-up fix) + +### Out-of-Scope Discovery (logged, not fixed) + +None beyond what's already documented in earlier phase-24 plans' `deferred-items.md`. + +## Checkpoint Status: BLOCKED — awaiting human verification + +Task 3 is ``. Per the executor's +worktree-agent instructions, this is a live, 9-step verification against a real AWS Route 53 +account (creating/updating/deleting an actual DNS TXT record, curl auth-gating checks against +a real session cookie, toggling `/admin/integrations`, and a live console DNS edit for drift +detection). None of these steps were fabricated or run unilaterally. + +**Confirmed before handoff:** +- The dev server responds on port 3100. +- `GET /admin/sync/route53` (unauthenticated) returns `307` to `/auth/sign-in?callbackUrl=%2Fadmin%2Fsync%2Froute53` — middleware gating is active as expected. + +**The nine verification steps (verbatim from `24-07-PLAN.md` Task 3 ``)** still +need to be run by a human against a real AWS account and Pulse admin session: + +1. Sync (SC-1) — `/admin/sync` tile → Sync Now → non-zero zone/record counts + a `completed` `sync_history` row. +2. Create (SC-2/SC-3/SC-4) — new TXT record → success toast with propagation status → confirm in AWS console → one `create`/`pulse_crud` history row. +3. Update — edit the value → confirm in AWS console → a second `update` history row with the original value in `before_value`. +4. Delete (D-03) — delete → single confirmation, executes immediately → confirm gone from AWS console → a third `delete` history row. +5. Audit completeness (SC-3/D-07) — `route53_audit_log` has 3 `committed` rows with the operator's email. +6. Failure logging (D-07/T-24-01) — NS write via curl → 400; a genuine AWS-side rejection (e.g. apex CNAME) → 502 + a `failed` audit row with a sanitized `error_message`. +7. Auth gating (D-04/T-24-02) — `user`-role session → create curl returns 403; `GET /api/route53/zones` still 200 for that session. +8. Drift detection (D-06) — edit a record directly in the AWS console, Sync Now → a `sync_detected_drift` history row with a null actor. +9. Health check (D-12/SC-6) — `/admin/integrations` shows an AWS Route 53 row with live status (and any NS-delegation mismatch); toggling Route 53 off suppresses the health row while `POST /api/route53/sync` still succeeds (D-10). + +**Resume signal (from the plan):** reply "approved" if all nine steps behave as described, or +list which step numbers failed and what was observed instead. + +**24-VALIDATION.md status:** the three Manual-Only Verifications rows this checkpoint is meant +to exercise (write-route auth gating, live AWS round-trip, D-10 display-only disable) have +**not** been updated with observed outcomes yet — that update is deferred until the human +completes the nine steps above and reports back. Do not mark 24-07 (or the phase) complete +until that happens. + +## Self-Check: PASSED + +- FOUND: app/admin/sync/route53/page.tsx +- FOUND: components/admin/route53/record-editor-dialog.tsx +- Commit `b3048be` present in `git log` +- Commit `fadfcb8` present in `git log` + +## Threat Flags + +None beyond what's already covered by this plan's own `` (T-24-25, T-24-02, +T-24-20, T-24-01, T-24-26) — all addressed as designed in "Accomplishments" above. No new +network endpoints, auth paths, or schema changes were introduced; both new files only call +the already-existing `/api/route53/*` surface from plan 24-05. diff --git a/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-VALIDATION.md b/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-VALIDATION.md index 1d50420..6108b19 100644 --- a/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-VALIDATION.md +++ b/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-VALIDATION.md @@ -61,8 +61,8 @@ AWS error sanitization, change-batch construction, propagation polling, NS compa | 24-05-T3m | 24-05 | 3 | SC-2 (D-04) | **T-24-02** | `requireAdmin()` returns 403 for a `user`-role session hitting a write route directly | manual / smoke | none automated — see Manual-Only table row 1 | ❌ manual by convention | ⬜ pending | | 24-06-T1 | 24-06 | 3 | SC-1, SC-6 | T-24-21, T-24-22, T-24-24 | Both sync types dispatch via dynamic import and gate on `isRoute53Configured()` only, never on `integration_settings` (D-10) | grep gate + typecheck | `npx tsc --noEmit --pretty && test $(grep -A12 "config.sync_type === 'route53" lib/services/sync-scheduler.ts \| grep -c integration_settings) -eq 0` | ➕ modified by task | ⬜ pending | | 24-06-T2 | 24-06 | 3 | SC-6 | T-24-23 | Tile entry uses a valid `COLOR_MAP` key; logo asset contains no `script` or `xlink:href` | grep gate + build | `npm run build && test $(grep -ci 'script\|xlink:href' public/logos/route53.svg) -eq 0` | ➕ created by task | ⬜ pending | -| 24-07-T1 | 24-07 | 4 | SC-4, SC-6 | T-24-25 | Page renders record values and history before/after as escaped text; no `dangerouslySetInnerHTML`; no SWR/react-query | grep gate + build | `npm run build && test $(grep -c 'dangerouslySetInnerHTML' app/admin/sync/route53/page.tsx) -eq 0` | ➕ created by task | ⬜ pending | -| 24-07-T2 | 24-07 | 4 | SC-2 | **T-24-01**, T-24-20, T-24-26 | Type selector offers only the six writable types; submit disabled while in flight; no approval-workflow state introduced | grep gate + build | `npm run build && test $(grep -c "'NS'\|'SOA'" components/admin/route53/record-editor-dialog.tsx) -eq 0` | ➕ created by task | ⬜ pending | +| 24-07-T1 | 24-07 | 4 | SC-4, SC-6 | T-24-25 | Page renders record values and history before/after as escaped text; no `dangerouslySetInnerHTML`; no SWR/react-query | grep gate + build | `npm run build && test $(grep -c 'dangerouslySetInnerHTML' app/admin/sync/route53/page.tsx) -eq 0` | ✅ created by task | ✅ green | +| 24-07-T2 | 24-07 | 4 | SC-2 | **T-24-01**, T-24-20, T-24-26 | Type selector offers only the six writable types; submit disabled while in flight; no approval-workflow state introduced | grep gate + build | `npm run build && test $(grep -c "'NS'\|'SOA'" components/admin/route53/record-editor-dialog.tsx) -eq 0` | ✅ created by task | ✅ green | | 24-07-T3 | 24-07 | 4 | SC-1..SC-6 | all | Full live round-trip: sync, create/update/delete against real AWS, audit completeness, failure logging, auth gating, drift detection, D-12 health, D-10 display-only disable | manual / checkpoint | none automated — blocking `checkpoint:human-verify`, 9 steps | ❌ manual by necessity | ⬜ pending | *Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* diff --git a/app/admin/sync/route53/page.tsx b/app/admin/sync/route53/page.tsx new file mode 100644 index 0000000..9d7ad41 --- /dev/null +++ b/app/admin/sync/route53/page.tsx @@ -0,0 +1,789 @@ +'use client'; + +/** + * /admin/sync/route53 — AWS Route 53 DNS sync detail page (D-09). + * + * Follows the app/admin/sync/veeam/page.tsx shape: 'use client', plain + * useState/useEffect/fetch — no client-side data-fetching library, no + * server-only actions (CLAUDE.md) — a header with a manual "Sync Now" + * trigger, and tabbed detail views built on the shared DataTable / + * DetailModal / SyncScheduler vocabulary. + * + * Record values (from AWS) and history before/after JSONB are rendered only + * through React's default text escaping — no raw-HTML injection helper, no + * anchor/src injection from record content (T-24-25). + */ + +import { useState, useEffect, useCallback } from 'react'; +import Link from 'next/link'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { EmptyState } from '@/components/ui/empty-state'; +import DataTable, { type Column } from '@/components/admin/DataTable'; +import DetailModal from '@/components/admin/DetailModal'; +import SyncScheduler from '@/components/admin/SyncScheduler'; +import { + RecordEditorDialog, + RecordDeleteConfirm, +} from '@/components/admin/route53/record-editor-dialog'; +import { useUserTimezone } from '@/lib/hooks/use-user-timezone'; +import { + ArrowLeft, + RefreshCw, + Loader2, + Plus, + Pencil, + Trash2, + History as HistoryIcon, + Globe, + ShieldOff, + X, +} from 'lucide-react'; +import type { + Route53Zone, + Route53Record, + Route53RecordHistory, + Route53HistorySource, +} from '@/lib/types/route53'; + +// ── Constants ──────────────────────────────────────────────────────────────── + +/** D-01: the closed allowlist of writable record types — must match + * lib/services/route53-record-validation.ts WRITABLE_RECORD_TYPES exactly. + * NS and SOA are deliberately absent from this list; they render read-only. */ +const WRITABLE_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV'] as const; +const RECORD_TYPE_FILTERS = [...WRITABLE_TYPES, 'NS', 'SOA']; + +const PAGE_SIZE = 25; +const HISTORY_PAGE_SIZE = 25; +const SYNC_POLL_INTERVAL_MS = 3000; +const SYNC_POLL_MAX_ATTEMPTS = 20; + +interface SyncHistoryEntry { + id: string; + sync_type: string; + status: string; + started_at: string; + completed_at: string | null; + error_message: string | null; + triggered_by: string; +} + +interface SyncStatusResponse { + inProgress: boolean; + counts: { zones: number; records: number; historyRows: number }; + history: SyncHistoryEntry[]; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function fmtDate(d: string | null | undefined, tz: string): string { + if (!d) return 'Never'; + return new Date(d).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZone: tz, + }); +} + +function paginate(rows: T[], page: number, pageSize: number): T[] { + const start = (page - 1) * pageSize; + return rows.slice(start, start + pageSize); +} + +function isWritableType(type: string): boolean { + return (WRITABLE_TYPES as readonly string[]).includes(type); +} + +function syncStatusTone(status: string): 'ok' | 'error' | 'info' | 'warn' { + if (status === 'completed') return 'ok'; + if (status === 'failed') return 'error'; + if (status === 'started') return 'info'; + return 'warn'; +} + +function changeActionTone(action: string): 'ok' | 'info' | 'error' | 'neutral' { + if (action === 'create') return 'ok'; + if (action === 'update') return 'info'; + if (action === 'delete') return 'error'; + return 'neutral'; +} + +/** D-06: visually distinguish a Pulse-initiated write from a change AWS + * detected outside Pulse — this is the whole point of drift detection. */ +function sourceTone(source: Route53HistorySource): 'info' | 'warn' { + return source === 'sync_detected_drift' ? 'warn' : 'info'; +} + +function sourceLabel(source: Route53HistorySource): string { + return source === 'sync_detected_drift' ? 'Drift detected' : 'Pulse'; +} + +// ── Page ───────────────────────────────────────────────────────────────────── + +export default function Route53SyncPage() { + const tz = useUserTimezone(); + const [activeTab, setActiveTab] = useState('zones'); + const [refreshKey, setRefreshKey] = useState(0); + + // ── Sync status ── + const [syncStatus, setSyncStatus] = useState(null); + const [unconfigured, setUnconfigured] = useState(false); + const [syncing, setSyncing] = useState(false); + + const fetchSyncStatus = useCallback(async () => { + try { + const res = await fetch('/api/route53/sync'); + if (res.status === 503) { + setUnconfigured(true); + return; + } + if (res.ok) { + setUnconfigured(false); + setSyncStatus(await res.json()); + } + } catch (err) { + console.error('Failed to fetch Route 53 sync status:', err); + } + }, []); + + useEffect(() => { + fetchSyncStatus(); + }, [fetchSyncStatus, refreshKey]); + + const handleSyncNow = async () => { + setSyncing(true); + try { + const res = await fetch('/api/route53/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ syncType: 'full' }), + }); + if (res.status === 503) { + setUnconfigured(true); + toast.error('AWS Route 53 is not configured'); + setSyncing(false); + return; + } + if (res.status === 409) { + toast.info('Route 53 sync is already in progress'); + setSyncing(false); + return; + } + if (!res.ok) { + const body = await res.json().catch(() => ({}) as Record); + toast.error(body.message || body.error || 'Failed to start Route 53 sync'); + setSyncing(false); + return; + } + toast.success('Route 53 sync started'); + + let attempts = 0; + const poll = setInterval(async () => { + attempts += 1; + try { + const r = await fetch('/api/route53/sync'); + if (r.ok) { + const d: SyncStatusResponse = await r.json(); + setSyncStatus(d); + if (!d.inProgress) { + clearInterval(poll); + setSyncing(false); + setRefreshKey((k) => k + 1); + return; + } + } + } catch { + /* keep polling */ + } + if (attempts >= SYNC_POLL_MAX_ATTEMPTS) { + clearInterval(poll); + setSyncing(false); + } + }, SYNC_POLL_INTERVAL_MS); + } catch (err) { + console.error(err); + toast.error('Failed to start Route 53 sync'); + setSyncing(false); + } + }; + + // ── Zones tab ── + const [zones, setZones] = useState([]); + const [zonesLoading, setZonesLoading] = useState(true); + const [zonePage, setZonePage] = useState(1); + const [zoneDetail, setZoneDetail] = useState(null); + const [zoneDetailOpen, setZoneDetailOpen] = useState(false); + + const fetchZones = useCallback(async () => { + setZonesLoading(true); + try { + const res = await fetch('/api/route53/zones'); + if (res.ok) { + const d = await res.json(); + setZones(d.items ?? []); + } + } catch (err) { + console.error('Failed to fetch Route 53 zones:', err); + } finally { + setZonesLoading(false); + } + }, []); + + useEffect(() => { + if (!unconfigured) fetchZones(); + }, [fetchZones, refreshKey, unconfigured]); + + // ── Records tab ── + const [selectedZoneId, setSelectedZoneId] = useState(''); + const [records, setRecords] = useState([]); + const [recordsLoading, setRecordsLoading] = useState(false); + const [recordPage, setRecordPage] = useState(1); + const [typeFilter, setTypeFilter] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + + const fetchRecords = useCallback(async (zoneId: string, type: string, search: string) => { + if (!zoneId) { + setRecords([]); + return; + } + setRecordsLoading(true); + try { + const params = new URLSearchParams(); + if (type && type !== 'all') params.set('type', type); + if (search) params.set('search', search); + const qs = params.toString(); + const res = await fetch(`/api/route53/zones/${zoneId}/records${qs ? `?${qs}` : ''}`); + if (res.ok) { + const d = await res.json(); + setRecords(d.items ?? []); + } + } catch (err) { + console.error('Failed to fetch Route 53 records:', err); + } finally { + setRecordsLoading(false); + } + }, []); + + useEffect(() => { + if (selectedZoneId) fetchRecords(selectedZoneId, typeFilter, searchQuery); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedZoneId, typeFilter, refreshKey]); + + const handleSelectZone = (zone: Route53Zone) => { + setSelectedZoneId(zone.id); + setRecordPage(1); + setSearchQuery(''); + setTypeFilter('all'); + setActiveTab('records'); + }; + + const handleZoneSelectChange = (zoneId: string) => { + setSelectedZoneId(zoneId); + setRecordPage(1); + setSearchQuery(''); + setTypeFilter('all'); + }; + + const handleRecordSearch = (q: string) => { + setSearchQuery(q); + setRecordPage(1); + fetchRecords(selectedZoneId, typeFilter, q); + }; + + // ── History tab ── + const [selectedRecord, setSelectedRecord] = useState(null); + const [historyRows, setHistoryRows] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyPage, setHistoryPage] = useState(1); + const [historyDetail, setHistoryDetail] = useState(null); + const [historyDetailOpen, setHistoryDetailOpen] = useState(false); + + const fetchHistory = useCallback(async (zoneId: string, recordKey: string) => { + setHistoryLoading(true); + try { + const res = await fetch( + `/api/route53/zones/${zoneId}/records/${encodeURIComponent(recordKey)}/history?limit=100` + ); + if (res.ok) { + const d = await res.json(); + setHistoryRows(d.items ?? []); + } + } catch (err) { + console.error('Failed to fetch Route 53 record history:', err); + } finally { + setHistoryLoading(false); + } + }, []); + + const handleViewHistory = (record: Route53Record) => { + setSelectedRecord(record); + setHistoryPage(1); + setActiveTab('history'); + fetchHistory(record.zoneId, record.recordKey); + }; + + const clearHistorySelection = () => { + setSelectedRecord(null); + setHistoryRows([]); + }; + + useEffect(() => { + if (selectedRecord) fetchHistory(selectedRecord.zoneId, selectedRecord.recordKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refreshKey]); + + // ── Record editor / delete dialogs ── + const [editorOpen, setEditorOpen] = useState(false); + const [editorMode, setEditorMode] = useState<'create' | 'edit'>('create'); + const [editingRecord, setEditingRecord] = useState(undefined); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deletingRecord, setDeletingRecord] = useState(undefined); + + const openCreate = () => { + setEditorMode('create'); + setEditingRecord(undefined); + setEditorOpen(true); + }; + const openEdit = (record: Route53Record) => { + setEditorMode('edit'); + setEditingRecord(record); + setEditorOpen(true); + }; + const openDelete = (record: Route53Record) => { + setDeletingRecord(record); + setDeleteOpen(true); + }; + + const handleSaved = () => { + fetchRecords(selectedZoneId, typeFilter, searchQuery); + fetchZones(); + if (selectedRecord) fetchHistory(selectedRecord.zoneId, selectedRecord.recordKey); + }; + + // ── Column definitions ── + + const zoneColumns: Column[] = [ + { key: 'name', label: 'Zone Name', sortable: true }, + { + key: 'id', + label: 'Zone ID', + render: (v: string) => {v}, + }, + { + key: 'privateZone', + label: 'Private', + render: (v: boolean) => ( + {v ? 'Private' : 'Public'} + ), + }, + { + key: 'recordCount', + label: 'Records', + render: (v: number) => {v ?? 0}, + }, + { + key: 'syncedAt', + label: 'Synced At', + render: (v: string) => ( + {fmtDate(v, tz)} + ), + }, + { + key: 'authoritativeNameServers', + label: 'Details', + render: (_v: unknown, row: Route53Zone) => ( + + ), + }, + ]; + + const recordColumns: Column[] = [ + { key: 'name', label: 'Name', sortable: true }, + { + key: 'type', + label: 'Type', + render: (v: string) => ( + {v} + ), + }, + { key: 'ttl', label: 'TTL', render: (v: number | null) => {v ?? '—'} }, + { + key: 'resourceRecords', + label: 'Values', + render: (v: Array<{ value: string }> | null) => { + const joined = (v ?? []).map((r) => r.value).join(', '); + return ( + + {joined || '—'} + + ); + }, + }, + { + key: 'recordKey', + label: 'Actions', + render: (_v: unknown, row: Route53Record) => { + const writable = isWritableType(row.type); + return ( +
+ + {writable ? ( + <> + + + + ) : ( + + + Read-only + + )} +
+ ); + }, + }, + ]; + + const historyColumns: Column[] = [ + { + key: 'changedAt', + label: 'Changed At', + sortable: true, + render: (v: string) => ( + {fmtDate(v, tz)} + ), + }, + { key: 'recordName', label: 'Record' }, + { + key: 'recordType', + label: 'Type', + render: (v: string) => {v}, + }, + { + key: 'changeAction', + label: 'Action', + render: (v: string) => {v}, + }, + { + key: 'source', + label: 'Source', + render: (v: Route53HistorySource) => ( + {sourceLabel(v)} + ), + }, + { + key: 'changedByEmail', + label: 'Actor', + render: (v: string | null) => {v ?? '—'}, + }, + ]; + + // ── Derived render values ── + + const lastSyncHistory = syncStatus?.history?.[0]; + const zoneCount = syncStatus?.counts?.zones ?? 0; + const recordCount = syncStatus?.counts?.records ?? 0; + const historyRowCount = syncStatus?.counts?.historyRows ?? 0; + + if (unconfigured) { + return ( +
+
+ + + +
+ AWS Route 53 +
+

AWS Route 53

+

Hosted zones, DNS records, change history

+
+
+
+ +
+ ); + } + + return ( +
+
+ + + +
+ AWS Route 53 +
+

AWS Route 53

+

Hosted zones, DNS records, change history

+
+
+
+ +
+
+ +
+
+

+ {zoneCount.toLocaleString()} zones · {recordCount.toLocaleString()} records ·{' '} + {historyRowCount.toLocaleString()} history rows +

+

+ Last sync:{' '} + {lastSyncHistory ? ( + <> + + {lastSyncHistory.status} + + {fmtDate(lastSyncHistory.completed_at ?? lastSyncHistory.started_at, tz)} + + ) : ( + 'Never' + )} +

+
+
+ + + + Zones + Records + History + Schedule + + + {/* ── Zones tab ── */} + + + + + {/* ── Records tab ── */} + +
+
+ +
+
+ +
+ +
+ + {!selectedZoneId ? ( + + ) : ( + + )} +
+ + {/* ── History tab ── */} + + {!selectedRecord ? ( + + ) : ( + <> +
+

+ History for {selectedRecord.name}{' '} + {selectedRecord.type} +

+ +
+ { + setHistoryDetail(row); + setHistoryDetailOpen(true); + }} + isLoading={historyLoading} + emptyTitle="No history yet" + emptyDescription="Changes to this record — from Pulse or detected drift — will appear here." + /> + + )} +
+ + {/* ── Schedule tab ── */} + + + +
+ + + + + + + + +
+ ); +} 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}
} + + + + + +
+
+ ); +}