mirror of
https://github.com/Buriburizaem0n/admin-frontend-domain.git
synced 2026-09-19 09:40:13 +00:00
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:
Generated
+10
-3
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
+55
-7
@@ -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 (text !== "") {
|
||||||
|
let responseData: CommonResponse<T>
|
||||||
|
try {
|
||||||
|
responseData = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
throw new Error("invalid server response")
|
||||||
|
}
|
||||||
if (!responseData.success) {
|
if (!responseData.success) {
|
||||||
throw new Error(responseData.error)
|
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 默认不带 init,method 为 undefined:必须落到 GET,否则 fetcher 会走
|
||||||
|
// 带 body 的分支并对只读请求附加 CSRF 头,把 token 暴露到 GET 请求上。
|
||||||
|
const method = (init?.method as FetcherMethod) ?? FetcherMethod.GET
|
||||||
|
return fetcher<T>(method, input.toString(), init?.body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,5 +92,30 @@
|
|||||||
"UserInvalid": "L’utilisateur est invalide",
|
"UserInvalid": "L’utilisateur 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "权限"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "範圍"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+34
-23
@@ -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,13 +116,12 @@ export default function SettingsPage() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
} finally {
|
}
|
||||||
if (values.language != i18n.language) {
|
if (values.language != i18n.language) {
|
||||||
i18n.changeLanguage(values.language)
|
i18n.changeLanguage(values.language)
|
||||||
}
|
}
|
||||||
toast(t("Success"))
|
toast(t("Success"))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3">
|
<div className="px-3">
|
||||||
@@ -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) {
|
|
||||||
field.disabled = true
|
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"web_real_ip_header",
|
"web_real_ip_header",
|
||||||
"NZ::Use-Peer-IP",
|
checked ? "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) {
|
|
||||||
field.disabled = true
|
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"agent_real_ip_header",
|
"agent_real_ip_header",
|
||||||
"NZ::Use-Peer-IP",
|
checked ? "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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
@@ -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变更提醒的通知组 */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user