"use client"; import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, ChevronDown, Copy, Key, Loader2, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useAuth } from "@/hooks/use-auth"; import { formatDate, formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; interface ApiTokenInfo { id: string; name: string; permissions: string[]; lastUsedAt: string | null; expiresAt: string | null; createdAt: string; revoked: boolean; } export function ApiSection() { // v1.18.6 (W9) — the visible heading + subtitle now come from the shared // `` in the route; this body is the API cards. return (
); } function ApiEndpointsCard() { const { t } = useTranslations(); // v1.4.27 MB5 — dual-render: `. The endpoint catalogue currently // ships a single row but stays array-driven so adding new rows // remains a one-line edit on both surfaces. const endpoints = [ { method: "POST", path: "/api/ingest/medication", auth: "Authorization: Bearer hlk_...", example: `{ "medicationName": "...", "scheduledFor": "..." }`, }, ]; return (
{/* Desktop table — verbatim layout for md+. */}
{endpoints.map((endpoint) => ( ))}
{t("settings.apiEndpointMethod")} {t("settings.apiEndpointPath")} {t("settings.apiEndpointAuth")} {t("settings.apiEndpointExample")}
{endpoint.method} {endpoint.path} {endpoint.auth} {endpoint.example}
{/* Mobile card list — each endpoint stacks its columns vertically with a labelled meta row. No horizontal scroll. */}
    {endpoints.map((endpoint) => (
  • {endpoint.method} {endpoint.path}

    {t("settings.apiEndpointAuth")}

    {endpoint.auth}

    {t("settings.apiEndpointExample")}

    {endpoint.example}
  • ))}
); } function ApiTokensCard() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); const [newName, setNewName] = useState(""); const [newToken, setNewToken] = useState(null); const [creating, setCreating] = useState(false); const [tokenMsg, setTokenMsg] = useState(null); const [showRevokedTokens, setShowRevokedTokens] = useState(false); const [tokenCopied, setTokenCopied] = useState(false); const { data: tokens } = useQuery({ queryKey: queryKeys.tokens(), queryFn: async () => { return apiGet("/api/tokens"); }, enabled: isAuthenticated, }); async function handleCreate(e: React.FormEvent) { e.preventDefault(); setCreating(true); setTokenMsg(null); setNewToken(null); try { const res = await apiFetchRaw("/api/tokens", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }), }); const json = await res.json(); if (res.ok) { setNewToken(json.data.token); setNewName(""); queryClient.invalidateQueries({ queryKey: queryKeys.tokens() }); } else { setTokenMsg(json.error || t("common.error")); } } catch { setTokenMsg(t("common.networkError")); } finally { setCreating(false); } } async function handleCopyToken() { if (!newToken) return; try { await navigator.clipboard.writeText(newToken); setTokenCopied(true); toast.success(t("settings.tokenCopied")); // Revert the inline check affordance after a short beat so a // second copy reads as a fresh action. setTimeout(() => setTokenCopied(false), 2_000); } catch { toast.error(t("settings.tokenCopyFailed")); } } async function handleRevoke(tokenId: string) { try { const res = await apiFetchRaw(`/api/tokens/${tokenId}`, { method: "DELETE", }); if (!res.ok) { toast.error(t("settings.tokenRevokeFailed")); return; } queryClient.invalidateQueries({ queryKey: queryKeys.tokens() }); } catch { toast.error(t("settings.tokenRevokeFailed")); } } const activeTokens = (tokens ?? []).filter((tok) => !tok.revoked); const revokedTokens = (tokens ?? []).filter((tok) => tok.revoked); const latestActiveUse = activeTokens.reduce((latest, tok) => { if (!tok.lastUsedAt) return latest; if (!latest || new Date(tok.lastUsedAt) > new Date(latest)) { return tok.lastUsedAt; } return latest; }, null); return (
{activeTokens.length > 0 && ( {t("settings.configured")} )} {latestActiveUse && ( {t("settings.tokenTableLastUsed")}:{" "} {formatDateTime(latestActiveUse)} )} } />
{/* The button mirrors the Input's responsive height (h-11 / sm:h-10) so the row reads as one aligned control group instead of a tall input next to a shorter `sm` button. */}
setNewName(e.target.value)} placeholder={t("settings.tokenNamePlaceholder")} maxLength={100} className="flex-1" />
{newToken && (

{t("settings.tokenCreated")}

{newToken}
)} {tokenMsg && (

{tokenMsg}

)}

