diff --git a/CHANGELOG.md b/CHANGELOG.md index e3fd4393..116ff908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,69 @@ +## [Unreleased] + +### Added -- Webhook subscriptions CRUD UI (PR #69) + API hardening (PR #68) +- **New ``/webhooks`` management page** (``web/src/app/webhooks/page.tsx``) + renders a full subscription lifecycle alongside the existing DLQ surface: + the ``WebhookSubscriptionsGrid`` (AG Grid, id/url/description/filter/created_at + columns + per-row ``Revoke`` action) and a ``CreateWebhookPanel`` + (3-phase state machine: closed → form → one-shot secret reveal with + a mandatory acknowledge-before-Done guard, modelled on Stripe / GitHub). +- **New ``CreateWebhookPanel`` 3-phase state machine**: closed → + form → reveal. The reveal phase surfaces the one-shot plaintext + ``secret`` returned by ``POST /api/v1/webhooks`` (Fernet envelope + encryption-at-rest for every later fetch), with copy-to-clipboard + + a ``I have securely stored this secret.`` acknowledgement gate before + ``Done`` (which calls ``router.refresh()`` so the new row appears in + the grid). +- **New ``DEFAULT_WEBHOOK_FILTER`` shared constant** exporting + ``{ kind: "upload_completed" }`` from ``web/src/lib/api/webhooks.ts`` + -- mirrors the Pydantic closed-set on the backend so callers (form + + third-party integrations) automatically produce a backend-acceptable + payload, closing the prod-bug "leave-empty-filter → 422" gap. +- **New network-boundary tests** (``web/tests/api/webhooks.test.ts``): + 6 ``vi.stubGlobal('fetch', ...)`` cases asserting the parsed JSON + body of ``POST /api/v1/webhooks`` carries ``filter: { kind: + upload_completed }`` when the caller omits or passes an empty filter, + that the inverse pattern (caller-supplied filter) is honoured, that + ``fetchWebhookSubscriptions`` forwards ``limit`` + ``offset`` as query + params, that ``revokeWebhook`` sends ``DELETE`` to the canonicalised + URL, and that 4xx upstream bodies propagate via ``ApiError``. +- **Global header nav expansion** (``web/src/app/layout.tsx``): the + pre-PR-69 nav exposed only ``Players`` + ``Compare``. PR-69 adds + ``/webhooks`` + ``/account`` + ``/upload`` so every primary + analyst surface is reachable from any other page. + +### Changed -- API hardening (PR #68) +- **DB session lifecycle**: ``get_session`` (``apps/api/src/gw2analytics_api/database.py``) + now commits on successful yield and rolls back on exception. Route + handlers no longer need to call ``db.commit()`` for their writes; + the canonical 5xx path rolls back automatically. +- **N+1 on ``GET /fights``**: ``selectinload(OrmFight.agents, OrmFight.skills)`` + eagerly fetches the per-fight agents + skills alongside the trimmed + page, so a 50-fight list with 5 agents/fight stops issuing 51 + round-trips. +- **Pagination on ``GET /webhooks``**: ``limit`` (1-1000) + + ``offset`` (>= 0) query parameters, mirroring the existing + ``GET /fights`` pattern. + +### Fixed +- **a11y warning on CreateWebhookPanel**: each of the 3 form inputs + (URL, description, filter) carries a ``name="..."`` attribute so + Chrome devtools no longer surfaces "A form field element should + have an id or name attribute". ``aria-describedby`` continues to + anchor the URL field's help text; the form's tab order remains + unchanged. + +### Changed (docs + repo hygiene) +- **CONTRIBUTING.md**: added a §"Branch cleanup after merge" + subsection documenting the GitHub UI delete button, the + ``git push origin --delete `` CLI path, and the + one-sweep cleanup snippet for accumulating dep-bumps + feature + branches. +- **.github/dependabot.yml**: header comment now references + the GitHub repo setting "Automatically delete head branches" + (Settings → General → Pull Requests) which is the canonical + way to stop dependabot branches from accumulating per PR. + ## [0.16.0] - 2026-07-24 ### Added — Full-stack refactoring: Phases 1-7 complete diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7360c28a..a4eeb35e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -493,6 +493,36 @@ the formatter for any file in the index. If it auto-fixes something, add the fix to the **same commit** (not a follow-up) to keep each commit self-contained. +### Branch cleanup after merge + +Once a PR is squash-merged into `main`, **delete the feature branch** +to keep the branch list scannable. Two paths: + +1. **GitHub UI** (one click): PR page → scroll to the bottom → + "Delete branch" button (appears immediately after the merge). +2. **CLI**: `git push origin --delete ` from any local repo. + +Dependabot-created branches (the ``dependabot/npm_and_yarn/...`` and +``dependabot/uv/...`` ones) are auto-deleted after merge **only** if +the repository setting **Settings → General → Pull Requests → +"Automatically delete head branches"** is enabled. The setting lives at +the repo level, not in ``.github/dependabot.yml`` -- verify it is on +after opening the repo (otherwise dependabot branches accumulate one +per dep-update PR). Stale feature branches from past merges can be +pruned in a single sweep with: + +```bash +# Lists + deletes every merged-into-main remote branch (read-only first): +git branch -r --merged main | grep -vE '^\s*origin/(main|HEAD)' | tee /tmp/merged-branches.txt +# Sanity-check the list, then delete (uncomment to actually run): +git push origin --delete $(awk '{$1=$1;print}' /tmp/merged-branches.txt | sed 's|^origin/||') +``` + +The ``grep -vE`` excludes ``main`` + ``HEAD`` so a typo doesn't nuke +the canonical branch. The same script works for cleaning up +``refactor/*`` / ``fix/*`` / ``feat/*`` branches whose PRs were +merged in past sprints. + ## Tagging We use **semver with a scope suffix**: diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index d297e498..a4a9a685 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gw2analytics_api" -version = "0.10.25" +version = "0.10.26" description = "FastAPI app for GW2Analytics." requires-python = ">=3.12" # v0.15.2: this nested workspace member has NO PEP 639 ``license`` / diff --git a/uv.lock b/uv.lock index b5446762..10735586 100644 --- a/uv.lock +++ b/uv.lock @@ -927,7 +927,7 @@ dev = [] [[package]] name = "gw2analytics-api" -version = "0.10.25" +version = "0.10.26" source = { editable = "apps/api" } dependencies = [ { name = "alembic" }, diff --git a/web/package.json b/web/package.json index 978fc301..1ec8ab19 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "0.10.28", + "version": "0.10.29", "private": true, "// license": "v0.15.2: marks the package as proprietary / unlicensed to match LICENSE + NOTICE.md. The package is also private (line above) which prevents npm publish by default; this field is the explicit publisher-facing posture for parity with PEP 639 / SPDX markers.", "license": "UNLICENSED", diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index a07bbcdf..cbe0b5f1 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -163,18 +163,22 @@ export default function RootLayout({ GW2Analytics - {/* v0.10.0 plan 032: secondary nav links between - the brand and the search bar. ``/players`` and - ``/players/compare`` are the 2 most common - cross-fight destinations; the analyst can pivot - from any page to either view without typing a - URL. The link styles mirror the brand link so - the nav reads as one consistent strip. */} + {/* v0.10.0 plan 032 + PR2 hardening: secondary nav + links between the brand and the search bar. The 5 + links cover the analyst's primary surfaces: + - /players : cross-fight roll-up of every account. + - /players/compare : side-by-side comparison. + - /account : GW2 API key resolver (BFF proxy). + - /webhooks : webhook subscription CRUD + DLQ. + - /upload : full 3-step upload wizard. + The link styles mirror the brand link so the nav + reads as one consistent strip. */} diff --git a/web/src/app/webhooks/page.tsx b/web/src/app/webhooks/page.tsx index 6522e5de..e991ad0a 100644 --- a/web/src/app/webhooks/page.tsx +++ b/web/src/app/webhooks/page.tsx @@ -1,28 +1,122 @@ -import { fetchWebhookDeliveries, type WebhookDlqRow } from "@/lib/api"; +/** + * v0.10.25 PR2 webhook management page — extends the pre-PR2 + * DLQ-only surface with the full subscription lifecycle: + * + * - \`CreateWebhookPanel\` (Client Component) renders an inline + * 3-phase state machine (closed / form / reveal) so the + * analyst can register a new subscription without leaving + * the page. The :class:\`CreateWebhookPanel\` docstring + * documents the rationale for the inline reveal flow + * (one-shot plaintext secret, Fernet envelope at rest). + * + * - \`WebhookSubscriptionsGrid\` (Client Component, AG Grid) + * renders the active subscriptions list with a per-row + * \`Revoke\` action that delegates to + * :func:\`revokeWebhook\` + \`router.refresh()\`. The + * subscriptions list is the operator's primary surface; the + * DLQ below it is the second-order view (failed deliveries + * belonging to a subscription). + * + * Why parallel \`Promise.all\` for the two fetches + * ================================================= + * The subscriptions list and the DLQ are independent reads + * from two unrelated tables. A sequential second fetch would + * double the round-trip latency; \`Promise.all\` shortens the + * critical path from \`2 \u00d7 ttfb\` to \`max(ttfb_sub, + * ttfb_dlq)\`. Each catch is isolated so a single failed fetch + * still renders the other surface (a DLQ outage is operational + * noise, not a full-page error). + * + * Why no auth gate + * ================ + * The whole app is unauthenticated; any visitor can hit + * \`/api/v1/webhooks\`. A future auth cycle can wrap this + * server component in \`if (!session) return \` without touching the client child components + * (\`CreateWebhookPanel\` + \`WebhookSubscriptionsGrid\` + DLQ + * grid are all self-contained). + * + * Why \`force-dynamic\` + * ==================== + * Bypasses Next.js's static caching so newly registered + * subscriptions + newly delivered/replayed rows surface on the + * next render without a 60s revalidate sweep. Standard for + * read-write ops surfaces. + */ + +import { + fetchWebhookDeliveries, + fetchWebhookSubscriptions, + formatApiError, + type WebhookDlqRow, + type WebhookSubscriptionRow, +} from "@/lib/api"; import { WebhookDlqGrid } from "@/components/WebhookDlqGrid"; +import { WebhookSubscriptionsGrid } from "@/components/WebhookSubscriptionsGrid"; +import { CreateWebhookPanel } from "@/components/CreateWebhookPanel"; import styles from "./page.module.css"; export const dynamic = "force-dynamic"; export default async function WebhooksPage() { - let rows: WebhookDlqRow[] = []; - let error: string | null = null; + const [subsResult, dlqResult] = await Promise.allSettled([ + fetchWebhookSubscriptions(), + fetchWebhookDeliveries(), + ]); + + let subscriptions: WebhookSubscriptionRow[] = []; + let subscriptionsError: string | null = null; + if (subsResult.status === "fulfilled") { + subscriptions = subsResult.value; + } else { + subscriptionsError = formatApiError(subsResult.reason); + } - try { - rows = await fetchWebhookDeliveries(); - } catch (err) { - error = err instanceof Error ? err.message : String(err); + let dlq: WebhookDlqRow[] = []; + let dlqError: string | null = null; + if (dlqResult.status === "fulfilled") { + dlq = dlqResult.value; + } else { + dlqError = formatApiError(dlqResult.reason); } return (
-

