feat(tokens): retire the generic API-token mint

POST /api/tokens minted ["medication:ingest"], and that token could never do
the job the settings copy advertised: /api/ingest/medication gates on the
per-medication medication:<id>:ingest grant as well, which this endpoint never
issued. It was refused at the ingest surface and admitted everywhere else.

With Bearer scope now fail-closed the token is inert rather than dangerous,
which is a worse thing to hand a user than the bug it replaces. Remove the
mint. The working credential comes from the per-medication API-endpoint
toggle, which issues both grants scoped to one medication.

List and revoke stay so existing tokens remain visible and revocable, and the
settings card now points at the per-medication toggle instead of offering a
name field. Six locales updated.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 00:51:29 +02:00
1 parent df611227f9
commit 8012aa095b
10 files changed
+232 -314

No files matched your search

+2 -1
View File
@@ -5177,7 +5177,8 @@
"resumeError": "Wieder verbinden fehlgeschlagen"
},
"apiTokens": "API-Tokens",
"apiTokensDescription": "Erstelle API-Tokens für die externe Medikamenten-Einnahme (z.B. Shortcuts, Automationen).",
"apiTokensDescription": "API-Tokens für die externe Medikamenten-Einnahme prüfen und widerrufen. Tokens werden pro Medikament über dessen API-Endpoint-Schalter ausgestellt.",
"tokenMintMovedDescription": "Tokens werden pro Medikament ausgestellt. Öffne ein Medikament und aktiviere dessen API-Endpoint, um ein Token zu erhalten, das nur für dieses Medikament gilt.",
"tokenNamePlaceholder": "Token-Name (z.B. iPhone Shortcut)",
"tokenCreated": "Token erstellt — kopiere ihn jetzt! Er wird nicht erneut angezeigt.",
"tokenCopy": "Token kopieren",
+2 -1
View File
@@ -5177,7 +5177,8 @@
"resumeError": "Reconnect failed"
},
"apiTokens": "API Tokens",
"apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).",
"apiTokensDescription": "Review and revoke API tokens for external medication intake. Tokens are issued per medication from the medication's API endpoint toggle.",
"tokenMintMovedDescription": "Tokens are issued per medication. Open a medication and enable its API endpoint to get a token scoped to that medication alone.",
"tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)",
"tokenCreated": "Token created — copy it now! It won't be shown again.",
"tokenCopy": "Copy token",
+2 -1
View File
@@ -5177,7 +5177,8 @@
"resumeError": "Reconexión fallida"
},
"apiTokens": "Tokens de API",
"apiTokensDescription": "Crea tokens de API para registrar tomas de medicación externas (p. ej., Atajos, automatizaciones).",
"apiTokensDescription": "Revisa y revoca tokens de API para el registro externo de tomas de medicación. Los tokens se emiten por medicamento desde su interruptor de endpoint de API.",
"tokenMintMovedDescription": "Los tokens se emiten por medicamento. Abre un medicamento y activa su endpoint de API para obtener un token limitado únicamente a ese medicamento.",
"tokenNamePlaceholder": "Nombre del token (p. ej., Atajo de iPhone)",
"tokenCreated": "Token creado — ¡cópialo ahora! No se mostrará de nuevo.",
"tokenCopy": "Copiar token",
+2 -1
View File
@@ -5177,7 +5177,8 @@
"resumeError": "Échec de la reconnexion"
},
"apiTokens": "Jetons API",
"apiTokensDescription": "Créez des jetons API pour enregistrer des prises de médicaments externes (par exemple Raccourcis, automatisations).",
"apiTokensDescription": "Consultez et révoquez les jetons API pour l’enregistrement externe des prises de médicaments. Les jetons sont émis par médicament depuis son interrupteur de point de terminaison API.",
"tokenMintMovedDescription": "Les jetons sont émis par médicament. Ouvrez un médicament et activez son point de terminaison API pour obtenir un jeton limité à ce seul médicament.",
"tokenNamePlaceholder": "Nom du jeton (par exemple Raccourci iPhone)",
"tokenCreated": "Jeton créé — copiez-le maintenant ! Il ne sera plus affiché.",
"tokenCopy": "Copier le jeton",
+2 -1
View File
@@ -5177,7 +5177,8 @@
"resumeError": "Riconnessione fallita"
},
"apiTokens": "Token API",
"apiTokensDescription": "Crea token API per registrare assunzioni di farmaci esterne (ad es. Comandi rapidi, automazioni).",
"apiTokensDescription": "Rivedi e revoca i token API per la registrazione esterna delle assunzioni di farmaci. I token vengono emessi per singolo farmaco dal relativo interruttore dell’endpoint API.",
"tokenMintMovedDescription": "I token vengono emessi per singolo farmaco. Apri un farmaco e attiva il suo endpoint API per ottenere un token valido solo per quel farmaco.",
"tokenNamePlaceholder": "Nome del token (ad es. Comando rapido iPhone)",
"tokenCreated": "Token creato — copialo ora! Non verrà mostrato di nuovo.",
"tokenCopy": "Copia token",
+2 -1
View File
@@ -5177,7 +5177,8 @@
"resumeError": "Ponowne połączenie nieudane"
},
"apiTokens": "Tokeny API",
"apiTokensDescription": "Twórz tokeny API do zewnętrznego rejestrowania przyjęć leków (np. Skróty, automatyzacje).",
"apiTokensDescription": "Przeglądaj i unieważniaj tokeny API do zewnętrznego rejestrowania przyjęć leków. Tokeny są wydawane dla pojedynczego leku z jego przełącznika punktu końcowego API.",
"tokenMintMovedDescription": "Tokeny są wydawane dla pojedynczego leku. Otwórz lek i włącz jego punkt końcowy API, aby otrzymać token obejmujący wyłącznie ten lek.",
"tokenNamePlaceholder": "Nazwa tokena (np. Skrót iPhone)",
"tokenCreated": "Token utworzony — skopiuj go teraz! Nie zostanie pokazany ponownie.",
"tokenCopy": "Kopiuj token",
@@ -0,0 +1,195 @@
/**
* Structural guards on the Bearer-scope enforcement boundary.
*
* The correctness argument for the fail-closed default is structural, not
* empirical: there is exactly ONE place a raw Bearer token becomes a user, and
* exactly ONE authorisation arm inside it. Seven behavioural tests cannot cover
* 300-odd routes; what they can do is rest on that invariant. These guards are
* what keep the invariant true.
*
* They are tripwires, not proofs. They cannot show an allowlist is correct —
* only that it has not changed without someone editing this file. A reviewer
* who waves through a bad addition defeats all four, and no test substitutes
* for that review.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join, sep } from "node:path";
import { globSync } from "node:fs";
const SRC = join(process.cwd(), "src");
/**
* Every non-test `.ts` / `.tsx` under `src/`, excluding the generated Prisma
* client (9 MB; never read it) and test files themselves.
*/
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC })
.filter(
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
function filesMatching(re: RegExp): string[] {
return sourceFiles().filter((rel) => re.test(read(rel)));
}
describe("T1 — the Bearer resolution set is frozen", () => {
/**
* Files allowed to turn a raw `Authorization: Bearer` value into a user.
* This is the set that can circumvent the enforcement point, so it is the
* only set that matters for the fail-closed guarantee.
*
* Two of these authenticate a request; the rest are the resolver itself and
* the two edges that wrap it.
*/
const RESOLUTION_ALLOWLIST = [
// The resolver. Owns the one authorisation arm.
"lib/auth/bearer.ts",
// The REST edge — maps `requireAuth`'s optional scope onto the union.
"lib/api-handler.ts",
// The MCP edge — the one deliberate `any-valid-token` posture.
"lib/mcp/auth.ts",
// The `/mcp` transport, via `resolveMcpAuthContext`.
"app/mcp/route.ts",
// Hand-rolled Bearer authentication: the external medication-ingest
// surface, which gates on BOTH `medication:ingest` and the per-medication
// `medication:<id>:ingest` grant and never touches `requireAuth`.
"app/api/ingest/medication/route.ts",
].sort();
it("no file outside the allowlist resolves a Bearer token to a user", () => {
// `resolveBearerToken` is the shared primitive; a `tokenHash` lookup is the
// hand-rolled equivalent. Either one turns a bearer credential into an
// identity, so both have to stay inside the allowlist.
const resolvers = filesMatching(
/resolveBearerToken|apiToken\.findUnique\([\s\S]*?where:\s*\{\s*tokenHash/,
);
expect(resolvers).toEqual(RESOLUTION_ALLOWLIST);
});
it("the resolver exposes exactly one authorisation arm", () => {
const src = read("lib/auth/bearer.ts");
// One wildcard escape hatch, guarding one deny block. If a second
// `permissions.includes("*")` short-circuit appears, the arm has forked.
const wildcardChecks = src.match(/permissions\.includes\("\*"\)/g) ?? [];
expect(wildcardChecks).toHaveLength(1);
});
});
describe("T2 — `any-valid-token` is the single deliberate fail-open posture", () => {
it("appears at exactly one call site, and that call site is the MCP edge", () => {
// `bearer.ts` necessarily names the variant to declare the union; what
// must stay unique is a caller PASSING it.
const optedOut = filesMatching(/kind:\s*"any-valid-token"/).filter(
(rel) => rel !== "lib/auth/bearer.ts",
);
expect(optedOut).toEqual(["lib/mcp/auth.ts"]);
});
it("no route file opts out of the fail-closed default", () => {
const routes = sourceFiles().filter((p) => p.endsWith("/route.ts"));
const optedOut = routes.filter((rel) =>
/kind:\s*"any-valid-token"/.test(read(rel)),
);
expect(optedOut).toEqual([]);
});
});
describe("T3 — the mint sites are frozen", () => {
/**
* Every place an `ApiToken` row is created, with the scope set it may mint.
* A new mint site, or a new scope on an existing one, has to be named here —
* which is the point: a user-facing mint that hands out a broad scope is the
* one failure mode the enforcement change cannot catch by itself.
*/
const MINT_SITES: Record<string, string> = {
// Cookie-equivalent. The ONLY `["*"]` mints, all behind a completed login.
"lib/auth/issue-token.ts": 'opts.permissions ?? ["*"]',
"lib/auth/login-response.ts": '["*"]',
"lib/auth/refresh-token.ts": '["*"]',
"app/api/auth/passkey/login-verify/route.ts": '["*"]',
// The working medication-ingest pair — family marker plus the per-
// medication grant that `/api/ingest/medication` actually gates on.
"app/api/medications/[id]/api-endpoint/route.ts":
'["medication:ingest", scope]',
// MCP, audience-bound to `/mcp`. `health:write` requires explicit consent.
"app/api/mcp/tokens/route.ts": "SCOPE_HEALTH_READ / SCOPE_HEALTH_WRITE",
"app/api/mcp/oauth/token/route.ts":
"SCOPE_HEALTH_READ / SCOPE_HEALTH_WRITE",
};
it("only the known files create ApiToken rows", () => {
const minters = filesMatching(/apiToken\.create\(|issueApiToken\(/).filter(
// `issue-token.ts` defines the helper; the others call it.
(rel) => rel !== "lib/auth/issue-token.ts" || true,
);
expect(minters).toEqual(Object.keys(MINT_SITES).sort());
});
it("no user-facing mint hands out a wildcard scope", () => {
// The four `["*"]` mints are all reached only by a completed
// password / passkey / refresh exchange. Every other mint is narrow.
const userFacing = [
"app/api/medications/[id]/api-endpoint/route.ts",
"app/api/mcp/tokens/route.ts",
"app/api/mcp/oauth/token/route.ts",
];
for (const rel of userFacing) {
expect(read(rel)).not.toMatch(/permissions:\s*\[\s*"\*"/);
}
});
it("the retired generic token mint is gone", () => {
// `POST /api/tokens` minted `["medication:ingest"]` — a token that could
// not perform its advertised job (it lacked the per-medication grant) and
// could reach every other authenticated route. List and revoke stay.
const tokensRoute = read("app/api/tokens/route.ts");
expect(tokensRoute).not.toMatch(/export const POST/);
expect(tokensRoute).toMatch(/export const GET/);
});
});
describe("T4 — declared scopes are exported constants, never string literals", () => {
it("every requireAuth argument is an identifier", () => {
const offenders: string[] = [];
for (const rel of sourceFiles()) {
const src = read(rel);
// Call sites only — skip the declaration in `api-handler.ts`, whose
// parameter list is not an argument.
for (const m of src.matchAll(
/(?<!function\s)requireAuth\(\s*([^)\s][^)]*)\)/g,
)) {
const arg = m[1].trim();
if (!/^[A-Za-z_$][\w$]*$/.test(arg)) {
offenders.push(`${rel}: requireAuth(${arg})`);
}
}
}
expect(offenders).toEqual([]);
});
it("the declared scope vocabulary is closed", () => {
// Scopes a route may name. Adding one means adding a route that accepts a
// narrow token, which is a widening — it belongs in a reviewed diff.
const DECLARED_SCOPES = ["FHIR_READ_SCOPE"];
const used = new Set<string>();
for (const rel of sourceFiles()) {
for (const m of read(rel).matchAll(
/(?<!function\s)requireAuth\(\s*([A-Za-z_$][\w$]*)\s*\)/g,
)) {
used.add(m[1]);
}
}
expect([...used].sort()).toEqual(DECLARED_SCOPES);
});
});
-120
View File
@@ -1,120 +0,0 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/tokens.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
apiToken: {
findMany: vi.fn(),
create: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
vi.mock("@/lib/app-settings", () => ({
isApiGloballyEnabled: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { isApiGloballyEnabled } from "@/lib/app-settings";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/tokens", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
});
describe("POST /api/tokens — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Empty name (min 1) + `expiresInDays` above max (365).
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
// Missing name + `expiresInDays` not a number + `expiresInDays` below min
// is hard since name and expiresInDays are the only knobs, so we use a
// name that is too long (max 100) + expiresInDays as a string and as
// a fraction (non-int).
const res = await POST(
postReq({
name: "x".repeat(200),
expiresInDays: "string",
// any extra unknown key Zod will silently strip; force a 3rd issue
// by including a stray nested shape that the schema doesn't accept.
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
// name too long + expiresInDays not a number = 2 issues at least.
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
it("writes a tokens.create.validation-failed audit row", async () => {
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("tokens.create.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
expect(res.status).toBe(422);
});
});
+15 -80
View File
@@ -1,23 +1,22 @@
/**
* `/api/tokens` — list and (via `[id]`) revoke a user's API tokens.
*
* The generic mint that used to live here (`POST`, minting
* `["medication:ingest"]`) was removed alongside the fail-closed scope
* default. That token could never do its advertised job — the external ingest
* surface gates on the per-medication `medication:<id>:ingest` grant, which
* this endpoint never issued — while the old fail-open default let it reach
* every other authenticated route. The working credential is minted by the
* per-medication API-endpoint toggle
* (`/api/medications/[id]/api-endpoint`), which issues both grants.
*
* Listing and revoking stay so existing tokens remain visible and revocable.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiSuccess,
apiError,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { hashToken } from "@/lib/auth/hmac";
import { apiSuccess, apiError } from "@/lib/api-response";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { randomBytes } from "node:crypto";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
const createTokenSchema = z.object({
name: z.string().min(1, "Name required").max(100),
expiresInDays: z.number().int().min(1).max(365).optional(),
});
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -43,67 +42,3 @@ export const GET = apiHandler(async () => {
return apiSuccess(tokens);
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "tokens.create" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createTokenSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422 + audit-ledger breadcrumb so an
// operator can grep `tokens.create.validation-failed` when a
// device sends a malformed mint payload.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "tokens.create.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// mint payload carries a free-text `name`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "tokens.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { name, expiresInDays } = parsed.data;
// Generate a random token
const rawToken = `hlk_${randomBytes(32).toString("hex")}`;
const tokenHashValue = hashToken(rawToken);
await prisma.apiToken.create({
data: {
userId: user.id,
name,
tokenHash: tokenHashValue,
permissions: ["medication:ingest"],
expiresAt: expiresInDays
? new Date(Date.now() + expiresInDays * 86400000)
: null,
},
});
// Return the raw token ONCE — it can never be retrieved again
return apiSuccess({ token: rawToken, name }, 201);
});
+10 -108
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ChevronDown, Copy, Key, Loader2, Trash2 } from "lucide-react";
import { ChevronDown, Key, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
@@ -18,7 +18,6 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -149,12 +148,7 @@ function ApiTokensCard() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [newName, setNewName] = useState("");
const [newToken, setNewToken] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
const [showRevokedTokens, setShowRevokedTokens] = useState(false);
const [tokenCopied, setTokenCopied] = useState(false);
const { data: tokens } = useQuery({
queryKey: queryKeys.tokens(),
@@ -164,47 +158,6 @@ function ApiTokensCard() {
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}`, {
@@ -254,66 +207,15 @@ function ApiTokensCard() {
/>
<div className="mt-4 space-y-4 pl-7">
{/* 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. */}
<form onSubmit={handleCreate} className="flex items-center gap-2">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("settings.tokenNamePlaceholder")}
maxLength={100}
className="flex-1"
/>
<Button
type="submit"
variant="outline"
className="h-11 sm:h-10"
disabled={creating || !newName.trim()}
>
{creating && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.create")}
</Button>
</form>
{newToken && (
<div
className="bg-success/10 rounded-lg p-3 text-sm"
data-slot="settings-api-token-created"
>
<p className="text-success mb-1 font-medium">
{t("settings.tokenCreated")}
</p>
<div className="flex items-start gap-2">
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
{newToken}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
onClick={() => void handleCopyToken()}
aria-label={t("settings.tokenCopy")}
data-slot="settings-api-token-copy"
>
{tokenCopied ? (
<Check className="text-success h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
)}
{tokenMsg && (
<p role="alert" className="text-destructive text-sm">
{tokenMsg}
</p>
)}
{/* 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. */}
<p className="text-muted-foreground text-sm">
{t("settings.tokenMintMovedDescription")}
</p>
<div>
<p className="mb-2 text-sm font-medium">