From 6f0d9b66370f157602483e6a1bdb82b3eb10ff01 Mon Sep 17 00:00:00 2001 From: naiba Date: Sat, 30 May 2026 15:56:53 +0000 Subject: [PATCH] feat(api-tokens): add PAT management UI, CSRF handling, and auth-loading fixes Add an API tokens management route to create, list, and revoke PATs, showing the plaintext token once on creation with scope and server-id selection. Mirror the nz-csrf cookie into the X-CSRF-Token header on unsafe fetcher methods (POST/PUT/PATCH/DELETE) for the server-side double-submit check, and self-heal expired sessions via refresh-token without a recursive fetch loop. Gate protected routes behind resolved auth state to avoid pre-auth SWR fetches, and fix the login loading/race so stale probes cannot clobber the session. Add i18n keys for the new screens across all locales. Co-authored-by: cloudcode --- package-lock.json | 13 +- src/api/api-tokens.ts | 130 +++++++++ src/api/api.ts | 66 ++++- src/components/settings-tab.tsx | 6 +- src/hooks/useAuth.tsx | 26 +- src/locales/de/translation.json | 27 +- src/locales/en/translation.json | 28 +- src/locales/es/translation.json | 26 +- src/locales/fr/translation.json | 27 +- src/locales/gl/translation.json | 28 +- src/locales/id/translation.json | 26 +- src/locales/it/translation.json | 27 +- src/locales/ja/translation.json | 29 +- src/locales/ru/translation.json | 27 +- src/locales/ta/translation.json | 27 +- src/locales/uk/translation.json | 27 +- src/locales/zh-CN/translation.json | 28 +- src/locales/zh-TW/translation.json | 27 +- src/main.tsx | 5 + src/routes/api-tokens.tsx | 338 ++++++++++++++++++++++ src/routes/protect.tsx | 38 ++- src/routes/settings.tsx | 75 +++-- src/test/api-tokens-expiry.test.ts | 30 ++ src/test/api-tokens-server-ids.test.ts | 26 ++ src/test/api-tokens.test.tsx | 137 +++++++++ src/test/auth-login-loading.test.tsx | 151 ++++++++++ src/test/auth-login-race.test.tsx | 96 ++++++ src/test/fetcher-csrf.test.ts | 97 +++++++ src/test/fetcher-delete.test.ts | 35 +++ src/test/fetcher-refresh-selfheal.test.ts | 61 ++++ src/test/protect-auth-loading.test.tsx | 108 +++++++ src/test/settings-auth-loading.test.tsx | 74 +++++ src/test/settings-submit-error.test.tsx | 107 +++++++ src/types/api.ts | 3 + src/types/authContext.ts | 1 + tests/e2e/api-tokens.spec.ts | 125 ++++++++ vite.config.ts | 8 + 37 files changed, 2041 insertions(+), 69 deletions(-) create mode 100644 src/api/api-tokens.ts create mode 100644 src/routes/api-tokens.tsx create mode 100644 src/test/api-tokens-expiry.test.ts create mode 100644 src/test/api-tokens-server-ids.test.ts create mode 100644 src/test/api-tokens.test.tsx create mode 100644 src/test/auth-login-loading.test.tsx create mode 100644 src/test/auth-login-race.test.tsx create mode 100644 src/test/fetcher-csrf.test.ts create mode 100644 src/test/fetcher-delete.test.ts create mode 100644 src/test/fetcher-refresh-selfheal.test.ts create mode 100644 src/test/protect-auth-loading.test.tsx create mode 100644 src/test/settings-auth-loading.test.tsx create mode 100644 src/test/settings-submit-error.test.tsx create mode 100644 tests/e2e/api-tokens.spec.ts diff --git a/package-lock.json b/package-lock.json index 64deb49..a0b136b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3720,11 +3720,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.10", + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", "devOptional": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bidi-js": { @@ -3854,7 +3859,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001746", + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", "devOptional": true, "funding": [ { diff --git a/src/api/api-tokens.ts b/src/api/api-tokens.ts new file mode 100644 index 0000000..c5ddba5 --- /dev/null +++ b/src/api/api-tokens.ts @@ -0,0 +1,130 @@ +import { FetcherMethod, fetcher } from "./api" + +export interface ApiTokenView { + id: number + name: string + scopes: string[] + server_ids?: number[] + expires_at?: string + last_used_at?: string + last_used_ip?: string + created_at: string +} + +export interface ApiTokenCreateRequest { + name: string + scopes: string[] + server_ids?: number[] + expires_in_days?: number +} + +export interface ApiTokenCreateResponse { + id: number + name: string + token: string + scopes: string[] + server_ids?: number[] + expires_at?: string +} + +export const listApiTokens = async (): Promise => { + const tokens = await fetcher(FetcherMethod.GET, "/api/v1/api-tokens", null) + // Go encodes an empty scope slice as JSON null; coerce so callers can map() safely. + return (tokens ?? []).map((tok) => ({ + ...tok, + scopes: Array.isArray(tok.scopes) ? tok.scopes : [], + })) +} + +export const createApiToken = async ( + data: ApiTokenCreateRequest, +): Promise => { + return fetcher(FetcherMethod.POST, "/api/v1/api-tokens", data) +} + +export const deleteApiToken = async (id: number): Promise => { + return fetcher(FetcherMethod.DELETE, `/api/v1/api-tokens/${id}`, null) +} + +export const SCOPE_OPTIONS = [ + { value: "nezha:server:read", label: "Server: read", desc: "List & inspect servers and files" }, + { value: "nezha:server:write", label: "Server: write", desc: "Edit servers, push files" }, + { value: "nezha:server:delete", label: "Server: delete", desc: "Delete servers, files" }, + { value: "nezha:server:exec", label: "Server: exec", desc: "Run shell commands on servers" }, + { value: "nezha:server:*", label: "Server: all", desc: "Every server permission (read+write+delete+exec)" }, + { value: "nezha:service:read", label: "Service monitor: read", desc: "List service monitors & history" }, + { value: "nezha:service:write", label: "Service monitor: write", desc: "Create / edit service monitors" }, + { value: "nezha:service:delete", label: "Service monitor: delete", desc: "Delete service monitors" }, + { value: "nezha:service:*", label: "Service monitor: all", desc: "Every service monitor permission" }, + { value: "nezha:alertrule:read", label: "Alert rule: read", desc: "List alert rules" }, + { value: "nezha:alertrule:write", label: "Alert rule: write", desc: "Create / edit alert rules" }, + { value: "nezha:alertrule:delete", label: "Alert rule: delete", desc: "Delete alert rules" }, + { value: "nezha:alertrule:*", label: "Alert rule: all", desc: "Every alert-rule permission" }, + { value: "nezha:cron:read", label: "Cron: read", desc: "List scheduled tasks" }, + { value: "nezha:cron:write", label: "Cron: write", desc: "Create / edit scheduled tasks" }, + { value: "nezha:cron:delete", label: "Cron: delete", desc: "Delete scheduled tasks" }, + { value: "nezha:cron:exec", label: "Cron: trigger", desc: "Manually trigger scheduled tasks" }, + { value: "nezha:cron:*", label: "Cron: all", desc: "Every cron permission" }, + { value: "nezha:notification:read", label: "Notification: read", desc: "List notifications" }, + { value: "nezha:notification:write", label: "Notification: write", desc: "Create / edit notifications" }, + { value: "nezha:notification:delete", label: "Notification: delete", desc: "Delete notifications" }, + { value: "nezha:notification:*", label: "Notification: all", desc: "Every notification permission" }, + { value: "nezha:notification-group:read", label: "Notification group: read", desc: "List notification groups" }, + { value: "nezha:notification-group:write", label: "Notification group: write", desc: "Create / edit groups" }, + { value: "nezha:notification-group:delete", label: "Notification group: delete", desc: "Delete groups" }, + { value: "nezha:notification-group:*", label: "Notification group: all", desc: "Every notification-group permission" }, + { value: "nezha:ddns:read", label: "DDNS: read", desc: "List DDNS profiles" }, + { value: "nezha:ddns:write", label: "DDNS: write", desc: "Create / edit DDNS profiles" }, + { value: "nezha:ddns:delete", label: "DDNS: delete", desc: "Delete DDNS profiles" }, + { value: "nezha:ddns:*", label: "DDNS: all", desc: "Every DDNS permission" }, + { value: "nezha:nat:read", label: "NAT: read", desc: "List NAT rules" }, + { value: "nezha:nat:write", label: "NAT: write", desc: "Create / edit NAT rules" }, + { value: "nezha:nat:delete", label: "NAT: delete", desc: "Delete NAT rules" }, + { value: "nezha:nat:*", label: "NAT: all", desc: "Every NAT permission" }, + { value: "nezha:transfer:read", label: "Transfer: read", desc: "Read server transfer state" }, + { value: "nezha:transfer:write", label: "Transfer: write", desc: "Cancel / retry transfers" }, + { value: "nezha:transfer:delete", label: "Transfer: delete", desc: "Delete server transfer records" }, + { value: "nezha:transfer:*", label: "Transfer: all", desc: "Every transfer permission" }, + { value: "nezha:admin:*", label: "Admin: all (admin only)", desc: "User / WAF / Setting / Online-user management" }, + { value: "nezha:*", label: "Everything (admin only)", desc: "Full access to all resources" }, +] as const + +export type Scope = (typeof SCOPE_OPTIONS)[number]["value"] + +export type ParseServerIDsResult = + | { ok: true; value: number[] | undefined } + | { ok: false; error: string } + +export type ParseExpiresInDaysResult = + | { ok: true; value: number | undefined } + | { ok: false; error: string } + +// Validates the "expires in days" field before it is sent to the backend. +// The backend model field is `ExpiresInDays int`, so a fractional value would +// fail JSON binding; reject it locally. Blank and 0 both mean "never expires" +// (undefined), matching the create handler's `expires_in_days == 0` semantics. +export function parseExpiresInDaysInput(raw: string): ParseExpiresInDaysResult { + const trimmed = raw.trim() + if (trimmed === "") return { ok: true, value: undefined } + if (!/^\d+$/.test(trimmed)) return { ok: false, error: `invalid expiry: ${raw}` } + const n = Number(trimmed) + if (!Number.isInteger(n) || n < 0 || n > 3650) { + return { ok: false, error: `invalid expiry: ${raw}` } + } + return { ok: true, value: n > 0 ? n : undefined } +} + +export function parseServerIDsInput(raw: string): ParseServerIDsResult { + const trimmed = raw.trim() + if (trimmed === "") return { ok: true, value: undefined } + const parts = trimmed.split(",").map((s) => s.trim()) + const out: number[] = [] + for (const p of parts) { + if (p === "") return { ok: false, error: "empty server id" } + if (!/^\d+$/.test(p)) return { ok: false, error: `invalid server id: ${p}` } + const n = Number(p) + if (!Number.isInteger(n) || n <= 0) return { ok: false, error: `invalid server id: ${p}` } + out.push(n) + } + return { ok: true, value: out } +} diff --git a/src/api/api.ts b/src/api/api.ts index 6b51824..33bcb11 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -23,17 +23,43 @@ export enum FetcherMethod { let lastestRefreshTokenAt = 0 +const csrfCookieName = "nz-csrf" +const csrfHeaderName = "X-CSRF-Token" + +function readCookie(name: string): string { + const prefix = name + "=" + for (const part of document.cookie.split(";")) { + const c = part.trim() + if (c.startsWith(prefix)) return c.slice(prefix.length) + } + return "" +} + +function isUnsafeMethod(method: FetcherMethod): boolean { + return method !== FetcherMethod.GET +} + +// Double-submit CSRF: backend requires X-CSRF-Token == nz-csrf cookie on +// cookie-authenticated unsafe methods. +function csrfHeaders(method: FetcherMethod): Record { + if (!isUnsafeMethod(method)) return {} + const token = readCookie(csrfCookieName) + return token ? { [csrfHeaderName]: token } : {} +} + export async function fetcher(method: FetcherMethod, path: string, data?: any): Promise { let response if (method === FetcherMethod.GET || method === FetcherMethod.DELETE) { response = await fetch(buildUrl(path, data), { - method: "GET", + method: method, + headers: csrfHeaders(method), }) } else { response = await fetch(path, { method: method, headers: { "Content-Type": "application/json", + ...csrfHeaders(method), }, body: data ? JSON.stringify(data) : null, }) @@ -41,23 +67,45 @@ export async function fetcher(method: FetcherMethod, path: string, data?: any if (!response.ok) { throw new Error(response.statusText) } - const responseData: CommonResponse = await response.json() - if (!responseData.success) { - throw new Error(responseData.error) + const text = await response.text() + if (text !== "") { + let responseData: CommonResponse + try { + responseData = JSON.parse(text) + } catch { + throw new Error("invalid server response") + } + if (!responseData.success) { + throw new Error(responseData.error) + } + triggerAutoRefresh() + return responseData.data } + triggerAutoRefresh() + return undefined as T +} - // auto refresh token +// Refresh route is POST behind the CSRF gate. Defer until an nz-csrf cookie is +// available: firing without the header would only 403 and burn the 1h throttle, +// stranding sessions that predate the cookie until the backend seeds one on a +// safe GET. +function triggerAutoRefresh() { + if (!readCookie(csrfCookieName)) return if ( document.cookie && (!lastestRefreshTokenAt || Date.now() - lastestRefreshTokenAt > 1000 * 60 * 60) ) { lastestRefreshTokenAt = Date.now() - fetch("/api/v1/refresh-token") + fetch("/api/v1/refresh-token", { + method: "POST", + headers: csrfHeaders(FetcherMethod.POST), + }) } - - return responseData.data } export async function swrFetcher(input: string | URL | globalThis.Request, init?: RequestInit) { - return fetcher(init?.method as FetcherMethod, input.toString(), init?.body) + // SWR 默认不带 init,method 为 undefined:必须落到 GET,否则 fetcher 会走 + // 带 body 的分支并对只读请求附加 CSRF 头,把 token 暴露到 GET 请求上。 + const method = (init?.method as FetcherMethod) ?? FetcherMethod.GET + return fetcher(method, input.toString(), init?.body) } diff --git a/src/components/settings-tab.tsx b/src/components/settings-tab.tsx index 30f260f..67b3e47 100644 --- a/src/components/settings-tab.tsx +++ b/src/components/settings-tab.tsx @@ -8,10 +8,11 @@ export const SettingsTab = ({ className }: { className?: string }) => { const { profile } = useAuth() const isAdmin = profile?.role === 0 + const colsClass = isAdmin ? "grid-cols-5" : "grid-cols-1" return ( - + {isAdmin && ( <> @@ -28,6 +29,9 @@ export const SettingsTab = ({ className }: { className?: string }) => { )} + + {t("ApiTokens")} + ) diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index 969cbd4..7ed256d 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -1,6 +1,6 @@ import { getProfile, login as loginRequest } from "@/api/user" import { AuthContextProps } from "@/types" -import { createContext, useCallback, useContext, useEffect, useMemo } from "react" +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { useNavigate } from "react-router-dom" import { toast } from "sonner" @@ -9,6 +9,7 @@ import { useMainStore } from "./useMainStore" const AuthContext = createContext({ profile: undefined, + loading: true, login: () => {}, loginOauth2: () => {}, logout: () => {}, @@ -17,16 +18,27 @@ const AuthContext = createContext({ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const profile = useMainStore((store) => store.profile) const setProfile = useMainStore((store) => store.setProfile) + const [loading, setLoading] = useState(true) const { t } = useTranslation() + // An explicit login/logout (or its getProfile) resolving while the initial + // mount probe is still in flight must win: bump this so the stale probe's + // result is discarded instead of clobbering the authenticated state. + const authEpoch = useRef(0) + useEffect(() => { + const epoch = authEpoch.current ;(async () => { try { const user = await getProfile() + if (authEpoch.current !== epoch) return user.role = user.role || 0 setProfile(user) } catch { + if (authEpoch.current !== epoch) return setProfile(undefined) + } finally { + setLoading(false) } })() }, [setProfile]) @@ -37,6 +49,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { try { await loginRequest(username, password) const user = await getProfile() + authEpoch.current++ user.role = user.role || 0 setProfile(user) navigate("/dashboard") @@ -47,40 +60,49 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { } else { toast(msg || t("NetworkError")) } + } finally { + // An explicit login resolves auth regardless of the still-pending + // mount probe; clear loading so ProtectedRoute stops blanking. + setLoading(false) } }, [navigate, setProfile, t]) const loginOauth2 = useCallback(async () => { try { const user = await getProfile() + authEpoch.current++ user.role = user.role || 0 setProfile(user) navigate("/dashboard") } catch (error: any) { toast(error.message) } finally { + setLoading(false) window.history.replaceState({}, document.title, window.location.pathname) } }, [navigate, setProfile]) const logout = useCallback(() => { + authEpoch.current++ document.cookie.split(";").forEach(function (c) { document.cookie = c .replace(/^ +/, "") .replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/") }) setProfile(undefined) + setLoading(false) navigate("/dashboard/login", { replace: true }) }, [navigate, setProfile]) const value = useMemo( () => ({ profile, + loading, login, loginOauth2, logout, }), - [profile, login, loginOauth2, logout], + [profile, loading, login, loginOauth2, logout], ) return {children} } diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 4f3fdb9..8c6828d 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -180,5 +180,30 @@ "EmptyNote": "Du hast noch keine Notizen.", "BackToHome": "Zurück zur Startseite", "OnAlert": "Server mit Warnung", - "EmptyText": "Text ist leer" + "EmptyText": "Text ist leer", + "EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)", + "ApiTokens": "API Tokens", + "CreateApiToken": "Create API token", + "CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.", + "ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.", + "ApiTokenRevoked": "API token revoked.", + "ApiTokenCreated": "API token created", + "ApiTokenRevealOnce": "Copy this token now. It will never be shown again.", + "ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.", + "ApiTokenServers": "Servers", + "ApiTokenAllServers": "all permitted", + "ApiTokenNever": "never", + "ApiTokenExpiresAt": "Expires", + "ApiTokenLastUsed": "Last used", + "ApiTokenServerIDs": "Restrict to server IDs (optional)", + "ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3", + "ApiTokenExpiresInDays": "Expires in (days, 0 = never)", + "ApiTokenScopeRequired": "At least one scope is required.", + "ApiTokenServersInvalid": "Server IDs must be positive integers.", + "ApiTokenExpiryInvalid": "Days must be between 0 and 3650.", + "NameRequired": "Name is required.", + "Revoke": "Revoke", + "Copy": "Copy", + "Copied": "Copied to clipboard", + "Scopes": "Scopes" } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 5d30ac8..b033d0a 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -155,6 +155,7 @@ "UseDirectConnectingIP": "Use direct connection IP", "IPChangeNotification": "IP Change Notification", "FullIPNotification": "Show Full IP Address in Notification Messages", + "EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)", "EditService": "Edit Service", "CreateService": "Create Service", "EditTask": "Edit Task", @@ -288,5 +289,30 @@ "StatusFailed": "Failed", "StatusTimeout": "Timeout", "StatusCancelled": "Cancelled" - } + }, + "ApiTokens": "API Tokens", + "CreateApiToken": "Create API token", + "CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.", + "ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.", + "ApiTokenRevoked": "API token revoked.", + "ApiTokenCreated": "API token created", + "ApiTokenRevealOnce": "Copy this token now. It will never be shown again.", + "ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.", + "ApiTokenServers": "Servers", + "ApiTokenAllServers": "all permitted", + "ApiTokenNever": "never", + "ApiTokenExpiresAt": "Expires", + "ApiTokenLastUsed": "Last used", + "ApiTokenServerIDs": "Restrict to server IDs (optional)", + "ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3", + "ApiTokenExpiresInDays": "Expires in (days, 0 = never)", + "ApiTokenScopeRequired": "At least one scope is required.", + "ApiTokenServersInvalid": "Server IDs must be positive integers.", + "ApiTokenExpiryInvalid": "Days must be between 0 and 3650.", + "NameRequired": "Name is required.", + "Revoke": "Revoke", + "Copy": "Copy", + "Copied": "Copied to clipboard", + "Done": "Done", + "Scopes": "Scopes" } diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 9230eb5..6652563 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -254,5 +254,29 @@ "CopiedToClipboard": "Copiar al portapapeles", "ClipboardWriteFailed": "Error al escribir en el portapapeles", "PastedFromClipboard": "Pegado del portapapeles", - "ClipboardReadFailed": "Falló la lectura del portapapeles" + "ClipboardReadFailed": "Falló la lectura del portapapeles", + "EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)", + "ApiTokens": "API Tokens", + "CreateApiToken": "Create API token", + "CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.", + "ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.", + "ApiTokenRevoked": "API token revoked.", + "ApiTokenCreated": "API token created", + "ApiTokenRevealOnce": "Copy this token now. It will never be shown again.", + "ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.", + "ApiTokenServers": "Servers", + "ApiTokenAllServers": "all permitted", + "ApiTokenNever": "never", + "ApiTokenExpiresAt": "Expires", + "ApiTokenLastUsed": "Last used", + "ApiTokenServerIDs": "Restrict to server IDs (optional)", + "ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3", + "ApiTokenExpiresInDays": "Expires in (days, 0 = never)", + "ApiTokenScopeRequired": "At least one scope is required.", + "ApiTokenServersInvalid": "Server IDs must be positive integers.", + "ApiTokenExpiryInvalid": "Days must be between 0 and 3650.", + "NameRequired": "Name is required.", + "Revoke": "Revoke", + "Copied": "Copied to clipboard", + "Scopes": "Scopes" } diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 18b9a7e..a775650 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -92,5 +92,30 @@ "UserInvalid": "L’utilisateur est invalide", "BlockByUser": "Bloqué par un administrateur", "OnlineUser": "Utilisateur en ligne", - "UserId": "ID utilisateur" + "UserId": "ID utilisateur", + "EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)", + "ApiTokens": "API Tokens", + "CreateApiToken": "Create API token", + "CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.", + "ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.", + "ApiTokenRevoked": "API token revoked.", + "ApiTokenCreated": "API token created", + "ApiTokenRevealOnce": "Copy this token now. It will never be shown again.", + "ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.", + "ApiTokenServers": "Servers", + "ApiTokenAllServers": "all permitted", + "ApiTokenNever": "never", + "ApiTokenExpiresAt": "Expires", + "ApiTokenLastUsed": "Last used", + "ApiTokenServerIDs": "Restrict to server IDs (optional)", + "ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3", + "ApiTokenExpiresInDays": "Expires in (days, 0 = never)", + "ApiTokenScopeRequired": "At least one scope is required.", + "ApiTokenServersInvalid": "Server IDs must be positive integers.", + "ApiTokenExpiryInvalid": "Days must be between 0 and 3650.", + "NameRequired": "Name is required.", + "Revoke": "Revoke", + "Copy": "Copy", + "Copied": "Copied to clipboard", + "Scopes": "Scopes" } diff --git a/src/locales/gl/translation.json b/src/locales/gl/translation.json index 48c8d0e..2b41b32 100644 --- a/src/locales/gl/translation.json +++ b/src/locales/gl/translation.json @@ -21,5 +21,31 @@ "NoRowsAreSelected": "Non hai filas seleccionadas", "ThisOperationIsUnrecoverable": "A operación non se pode desfacer!", "TaskTriggeredSuccessfully": "A tarefa desencadeouse correctamente" - } + }, + "EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)", + "ApiTokens": "API Tokens", + "CreateApiToken": "Create API token", + "CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.", + "ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.", + "ApiTokenRevoked": "API token revoked.", + "ApiTokenCreated": "API token created", + "ApiTokenRevealOnce": "Copy this token now. It will never be shown again.", + "ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.", + "ApiTokenServers": "Servers", + "ApiTokenAllServers": "all permitted", + "ApiTokenNever": "never", + "ApiTokenExpiresAt": "Expires", + "ApiTokenLastUsed": "Last used", + "ApiTokenServerIDs": "Restrict to server IDs (optional)", + "ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3", + "ApiTokenExpiresInDays": "Expires in (days, 0 = never)", + "ApiTokenScopeRequired": "At least one scope is required.", + "ApiTokenServersInvalid": "Server IDs must be positive integers.", + "ApiTokenExpiryInvalid": "Days must be between 0 and 3650.", + "NameRequired": "Name is required.", + "Revoke": "Revoke", + "Copy": "Copy", + "Copied": "Copied to clipboard", + "Done": "Done", + "Scopes": "Scopes" } diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index fbe55db..6d622af 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -255,5 +255,29 @@ "ClipboardWriteFailed": "Gagal menulis ke papan klip", "PastedFromClipboard": "Ditempel dari papan klip", "ClipboardReadFailed": "Gagal membaca papan klip", - "FormatMetricUnits": "Format Satuan Metrik" + "FormatMetricUnits": "Format Satuan Metrik", + "EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)", + "ApiTokens": "API Tokens", + "CreateApiToken": "Create API token", + "CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.", + "ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.", + "ApiTokenRevoked": "API token revoked.", + "ApiTokenCreated": "API token created", + "ApiTokenRevealOnce": "Copy this token now. It will never be shown again.", + "ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.", + "ApiTokenServers": "Servers", + "ApiTokenAllServers": "all permitted", + "ApiTokenNever": "never", + "ApiTokenExpiresAt": "Expires", + "ApiTokenLastUsed": "Last used", + "ApiTokenServerIDs": "Restrict to server IDs (optional)", + "ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3", + "ApiTokenExpiresInDays": "Expires in (days, 0 = never)", + "ApiTokenScopeRequired": "At least one scope is required.", + "ApiTokenServersInvalid": "Server IDs must be positive integers.", + "ApiTokenExpiryInvalid": "Days must be between 0 and 3650.", + "NameRequired": "Name is required.", + "Revoke": "Revoke", + "Copied": "Copied to clipboard", + "Scopes": "Scopes" } diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index 0bd4c9d..263518e 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -175,5 +175,30 @@ "OnlineUser": "Utente online", "BlockIdentifier": "Identificatore del blocco", "UserId": "ID utente", - "ConnectedAt": "Connesso alle" + "ConnectedAt": "Connesso alle", + "EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)", + "ApiTokens": "API Tokens", + "CreateApiToken": "Create API token", + "CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.", + "ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.", + "ApiTokenRevoked": "API token revoked.", + "ApiTokenCreated": "API token created", + "ApiTokenRevealOnce": "Copy this token now. It will never be shown again.", + "ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.", + "ApiTokenServers": "Servers", + "ApiTokenAllServers": "all permitted", + "ApiTokenNever": "never", + "ApiTokenExpiresAt": "Expires", + "ApiTokenLastUsed": "Last used", + "ApiTokenServerIDs": "Restrict to server IDs (optional)", + "ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3", + "ApiTokenExpiresInDays": "Expires in (days, 0 = never)", + "ApiTokenScopeRequired": "At least one scope is required.", + "ApiTokenServersInvalid": "Server IDs must be positive integers.", + "ApiTokenExpiryInvalid": "Days must be between 0 and 3650.", + "NameRequired": "Name is required.", + "Revoke": "Revoke", + "Copy": "Copy", + "Copied": "Copied to clipboard", + "Scopes": "Scopes" } diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index 0967ef4..d31bd7a 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -1 +1,28 @@ -{} +{ + "EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)", + "ApiTokens": "API Tokens", + "CreateApiToken": "Create API token", + "CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.", + "ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.", + "ApiTokenRevoked": "API token revoked.", + "ApiTokenCreated": "API token created", + "ApiTokenRevealOnce": "Copy this token now. It will never be shown again.", + "ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.", + "ApiTokenServers": "Servers", + "ApiTokenAllServers": "all permitted", + "ApiTokenNever": "never", + "ApiTokenExpiresAt": "Expires", + "ApiTokenLastUsed": "Last used", + "ApiTokenServerIDs": "Restrict to server IDs (optional)", + "ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3", + "ApiTokenExpiresInDays": "Expires in (days, 0 = never)", + "ApiTokenScopeRequired": "At least one scope is required.", + "ApiTokenServersInvalid": "Server IDs must be positive integers.", + "ApiTokenExpiryInvalid": "Days must be between 0 and 3650.", + "NameRequired": "Name is required.", + "Revoke": "Revoke", + "Copy": "Copy", + "Copied": "Copied to clipboard", + "Done": "Done", + "Scopes": "Scopes" +} diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 2b29176..246ba70 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -185,5 +185,30 @@ "IPChangeNotification": "Уведомление об изменении IP", "CreateAlertRule": "Создать правила оповещений", "UserId": "ID пользователя", - "NewUser": "Новый пользователь" + "NewUser": "Новый пользователь", + "EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)", + "ApiTokens": "API Tokens", + "CreateApiToken": "Create API token", + "CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.", + "ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.", + "ApiTokenRevoked": "API token revoked.", + "ApiTokenCreated": "API token created", + "ApiTokenRevealOnce": "Copy this token now. It will never be shown again.", + "ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.", + "ApiTokenServers": "Servers", + "ApiTokenAllServers": "all permitted", + "ApiTokenNever": "never", + "ApiTokenExpiresAt": "Expires", + "ApiTokenLastUsed": "Last used", + "ApiTokenServerIDs": "Restrict to server IDs (optional)", + "ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3", + "ApiTokenExpiresInDays": "Expires in (days, 0 = never)", + "ApiTokenScopeRequired": "At least one scope is required.", + "ApiTokenServersInvalid": "Server IDs must be positive integers.", + "ApiTokenExpiryInvalid": "Days must be between 0 and 3650.", + "NameRequired": "Name is required.", + "Revoke": "Revoke", + "Copy": "Copy", + "Copied": "Copied to clipboard", + "Scopes": "Scopes" } diff --git a/src/locales/ta/translation.json b/src/locales/ta/translation.json index bc72475..5efb181 100644 --- a/src/locales/ta/translation.json +++ b/src/locales/ta/translation.json @@ -180,5 +180,30 @@ "ConfirmBlock": "தொகுதி உறுதிப்படுத்தவும்", "RejectPassword": "கடவுச்சொல் உள்நுழைவை நிராகரிக்கவும்", "EmptyText": "உரை காலியாக உள்ளது", - "EmptyNote": "உங்களிடம் எந்த குறிப்பும் இல்லை." + "EmptyNote": "உங்களிடம் எந்த குறிப்பும் இல்லை.", + "EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)", + "ApiTokens": "API Tokens", + "CreateApiToken": "Create API token", + "CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.", + "ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.", + "ApiTokenRevoked": "API token revoked.", + "ApiTokenCreated": "API token created", + "ApiTokenRevealOnce": "Copy this token now. It will never be shown again.", + "ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.", + "ApiTokenServers": "Servers", + "ApiTokenAllServers": "all permitted", + "ApiTokenNever": "never", + "ApiTokenExpiresAt": "Expires", + "ApiTokenLastUsed": "Last used", + "ApiTokenServerIDs": "Restrict to server IDs (optional)", + "ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3", + "ApiTokenExpiresInDays": "Expires in (days, 0 = never)", + "ApiTokenScopeRequired": "At least one scope is required.", + "ApiTokenServersInvalid": "Server IDs must be positive integers.", + "ApiTokenExpiryInvalid": "Days must be between 0 and 3650.", + "NameRequired": "Name is required.", + "Revoke": "Revoke", + "Copy": "Copy", + "Copied": "Copied to clipboard", + "Scopes": "Scopes" } diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index 0bc4aac..e409c8d 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -127,5 +127,30 @@ "RequestType": "Тип запиту", "RequestBody": "Тіло запиту", "FileManager": "Псевдо Менеджер файлів", - "Downloading": "Завантаження" + "Downloading": "Завантаження", + "EnableMCP": "Enable MCP endpoint (off by default; review API token scopes and server allow-list first)", + "ApiTokens": "API Tokens", + "CreateApiToken": "Create API token", + "CreateApiTokenDescription": "Tokens authenticate MCP and external clients on your behalf. They cannot exceed your own permissions.", + "ConfirmDeleteApiToken": "Revoke API token '{{name}}'? This cannot be undone.", + "ApiTokenRevoked": "API token revoked.", + "ApiTokenCreated": "API token created", + "ApiTokenRevealOnce": "Copy this token now. It will never be shown again.", + "ApiTokenStoreSafely": "Treat this token like a password. Anyone with it can act on your behalf within its scopes.", + "ApiTokenServers": "Servers", + "ApiTokenAllServers": "all permitted", + "ApiTokenNever": "never", + "ApiTokenExpiresAt": "Expires", + "ApiTokenLastUsed": "Last used", + "ApiTokenServerIDs": "Restrict to server IDs (optional)", + "ApiTokenServerIDsPlaceholder": "comma-separated, e.g. 1,2,3", + "ApiTokenExpiresInDays": "Expires in (days, 0 = never)", + "ApiTokenScopeRequired": "At least one scope is required.", + "ApiTokenServersInvalid": "Server IDs must be positive integers.", + "ApiTokenExpiryInvalid": "Days must be between 0 and 3650.", + "NameRequired": "Name is required.", + "Revoke": "Revoke", + "Copy": "Copy", + "Copied": "Copied to clipboard", + "Scopes": "Scopes" } diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 5a15fef..71b772e 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -162,6 +162,7 @@ "UseDirectConnectingIP": "使用直连 IP", "IPChangeNotification": "IP变更通知", "FullIPNotification": "在通知消息中显示完整的 IP 地址", + "EnableMCP": "启用 MCP 接入(默认关闭;启用前请确认已审阅 API 令牌 scope 与服务器白名单)", "LoginFailed": "登录失败", "BruteForceAttackingToken": "暴力攻击令牌", "BruteForceAttackingAgentSecret": "暴力攻击代理秘密", @@ -288,5 +289,30 @@ "StatusFailed": "已失败", "StatusTimeout": "已超时", "StatusCancelled": "已取消" - } + }, + "ApiTokens": "API 令牌", + "CreateApiToken": "创建 API 令牌", + "CreateApiTokenDescription": "令牌用于 MCP 和外部客户端以你的身份调用 API,权限不能超过你本人。", + "ConfirmDeleteApiToken": "确定吊销 API 令牌 '{{name}}'?此操作不可撤销。", + "ApiTokenRevoked": "API 令牌已吊销。", + "ApiTokenCreated": "API 令牌已创建", + "ApiTokenRevealOnce": "请立即复制此令牌,离开后将无法再次查看。", + "ApiTokenStoreSafely": "请像密码一样妥善保管。任何持有者都可在 scope 内以你的身份操作。", + "ApiTokenServers": "服务器", + "ApiTokenAllServers": "全部可访问", + "ApiTokenNever": "永不", + "ApiTokenExpiresAt": "过期时间", + "ApiTokenLastUsed": "最近使用", + "ApiTokenServerIDs": "限定服务器 ID(可选)", + "ApiTokenServerIDsPlaceholder": "逗号分隔,例如 1,2,3", + "ApiTokenExpiresInDays": "有效期(天,0 = 永不过期)", + "ApiTokenScopeRequired": "至少选择一个 scope。", + "ApiTokenServersInvalid": "服务器 ID 必须为正整数。", + "ApiTokenExpiryInvalid": "天数必须在 0 到 3650 之间。", + "NameRequired": "名称必填。", + "Revoke": "吊销", + "Copy": "复制", + "Copied": "已复制到剪贴板", + "Done": "完成", + "Scopes": "权限" } diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 04714d1..b8e5788 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -219,5 +219,30 @@ "StatusFailed": "已失敗", "StatusTimeout": "已逾時", "StatusCancelled": "已取消" - } + }, + "EnableMCP": "啟用 MCP 端點(預設關閉;啟用前請先檢查 API token 範圍與伺服器允許清單)", + "ApiTokens": "API Token", + "CreateApiToken": "建立 API token", + "CreateApiTokenDescription": "Token 會代表你對 MCP 與外部用戶端進行驗證,其權限不會超過你本身的權限。", + "ConfirmDeleteApiToken": "撤銷 API token「{{name}}」?此操作無法復原。", + "ApiTokenRevoked": "API token 已撤銷。", + "ApiTokenCreated": "API token 已建立", + "ApiTokenRevealOnce": "請立即複製此 token,之後將不再顯示。", + "ApiTokenStoreSafely": "請將此 token 視為密碼。任何持有者都能在其範圍內代表你操作。", + "ApiTokenServers": "伺服器", + "ApiTokenAllServers": "全部允許", + "ApiTokenNever": "永不", + "ApiTokenExpiresAt": "到期時間", + "ApiTokenLastUsed": "最後使用", + "ApiTokenServerIDs": "限制伺服器 ID(選填)", + "ApiTokenServerIDsPlaceholder": "以逗號分隔,例如 1,2,3", + "ApiTokenExpiresInDays": "有效天數(0 = 永不過期)", + "ApiTokenScopeRequired": "至少需要一個範圍。", + "ApiTokenServersInvalid": "伺服器 ID 必須為正整數。", + "ApiTokenExpiryInvalid": "天數必須介於 0 到 3650 之間。", + "NameRequired": "名稱為必填。", + "Revoke": "撤銷", + "Copy": "複製", + "Copied": "已複製到剪貼簿", + "Scopes": "範圍" } diff --git a/src/main.tsx b/src/main.tsx index 44fbdf6..670b7ef 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -30,6 +30,7 @@ import SettingsPage from "./routes/settings" import TransferPage from "./routes/transfer" import UserPage from "./routes/user" import WAFPage from "./routes/waf" +import ApiTokensPage from "./routes/api-tokens" const router = createBrowserRouter([ { @@ -147,6 +148,10 @@ const router = createBrowserRouter([ path: "/dashboard/settings/online-user", element: , }, + { + path: "/dashboard/settings/api-tokens", + element: , + }, { path: "/dashboard/transfer", element: , diff --git a/src/routes/api-tokens.tsx b/src/routes/api-tokens.tsx new file mode 100644 index 0000000..0a31537 --- /dev/null +++ b/src/routes/api-tokens.tsx @@ -0,0 +1,338 @@ +import { + ApiTokenCreateResponse, + ApiTokenView, + SCOPE_OPTIONS, + createApiToken, + deleteApiToken, + listApiTokens, + parseExpiresInDaysInput, + parseServerIDsInput, +} from "@/api/api-tokens" +import { SettingsTab } from "@/components/settings-tab" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { useAuth } from "@/hooks/useAuth" +import { useEffect, useState } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" +import useSWR from "swr" + +export default function ApiTokensPage() { + const { t } = useTranslation() + const { profile } = useAuth() + const isAdmin = profile?.role === 0 + + const { data, mutate, isLoading, error } = useSWR( + "/api/v1/api-tokens", + listApiTokens, + ) + + useEffect(() => { + if (!error) return + toast(t("Error"), { + description: t("Results.ErrorFetchingResource", { + error: (error as Error)?.message ?? String(error), + }), + }) + }, [error, t]) + + const [createOpen, setCreateOpen] = useState(false) + const [revealed, setRevealed] = useState(null) + + const handleDelete = async (id: number, name: string) => { + if (!window.confirm(t("ConfirmDeleteApiToken", { name }))) return + try { + await deleteApiToken(id) + toast(t("ApiTokenRevoked")) + await mutate() + } catch (e: any) { + toast(t("Error"), { description: e.message }) + } + } + + return ( +
+ + +
+

{t("ApiTokens")}

+ +
+ + + + + {t("Name")} + {t("Scopes")} + {t("ApiTokenServers")} + {t("ApiTokenExpiresAt")} + {t("ApiTokenLastUsed")} + {t("Actions")} + + + + {isLoading ? ( + + + {t("Loading")}... + + + ) : !data || data.length === 0 ? ( + + + {t("NoResults")} + + + ) : ( + data.map((tok) => ( + + {tok.name} + +
+ {(tok.scopes ?? []).map((s) => ( + + {s} + + ))} +
+
+ + {tok.server_ids?.length ? tok.server_ids.join(", ") : t("ApiTokenAllServers")} + + + {tok.expires_at ? new Date(tok.expires_at).toLocaleString() : t("ApiTokenNever")} + + + {tok.last_used_at + ? `${new Date(tok.last_used_at).toLocaleString()} (${tok.last_used_ip ?? "?"})` + : t("ApiTokenNever")} + + + + +
+ )) + )} +
+
+ + { + setRevealed(res) + mutate() + }} + /> + + setRevealed(null)} /> +
+ ) +} + +function CreateApiTokenDialog({ + open, + onOpenChange, + isAdmin, + onCreated, +}: { + open: boolean + onOpenChange: (v: boolean) => void + isAdmin: boolean + onCreated: (res: ApiTokenCreateResponse) => void +}) { + const { t } = useTranslation() + const [name, setName] = useState("") + const [scopes, setScopes] = useState([]) + const [serverIDs, setServerIDs] = useState("") + const [expiresInDays, setExpiresInDays] = useState("90") + const [submitting, setSubmitting] = useState(false) + + const toggleScope = (s: string) => { + setScopes((cur) => (cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s])) + } + + const submit = async () => { + if (!name.trim()) { + toast(t("Error"), { description: t("NameRequired") }) + return + } + if (scopes.length === 0) { + toast(t("Error"), { description: t("ApiTokenScopeRequired") }) + return + } + const parsedRes = parseServerIDsInput(serverIDs) + if (!parsedRes.ok) { + toast(t("Error"), { description: t("ApiTokenServersInvalid") }) + return + } + const parsedServers = parsedRes.value + const expRes = parseExpiresInDaysInput(expiresInDays) + if (!expRes.ok) { + toast(t("Error"), { description: t("ApiTokenExpiryInvalid") }) + return + } + const expDays = expRes.value + setSubmitting(true) + try { + const res = await createApiToken({ + name: name.trim(), + scopes, + server_ids: parsedServers, + expires_in_days: expDays, + }) + onCreated(res) + onOpenChange(false) + setName("") + setScopes([]) + setServerIDs("") + setExpiresInDays("90") + } catch (e: any) { + toast(t("Error"), { description: e.message }) + } finally { + setSubmitting(false) + } + } + + return ( + + + + {t("CreateApiToken")} + {t("CreateApiTokenDescription")} + +
+
+ + setName(e.target.value)} /> +
+
+ +
+ {SCOPE_OPTIONS.map((s) => { + const adminOnly = s.value === "nezha:*" || s.value === "nezha:admin:*" + const disabled = adminOnly && !isAdmin + return ( + + ) + })} +
+
+
+ + setServerIDs(e.target.value)} + /> +
+
+ + setExpiresInDays(e.target.value)} + /> +
+
+ + + + + + +
+
+ ) +} + +function RevealedTokenDialog({ + token, + onClose, +}: { + token: ApiTokenCreateResponse | null + onClose: () => void +}) { + const { t } = useTranslation() + return ( + !v && onClose()}> + + + {t("ApiTokenCreated")} + {t("ApiTokenRevealOnce")} + + {token && ( +
+
+ {t("ApiTokenStoreSafely")} +
+ + {token.token} + + +
+ )} + + + +
+
+ ) +} diff --git a/src/routes/protect.tsx b/src/routes/protect.tsx index 661f520..b70ba77 100644 --- a/src/routes/protect.tsx +++ b/src/routes/protect.tsx @@ -1,16 +1,36 @@ import { useAuth } from "@/hooks/useAuth" -import { Navigate } from "react-router-dom" +import { Navigate, useLocation } from "react-router-dom" + +const LOGIN_PATH = "/dashboard/login" export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { - const { profile } = useAuth() + const { profile, loading } = useAuth() + const { pathname } = useLocation() - if (!profile && window.location.pathname !== "/dashboard/login") { - return ( - <> - - {children} - - ) + // While AuthProvider's initial getProfile() round-trip is in flight we + // can't yet decide between "render protected subtree" and "redirect to + // login". Two key invariants: + // - For the login page itself, render children so the user can log in + // without waiting for an unrelated /api/v1/profile probe. + // - For every other /dashboard/* path, do NOT mount children — the + // protected subtree (Root + Outlet + page) would fire authenticated + // SWR fetches like /api/v1/setting before auth is even confirmed, + // and a subsequent redirect would leave that work unobserved. A + // blank render during the (short) probe avoids that wasted round-trip + // and the flash of protected UI before redirect. + if (loading) { + if (pathname === LOGIN_PATH) { + return children + } + return null + } + + if (!profile && pathname !== LOGIN_PATH) { + // `replace` keeps the unauthenticated URL out of history. Crucially do + // NOT render `children` alongside Navigate: that would mount the + // protected subtree for one paint and fire authenticated requests we + // are about to redirect away from. + return } return children diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index b6a7199..4cec5f1 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -31,7 +31,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useEffect } from "react" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" -import { useNavigate } from "react-router-dom" +import { Navigate } from "react-router-dom" import { toast } from "sonner" import { z } from "zod" @@ -52,13 +52,13 @@ const settingFormSchema = z.object({ tls: asOptionalField(z.boolean()), enable_ip_change_notification: asOptionalField(z.boolean()), enable_plain_ip_in_notification: asOptionalField(z.boolean()), + enable_mcp: asOptionalField(z.boolean()), }) export default function SettingsPage() { const { t, i18n } = useTranslation() const { data: config, mutate } = useSetting() - const { profile } = useAuth() - const navigate = useNavigate() + const { profile, loading: authLoading } = useAuth() const { notifierGroup } = useNotification() const ngroupList = notifierGroup?.map((ng) => ({ @@ -68,10 +68,7 @@ export default function SettingsPage() { const isAdmin = profile?.role === 0 - if (!isAdmin) { - navigate("/dashboard/settings/online-user") - } - + // 所有 hooks 必须在条件 return 之前调用,否则违反 rules-of-hooks。 const form = useForm({ resolver: zodResolver(settingFormSchema) as any, defaultValues: config @@ -100,6 +97,13 @@ export default function SettingsPage() { } }, [config?.config, form]) + if (authLoading) { + return null + } + if (!isAdmin) { + return + } + const onSubmit = async (values: any) => { try { await updateSettings(values) @@ -112,12 +116,11 @@ export default function SettingsPage() { }), }) return - } finally { - if (values.language != i18n.language) { - i18n.changeLanguage(values.language) - } - toast(t("Success")) } + if (values.language != i18n.language) { + i18n.changeLanguage(values.language) + } + toast(t("Success")) } return ( @@ -340,16 +343,10 @@ export default function SettingsPage() { checked={field.value == "NZ::Use-Peer-IP"} className="ml-2" onCheckedChange={(checked) => { - if (checked) { - field.disabled = true - form.setValue( - "web_real_ip_header", - "NZ::Use-Peer-IP", - ) - } else { - field.disabled = false - form.setValue("web_real_ip_header", "") - } + form.setValue( + "web_real_ip_header", + checked ? "NZ::Use-Peer-IP" : "", + ) }} /> @@ -379,16 +376,10 @@ export default function SettingsPage() { checked={field.value == "NZ::Use-Peer-IP"} className="ml-2" onCheckedChange={(checked) => { - if (checked) { - field.disabled = true - form.setValue( - "agent_real_ip_header", - "NZ::Use-Peer-IP", - ) - } else { - field.disabled = false - form.setValue("agent_real_ip_header", "") - } + form.setValue( + "agent_real_ip_header", + checked ? "NZ::Use-Peer-IP" : "", + ) }} /> @@ -513,6 +504,26 @@ export default function SettingsPage() { )} /> + ( + + +
+ + +
+
+ +
+ )} + /> diff --git a/src/test/api-tokens-expiry.test.ts b/src/test/api-tokens-expiry.test.ts new file mode 100644 index 0000000..4c5395a --- /dev/null +++ b/src/test/api-tokens-expiry.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "vitest" + +import { parseExpiresInDaysInput } from "../api/api-tokens" + +test("parseExpiresInDaysInput treats blank as 'never expires' (undefined)", () => { + expect(parseExpiresInDaysInput("")).toEqual({ ok: true, value: undefined }) + expect(parseExpiresInDaysInput(" ")).toEqual({ ok: true, value: undefined }) +}) + +test("parseExpiresInDaysInput accepts whole-number days in range", () => { + expect(parseExpiresInDaysInput("30")).toEqual({ ok: true, value: 30 }) + expect(parseExpiresInDaysInput(" 3650 ")).toEqual({ ok: true, value: 3650 }) +}) + +test("parseExpiresInDaysInput maps 0 to undefined (never expires)", () => { + expect(parseExpiresInDaysInput("0")).toEqual({ ok: true, value: undefined }) +}) + +// Backend model field is `ExpiresInDays int` (nezha/model/api_token.go); a +// fractional value would fail JSON binding server-side, so the UI must reject +// it locally rather than send 1.5 and surface a confusing backend error. +test("parseExpiresInDaysInput rejects fractional days", () => { + expect(parseExpiresInDaysInput("1.5").ok).toBe(false) +}) + +test("parseExpiresInDaysInput rejects out-of-range and non-numeric input", () => { + expect(parseExpiresInDaysInput("-1").ok).toBe(false) + expect(parseExpiresInDaysInput("3651").ok).toBe(false) + expect(parseExpiresInDaysInput("abc").ok).toBe(false) +}) diff --git a/src/test/api-tokens-server-ids.test.ts b/src/test/api-tokens-server-ids.test.ts new file mode 100644 index 0000000..fa4aea3 --- /dev/null +++ b/src/test/api-tokens-server-ids.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "vitest" + +import { parseServerIDsInput } from "../api/api-tokens" + +test("parseServerIDsInput returns undefined for empty input", () => { + expect(parseServerIDsInput("")).toEqual({ ok: true, value: undefined }) + expect(parseServerIDsInput(" ")).toEqual({ ok: true, value: undefined }) +}) + +test("parseServerIDsInput parses valid comma-separated positive ints", () => { + expect(parseServerIDsInput("1,2,3")).toEqual({ ok: true, value: [1, 2, 3] }) + expect(parseServerIDsInput(" 10 , 11 ")).toEqual({ ok: true, value: [10, 11] }) +}) + +test("parseServerIDsInput rejects any non-numeric token rather than silently dropping it", () => { + // 历史 UI 把 "1,abc" 静默裁剪为 [1] 再上送,等价于把"输入完整接受"骗给用户。 + // 这条契约要求:任一片段非法 → 整次解析失败,前端必须报错而不是吞掉。 + const r = parseServerIDsInput("1,abc") + expect(r.ok).toBe(false) +}) + +test("parseServerIDsInput rejects non-positive ids", () => { + expect(parseServerIDsInput("0").ok).toBe(false) + expect(parseServerIDsInput("-3").ok).toBe(false) + expect(parseServerIDsInput("1.5").ok).toBe(false) +}) diff --git a/src/test/api-tokens.test.tsx b/src/test/api-tokens.test.tsx new file mode 100644 index 0000000..6a1661b --- /dev/null +++ b/src/test/api-tokens.test.tsx @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest" + +import { createApiToken, deleteApiToken, listApiTokens } from "../api/api-tokens" + +const realFetch = global.fetch + +function mockFetch(payload: unknown, ok = true, success = true) { + global.fetch = vi.fn(async () => { + return new Response(JSON.stringify({ success, error: success ? "" : "boom", data: payload }), { + status: ok ? 200 : 500, + headers: { "Content-Type": "application/json" }, + }) + }) as unknown as typeof fetch +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() + global.fetch = realFetch + vi.restoreAllMocks() +}) + +test("listApiTokens GETs /api/v1/api-tokens and returns parsed array", async () => { + const calls: Array<{ url: string; method: string }> = [] + global.fetch = vi.fn(async (input: any, init?: any) => { + calls.push({ url: String(input), method: String(init?.method ?? "GET") }) + return new Response( + JSON.stringify({ + success: true, + data: [ + { + id: 1, + name: "claude", + scopes: ["nezha:server:read"], + created_at: "2025-01-01T00:00:00Z", + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + }) as unknown as typeof fetch + + const got = await listApiTokens() + expect(calls).toHaveLength(1) + expect(calls[0].method).toBe("GET") + expect(calls[0].url).toContain("/api/v1/api-tokens") + expect(got).toHaveLength(1) + expect(got[0].name).toBe("claude") + expect(got[0].scopes).toContain("nezha:server:read") +}) + +test("createApiToken POSTs /api/v1/api-tokens and serializes scopes / server_ids / expires_in_days", async () => { + let captured: { url: string; method: string; body: any } | null = null + global.fetch = vi.fn(async (input: any, init?: any) => { + captured = { + url: String(input), + method: String(init?.method ?? ""), + body: init?.body ? JSON.parse(init.body as string) : null, + } + return new Response( + JSON.stringify({ + success: true, + data: { + id: 2, + name: "x", + token: "nzp_FAKEABC", + scopes: ["nezha:server:read", "nezha:server:write"], + server_ids: [10, 11], + expires_at: "2026-01-01T00:00:00Z", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + }) as unknown as typeof fetch + + const res = await createApiToken({ + name: "x", + scopes: ["nezha:server:read", "nezha:server:write"], + server_ids: [10, 11], + expires_in_days: 30, + }) + expect(res.token).toBe("nzp_FAKEABC") + expect(captured).not.toBeNull() + expect(captured!.method).toBe("POST") + expect(captured!.url).toContain("/api/v1/api-tokens") + expect(captured!.body.name).toBe("x") + expect(captured!.body.scopes).toEqual(["nezha:server:read", "nezha:server:write"]) + expect(captured!.body.server_ids).toEqual([10, 11]) + expect(captured!.body.expires_in_days).toBe(30) +}) + +test("createApiToken surfaces server error via thrown Error", async () => { + mockFetch(null, true, false) + await expect( + createApiToken({ name: "x", scopes: ["nezha:server:read"] }), + ).rejects.toThrow("boom") +}) + +test("listApiTokens normalizes null scopes to an empty array so the table cannot crash", async () => { + // Backend APIToken.Scopes() returns nil for ScopesCSV=="" which JSON-encodes + // as null; migrated/legacy/hand-edited rows hit this. Without normalization + // the list page does tok.scopes.map(...) on null and the whole page crashes. + global.fetch = vi.fn(async () => { + return new Response( + JSON.stringify({ + success: true, + data: [ + { id: 1, name: "legacy", scopes: null, created_at: "2025-01-01T00:00:00Z" }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + }) as unknown as typeof fetch + + const got = await listApiTokens() + expect(Array.isArray(got[0].scopes)).toBe(true) + expect(got[0].scopes).toEqual([]) +}) + +test("deleteApiToken DELETEs /api/v1/api-tokens/:id", async () => { + const calls: Array<{ url: string; method: string }> = [] + global.fetch = vi.fn(async (input: any, init?: any) => { + calls.push({ url: String(input), method: String(init?.method ?? "GET") }) + return new Response(JSON.stringify({ success: true, data: null }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) as unknown as typeof fetch + + await deleteApiToken(42) + expect(calls).toHaveLength(1) + expect(calls[0].method).toBe("DELETE") + expect(calls[0].url).toContain("/api/v1/api-tokens/42") +}) diff --git a/src/test/auth-login-loading.test.tsx b/src/test/auth-login-loading.test.tsx new file mode 100644 index 0000000..ef79291 --- /dev/null +++ b/src/test/auth-login-loading.test.tsx @@ -0,0 +1,151 @@ +import { act, render } from "@testing-library/react" +import { useEffect } from "react" +import { afterEach, beforeEach, expect, test, vi } from "vitest" + +let profileStore: { id: number; role: number } | undefined +const setProfileSpy = vi.fn((p: any) => { + profileStore = p +}) + +vi.mock("./useMainStore", () => ({})) +vi.mock("@/hooks/useMainStore", () => ({ + useMainStore: (selector: any) => + selector({ profile: profileStore, setProfile: setProfileSpy }), +})) + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})) + +vi.mock("sonner", () => ({ toast: () => {} })) + +const navigate = vi.fn() +vi.mock("react-router-dom", () => ({ + useNavigate: () => navigate, +})) + +// Initial getProfile() stays pending forever so loading can only be cleared +// by an explicit login/logout, which is exactly what these tests assert. +let initialProfilePromise: Promise +let getProfileCall = 0 +const loginRequest = vi.fn(async () => {}) +vi.mock("@/api/user", () => ({ + getProfile: vi.fn(() => { + getProfileCall++ + if (getProfileCall === 1) return initialProfilePromise + return Promise.resolve({ id: 42, role: 0 }) + }), + login: () => loginRequest(), +})) + +beforeEach(() => { + profileStore = undefined + getProfileCall = 0 + initialProfilePromise = new Promise(() => {}) + setProfileSpy.mockClear() + navigate.mockClear() +}) + +afterEach(() => { + document.body.innerHTML = "" + vi.clearAllMocks() +}) + +// AuthProvider starts with loading=true and only clears it in the initial +// mount probe's finally{}. A user can log in while that probe is still in +// flight (ProtectedRoute renders the login page during loading). If login() +// does not clear loading itself, ProtectedRoute keeps returning null for +// /dashboard and the freshly-authenticated user sees a blank screen until the +// unrelated probe settles. +test("login() clears loading even while the initial profile probe is still pending", async () => { + const { AuthProvider, useAuth } = await import("@/hooks/useAuth") + + const captured: { auth?: ReturnType } = {} + function Capture() { + const auth = useAuth() + useEffect(() => { + captured.auth = auth + }) + captured.auth = auth + return null + } + + await act(async () => { + render( + + + , + ) + }) + + // Initial probe still pending -> loading must be true. + expect(captured.auth!.loading).toBe(true) + + await act(async () => { + await captured.auth!.login("u", "p") + }) + + // Login succeeded; loading must be false so ProtectedRoute renders. + expect(profileStore).toEqual({ id: 42, role: 0 }) + expect(captured.auth!.loading).toBe(false) +}) + +test("loginOauth2() clears loading even while the initial profile probe is still pending", async () => { + const { AuthProvider, useAuth } = await import("@/hooks/useAuth") + + const captured: { auth?: ReturnType } = {} + function Capture() { + const auth = useAuth() + useEffect(() => { + captured.auth = auth + }) + captured.auth = auth + return null + } + + await act(async () => { + render( + + + , + ) + }) + + expect(captured.auth!.loading).toBe(true) + + await act(async () => { + await captured.auth!.loginOauth2() + }) + + expect(captured.auth!.loading).toBe(false) +}) + +test("logout() clears loading even while the initial profile probe is still pending", async () => { + const { AuthProvider, useAuth } = await import("@/hooks/useAuth") + + const captured: { auth?: ReturnType } = {} + function Capture() { + const auth = useAuth() + useEffect(() => { + captured.auth = auth + }) + captured.auth = auth + return null + } + + await act(async () => { + render( + + + , + ) + }) + + expect(captured.auth!.loading).toBe(true) + + await act(async () => { + captured.auth!.logout() + }) + + expect(captured.auth!.loading).toBe(false) +}) diff --git a/src/test/auth-login-race.test.tsx b/src/test/auth-login-race.test.tsx new file mode 100644 index 0000000..8f7ca53 --- /dev/null +++ b/src/test/auth-login-race.test.tsx @@ -0,0 +1,96 @@ +import { act, render } from "@testing-library/react" +import { useEffect } from "react" +import { afterEach, beforeEach, expect, test, vi } from "vitest" + +let profileStore: { id: number; role: number } | undefined +const setProfileSpy = vi.fn((p: any) => { + profileStore = p +}) + +vi.mock("./useMainStore", () => ({})) +vi.mock("@/hooks/useMainStore", () => ({ + useMainStore: (selector: any) => + selector({ profile: profileStore, setProfile: setProfileSpy }), +})) + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})) + +vi.mock("sonner", () => ({ toast: () => {} })) + +const navigate = vi.fn() +vi.mock("react-router-dom", () => ({ + useNavigate: () => navigate, +})) + +// Deferred initial getProfile() so we can resolve/reject it AFTER login(). +let rejectInitial: (e: any) => void +const initialProfilePromise = new Promise((_res, rej) => { + rejectInitial = rej +}) +let getProfileCall = 0 +const loginRequest = vi.fn(async () => {}) +vi.mock("@/api/user", () => ({ + getProfile: vi.fn(() => { + getProfileCall++ + if (getProfileCall === 1) return initialProfilePromise + return Promise.resolve({ id: 42, role: 0 }) + }), + login: () => loginRequest(), +})) + +beforeEach(() => { + profileStore = undefined + getProfileCall = 0 + setProfileSpy.mockClear() + navigate.mockClear() +}) + +afterEach(() => { + document.body.innerHTML = "" + vi.clearAllMocks() +}) + +// The AuthProvider fires getProfile() on mount. While that probe is in flight a +// user can submit the login form (ProtectedRoute renders the login page during +// loading). If the in-flight probe later REJECTS (e.g. it 401'd because the +// user was not yet authenticated), its catch{} must NOT clobber the profile a +// successful login() already set — otherwise the freshly-authenticated user is +// bounced back to the login page. +test("late-rejecting initial profile probe does not clobber a successful login", async () => { + const { AuthProvider, useAuth } = await import("@/hooks/useAuth") + + const captured: { auth?: ReturnType } = {} + function Capture() { + const auth = useAuth() + useEffect(() => { + captured.auth = auth + }) + return null + } + + await act(async () => { + render( + + + , + ) + }) + + // User logs in while the initial probe is still pending. + await act(async () => { + await captured.auth!.login("u", "p") + }) + expect(profileStore).toEqual({ id: 42, role: 0 }) + + // Now the stale initial probe rejects (it was a pre-auth 401). + await act(async () => { + rejectInitial(new Error("401")) + await initialProfilePromise.catch(() => {}) + }) + + // The logged-in profile must survive. + expect(profileStore).toEqual({ id: 42, role: 0 }) + expect(setProfileSpy).not.toHaveBeenLastCalledWith(undefined) +}) diff --git a/src/test/fetcher-csrf.test.ts b/src/test/fetcher-csrf.test.ts new file mode 100644 index 0000000..b48055e --- /dev/null +++ b/src/test/fetcher-csrf.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest" + +import { FetcherMethod, fetcher } from "../api/api" + +const realFetch = global.fetch + +function setCookie(value: string) { + Object.defineProperty(document, "cookie", { value, configurable: true }) +} + +beforeEach(() => { + setCookie("") +}) + +afterEach(() => { + global.fetch = realFetch + vi.restoreAllMocks() +}) + +function jsonOk() { + return new Response(JSON.stringify({ success: true, data: null }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) +} + +function headerOf(init: RequestInit | undefined, name: string): string | null { + const h = init?.headers + if (!h) return null + if (h instanceof Headers) return h.get(name) + const rec = h as Record + const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase()) + return key ? rec[key] : null +} + +// Backend csrfMiddleware (nezha/cmd/dashboard/controller/csrf.go) enforces a +// double-submit cookie on every cookie-auth unsafe method: it rejects the +// request unless X-CSRF-Token header == nz-csrf cookie. The fetcher must mirror +// the cookie into the header for POST/PATCH/PUT/DELETE. +test("POST sends X-CSRF-Token mirrored from nz-csrf cookie", async () => { + setCookie("nz-csrf=abc123; other=1") + const seen: { init?: RequestInit }[] = [] + global.fetch = vi.fn(async (_input: any, init?: RequestInit) => { + seen.push({ init }) + return jsonOk() + }) as unknown as typeof fetch + + await fetcher(FetcherMethod.POST, "/api/v1/api-tokens", { name: "x" }) + + expect(headerOf(seen[0].init, "X-CSRF-Token")).toBe("abc123") +}) + +test("DELETE sends X-CSRF-Token mirrored from nz-csrf cookie", async () => { + setCookie("nz-csrf=del-token") + const seen: { init?: RequestInit }[] = [] + global.fetch = vi.fn(async (_input: any, init?: RequestInit) => { + seen.push({ init }) + return jsonOk() + }) as unknown as typeof fetch + + await fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/7") + + expect(headerOf(seen[0].init, "X-CSRF-Token")).toBe("del-token") +}) + +test("auto refresh-token uses POST (backend route is POST)", async () => { + vi.resetModules() + const { FetcherMethod: M, fetcher: f } = await import("../api/api") + setCookie("nz-jwt=sess; nz-csrf=c") + const seen: { url: string; init?: RequestInit }[] = [] + global.fetch = vi.fn(async (input: any, init?: RequestInit) => { + seen.push({ url: String(input), init }) + return jsonOk() + }) as unknown as typeof fetch + + await f(M.GET, "/api/v1/server") + + const refresh = seen.find((s) => s.url.includes("/api/v1/refresh-token")) + expect(refresh, "auto refresh request should be issued").toBeTruthy() + expect(refresh!.init?.method).toBe("POST") +}) + +// Revoke (DELETE) commonly returns 204 / empty body. The fetcher must not +// blow up on response.json() of an empty body and must resolve successfully. +test("DELETE tolerates an empty 204 response body", async () => { + global.fetch = vi.fn(async () => new Response(null, { status: 204 })) as unknown as typeof fetch + + await expect(fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/9")).resolves.toBeUndefined() +}) + +test("empty 200 body does not throw", async () => { + global.fetch = vi.fn( + async () => new Response("", { status: 200 }), + ) as unknown as typeof fetch + + await expect(fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/9")).resolves.toBeUndefined() +}) diff --git a/src/test/fetcher-delete.test.ts b/src/test/fetcher-delete.test.ts new file mode 100644 index 0000000..96ec46e --- /dev/null +++ b/src/test/fetcher-delete.test.ts @@ -0,0 +1,35 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest" + +import { FetcherMethod, fetcher } from "../api/api" + +const realFetch = global.fetch + +beforeEach(() => { + // Avoid the auto refresh-token branch interfering with assertions. + Object.defineProperty(document, "cookie", { value: "", configurable: true }) +}) + +afterEach(() => { + global.fetch = realFetch + vi.restoreAllMocks() +}) + +// Regression: fetcher used to collapse GET and DELETE into the same HTTP GET, +// so DELETE callers (e.g. revoke API token) never actually hit the backend +// DELETE route. The wire-level method must match the requested FetcherMethod. +test("fetcher uses HTTP DELETE for FetcherMethod.DELETE", async () => { + const seen: { url: string; init?: RequestInit }[] = [] + global.fetch = vi.fn(async (input: any, init?: RequestInit) => { + seen.push({ url: String(input), init }) + return new Response(JSON.stringify({ success: true, data: null }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) as unknown as typeof fetch + + await fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/42") + + expect(seen).toHaveLength(1) + expect(seen[0].init?.method).toBe("DELETE") + expect(seen[0].url).toContain("/api/v1/api-tokens/42") +}) diff --git a/src/test/fetcher-refresh-selfheal.test.ts b/src/test/fetcher-refresh-selfheal.test.ts new file mode 100644 index 0000000..b92825e --- /dev/null +++ b/src/test/fetcher-refresh-selfheal.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest" + +const realFetch = global.fetch + +function setCookie(value: string) { + Object.defineProperty(document, "cookie", { value, configurable: true }) +} + +function jsonOk() { + return new Response(JSON.stringify({ success: true, data: null }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) +} + +beforeEach(() => { + setCookie("") + vi.resetModules() +}) + +afterEach(() => { + global.fetch = realFetch + vi.restoreAllMocks() +}) + +// Auto-refresh is a POST behind the CSRF gate. When the session still lacks the +// nz-csrf cookie (e.g. just upgraded), firing the refresh without a header only +// burns the 1h throttle window and 403s. The refresh must instead wait until a +// CSRF token is available so it can actually succeed once the cookie is seeded. +test("auto refresh is deferred while nz-csrf cookie is missing", async () => { + const { FetcherMethod, fetcher } = await import("../api/api") + setCookie("nz-jwt=session") // jwt present, but no nz-csrf yet + const urls: string[] = [] + global.fetch = vi.fn(async (input: any) => { + urls.push(String(input)) + return jsonOk() + }) as unknown as typeof fetch + + await fetcher(FetcherMethod.GET, "/api/v1/server") + + expect(urls.some((u) => u.includes("/api/v1/refresh-token"))).toBe(false) +}) + +// Once the cookie exists, the next GET must be allowed to fire the refresh — +// proving the missing-cookie skip did not permanently consume the throttle. +test("auto refresh fires after nz-csrf cookie becomes available", async () => { + const { FetcherMethod, fetcher } = await import("../api/api") + const urls: string[] = [] + global.fetch = vi.fn(async (input: any) => { + urls.push(String(input)) + return jsonOk() + }) as unknown as typeof fetch + + setCookie("nz-jwt=session") // first GET: no csrf, refresh skipped + await fetcher(FetcherMethod.GET, "/api/v1/server") + expect(urls.some((u) => u.includes("/api/v1/refresh-token"))).toBe(false) + + setCookie("nz-jwt=session; nz-csrf=seeded") // backend seeded it + await fetcher(FetcherMethod.GET, "/api/v1/server") + expect(urls.some((u) => u.includes("/api/v1/refresh-token"))).toBe(true) +}) diff --git a/src/test/protect-auth-loading.test.tsx b/src/test/protect-auth-loading.test.tsx new file mode 100644 index 0000000..0b3ea7b --- /dev/null +++ b/src/test/protect-auth-loading.test.tsx @@ -0,0 +1,108 @@ +import { act, render } from "@testing-library/react" +import { MemoryRouter, Route, Routes } from "react-router-dom" +import { afterEach, beforeEach, expect, test, vi } from "vitest" + +// ProtectedRoute must NOT mount its children while AuthProvider is still +// running the initial getProfile() probe — except for the /dashboard/login +// path itself. Mounting the protected subtree during the probe would fire +// authenticated SWR fetches like /api/v1/setting before auth is confirmed. +// Without this contract, a regression in protect.tsx silently re-introduces +// pre-auth traffic and a flash of protected UI before redirect. + +let mockProfile: { id: number; role: number } | undefined +let mockLoading = false +vi.mock("@/hooks/useAuth", () => ({ + useAuth: () => ({ profile: mockProfile, loading: mockLoading }), +})) + +beforeEach(() => { + mockProfile = undefined + mockLoading = true +}) + +afterEach(() => { + document.body.innerHTML = "" +}) + +function renderAtPath(path: string, child: React.ReactNode) { + return import("@/routes/protect").then(({ default: ProtectedRoute }) => { + return act(async () => { + render( + + + {child} + } + /> + {child} + } + /> + + , + ) + }) + }) +} + +test("ProtectedRoute does not mount children for protected paths while auth is loading", async () => { + await renderAtPath( + "/dashboard", +
protected
, + ) + expect(document.querySelector("[data-testid='protected-child']")).toBeNull() +}) + +test("ProtectedRoute renders children on the login page even while auth is loading", async () => { + await renderAtPath( + "/dashboard/login", +
login
, + ) + expect(document.querySelector("[data-testid='login-child']")).not.toBeNull() +}) + +test("ProtectedRoute redirects unauthenticated users without mounting protected children", async () => { + mockLoading = false + mockProfile = undefined + const { default: ProtectedRoute } = await import("@/routes/protect") + await act(async () => { + render( + + + +
login
+ + } + /> + +
protected
+ + } + /> +
+
, + ) + }) + expect(document.querySelector("[data-testid='protected-child']")).toBeNull() + expect(document.querySelector("[data-testid='login-child']")).not.toBeNull() +}) + +test("ProtectedRoute renders children once an authenticated profile resolves", async () => { + mockLoading = false + mockProfile = { id: 1, role: 0 } + await renderAtPath( + "/dashboard", +
protected
, + ) + expect(document.querySelector("[data-testid='protected-child']")).not.toBeNull() +}) diff --git a/src/test/settings-auth-loading.test.tsx b/src/test/settings-auth-loading.test.tsx new file mode 100644 index 0000000..34bf302 --- /dev/null +++ b/src/test/settings-auth-loading.test.tsx @@ -0,0 +1,74 @@ +import { act, render } from "@testing-library/react" +import { afterEach, beforeEach, expect, test, vi } from "vitest" + +vi.mock("sonner", () => ({ toast: () => {} })) + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string) => k, + i18n: { language: "en", changeLanguage: () => {} }, + }), + initReactI18next: { type: "3rdParty", init: () => undefined }, + Trans: ({ children }: { children?: React.ReactNode }) => children ?? null, +})) + +let mockProfile: { id: number; role: number } | undefined +let mockLoading = false +vi.mock("@/hooks/useAuth", () => ({ + useAuth: () => ({ profile: mockProfile, loading: mockLoading }), +})) + +vi.mock("@/hooks/useSetting", () => ({ + default: () => ({ data: undefined, mutate: () => {} }), +})) + +vi.mock("@/hooks/useNotfication", () => ({ + useNotification: () => ({ notifierGroup: [] }), +})) + +vi.mock("@/api/settings", () => ({ updateSettings: vi.fn() })) + +const navigateRenders: string[] = [] +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom") + return { + ...actual, + Navigate: ({ to }: { to: string }) => { + navigateRenders.push(to) + return
redirect:{to}
+ }, + } +}) + +beforeEach(() => { + navigateRenders.length = 0 + mockProfile = undefined + mockLoading = true +}) + +afterEach(() => { + document.body.innerHTML = "" +}) + +// SettingsPage 在 profile 还没 fetch 完成时不能就把用户当作非管理员重定向。 +// useAuth.loading=true 表示请求未回来;此时必须按"加载中"渲染,不能 Navigate +// 到 /dashboard/settings/api-tokens,否则管理员每次直达 /dashboard/settings +// 都会先闪一下到 api-tokens 页。 +test("SettingsPage waits for auth load before redirecting non-admin", async () => { + const { default: SettingsPage } = await import("@/routes/settings") + await act(async () => { + render() + }) + expect(navigateRenders).toEqual([]) +}) + +// 一旦 loading=false 且确认 profile 不是 admin,才允许跳转。 +test("SettingsPage redirects to api-tokens once auth resolves and user is not admin", async () => { + mockLoading = false + mockProfile = { id: 1, role: 1 } + const { default: SettingsPage } = await import("@/routes/settings") + await act(async () => { + render() + }) + expect(navigateRenders).toContain("/dashboard/settings/api-tokens") +}) diff --git a/src/test/settings-submit-error.test.tsx b/src/test/settings-submit-error.test.tsx new file mode 100644 index 0000000..6e0fb71 --- /dev/null +++ b/src/test/settings-submit-error.test.tsx @@ -0,0 +1,107 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { MemoryRouter } from "react-router-dom" +import { afterEach, beforeEach, expect, test, vi } from "vitest" + +const toastCalls: string[] = [] +vi.mock("sonner", () => ({ + toast: (msg: string) => { + toastCalls.push(msg) + }, +})) + +const changeLanguageCalls: string[] = [] +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string) => k, + i18n: { + language: "en", + changeLanguage: (lng: string) => { + changeLanguageCalls.push(lng) + }, + }, + }), + initReactI18next: { type: "3rdParty", init: () => undefined }, + Trans: ({ children }: { children?: React.ReactNode }) => children ?? null, +})) + +vi.mock("@/hooks/useAuth", () => ({ + useAuth: () => ({ profile: { id: 1, role: 0 }, loading: false }), +})) + +const validConfig = { + config: { + site_name: "Nezha", + language: "zh-CN", + user_template: "user-dist", + cover: 1, + ip_change_notification_group_id: 0, + }, + frontend_templates: [], +} +vi.mock("@/hooks/useSetting", () => ({ + default: () => ({ data: validConfig, mutate: vi.fn() }), +})) + +vi.mock("@/hooks/useNotfication", () => ({ + useNotification: () => ({ notifierGroup: [] }), +})) + +const updateSettings = vi.fn() +vi.mock("@/api/settings", () => ({ updateSettings: (...args: unknown[]) => updateSettings(...args) })) + +beforeEach(() => { + toastCalls.length = 0 + changeLanguageCalls.length = 0 + updateSettings.mockReset() +}) + +afterEach(() => { + document.body.innerHTML = "" +}) + +test("SettingsPage does not toast Success when updateSettings rejects", async () => { + updateSettings.mockRejectedValue(new Error("boom")) + const { default: SettingsPage } = await import("@/routes/settings") + + await act(async () => { + render( + + + , + ) + }) + + const submit = screen.getByRole("button", { name: /Confirm|Submit|Save/i }) + await act(async () => { + fireEvent.click(submit) + }) + + await waitFor(() => expect(updateSettings).toHaveBeenCalled()) + + expect(toastCalls).toContain("Error") + expect(toastCalls).not.toContain("Success") + expect(changeLanguageCalls).toEqual([]) +}) + +test("SettingsPage toasts Success when updateSettings resolves", async () => { + updateSettings.mockResolvedValue(undefined) + const { default: SettingsPage } = await import("@/routes/settings") + + await act(async () => { + render( + + + , + ) + }) + + const submit = screen.getByRole("button", { name: /Confirm|Submit|Save/i }) + await act(async () => { + fireEvent.click(submit) + }) + + await waitFor(() => expect(updateSettings).toHaveBeenCalled()) + + expect(toastCalls).toContain("Success") + expect(toastCalls).not.toContain("Error") +}) diff --git a/src/types/api.ts b/src/types/api.ts index 14723a4..ad30d71 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -712,6 +712,8 @@ export interface ModelSetting { enable_ip_change_notification: boolean /** 通知信息IP不打码 */ enable_plain_ip_in_notification: boolean + /** 是否启用 MCP 入口(默认关闭) */ + enable_mcp: boolean /** 特定服务器IP(多个服务器用逗号分隔) */ ignored_ip_notification: string ignored_ip_notification_server_ids: Record @@ -737,6 +739,7 @@ export interface ModelSettingForm { dns_servers?: string enable_ip_change_notification?: boolean enable_plain_ip_in_notification?: boolean + enable_mcp?: boolean ignored_ip_notification?: string install_host?: string /** IP变更提醒的通知组 */ diff --git a/src/types/authContext.ts b/src/types/authContext.ts index 53a8d99..13aee6d 100644 --- a/src/types/authContext.ts +++ b/src/types/authContext.ts @@ -2,6 +2,7 @@ import { ModelProfile } from "@/types" export interface AuthContextProps { profile: ModelProfile | undefined + loading: boolean login: (username: string, password: string) => void loginOauth2: () => void logout: () => void diff --git a/tests/e2e/api-tokens.spec.ts b/tests/e2e/api-tokens.spec.ts new file mode 100644 index 0000000..4097dba --- /dev/null +++ b/tests/e2e/api-tokens.spec.ts @@ -0,0 +1,125 @@ +import { expect } from "@playwright/test" + +import { defaultAdmin, loginAs, test } from "./fixtures" + +test("admin can create and reveal an API token via UI", async ({ page }) => { + await loginAs(page, defaultAdmin) + await page.goto("/dashboard/settings/api-tokens") + await expect(page.getByRole("heading", { name: /API Tokens|API 令牌/ })).toBeVisible() + + const name = `e2e-${Date.now().toString(36)}` + + await page.getByRole("button", { name: /Create API token|创建 API 令牌/ }).click() + + const dialog = page.getByRole("dialog") + await dialog.getByLabel(/^Name|^名称/).fill(name) + await dialog.getByText("nezha:server:read").click() + + await page.getByRole("button", { name: /^Create$|^创建$/ }).click() + + const codeBlock = page.locator("code").filter({ hasText: /^nzp_/ }) + await expect(codeBlock).toBeVisible({ timeout: 10_000 }) + const revealed = (await codeBlock.textContent())?.trim() ?? "" + expect(revealed).toMatch(/^nzp_/) + + await page.getByRole("button", { name: /^Done$|^完成$/ }).click() + + await expect(page.getByRole("cell", { name })).toBeVisible() + + // 仅通过 API 清理:headless 下 window.confirm() 的时序对 UI revoke 不稳定, + // 这里只保证 token 不残留到下一次跑测试。UI revoke 路径由专门的测试覆盖。 + const tokens = await page.request.get("/api/v1/api-tokens").then((r) => r.json()) + const target = tokens.data.find((t: { name: string }) => t.name === name) + if (target) { + const del = await page.request.delete(`/api/v1/api-tokens/${target.id}`) + expect(del.ok()).toBeTruthy() + } +}) + +test("admin can revoke an API token via UI revoke button", async ({ page }) => { + await loginAs(page, defaultAdmin) + + const name = `e2e-revoke-${Date.now().toString(36)}` + const created = await page.request.post("/api/v1/api-tokens", { + data: { name, scopes: ["nezha:server:read"] }, + }) + expect(created.ok()).toBeTruthy() + const tokenID: number = (await created.json()).data.id + + await page.goto("/dashboard/settings/api-tokens") + const row = page.getByRole("row").filter({ hasText: name }) + await expect(row).toBeVisible() + + page.once("dialog", (dialog) => dialog.accept()) + await row.getByRole("button", { name: /Revoke|撤销/ }).click() + + await expect(row).toHaveCount(0) + + const after = await page.request.get("/api/v1/api-tokens").then((r) => r.json()) + expect(after.data.find((t: { id: number }) => t.id === tokenID)).toBeUndefined() +}) + +test("an API token can authenticate /mcp", async ({ page }) => { + await loginAs(page, defaultAdmin) + await page.goto("/dashboard/settings/api-tokens") + + // EnableMCP defaults to false: snapshot + enable + restore so the test is hermetic. + const settingBefore = (await page.request.get("/api/v1/setting").then((r) => r.json())).data + ?.config as Record & { + site_name?: string + user_template?: string + enable_mcp?: boolean + } + const baseSettings: Record = { + ...settingBefore, + site_name: settingBefore?.site_name || "Nezha", + user_template: settingBefore?.user_template || "user-dist", + } + const enableResp = await page.request.patch("/api/v1/setting", { + data: { ...baseSettings, enable_mcp: true }, + }) + expect(enableResp.ok(), "PATCH /api/v1/setting must succeed to enable MCP for the test").toBeTruthy() + + try { + const apiResp = await page.request.post("/api/v1/api-tokens", { + data: { + name: `e2e-mcp-${Date.now().toString(36)}`, + scopes: ["nezha:server:read"], + }, + }) + expect(apiResp.ok()).toBeTruthy() + const body = await apiResp.json() + expect(body.success).toBe(true) + const token: string = body.data.token + const tokenID: number = body.data.id + expect(token).toMatch(/^nzp_/) + + const mcpResp = await page.request.post("/mcp", { + headers: { Authorization: `Bearer ${token}` }, + data: { jsonrpc: "2.0", id: 1, method: "initialize" }, + }) + expect(mcpResp.ok()).toBeTruthy() + const mcpBody = await mcpResp.json() + expect(mcpBody.result?.serverInfo?.name).toBe("nezha-mcp") + + const whoamiResp = await page.request.post("/mcp", { + headers: { Authorization: `Bearer ${token}` }, + data: { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "meta.whoami", arguments: {} }, + }, + }) + const whoamiBody = await whoamiResp.json() + expect(whoamiBody.result?.isError).toBeFalsy() + expect(whoamiBody.result?.structuredContent?.scopes).toContain("nezha:server:read") + + const delResp = await page.request.delete(`/api/v1/api-tokens/${tokenID}`) + expect(delResp.ok()).toBeTruthy() + } finally { + await page.request.patch("/api/v1/setting", { + data: { ...baseSettings, enable_mcp: !!settingBefore?.enable_mcp }, + }) + } +}) diff --git a/vite.config.ts b/vite.config.ts index 540d5b6..97319c1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,10 @@ export default defineConfig({ base: "/dashboard", plugins: [react()], server: { + // Bind the dev server to loopback so an in-browser CSRF or LAN + // scan cannot reach a developer's dashboard token. Override with + // `bun run dev -- --host 0.0.0.0` for Docker / remote dev only. + host: "127.0.0.1", proxy: { "^/api/v1/ws/.*": { target: "ws://127.0.0.1:8008", @@ -16,6 +20,10 @@ export default defineConfig({ target: "http://127.0.0.1:8008", changeOrigin: true, }, + "/mcp": { + target: "http://127.0.0.1:8008", + changeOrigin: true, + }, }, }, resolve: {