{t("settings.activeTokensTitle")}

{/* Desktop table — verbatim layout for md+, hidden on phones. */}
{activeTokens.length === 0 && ( )} {activeTokens.map((tok, index) => { const isExpired = tok.expiresAt && new Date(tok.expiresAt) < new Date(); return ( ); })}
{t("settings.tokenTableName")} {t("settings.tokenTablePermissions")} {t("settings.tokenTableStatus")} {t("settings.tokenTableCreated")} {t("settings.tokenTableLastUsed")} {t("settings.tokenTableActions")}
{t("settings.noActiveTokens")}
{tok.name} {tok.permissions.join(", ")} {isExpired ? ( {t("settings.tokenExpired")} ) : ( {t("settings.tokenActive")} )} {formatDate(tok.createdAt)} {tok.lastUsedAt ? formatDateTime(tok.lastUsedAt) : t("settings.tokenNeverUsed")} {t("settings.tokenRevoke")} {t("settings.tokenRevokeDescription")} {t("common.cancel")} handleRevoke(tok.id)} > {t("settings.tokenRevoked")}
{/* Mobile card list — each row stacks its columns vertically with explicit labels so the data is readable inside a narrow viewport. The revoke action stays full-width to clear the 44pt tap-target floor. */} {activeTokens.length === 0 ? (

{t("settings.noActiveTokens")}

) : (
    {activeTokens.map((tok) => { const isExpired = tok.expiresAt && new Date(tok.expiresAt) < new Date(); return (
  • {tok.name}

    {isExpired ? ( {t("settings.tokenExpired")} ) : ( {t("settings.tokenActive")} )}

    {t("settings.tokenTablePermissions")}: {" "} {tok.permissions.join(", ")}

    {t("settings.tokenTableCreated")}: {" "} {formatDate(tok.createdAt)}

    {t("settings.tokenTableLastUsed")}: {" "} {tok.lastUsedAt ? formatDateTime(tok.lastUsedAt) : t("settings.tokenNeverUsed")}

    {t("settings.tokenRevoke")} {t("settings.tokenRevokeDescription")} {t("common.cancel")} handleRevoke(tok.id)} > {t("settings.tokenRevoked")}
  • ); })}
)}
{revokedTokens.length > 0 && (
{showRevokedTokens && ( <> {/* Desktop table — verbatim layout for md+. */}
{revokedTokens.map((tok, index) => ( ))}
{t("settings.tokenTableName")} {t("settings.tokenTablePermissions")} {t("settings.tokenTableCreated")} {t("settings.tokenTableLastUsed")}
{tok.name} {tok.permissions.join(", ")} {formatDate(tok.createdAt)} {tok.lastUsedAt ? formatDateTime(tok.lastUsedAt) : t("settings.tokenNeverUsed")}
{/* Mobile card list — stacked meta layout, no horizontal scroll. Revoked tokens are read-only so no action footer is needed. */}
    {revokedTokens.map((tok) => (
  • {tok.name}

    {t("settings.tokenTablePermissions")}: {" "} {tok.permissions.join(", ")}

    {t("settings.tokenTableCreated")}: {" "} {formatDate(tok.createdAt)}

    {t("settings.tokenTableLastUsed")}: {" "} {tok.lastUsedAt ? formatDateTime(tok.lastUsedAt) : t("settings.tokenNeverUsed")}

  • ))}
)}
)}
); }