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 <cloudcode@users.noreply.github.com>
This commit is contained in:
naiba
2026-05-30 15:56:53 +00:00
co-authored by cloudcode
parent f47d4a8772
commit f07c557029
37 changed files with 2041 additions and 69 deletions
+10 -3
View File
@@ -3720,11 +3720,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "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, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
} }
}, },
"node_modules/bidi-js": { "node_modules/bidi-js": {
@@ -3854,7 +3859,9 @@
} }
}, },
"node_modules/caniuse-lite": { "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, "devOptional": true,
"funding": [ "funding": [
{ {
+130
View File
@@ -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<ApiTokenView[]> => {
const tokens = await fetcher<ApiTokenView[]>(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<ApiTokenCreateResponse> => {
return fetcher<ApiTokenCreateResponse>(FetcherMethod.POST, "/api/v1/api-tokens", data)
}
export const deleteApiToken = async (id: number): Promise<void> => {
return fetcher<void>(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 }
}
+57 -9
View File
@@ -23,17 +23,43 @@ export enum FetcherMethod {
let lastestRefreshTokenAt = 0 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<string, string> {
if (!isUnsafeMethod(method)) return {}
const token = readCookie(csrfCookieName)
return token ? { [csrfHeaderName]: token } : {}
}
export async function fetcher<T>(method: FetcherMethod, path: string, data?: any): Promise<T> { export async function fetcher<T>(method: FetcherMethod, path: string, data?: any): Promise<T> {
let response let response
if (method === FetcherMethod.GET || method === FetcherMethod.DELETE) { if (method === FetcherMethod.GET || method === FetcherMethod.DELETE) {
response = await fetch(buildUrl(path, data), { response = await fetch(buildUrl(path, data), {
method: "GET", method: method,
headers: csrfHeaders(method),
}) })
} else { } else {
response = await fetch(path, { response = await fetch(path, {
method: method, method: method,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...csrfHeaders(method),
}, },
body: data ? JSON.stringify(data) : null, body: data ? JSON.stringify(data) : null,
}) })
@@ -41,23 +67,45 @@ export async function fetcher<T>(method: FetcherMethod, path: string, data?: any
if (!response.ok) { if (!response.ok) {
throw new Error(response.statusText) throw new Error(response.statusText)
} }
const responseData: CommonResponse<T> = await response.json() const text = await response.text()
if (!responseData.success) { if (text !== "") {
throw new Error(responseData.error) let responseData: CommonResponse<T>
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 ( if (
document.cookie && document.cookie &&
(!lastestRefreshTokenAt || Date.now() - lastestRefreshTokenAt > 1000 * 60 * 60) (!lastestRefreshTokenAt || Date.now() - lastestRefreshTokenAt > 1000 * 60 * 60)
) { ) {
lastestRefreshTokenAt = Date.now() 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<T>(input: string | URL | globalThis.Request, init?: RequestInit) { export async function swrFetcher<T>(input: string | URL | globalThis.Request, init?: RequestInit) {
return fetcher<T>(init?.method as FetcherMethod, input.toString(), init?.body) // SWR 默认不带 initmethod 为 undefined:必须落到 GET,否则 fetcher 会走
// 带 body 的分支并对只读请求附加 CSRF 头,把 token 暴露到 GET 请求上。
const method = (init?.method as FetcherMethod) ?? FetcherMethod.GET
return fetcher<T>(method, input.toString(), init?.body)
} }
+5 -1
View File
@@ -8,10 +8,11 @@ export const SettingsTab = ({ className }: { className?: string }) => {
const { profile } = useAuth() const { profile } = useAuth()
const isAdmin = profile?.role === 0 const isAdmin = profile?.role === 0
const colsClass = isAdmin ? "grid-cols-5" : "grid-cols-1"
return ( return (
<Tabs defaultValue={window.location.pathname} className={className}> <Tabs defaultValue={window.location.pathname} className={className}>
<TabsList className="grid w-full grid-cols-4"> <TabsList className={`grid w-full ${colsClass}`}>
{isAdmin && ( {isAdmin && (
<> <>
<TabsTrigger value="/dashboard/settings" asChild> <TabsTrigger value="/dashboard/settings" asChild>
@@ -28,6 +29,9 @@ export const SettingsTab = ({ className }: { className?: string }) => {
</TabsTrigger> </TabsTrigger>
</> </>
)} )}
<TabsTrigger value="/dashboard/settings/api-tokens" asChild>
<Link to="/dashboard/settings/api-tokens">{t("ApiTokens")}</Link>
</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
) )
+24 -2
View File
@@ -1,6 +1,6 @@
import { getProfile, login as loginRequest } from "@/api/user" import { getProfile, login as loginRequest } from "@/api/user"
import { AuthContextProps } from "@/types" 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 { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
@@ -9,6 +9,7 @@ import { useMainStore } from "./useMainStore"
const AuthContext = createContext<AuthContextProps>({ const AuthContext = createContext<AuthContextProps>({
profile: undefined, profile: undefined,
loading: true,
login: () => {}, login: () => {},
loginOauth2: () => {}, loginOauth2: () => {},
logout: () => {}, logout: () => {},
@@ -17,16 +18,27 @@ const AuthContext = createContext<AuthContextProps>({
export const AuthProvider = ({ children }: { children: React.ReactNode }) => { export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const profile = useMainStore((store) => store.profile) const profile = useMainStore((store) => store.profile)
const setProfile = useMainStore((store) => store.setProfile) const setProfile = useMainStore((store) => store.setProfile)
const [loading, setLoading] = useState(true)
const { t } = useTranslation() 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(() => { useEffect(() => {
const epoch = authEpoch.current
;(async () => { ;(async () => {
try { try {
const user = await getProfile() const user = await getProfile()
if (authEpoch.current !== epoch) return
user.role = user.role || 0 user.role = user.role || 0
setProfile(user) setProfile(user)
} catch { } catch {
if (authEpoch.current !== epoch) return
setProfile(undefined) setProfile(undefined)
} finally {
setLoading(false)
} }
})() })()
}, [setProfile]) }, [setProfile])
@@ -37,6 +49,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
try { try {
await loginRequest(username, password) await loginRequest(username, password)
const user = await getProfile() const user = await getProfile()
authEpoch.current++
user.role = user.role || 0 user.role = user.role || 0
setProfile(user) setProfile(user)
navigate("/dashboard") navigate("/dashboard")
@@ -47,40 +60,49 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
} else { } else {
toast(msg || t("NetworkError")) 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]) }, [navigate, setProfile, t])
const loginOauth2 = useCallback(async () => { const loginOauth2 = useCallback(async () => {
try { try {
const user = await getProfile() const user = await getProfile()
authEpoch.current++
user.role = user.role || 0 user.role = user.role || 0
setProfile(user) setProfile(user)
navigate("/dashboard") navigate("/dashboard")
} catch (error: any) { } catch (error: any) {
toast(error.message) toast(error.message)
} finally { } finally {
setLoading(false)
window.history.replaceState({}, document.title, window.location.pathname) window.history.replaceState({}, document.title, window.location.pathname)
} }
}, [navigate, setProfile]) }, [navigate, setProfile])
const logout = useCallback(() => { const logout = useCallback(() => {
authEpoch.current++
document.cookie.split(";").forEach(function (c) { document.cookie.split(";").forEach(function (c) {
document.cookie = c document.cookie = c
.replace(/^ +/, "") .replace(/^ +/, "")
.replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/") .replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/")
}) })
setProfile(undefined) setProfile(undefined)
setLoading(false)
navigate("/dashboard/login", { replace: true }) navigate("/dashboard/login", { replace: true })
}, [navigate, setProfile]) }, [navigate, setProfile])
const value = useMemo( const value = useMemo(
() => ({ () => ({
profile, profile,
loading,
login, login,
loginOauth2, loginOauth2,
logout, logout,
}), }),
[profile, login, loginOauth2, logout], [profile, loading, login, loginOauth2, logout],
) )
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
} }
+26 -1
View File
@@ -180,5 +180,30 @@
"EmptyNote": "Du hast noch keine Notizen.", "EmptyNote": "Du hast noch keine Notizen.",
"BackToHome": "Zurück zur Startseite", "BackToHome": "Zurück zur Startseite",
"OnAlert": "Server mit Warnung", "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"
} }
+27 -1
View File
@@ -155,6 +155,7 @@
"UseDirectConnectingIP": "Use direct connection IP", "UseDirectConnectingIP": "Use direct connection IP",
"IPChangeNotification": "IP Change Notification", "IPChangeNotification": "IP Change Notification",
"FullIPNotification": "Show Full IP Address in Notification Messages", "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", "EditService": "Edit Service",
"CreateService": "Create Service", "CreateService": "Create Service",
"EditTask": "Edit Task", "EditTask": "Edit Task",
@@ -288,5 +289,30 @@
"StatusFailed": "Failed", "StatusFailed": "Failed",
"StatusTimeout": "Timeout", "StatusTimeout": "Timeout",
"StatusCancelled": "Cancelled" "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"
} }
+25 -1
View File
@@ -254,5 +254,29 @@
"CopiedToClipboard": "Copiar al portapapeles", "CopiedToClipboard": "Copiar al portapapeles",
"ClipboardWriteFailed": "Error al escribir en el portapapeles", "ClipboardWriteFailed": "Error al escribir en el portapapeles",
"PastedFromClipboard": "Pegado del 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"
} }
+26 -1
View File
@@ -92,5 +92,30 @@
"UserInvalid": "Lutilisateur est invalide", "UserInvalid": "Lutilisateur est invalide",
"BlockByUser": "Bloqué par un administrateur", "BlockByUser": "Bloqué par un administrateur",
"OnlineUser": "Utilisateur en ligne", "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"
} }
+27 -1
View File
@@ -21,5 +21,31 @@
"NoRowsAreSelected": "Non hai filas seleccionadas", "NoRowsAreSelected": "Non hai filas seleccionadas",
"ThisOperationIsUnrecoverable": "A operación non se pode desfacer!", "ThisOperationIsUnrecoverable": "A operación non se pode desfacer!",
"TaskTriggeredSuccessfully": "A tarefa desencadeouse correctamente" "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"
} }
+25 -1
View File
@@ -255,5 +255,29 @@
"ClipboardWriteFailed": "Gagal menulis ke papan klip", "ClipboardWriteFailed": "Gagal menulis ke papan klip",
"PastedFromClipboard": "Ditempel dari papan klip", "PastedFromClipboard": "Ditempel dari papan klip",
"ClipboardReadFailed": "Gagal membaca 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"
} }
+26 -1
View File
@@ -175,5 +175,30 @@
"OnlineUser": "Utente online", "OnlineUser": "Utente online",
"BlockIdentifier": "Identificatore del blocco", "BlockIdentifier": "Identificatore del blocco",
"UserId": "ID utente", "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"
} }
+28 -1
View File
@@ -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"
}
+26 -1
View File
@@ -185,5 +185,30 @@
"IPChangeNotification": "Уведомление об изменении IP", "IPChangeNotification": "Уведомление об изменении IP",
"CreateAlertRule": "Создать правила оповещений", "CreateAlertRule": "Создать правила оповещений",
"UserId": "ID пользователя", "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"
} }
+26 -1
View File
@@ -180,5 +180,30 @@
"ConfirmBlock": "தொகுதி உறுதிப்படுத்தவும்", "ConfirmBlock": "தொகுதி உறுதிப்படுத்தவும்",
"RejectPassword": "கடவுச்சொல் உள்நுழைவை நிராகரிக்கவும்", "RejectPassword": "கடவுச்சொல் உள்நுழைவை நிராகரிக்கவும்",
"EmptyText": "உரை காலியாக உள்ளது", "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"
} }
+26 -1
View File
@@ -127,5 +127,30 @@
"RequestType": "Тип запиту", "RequestType": "Тип запиту",
"RequestBody": "Тіло запиту", "RequestBody": "Тіло запиту",
"FileManager": "Псевдо Менеджер файлів", "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"
} }
+27 -1
View File
@@ -162,6 +162,7 @@
"UseDirectConnectingIP": "使用直连 IP", "UseDirectConnectingIP": "使用直连 IP",
"IPChangeNotification": "IP变更通知", "IPChangeNotification": "IP变更通知",
"FullIPNotification": "在通知消息中显示完整的 IP 地址", "FullIPNotification": "在通知消息中显示完整的 IP 地址",
"EnableMCP": "启用 MCP 接入(默认关闭;启用前请确认已审阅 API 令牌 scope 与服务器白名单)",
"LoginFailed": "登录失败", "LoginFailed": "登录失败",
"BruteForceAttackingToken": "暴力攻击令牌", "BruteForceAttackingToken": "暴力攻击令牌",
"BruteForceAttackingAgentSecret": "暴力攻击代理秘密", "BruteForceAttackingAgentSecret": "暴力攻击代理秘密",
@@ -288,5 +289,30 @@
"StatusFailed": "已失败", "StatusFailed": "已失败",
"StatusTimeout": "已超时", "StatusTimeout": "已超时",
"StatusCancelled": "已取消" "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": "权限"
} }
+26 -1
View File
@@ -219,5 +219,30 @@
"StatusFailed": "已失敗", "StatusFailed": "已失敗",
"StatusTimeout": "已逾時", "StatusTimeout": "已逾時",
"StatusCancelled": "已取消" "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": "範圍"
} }
+5
View File
@@ -30,6 +30,7 @@ import SettingsPage from "./routes/settings"
import TransferPage from "./routes/transfer" import TransferPage from "./routes/transfer"
import UserPage from "./routes/user" import UserPage from "./routes/user"
import WAFPage from "./routes/waf" import WAFPage from "./routes/waf"
import ApiTokensPage from "./routes/api-tokens"
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@@ -147,6 +148,10 @@ const router = createBrowserRouter([
path: "/dashboard/settings/online-user", path: "/dashboard/settings/online-user",
element: <OnlineUserPage />, element: <OnlineUserPage />,
}, },
{
path: "/dashboard/settings/api-tokens",
element: <ApiTokensPage />,
},
{ {
path: "/dashboard/transfer", path: "/dashboard/transfer",
element: <TransferPage />, element: <TransferPage />,
+338
View File
@@ -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<ApiTokenView[]>(
"/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<ApiTokenCreateResponse | null>(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 (
<div className="px-3">
<SettingsTab className="mt-6 w-full" />
<div className="flex mt-4 mb-4 items-center justify-between">
<h2 className="text-lg font-semibold">{t("ApiTokens")}</h2>
<Button onClick={() => setCreateOpen(true)}>{t("CreateApiToken")}</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("Name")}</TableHead>
<TableHead>{t("Scopes")}</TableHead>
<TableHead>{t("ApiTokenServers")}</TableHead>
<TableHead>{t("ApiTokenExpiresAt")}</TableHead>
<TableHead>{t("ApiTokenLastUsed")}</TableHead>
<TableHead>{t("Actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
{t("Loading")}...
</TableCell>
</TableRow>
) : !data || data.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
{t("NoResults")}
</TableCell>
</TableRow>
) : (
data.map((tok) => (
<TableRow key={tok.id}>
<TableCell>{tok.name}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{(tok.scopes ?? []).map((s) => (
<span
key={s}
className="rounded bg-secondary px-1.5 py-0.5 text-xs"
>
{s}
</span>
))}
</div>
</TableCell>
<TableCell className="text-xs">
{tok.server_ids?.length ? tok.server_ids.join(", ") : t("ApiTokenAllServers")}
</TableCell>
<TableCell className="text-xs">
{tok.expires_at ? new Date(tok.expires_at).toLocaleString() : t("ApiTokenNever")}
</TableCell>
<TableCell className="text-xs">
{tok.last_used_at
? `${new Date(tok.last_used_at).toLocaleString()} (${tok.last_used_ip ?? "?"})`
: t("ApiTokenNever")}
</TableCell>
<TableCell>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(tok.id, tok.name)}
>
{t("Revoke")}
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<CreateApiTokenDialog
open={createOpen}
onOpenChange={setCreateOpen}
isAdmin={isAdmin}
onCreated={(res) => {
setRevealed(res)
mutate()
}}
/>
<RevealedTokenDialog token={revealed} onClose={() => setRevealed(null)} />
</div>
)
}
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<string[]>([])
const [serverIDs, setServerIDs] = useState("")
const [expiresInDays, setExpiresInDays] = useState<string>("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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("CreateApiToken")}</DialogTitle>
<DialogDescription>{t("CreateApiTokenDescription")}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="name">{t("Name")}</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid gap-2">
<Label>{t("Scopes")}</Label>
<div className="max-h-72 space-y-2 overflow-y-auto pr-2">
{SCOPE_OPTIONS.map((s) => {
const adminOnly = s.value === "nezha:*" || s.value === "nezha:admin:*"
const disabled = adminOnly && !isAdmin
return (
<label
key={s.value}
className={`flex items-start gap-2 text-sm ${disabled ? "opacity-40" : ""}`}
>
<Checkbox
checked={scopes.includes(s.value)}
onCheckedChange={() => !disabled && toggleScope(s.value)}
disabled={disabled}
/>
<div className="flex flex-col">
<span className="font-mono text-xs">{s.value}</span>
<span className="text-xs text-muted-foreground">{s.desc}</span>
</div>
</label>
)
})}
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="server-ids">{t("ApiTokenServerIDs")}</Label>
<Input
id="server-ids"
placeholder={t("ApiTokenServerIDsPlaceholder")}
value={serverIDs}
onChange={(e) => setServerIDs(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="expires">{t("ApiTokenExpiresInDays")}</Label>
<Input
id="expires"
type="number"
min={0}
max={3650}
value={expiresInDays}
onChange={(e) => setExpiresInDays(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">{t("Cancel")}</Button>
</DialogClose>
<Button onClick={submit} disabled={submitting}>
{submitting ? t("Loading") + "..." : t("CreateApiToken")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function RevealedTokenDialog({
token,
onClose,
}: {
token: ApiTokenCreateResponse | null
onClose: () => void
}) {
const { t } = useTranslation()
return (
<Dialog open={!!token} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("ApiTokenCreated")}</DialogTitle>
<DialogDescription>{t("ApiTokenRevealOnce")}</DialogDescription>
</DialogHeader>
{token && (
<div className="space-y-3">
<div className="rounded border border-amber-500/40 bg-amber-100 dark:bg-amber-950/40 p-3 text-sm">
{t("ApiTokenStoreSafely")}
</div>
<code className="block break-all rounded bg-muted p-3 font-mono text-xs">
{token.token}
</code>
<Button
variant="secondary"
size="sm"
onClick={async () => {
await navigator.clipboard.writeText(token.token)
toast(t("Copied"))
}}
>
{t("Copy")}
</Button>
</div>
)}
<DialogFooter>
<Button onClick={onClose}>{t("Done")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+29 -9
View File
@@ -1,16 +1,36 @@
import { useAuth } from "@/hooks/useAuth" 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 }) => { export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { profile } = useAuth() const { profile, loading } = useAuth()
const { pathname } = useLocation()
if (!profile && window.location.pathname !== "/dashboard/login") { // While AuthProvider's initial getProfile() round-trip is in flight we
return ( // can't yet decide between "render protected subtree" and "redirect to
<> // login". Two key invariants:
<Navigate to="/dashboard/login" /> // - For the login page itself, render children so the user can log in
{children} // 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 <Navigate to={LOGIN_PATH} replace />
} }
return children return children
+43 -32
View File
@@ -31,7 +31,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect } from "react" import { useEffect } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom" import { Navigate } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
import { z } from "zod" import { z } from "zod"
@@ -52,13 +52,13 @@ const settingFormSchema = z.object({
tls: asOptionalField(z.boolean()), tls: asOptionalField(z.boolean()),
enable_ip_change_notification: asOptionalField(z.boolean()), enable_ip_change_notification: asOptionalField(z.boolean()),
enable_plain_ip_in_notification: asOptionalField(z.boolean()), enable_plain_ip_in_notification: asOptionalField(z.boolean()),
enable_mcp: asOptionalField(z.boolean()),
}) })
export default function SettingsPage() { export default function SettingsPage() {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const { data: config, mutate } = useSetting() const { data: config, mutate } = useSetting()
const { profile } = useAuth() const { profile, loading: authLoading } = useAuth()
const navigate = useNavigate()
const { notifierGroup } = useNotification() const { notifierGroup } = useNotification()
const ngroupList = notifierGroup?.map((ng) => ({ const ngroupList = notifierGroup?.map((ng) => ({
@@ -68,10 +68,7 @@ export default function SettingsPage() {
const isAdmin = profile?.role === 0 const isAdmin = profile?.role === 0
if (!isAdmin) { // 所有 hooks 必须在条件 return 之前调用,否则违反 rules-of-hooks。
navigate("/dashboard/settings/online-user")
}
const form = useForm({ const form = useForm({
resolver: zodResolver(settingFormSchema) as any, resolver: zodResolver(settingFormSchema) as any,
defaultValues: config defaultValues: config
@@ -100,6 +97,13 @@ export default function SettingsPage() {
} }
}, [config?.config, form]) }, [config?.config, form])
if (authLoading) {
return null
}
if (!isAdmin) {
return <Navigate to="/dashboard/settings/api-tokens" replace />
}
const onSubmit = async (values: any) => { const onSubmit = async (values: any) => {
try { try {
await updateSettings(values) await updateSettings(values)
@@ -112,12 +116,11 @@ export default function SettingsPage() {
}), }),
}) })
return 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 ( return (
@@ -340,16 +343,10 @@ export default function SettingsPage() {
checked={field.value == "NZ::Use-Peer-IP"} checked={field.value == "NZ::Use-Peer-IP"}
className="ml-2" className="ml-2"
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
if (checked) { form.setValue(
field.disabled = true "web_real_ip_header",
form.setValue( checked ? "NZ::Use-Peer-IP" : "",
"web_real_ip_header", )
"NZ::Use-Peer-IP",
)
} else {
field.disabled = false
form.setValue("web_real_ip_header", "")
}
}} }}
/> />
<FormLabel className="font-normal ml-2"> <FormLabel className="font-normal ml-2">
@@ -379,16 +376,10 @@ export default function SettingsPage() {
checked={field.value == "NZ::Use-Peer-IP"} checked={field.value == "NZ::Use-Peer-IP"}
className="ml-2" className="ml-2"
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
if (checked) { form.setValue(
field.disabled = true "agent_real_ip_header",
form.setValue( checked ? "NZ::Use-Peer-IP" : "",
"agent_real_ip_header", )
"NZ::Use-Peer-IP",
)
} else {
field.disabled = false
form.setValue("agent_real_ip_header", "")
}
}} }}
/> />
<FormLabel className="font-normal ml-2"> <FormLabel className="font-normal ml-2">
@@ -513,6 +504,26 @@ export default function SettingsPage() {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="enable_mcp"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">
{t("EnableMCP")}
</Label>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">{t("Confirm")}</Button> <Button type="submit">{t("Confirm")}</Button>
</form> </form>
</Form> </Form>
+30
View File
@@ -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)
})
+26
View File
@@ -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)
})
+137
View File
@@ -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")
})
+151
View File
@@ -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<any>
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<any>(() => {})
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<typeof useAuth> } = {}
function Capture() {
const auth = useAuth()
useEffect(() => {
captured.auth = auth
})
captured.auth = auth
return null
}
await act(async () => {
render(
<AuthProvider>
<Capture />
</AuthProvider>,
)
})
// 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<typeof useAuth> } = {}
function Capture() {
const auth = useAuth()
useEffect(() => {
captured.auth = auth
})
captured.auth = auth
return null
}
await act(async () => {
render(
<AuthProvider>
<Capture />
</AuthProvider>,
)
})
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<typeof useAuth> } = {}
function Capture() {
const auth = useAuth()
useEffect(() => {
captured.auth = auth
})
captured.auth = auth
return null
}
await act(async () => {
render(
<AuthProvider>
<Capture />
</AuthProvider>,
)
})
expect(captured.auth!.loading).toBe(true)
await act(async () => {
captured.auth!.logout()
})
expect(captured.auth!.loading).toBe(false)
})
+96
View File
@@ -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<typeof useAuth> } = {}
function Capture() {
const auth = useAuth()
useEffect(() => {
captured.auth = auth
})
return null
}
await act(async () => {
render(
<AuthProvider>
<Capture />
</AuthProvider>,
)
})
// 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)
})
+97
View File
@@ -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<string, string>
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()
})
+35
View File
@@ -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")
})
+61
View File
@@ -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)
})
+108
View File
@@ -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(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route
path="/dashboard/login"
element={
<ProtectedRoute>{child}</ProtectedRoute>
}
/>
<Route
path="/dashboard/*"
element={
<ProtectedRoute>{child}</ProtectedRoute>
}
/>
</Routes>
</MemoryRouter>,
)
})
})
}
test("ProtectedRoute does not mount children for protected paths while auth is loading", async () => {
await renderAtPath(
"/dashboard",
<div data-testid="protected-child">protected</div>,
)
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",
<div data-testid="login-child">login</div>,
)
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(
<MemoryRouter initialEntries={["/dashboard"]}>
<Routes>
<Route
path="/dashboard/login"
element={
<ProtectedRoute>
<div data-testid="login-child">login</div>
</ProtectedRoute>
}
/>
<Route
path="/dashboard/*"
element={
<ProtectedRoute>
<div data-testid="protected-child">protected</div>
</ProtectedRoute>
}
/>
</Routes>
</MemoryRouter>,
)
})
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",
<div data-testid="protected-child">protected</div>,
)
expect(document.querySelector("[data-testid='protected-child']")).not.toBeNull()
})
+74
View File
@@ -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<any>("react-router-dom")
return {
...actual,
Navigate: ({ to }: { to: string }) => {
navigateRenders.push(to)
return <div data-testid="nav-stub">redirect:{to}</div>
},
}
})
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(<SettingsPage />)
})
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(<SettingsPage />)
})
expect(navigateRenders).toContain("/dashboard/settings/api-tokens")
})
+107
View File
@@ -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(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>,
)
})
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(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>,
)
})
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")
})
+3
View File
@@ -712,6 +712,8 @@ export interface ModelSetting {
enable_ip_change_notification: boolean enable_ip_change_notification: boolean
/** 通知信息IP不打码 */ /** 通知信息IP不打码 */
enable_plain_ip_in_notification: boolean enable_plain_ip_in_notification: boolean
/** 是否启用 MCP 入口(默认关闭) */
enable_mcp: boolean
/** 特定服务器IP(多个服务器用逗号分隔) */ /** 特定服务器IP(多个服务器用逗号分隔) */
ignored_ip_notification: string ignored_ip_notification: string
ignored_ip_notification_server_ids: Record<string, boolean> ignored_ip_notification_server_ids: Record<string, boolean>
@@ -737,6 +739,7 @@ export interface ModelSettingForm {
dns_servers?: string dns_servers?: string
enable_ip_change_notification?: boolean enable_ip_change_notification?: boolean
enable_plain_ip_in_notification?: boolean enable_plain_ip_in_notification?: boolean
enable_mcp?: boolean
ignored_ip_notification?: string ignored_ip_notification?: string
install_host?: string install_host?: string
/** IP变更提醒的通知组 */ /** IP变更提醒的通知组 */
+1
View File
@@ -2,6 +2,7 @@ import { ModelProfile } from "@/types"
export interface AuthContextProps { export interface AuthContextProps {
profile: ModelProfile | undefined profile: ModelProfile | undefined
loading: boolean
login: (username: string, password: string) => void login: (username: string, password: string) => void
loginOauth2: () => void loginOauth2: () => void
logout: () => void logout: () => void
+125
View File
@@ -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<string, unknown> & {
site_name?: string
user_template?: string
enable_mcp?: boolean
}
const baseSettings: Record<string, unknown> = {
...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 },
})
}
})
+8
View File
@@ -6,6 +6,10 @@ export default defineConfig({
base: "/dashboard", base: "/dashboard",
plugins: [react()], plugins: [react()],
server: { 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: { proxy: {
"^/api/v1/ws/.*": { "^/api/v1/ws/.*": {
target: "ws://127.0.0.1:8008", target: "ws://127.0.0.1:8008",
@@ -16,6 +20,10 @@ export default defineConfig({
target: "http://127.0.0.1:8008", target: "http://127.0.0.1:8008",
changeOrigin: true, changeOrigin: true,
}, },
"/mcp": {
target: "http://127.0.0.1:8008",
changeOrigin: true,
},
}, },
}, },
resolve: { resolve: {