import { useMemo, useState, type FormEvent } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuth } from "../auth/AuthContext"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; import { apiDelete, apiGet, apiPatch, apiPost } from "../services/api"; import { formatSystemTime } from "../utils/time"; type TokenRow = { id: string; name: string; user_id: string; username: string; scopes?: string[]; created_at: string | null; expires_at: string | null; last_used_at: string | null; revoked: boolean; expired: boolean; active: boolean; }; type UserRow = { id: string; username: string; role: string; scopes?: string[]; is_active: boolean; }; const ALL_SCOPE_KEYS = [ "alarms:read", "ne:read", "ne:write", "ne:exec", "sql:query", "webcrt:session", "admin:users", "ops:write", ] as const; /** Applied on create; refine later via「改权限」. */ const CREATE_DEFAULT_SCOPES = ["alarms:read", "ne:read", "ne:exec", "ne:write"] as const; const SCOPE_LABEL_KEYS: Record<(typeof ALL_SCOPE_KEYS)[number], string> = { "alarms:read": "auth.scopeAlarmsRead", "ne:read": "auth.scopeNeRead", "ne:write": "auth.scopeNeWrite", "ne:exec": "auth.scopeNeExec", "sql:query": "auth.scopeSql", "webcrt:session": "auth.scopeWebcrt", "admin:users": "auth.scopeAdminUsers", "ops:write": "auth.scopeOpsWrite", }; const EXPIRY_OPTIONS = [ { value: 7, labelKey: "auth.expire7d" }, { value: 30, labelKey: "auth.expire30d" }, { value: 90, labelKey: "auth.expire90d" }, { value: 365, labelKey: "auth.expire365d" }, { value: 0, labelKey: "auth.expireNever" }, ] as const; function tokenStatusClass(row: TokenRow): string { if (row.revoked) return "pt-list-status--failed"; if (row.expired) return "pt-list-status--warning"; return "pt-list-status--ok"; } function intersectScopes(available: string[], desired: readonly string[]): string[] { const allow = new Set(available); return desired.filter((s) => allow.has(s)); } export function ApiTokensPage() { const { t } = useI18n(); const { ready, user, isAdmin, scopes: myScopes } = useAuth(); const { showOk, showError } = useToast(); const qc = useQueryClient(); const [name, setName] = useState("mcp"); const [expiresInDays, setExpiresInDays] = useState(90); const [ownerUserId, setOwnerUserId] = useState(""); const [createdPlain, setCreatedPlain] = useState(""); const [editingRow, setEditingRow] = useState(null); const [editScopes, setEditScopes] = useState([]); const tokensQuery = useQuery({ queryKey: ["apiTokens"], queryFn: () => apiGet<{ items: TokenRow[] }>("/v1/api-tokens"), enabled: ready, }); const usersQuery = useQuery({ queryKey: ["appUsers"], queryFn: () => apiGet<{ items: UserRow[] }>("/v1/users"), enabled: ready && isAdmin, }); const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]); const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]); const availableForCreate = useMemo(() => { if (ownerUserId) { const owner = users.find((u) => u.id === ownerUserId); return [...(owner?.scopes || [])].sort(); } return [...(myScopes || [])].sort(); }, [ownerUserId, users, myScopes]); const editAvailable = useMemo(() => { if (!editingRow) return []; if (editingRow.user_id === user?.id) return [...(myScopes || [])].sort(); const owner = users.find((u) => u.id === editingRow.user_id); return [...(owner?.scopes || myScopes || [])].sort(); }, [editingRow, user?.id, users, myScopes]); const createMut = useMutation({ mutationFn: () => { const scopes = intersectScopes(availableForCreate, CREATE_DEFAULT_SCOPES); if (!scopes.length) { throw new Error(t("auth.scopesNoneAvailable")); } return apiPost<{ token: TokenRow & { token: string } }>("/v1/api-tokens", { name: name.trim() || "mcp", expires_in_days: expiresInDays, user_id: isAdmin && ownerUserId ? ownerUserId : undefined, scopes, }); }, onSuccess: async (data) => { setCreatedPlain(data.token.token); showOk(t("auth.tokenCreated")); await qc.invalidateQueries({ queryKey: ["apiTokens"] }); }, onError: (e) => showError(String(e instanceof Error ? e.message : e)), }); const updateMut = useMutation({ mutationFn: (payload: { id: string; scopes: string[] }) => apiPatch<{ token: TokenRow }>(`/v1/api-tokens/${encodeURIComponent(payload.id)}`, { scopes: payload.scopes, }), onSuccess: async () => { showOk(t("auth.tokenUpdated")); setEditingRow(null); await qc.invalidateQueries({ queryKey: ["apiTokens"] }); }, onError: (e) => showError(String(e instanceof Error ? e.message : e)), }); const revokeMut = useMutation({ mutationFn: (id: string) => apiDelete(`/v1/api-tokens/${encodeURIComponent(id)}`), onSuccess: async () => { showOk(t("auth.tokenRevoked")); setEditingRow(null); await qc.invalidateQueries({ queryKey: ["apiTokens"] }); }, onError: (e) => showError(String(e instanceof Error ? e.message : e)), }); const onCreate = (e: FormEvent) => { e.preventDefault(); setCreatedPlain(""); createMut.mutate(); }; const copyToken = async () => { try { await navigator.clipboard.writeText(createdPlain); showOk(t("auth.tokenCopied")); } catch { showError(t("auth.tokenCopyFailed")); } }; const toggleEditScope = (scope: string) => { setEditScopes((prev) => prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope].sort(), ); }; const startEdit = (row: TokenRow) => { const available = row.user_id === user?.id ? [...(myScopes || [])] : [...(users.find((u) => u.id === row.user_id)?.scopes || myScopes || [])]; const current = row.scopes?.length ? [...row.scopes] : [...available]; setEditingRow(row); setEditScopes(intersectScopes(available, current.length ? current : CREATE_DEFAULT_SCOPES)); }; const formatScopes = (scopes: string[] | undefined) => { if (!scopes || scopes.length === 0) return t("auth.scopesUnsetLegacy"); return scopes.join(", "); }; return (

{t("auth.apiKeysTitle")} ? {t("auth.apiKeysHelp")}

setName(e.target.value)} required /> {isAdmin ? ( ) : null}
{createdPlain ? (
{t("auth.tokenOnceHint")}
{createdPlain}
) : null} {tokensQuery.isLoading ?

{t("common.refreshing")}

: null} {!items.length && !tokensQuery.isLoading ? (

{t("common.empty")}

) : (
{items.map((row) => ( ))}
{t("auth.tokenName")} {t("auth.tokenOwner")} {t("auth.scopesCol")} {t("auth.colTime")} {t("auth.expiresAt")} {t("auth.lastUsed")} {t("auth.status")} {t("auth.actions")}
{row.name} {row.username || row.user_id} {formatScopes(row.scopes)} {row.created_at ? formatSystemTime(row.created_at) : t("common.empty")} {row.expires_at ? formatSystemTime(row.expires_at) : t("auth.expireNever")} {row.last_used_at ? formatSystemTime(row.last_used_at) : t("common.empty")} {row.revoked ? t("auth.tokenStatusRevoked") : row.expired ? t("auth.tokenStatusExpired") : t("auth.tokenStatusActive")}
)}
{editingRow ? (
setEditingRow(null)} />

{t("auth.editScopes")}

{editingRow.name} {editingRow.username ? ` · ${editingRow.username}` : ""}

    {ALL_SCOPE_KEYS.filter((s) => editAvailable.includes(s)).map((scope) => (
  • ))} {!editAvailable.length ? (
  • {t("auth.scopesNoneAvailable")}
  • ) : null}
) : null}
); }