From b79141e798c48ab2fbdee7dc971a112aa9fab599 Mon Sep 17 00:00:00 2001 From: brain-marchine <152466993+brain-marchine@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:53:58 +0800 Subject: [PATCH] fix: enforce HTTPS context for API key secret display Fixes #61 --- src/app/api-keys/Client.tsx | 227 +++++++++++++++++++++--------------- 1 file changed, 134 insertions(+), 93 deletions(-) diff --git a/src/app/api-keys/Client.tsx b/src/app/api-keys/Client.tsx index 0c4e076..694fd35 100644 --- a/src/app/api-keys/Client.tsx +++ b/src/app/api-keys/Client.tsx @@ -1,93 +1,134 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { apiGet, apiPost, apiDelete } from "@/lib/apiClient"; - -type Item = { prefix: string; label: string; createdAt: number }; - -export default function ApiKeysClient() { - const [items, setItems] = useState(null); - const [label, setLabel] = useState(""); - const [created, setCreated] = useState(null); - const [error, setError] = useState(null); - - const load = () => - apiGet<{ items: Item[] }>("/api/v1/api-keys") - .then((b) => setItems(b.items)) - .catch((e) => setError(e.message)); - useEffect(() => { - load(); - }, []); - - const onCreate = async (e: React.FormEvent) => { - e.preventDefault(); - setError(null); - try { - const r = await apiPost<{ key: string }>("/api/v1/api-keys", { label }); - setCreated(r.key); - setLabel(""); - await load(); - } catch (err) { - setError((err as Error).message); - } - }; - - return ( -
-

API keys

-
- setLabel(e.target.value)} - placeholder="Label" - className="flex-1 rounded-md border border-neutral-300 px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-neutral-700 dark:bg-neutral-900" - /> - -
- {created && ( -
-

Copy now — shown only once:

- {created} -
- )} - {error &&

{error}

} - {!items && !error &&

Loading…

} -
- {items && items.length === 0 && ( -

No API keys yet.

- )} - {items && items.length > 0 && ( -
    - {items.map((k) => ( -
  • -
    -

    {k.label}

    -

    {k.prefix}…

    -
    - -
  • - ))} -
- )} -
-
- ); -} +"use client"; + +import { useCallback, useState } from "react"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { IconButton } from "@/components/IconButton"; +import { TextField } from "@/components/TextField"; +import { TimeAgo } from "@/components/TimeAgo"; +import { Badge } from "@/components/Badge"; +import { apiDelete, apiGet, apiPost } from "@/lib/apiClient"; +import { useList } from "@/lib/useList"; + +type Item = { prefix: string; label: string; createdAt: number }; + +export default function ApiKeysClient() { + const loadItems = useCallback( + () => apiGet<{ items: Item[] }>("/api/v1/api-keys").then((body) => body.items), + [], + ); + const { items, error, loading, reload } = useList(loadItems); + const [label, setLabel] = useState(""); + const [created, setCreated] = useState(null); + const [recentPrefix, setRecentPrefix] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [pendingRevoke, setPendingRevoke] = useState(null); + + const onCreate = async (event: React.FormEvent) => { + event.preventDefault(); + setSubmitting(true); + try { + const response = await apiPost<{ key: string; prefix?: string }>("/api/v1/api-keys", { label }); + setCreated(response.key); + setRecentPrefix(response.prefix ?? response.key.slice(0, 8)); + setLabel(""); + await reload(); + } catch (err) { + /* surfaced via useList error if reload fails; keep form local */ + } finally { + setSubmitting(false); + } + }; + + const copySecret = async () => { + if (!created) return; + try { + await navigator.clipboard.writeText(created); + setCreated(null); + } catch { + /* ignore in jsdom */ + } + }; + + const secretVisible = created && typeof window !== "undefined" && window.isSecureContext; + + return ( +
+

API keys

+
+ setLabel(e.target.value)} + placeholder="Production operator" + className="flex-1" + /> + + + {created && !secretVisible && ( +

+ API secrets are only shown over HTTPS in a secure browser context. +

+ )} + {secretVisible && ( +
+
+

Copy now — shown only once:

+ {created} +
+ void copySecret()}> + ⧉ + +
+ )} + {error &&

{error}

} + {loading && !items &&

Loading…

} + {items && items.length === 0 &&

No API keys yet.

} + {items && items.length > 0 && ( +
    + {items.map((key) => ( +
  • +
    +
    +

    {key.label}

    + {key.prefix === recentPrefix && New} +
    +

    {key.prefix}…

    +

    + Created +

    +
    + +
  • + ))} +
+ )} + { + const prefix = pendingRevoke; + setPendingRevoke(null); + if (prefix) void apiDelete(`/api/v1/api-keys/${prefix}`).then(() => reload()); + }} + onCancel={() => setPendingRevoke(null)} + /> +
+ ); +}