Webhook DLQ

- {error ? ( -

Error: {error}

- ) : ( - - )} +

Webhooks

+ +
+

Subscriptions

+

+ Registered webhook endpoints. The plaintext secret is + shown only when a subscription is first created; copy it + then. Click on any subscription below to inspect or + revoke it. +

+ + {subscriptionsError ? ( +

+ Error: {subscriptionsError} +

+ ) : ( + + )} +
+ +
+

DLQ (failed deliveries)

+

+ Webhook deliveries that exhausted retries. Each row + offers a one-shot replay. +

+ {dlqError ? ( +

+ Error: {dlqError} +

+ ) : ( + + )} +
); } diff --git a/web/src/components/CreateWebhookPanel.module.css b/web/src/components/CreateWebhookPanel.module.css new file mode 100644 index 00000000..59b0d855 --- /dev/null +++ b/web/src/components/CreateWebhookPanel.module.css @@ -0,0 +1,251 @@ +.container { + margin-bottom: 16px; +} + +.openButton { + padding: 10px 16px; + border-radius: 8px; + border: 1px solid var(--accent); + background: transparent; + color: var(--accent); + cursor: pointer; + font-size: 14px; + font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; + transition: opacity 0.15s ease-in-out; +} + +.openButton:hover { + opacity: 0.85; +} + +.form { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface); +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.labelText { + font-size: 13px; + font-weight: 600; + color: var(--foreground); +} + +.input { + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--background); + color: var(--foreground); + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 13px; +} + +.input:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.textarea { + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--background); + color: var(--foreground); + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 13px; + resize: vertical; + min-height: 60px; +} + +.textarea:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.helpText { + font-size: 12px; + color: var(--foreground-muted, var(--foreground)); + opacity: 0.65; +} + +.error { + padding: 8px 12px; + border-radius: 6px; + background: color-mix(in srgb, var(--error, #ff6e6e) 12%, transparent); + border: 1px solid var(--error, #ff6e6e); + color: var(--error, #ff6e6e); + font-size: 13px; +} + +.buttonRow { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.submit { + padding: 10px 16px; + border-radius: 6px; + border: 1px solid var(--accent); + background: var(--accent); + color: var(--accent-foreground, #ffffff); + cursor: pointer; + font-size: 14px; + font-weight: 600; + font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; + transition: opacity 0.15s ease-in-out; +} + +.submit:hover:not(:disabled) { + opacity: 0.9; +} + +.submit:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.muted { + padding: 10px 16px; + border-radius: 6px; + border: 1px solid var(--border); + background: transparent; + color: var(--foreground); + cursor: pointer; + font-size: 14px; + font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; + transition: opacity 0.15s ease-in-out; +} + +.muted:hover:not(:disabled) { + opacity: 0.85; +} + +.muted:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.reveal { + padding: 16px; + border-radius: 8px; + border: 1px solid var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--surface)); + display: flex; + flex-direction: column; + gap: 12px; + outline: none; +} + +.revealTitle { + font-size: 16px; + font-weight: 700; + color: var(--foreground); + margin: 0; +} + +.revealLede { + font-size: 13px; + line-height: 1.5; + color: var(--foreground); + margin: 0; +} + +.revealLede code { + font-family: var(--font-geist-mono), ui-monospace, monospace; + color: var(--accent); +} + +/* + ``.copyBlock`` is the focus-target wrapper for the + :focus-within hint rule below. The :focus-within selector + matches when ANY descendant (the secret ````, + the Copy button) has keyboard focus, so the cursor + selection fallback surfaces for keyboard-only users. +*/ +.copyBlock { + display: flex; + flex-direction: column; + gap: 6px; +} + +.copyBlock:focus-within .copyStatusHint { + display: block; +} + +.secretRow { + display: flex; + align-items: stretch; + gap: 8px; +} + +.secret { + flex: 1; + padding: 10px 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--background); + color: var(--foreground); + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 13px; + word-break: break-all; + user-select: all; +} + +.copyButton { + padding: 0 16px; + border-radius: 6px; + border: 1px solid var(--accent); + background: transparent; + color: var(--accent); + cursor: pointer; + font-size: 13px; + font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; + transition: opacity 0.15s ease-in-out; + white-space: nowrap; +} + +.copyButton:hover { + opacity: 0.85; +} + +.copyStatus { + font-size: 12px; + color: var(--accent); + margin: 0; +} + +.copyStatusHint { + font-size: 12px; + color: var(--foreground-muted, var(--foreground)); + opacity: 0.65; + margin: 0; + display: none; +} + +.copyStatusHint kbd { + font-family: var(--font-geist-mono), ui-monospace, monospace; + background: var(--background); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 6px; + font-size: 11px; +} + +.ackRow { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--foreground); +} diff --git a/web/src/components/CreateWebhookPanel.tsx b/web/src/components/CreateWebhookPanel.tsx new file mode 100644 index 00000000..f2fe375b --- /dev/null +++ b/web/src/components/CreateWebhookPanel.tsx @@ -0,0 +1,516 @@ +/** + * CreateWebhookPanel -- Client Component that drives a 3-phase + * state machine for registering a new webhook subscription: + * + * 1. ``closed`` -- only the ``New subscription`` trigger + * button is visible. Keeps the subscriptions table compact + * for operators who are just here to inspect / revoke / + * replay. + * 2. ``form`` -- URL (required) + description (optional) + + * filter (optional JSON object, advanced). Submit + * transitions to either ``reveal`` (on 201) OR renders an + * inline error card (on 4xx -- typically the + * `_validate_webhook_url` SSRF guard returning 422). + * 3. ``reveal`` -- the one-shot plaintext ``secret`` callout + * with a Copy-to-clipboard button + a mandatory + * ``I have securely stored this secret.`` acknowledgement + * checkbox. The ``Done`` button is disabled until the + * checkbox is checked. Closing the panel triggers + * ``router.refresh()`` so the new subscription surfaces in + * the WebhookSubscriptionsGrid table. + * + * Why a 3-phase state machine (not a single form with the + * secret revealed inline) + * ============================================================ + * The backend returns the plaintext secret ONCE on the 201 + * response (Fernet envelope encryption-at-rest for every later + * fetch). If the form simply closed after a successful POST, + * the secret would be silently lost and the operator would + * have to re-register. The forced acknowledgement flow mirrors + * industry standards (Stripe, GitHub webhooks, Slack apps) so + * the analyst is forced to interact with the secret value + * BEFORE it disappears, not after. + * + * Why a single Client Component (not a multi-page wizard) + * ========================================================== + * The whole flow is short (< 30 s typical) and has zero + * cross-page state. A multi-page wizard would require + * server-side storage of the in-flight secret (because the + * one-shot contract means the secret does NOT survive a + * refresh). The 3-phase Client Component flow keeps the + * plaintext secret in React state for the lifetime of the + * wizard and only crosses the network once. + * + * Why useReducer + discriminated union + * ------------------------------------- + * The 3 phases have explicit forbidden transitions (cannot + * submit from `reveal`, cannot edit from `closed`, etc.). + * 3 separate `useState`s would invite incoherent + * combinations (e.g. `mode === "reveal"` but + * `revealedSecret === null`). The discriminated union + * narrows the legal states to one-of-N and makes each + * transition a named action. + */ + +"use client"; + +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useReducer, useRef } from "react"; + +import { + createWebhook, + DEFAULT_WEBHOOK_FILTER, + formatApiError, + type CreateWebhookPayload, + type WebhookSubscriptionCreatedRow, +} from "@/lib/api"; + +import styles from "./CreateWebhookPanel.module.css"; + +type Phase = "closed" | "form" | "reveal"; + +type State = + | { phase: "closed" } + | { + phase: "form"; + url: string; + description: string; + filter: string; + submitting: boolean; + error: string | null; + } + | { + phase: "reveal"; + created: WebhookSubscriptionCreatedRow; + acknowledged: boolean; + copied: boolean; + }; + +type Action = + | { type: "open" } + | { type: "cancel" } + | { type: "update"; field: "url" | "description" | "filter"; value: string } + | { type: "submit-start" } + | { type: "submit-success"; created: WebhookSubscriptionCreatedRow } + | { type: "submit-failure"; message: string } + | { type: "acknowledge"; value: boolean } + | { type: "copied" } + | { type: "done" }; + +const INITIAL_STATE: State = { phase: "closed" }; + +function emptyForm(): Extract { + return { + phase: "form", + url: "", + description: "", + filter: "", + submitting: false, + error: null, + }; +} + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "open": + // From any closed or revealed state, re-entering the form + // resets every field. We do NOT reuse the prior values for + // two reasons: (1) the operator could accidentally re-POST + // the same URL twice; (2) starting fresh matches the + // expect-after-error flow (the analyst fixes the URL and + // re-submits -- starting from a stale URL adds confusion). + return emptyForm(); + case "cancel": + return { phase: "closed" }; + case "update": + if (state.phase !== "form") { + return state; + } + return { ...state, [action.field]: action.value, error: null }; + case "submit-start": + if (state.phase !== "form") { + return state; + } + return { ...state, submitting: true, error: null }; + case "submit-success": + // Symmetric with submit-start / submit-failure: only + // transitions from `form`. Guards against the dispatcher + // ever firing this from a non-form phase (e.g. an + // accidental post from the reveal panel's async code). + if (state.phase !== "form") { + return state; + } + return { + phase: "reveal", + created: action.created, + acknowledged: false, + copied: false, + }; + case "submit-failure": + if (state.phase !== "form") { + return state; + } + return { ...state, submitting: false, error: action.message }; + case "acknowledge": + if (state.phase !== "reveal") { + return state; + } + return { ...state, acknowledged: action.value }; + case "copied": + if (state.phase !== "reveal") { + return state; + } + return { ...state, copied: true }; + case "done": + if (state.phase !== "reveal") { + return state; + } + return { phase: "closed" }; + } +} + +export function CreateWebhookPanel() { + const router = useRouter(); + const [state, dispatch] = useReducer(reducer, INITIAL_STATE); + + // When the panel moves INTO the `reveal` phase, focus the + // secret callout so a keyboard-only operator can navigate the + // acknowledge / done controls immediately. We skip the call + // on the FIRST render so re-mounts mid-task don't yank + // focus (parallels the `upload/page.tsx` focus effect). + const hasMountedRef = useRef(false); + const revealRef = useRef(null); + useEffect(() => { + if (!hasMountedRef.current) { + hasMountedRef.current = true; + return; + } + if (state.phase === "reveal") { + revealRef.current?.focus(); + } + }, [state.phase]); + + const handleSubmit = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + if (state.phase !== "form" || state.submitting) { + return; + } + const url = state.url.trim(); + if (!url) { + dispatch({ type: "submit-failure", message: "URL is required" }); + return; + } + // Parse the optional filter field as JSON. An empty / + // whitespace string is treated as :data:\`DEFAULT_WEBHOOK_FILTER\` + // (``{ kind: "upload_completed" }``) because the backend + // rejects ``filter.kind``-missing subscriptions at the + // Pydantic validator. The closed set of supported kinds + // mirrors the dispatcher's event types -- sending + // anything else at creation yields a subscription that + // is never fired (the dispatcher silently skips it). + // A parse failure surfaces as an inline error so the + // analyst can fix the JSON before submitting; we do NOT + // silently send malformed JSON. + let filterPayload: Record = { + ...DEFAULT_WEBHOOK_FILTER, + }; + const filterTrimmed = state.filter.trim(); + if (filterTrimmed !== "") { + try { + const parsed: unknown = JSON.parse(filterTrimmed); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + throw new Error("filter must be a JSON object"); + } + filterPayload = parsed as Record; + } catch (err) { + dispatch({ + type: "submit-failure", + message: `filter: ${ + err instanceof Error ? err.message : String(err) + }`, + }); + return; + } + } + dispatch({ type: "submit-start" }); + const payload: CreateWebhookPayload = { + url, + description: + state.description.trim() === "" ? null : state.description, + filter: filterPayload, + }; + try { + const created = await createWebhook(payload); + dispatch({ type: "submit-success", created }); + } catch (err) { + dispatch({ type: "submit-failure", message: formatApiError(err) }); + } + }, + // Deps: TypeScript cannot narrow a discriminated-union + // type at the useCallback deps-array level (the early + // return inside the closure narrows ``state`` after the + // guard, but the deps array is evaluated at the + // useCallback call site where ``state`` is the whole + // union -- ``state.url`` etc. don't statically exist on + // the union, so a per-field deps list fails with TS2339). + // + // The canonical workaround is ``[state]`` -- wider than + // ideal but safe: form-field edits always dispatch a + // reducer action that constructs a new ``state`` object, + // so the wider deps doesn't introduce a stale-closure + // bug. The early-return guard inside the closure + // (``state.phase !== "form" || state.submitting``) + // rejects every non-form case so the body never + // dereferences a missing field. + [state], + ); + + const handleCopy = useCallback(async (secret: string) => { + try { + if (typeof navigator !== "undefined" && navigator.clipboard) { + await navigator.clipboard.writeText(secret); + } else { + // Fallback for exotic browsers without the Clipboard + // API: create a throwaway textarea, select, and use + // the legacy `document.execCommand("copy")` shim. + const ta = document.createElement("textarea"); + ta.value = secret; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + } + dispatch({ type: "copied" }); + } catch { + // Copy failure is non-fatal: the analyst can still + // visually read the secret and copy it manually. We do + // not surface an error card because the secret is + // already revealed -- surfacing would only distract. + } + }, []); + + const handleDone = useCallback(() => { + dispatch({ type: "done" }); + // `router.refresh()` re-runs the server component which + // re-fetches both the subscriptions list + the DLQ (the + // latter is unaffected, the former picks up the newly + // registered row). + router.refresh(); + }, [router]); + + if (state.phase === "closed") { + return ( +
+ +
+ ); + } + + if (state.phase === "form") { + const canSubmit = state.url.trim() !== "" && !state.submitting; + return ( +
+
+ + + + +