"use client"; import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Activity, Check, ChevronDown, Copy, FileUp, Key, Loader2, Trash2, Upload, type LucideIcon, } 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 { EmptyState } from "@/components/ui/empty-state"; import { Input } from "@/components/ui/input"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCard } from "@/components/settings/settings-card"; 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"; import { recentProofErrorMessage, throwIfReproofRequired, useRecentProof, } from "@/components/settings/security-section/use-recent-proof"; 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 (
); } /** * Mint a token for third-party measurement ingest. * * No list of its own. `ApiTokensCard` below already reads every token the * account holds and shows its scopes, so a second query here would be the same * rows under a different heading — and a revoke button in two places is two * places to keep agreeing about what revoking means. This card mints; the one * below is where credentials are reviewed and killed. */ function MeasurementTokensCard() { const { t } = useTranslations(); return ( ); } /** * Mint a token a workout bridge pushes through (#1054): a watch vendor's sync * relayed by a small service, or a script replaying an export. Same card, * same no-list reasoning as the measurement one above. */ function WorkoutTokensCard() { const { t } = useTranslations(); return ( ); } /** * Mint a token another document system uploads through (#1038): a Paperless * workflow or the import script. Same card, same no-list reasoning as the * measurement one above. */ function DocumentTokensCard() { const { t } = useTranslations(); return ( ); } /** One single-scope mint: a name field, the token shown once, a copy button. */ function IngestTokenCard({ endpoint, icon, slot, copy, }: { endpoint: | "/api/tokens/measurements" | "/api/tokens/workouts" | "/api/tokens/documents"; icon: LucideIcon; slot: string; copy: { title: string; description: string; detail: string; scopeNote: string; createFailed: string; }; }) { const { t } = useTranslations(); const queryClient = useQueryClient(); const [newName, setNewName] = useState(""); const [newToken, setNewToken] = useState(null); const [creating, setCreating] = useState(false); const [tokenMsg, setTokenMsg] = useState(null); const [tokenCopied, setTokenCopied] = useState(false); // The token outlives the session, so minting one asks for a fresh proof // unless the session signed in or re-proved within five minutes. const recentProof = useRecentProof(); async function handleCreate(e: React.FormEvent) { e.preventDefault(); setCreating(true); setTokenMsg(null); setNewToken(null); try { // No scope field: the endpoint mints one shape, so there is nothing // here for a caller to choose or for this form to get wrong. const body = JSON.stringify({ name: newName.trim() }); const res = await recentProof.run(async () => throwIfReproofRequired( await apiFetchRaw(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body, }), ), ); const json = await res.json(); if (res.ok) { setNewToken(json.data.token); setNewName(""); // The sibling card lists this token, so it has to hear about it. queryClient.invalidateQueries({ queryKey: queryKeys.tokens() }); } else { // The server's own message when it sent one — it names the actual // cause (switch off, rate limit, validation) — and a message about // this specific action when it did not. setTokenMsg(json.error || copy.createFailed); } } catch (err) { setTokenMsg(recentProofErrorMessage(err, 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")); setTimeout(() => setTokenCopied(false), 2_000); } catch { toast.error(t("settings.tokenCopyFailed")); } } return ( {recentProof.dialog}

{copy.detail}

{copy.scopeNote}

setNewName(e.target.value)} placeholder={t("settings.tokenNamePlaceholder")} maxLength={100} className="flex-1" />
{newToken && (

{t("settings.tokenCreated")}

{newToken}
)} {tokenMsg &&

{tokenMsg}

}
); } function ApiEndpointsCard() { const { t } = useTranslations(); // v1.4.27 MB5 — dual-render: `. The endpoint catalogue is array-driven // so adding a row remains a one-line edit on both surfaces — which is // what the two measurement ingest rows below cost. const endpoints = [ { method: "POST", path: "/api/ingest/medication", auth: "Authorization: Bearer hlk_...", example: `{ "medicationName": "...", "scheduledFor": "..." }`, }, { method: "POST", path: "/api/measurements", auth: "Authorization: Bearer hlk_...", example: `{ "type": "WEIGHT", "value": 82.4, "measuredAt": "..." }`, }, { method: "POST", path: "/api/measurements/batch", auth: "Authorization: Bearer hlk_...", example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`, }, { method: "POST", path: "/api/workouts/batch", auth: "Authorization: Bearer hlk_...", example: `{ "workouts": [ { "sportType": "running", "startedAt": "...", "endedAt": "...", "externalId": "..." } ] }`, }, { method: "POST", path: "/api/documents/inbound", auth: "Authorization: Bearer hlk_...", example: `multipart: file, title, documentDate, sourceSystem, sourceId`, }, ]; 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 [showRevokedTokens, setShowRevokedTokens] = useState(false); const { data: tokens, isError, isLoading, refetch, } = useQuery({ queryKey: queryKeys.tokens(), queryFn: async () => { return apiGet("/api/tokens"); }, enabled: isAuthenticated, }); 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)} )} } />
{/* Tokens are no longer minted here. The generic mint issued a `medication:ingest` token that the ingest endpoint refused (it gates on the per-medication grant too) and that every other authenticated route accepted. The per-medication API-endpoint toggle issues the pair that actually works, scoped to one medication. This card lists and revokes. */}

{t("settings.tokenMintMovedDescription")}

{isError ? ( // A read failure must not read as "no active tokens" on this // security surface — the §6 fall-through the token list must avoid. refetch()} /> ) : isLoading ? (
) : ( <>

{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 ? ( ) : (
    {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")}

  • ))}
)}
)} )}
); }