+
{provider}:
{profile.oauth2_bind?.[provider.toLowerCase()] && (
diff --git a/src/routes/protect.tsx b/src/routes/protect.tsx
index 661f520..b70ba77 100644
--- a/src/routes/protect.tsx
+++ b/src/routes/protect.tsx
@@ -1,16 +1,36 @@
import { useAuth } from "@/hooks/useAuth"
-import { Navigate } from "react-router-dom"
+import { Navigate, useLocation } from "react-router-dom"
+
+const LOGIN_PATH = "/dashboard/login"
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
- const { profile } = useAuth()
+ const { profile, loading } = useAuth()
+ const { pathname } = useLocation()
- if (!profile && window.location.pathname !== "/dashboard/login") {
- return (
- <>
-
- {children}
- >
- )
+ // While AuthProvider's initial getProfile() round-trip is in flight we
+ // can't yet decide between "render protected subtree" and "redirect to
+ // login". Two key invariants:
+ // - For the login page itself, render children so the user can log in
+ // without waiting for an unrelated /api/v1/profile probe.
+ // - For every other /dashboard/* path, do NOT mount children — the
+ // protected subtree (Root + Outlet + page) would fire authenticated
+ // SWR fetches like /api/v1/setting before auth is even confirmed,
+ // and a subsequent redirect would leave that work unobserved. A
+ // blank render during the (short) probe avoids that wasted round-trip
+ // and the flash of protected UI before redirect.
+ if (loading) {
+ if (pathname === LOGIN_PATH) {
+ return children
+ }
+ return null
+ }
+
+ if (!profile && pathname !== LOGIN_PATH) {
+ // `replace` keeps the unauthenticated URL out of history. Crucially do
+ // NOT render `children` alongside Navigate: that would mount the
+ // protected subtree for one paint and fire authenticated requests we
+ // are about to redirect away from.
+ return
}
return children
diff --git a/src/routes/root.tsx b/src/routes/root.tsx
index 2b2d930..fa5884f 100644
--- a/src/routes/root.tsx
+++ b/src/routes/root.tsx
@@ -22,6 +22,12 @@ export default function Root() {
}
}, [settingData?.config?.custom_code_dashboard])
+ useEffect(() => {
+ if (settingData?.config?.language && !localStorage.getItem("language")) {
+ i18n.changeLanguage(settingData?.config?.language)
+ }
+ }, [settingData?.config?.language])
+
if (error) {
throw error
}
@@ -30,10 +36,6 @@ export default function Root() {
return null
}
- if (settingData?.config?.language && !localStorage.getItem("language")) {
- i18n.changeLanguage(settingData?.config?.language)
- }
-
return (
[] = [
+ const columns: ColumnDef[] = [
{
id: "select",
header: ({ table }) => (
@@ -57,7 +58,6 @@ export default function ServerGroupPage() {
aria-label="Select row"
/>
),
- enableSorting: false,
enableHiding: false,
},
{
@@ -112,10 +112,10 @@ export default function ServerGroupPage() {
return data ?? []
}, [data])
- const table = useReactTable({
+ const table = useTable({
+ features: selectableTableFeatures,
data: dataCache,
columns,
- getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
diff --git a/src/routes/server.tsx b/src/routes/server.tsx
index 30e2990..eddb67f 100644
--- a/src/routes/server.tsx
+++ b/src/routes/server.tsx
@@ -20,9 +20,10 @@ import {
} from "@/components/ui/table"
import { IconButton } from "@/components/xui/icon-button"
import { useServer } from "@/hooks/useServer"
+import { selectableTableFeatures } from "@/lib/table"
import { joinIP } from "@/lib/utils"
import { ModelServerTaskResponse, ModelServer as Server } from "@/types"
-import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
+import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@@ -43,7 +44,7 @@ export default function ServerPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
- const columns = useMemo[]>(
+ const columns = useMemo[]>(
() => [
{
id: "select",
@@ -64,7 +65,6 @@ export default function ServerPage() {
aria-label="Select row"
/>
),
- enableSorting: false,
enableHiding: false,
},
{
@@ -81,6 +81,33 @@ export default function ServerPage() {
return {s.name}
},
},
+ {
+ id: "owner",
+ header: t("Owner"),
+ accessorFn: (row) => {
+ if (!row.owner) return ""
+ if (row.owner.id === 0) return t("GlobalAgent")
+ return row.owner.username || t("UnknownUser", { id: row.owner.id })
+ },
+ cell: ({ row }) => {
+ const owner = row.original.owner
+ if (!owner) {
+ return -
+ }
+ if (owner.id === 0) {
+ return {t("GlobalAgent")}
+ }
+ const label = owner.username || t("UnknownUser", { id: owner.id })
+ return (
+
+ {label}
+
+ )
+ },
+ },
{
header: t("Group"),
accessorKey: "groups",
@@ -161,10 +188,10 @@ export default function ServerPage() {
return data ?? []
}, [data])
- const table = useReactTable({
+ const table = useTable({
+ features: selectableTableFeatures,
data: dataCache,
columns,
- getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
diff --git a/src/routes/service.tsx b/src/routes/service.tsx
index 16f5fe3..6c70490 100644
--- a/src/routes/service.tsx
+++ b/src/routes/service.tsx
@@ -12,9 +12,10 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
+import { selectableTableFeatures } from "@/lib/table"
import { ModelService as Service } from "@/types"
import { serviceTypes } from "@/types"
-import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
+import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@@ -34,7 +35,7 @@ export default function ServicePage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
- const columns: ColumnDef[] = [
+ const columns: ColumnDef[] = [
{
id: "select",
header: ({ table }) => (
@@ -54,7 +55,6 @@ export default function ServicePage() {
aria-label="Select row"
/>
),
- enableSorting: false,
enableHiding: false,
},
{
@@ -165,10 +165,10 @@ export default function ServicePage() {
return data ?? []
}, [data])
- const table = useReactTable({
+ const table = useTable({
+ features: selectableTableFeatures,
data: dataCache,
columns,
- getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx
index ddd5ace..12389ff 100644
--- a/src/routes/settings.tsx
+++ b/src/routes/settings.tsx
@@ -7,6 +7,7 @@ import { Combobox } from "@/components/ui/combobox"
import {
Form,
FormControl,
+ FormDescription,
FormField,
FormItem,
FormLabel,
@@ -25,13 +26,13 @@ import { Textarea } from "@/components/ui/textarea"
import { useAuth } from "@/hooks/useAuth"
import { useNotification } from "@/hooks/useNotfication"
import useSetting from "@/hooks/useSetting"
-import { asOptionalField } from "@/lib/utils"
+import { asOptionalField, safeExternalHref } from "@/lib/utils"
import { nezhaLang, settingCoverageTypes } from "@/types"
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
-import { useNavigate } from "react-router-dom"
+import { Navigate } from "react-router-dom"
import { toast } from "sonner"
import { z } from "zod"
@@ -44,6 +45,8 @@ const settingFormSchema = z.object({
language: z.string().min(2),
user_template: z.string().min(1),
install_host: asOptionalField(z.string()),
+ dashboard_host: asOptionalField(z.string()),
+ reserved_hosts: asOptionalField(z.string()),
custom_code: asOptionalField(z.string()),
custom_code_dashboard: asOptionalField(z.string()),
web_real_ip_header: asOptionalField(z.string()),
@@ -53,6 +56,7 @@ const settingFormSchema = z.object({
enable_ip_change_notification: asOptionalField(z.boolean()),
enable_plain_ip_in_notification: asOptionalField(z.boolean()),
custom_logo: asOptionalField(z.string()),
+
custom_description: asOptionalField(z.string()),
custom_links: asOptionalField(z.string()),
background_image_day: asOptionalField(z.string()),
@@ -66,13 +70,14 @@ const settingFormSchema = z.object({
domain_expiry_notification_days: asOptionalField(z.string()),
server_expiry_notification_days: asOptionalField(z.string()),
expiry_notification_group_id: z.coerce.number().int().min(0),
+ enable_mcp: asOptionalField(z.boolean()),
+
})
export default function SettingsPage() {
const { t, i18n } = useTranslation()
const { data: config, mutate } = useSetting()
- const { profile } = useAuth()
- const navigate = useNavigate()
+ const { profile, loading: authLoading } = useAuth()
const { notifierGroup } = useNotification()
const ngroupList = notifierGroup?.map((ng) => ({
@@ -82,12 +87,10 @@ export default function SettingsPage() {
const isAdmin = profile?.role === 0
- if (!isAdmin) {
- navigate("/dashboard/settings/online-user")
- }
-
- const form = useForm({
+ // 所有 hooks 必须在条件 return 之前调用,否则违反 rules-of-hooks。
+ const form = useForm>({
resolver: zodResolver(settingFormSchema) as any,
+
defaultValues: config
? {
...config.config,
@@ -117,6 +120,13 @@ export default function SettingsPage() {
}
}, [config?.config, form])
+ if (authLoading) {
+ return null
+ }
+ if (!isAdmin) {
+ return
+ }
+
const onSubmit = async (values: any) => {
try {
await updateSettings(values)
@@ -129,19 +139,19 @@ export default function SettingsPage() {
}),
})
return
- } finally {
- if (values.language != i18n.language) {
- i18n.changeLanguage(values.language)
- }
- toast(t("Success"))
}
+ if (values.language != i18n.language) {
+ i18n.changeLanguage(values.language)
+ }
+ toast(t("Success"))
}
return (
))}
@@ -459,6 +479,34 @@ export default function SettingsPage() {
)}
/>
+
(
+
+ {t("DashboardHost")}
+
+
+
+ {t("DashboardHostHint")}
+
+
+ )}
+ />
+ (
+
+ {t("ReservedHosts")}
+
+
+
+ {t("ReservedHostsHint")}
+
+
+ )}
+ />
{
- if (checked) {
- field.disabled = true
- form.setValue(
- "web_real_ip_header",
- "NZ::Use-Peer-IP",
- )
- } else {
- field.disabled = false
- form.setValue("web_real_ip_header", "")
- }
+ form.setValue(
+ "web_real_ip_header",
+ checked ? "NZ::Use-Peer-IP" : "",
+ )
}}
/>
@@ -551,16 +593,10 @@ export default function SettingsPage() {
checked={field.value == "NZ::Use-Peer-IP"}
className="ml-2"
onCheckedChange={(checked) => {
- if (checked) {
- field.disabled = true
- form.setValue(
- "agent_real_ip_header",
- "NZ::Use-Peer-IP",
- )
- } else {
- field.disabled = false
- form.setValue("agent_real_ip_header", "")
- }
+ form.setValue(
+ "agent_real_ip_header",
+ checked ? "NZ::Use-Peer-IP" : "",
+ )
}}
/>
@@ -749,6 +785,24 @@ export default function SettingsPage() {
)}
/>
+ (
+
+
+
+
+
+
+
+
+
+ )}
+ />
diff --git a/src/routes/transfer.tsx b/src/routes/transfer.tsx
new file mode 100644
index 0000000..3e8fb6e
--- /dev/null
+++ b/src/routes/transfer.tsx
@@ -0,0 +1,257 @@
+import { swrFetcher } from "@/api/api"
+import { cancelServerTransfer, retryServerTransfer } from "@/api/transfer"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { useAuth } from "@/hooks/useAuth"
+import {
+ ModelServerTransfer,
+ ModelServerTransferStatus,
+ ModelServerTransferStatusCancelled,
+ ModelServerTransferStatusFailed,
+ ModelServerTransferStatusPending,
+ ModelServerTransferStatusTimeout,
+ ModelServerTransferStatusVerified,
+} from "@/types"
+import { useCallback, useEffect, useMemo, useRef } from "react"
+import { useTranslation } from "react-i18next"
+import { toast } from "sonner"
+import useSWR from "swr"
+
+// Map status enum to a label key + a Badge variant. Centralised here so the
+// table cell and any future drawer agree. Verified is "secondary" because
+// the row is no longer actionable; Failed/Timeout use "destructive" to draw
+// the operator's attention. Cancelled is "outline" — soft signal, expected.
+const statusMeta: Record<
+ ModelServerTransferStatus,
+ { key: string; variant: "default" | "secondary" | "destructive" | "outline" }
+> = {
+ [ModelServerTransferStatusPending]: { key: "Transfer.StatusPending", variant: "default" },
+ [ModelServerTransferStatusVerified]: { key: "Transfer.StatusVerified", variant: "secondary" },
+ [ModelServerTransferStatusFailed]: { key: "Transfer.StatusFailed", variant: "destructive" },
+ [ModelServerTransferStatusTimeout]: { key: "Transfer.StatusTimeout", variant: "destructive" },
+ [ModelServerTransferStatusCancelled]: { key: "Transfer.StatusCancelled", variant: "outline" },
+}
+
+export default function TransferPage() {
+ const { t } = useTranslation()
+ const { profile } = useAuth()
+ const isAdmin = profile?.role === 0
+ const callerID = profile?.id
+ const { data, mutate, error } = useSWR("/api/v1/transfer", swrFetcher)
+
+ useEffect(() => {
+ if (error) {
+ toast(t("Error"), {
+ description: t("Results.UnExpectedError", { error: error.message }),
+ })
+ }
+ }, [error, t])
+
+ // Live updates: subscribe to /ws/transfer and patch the SWR cache on each
+ // pushed event. We don't refetch on every event because the WS payload IS
+ // the latest row — refetching would just cost a round trip.
+ //
+ // Reconnect contract: the original implementation opened the socket once
+ // and never reconnected, so a dashboard restart, deploy, or transient
+ // network blip silently froze this view — rows stayed Pending forever
+ // even after the agent had reconnected and the backend had transitioned
+ // them, and operators only discovered it on a hard refresh. Reconnect
+ // with capped exponential backoff (1s → 30s) and trigger a SWR revalidate
+ // on each open so any transitions that broadcast while we were offline
+ // are reconciled.
+ const wsRef = useRef(null)
+ useEffect(() => {
+ let cancelled = false
+ let reconnectTimer: ReturnType | null = null
+ let backoffMs = 1000
+ const maxBackoffMs = 30000
+
+ const connect = () => {
+ if (cancelled) return
+ const proto = window.location.protocol === "https:" ? "wss:" : "ws:"
+ const ws = new WebSocket(`${proto}//${window.location.host}/api/v1/ws/transfer`)
+ wsRef.current = ws
+
+ ws.onopen = () => {
+ backoffMs = 1000
+ // Backfill anything the broker fanned out while we were
+ // reconnecting — the broker drops events for full subscribers
+ // and there is no replay on the wire.
+ mutate()
+ }
+ ws.onmessage = (ev) => {
+ try {
+ const t: ModelServerTransfer = JSON.parse(ev.data)
+ mutate((prev) => {
+ const list = prev ?? []
+ const idx = list.findIndex((x) => x.id === t.id)
+ if (idx === -1) return [t, ...list]
+ const next = list.slice()
+ next[idx] = t
+ return next
+ }, false)
+ } catch (e) {
+ // Malformed payload — log and keep the socket open; a refetch
+ // on next mount will re-sync. Closing the WS would be louder
+ // than the actual problem.
+ console.error("transfer ws parse failed", e)
+ }
+ }
+ const scheduleReconnect = () => {
+ if (cancelled) return
+ if (reconnectTimer !== null) return
+ const delay = backoffMs
+ backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
+ reconnectTimer = setTimeout(() => {
+ reconnectTimer = null
+ connect()
+ }, delay)
+ }
+ ws.onclose = scheduleReconnect
+ ws.onerror = () => {
+ // onerror fires before onclose and may not produce a close
+ // event in every browser when the handshake is rejected.
+ // Force-close so onclose's reconnect path runs deterministically.
+ ws.close()
+ }
+ }
+
+ connect()
+
+ return () => {
+ cancelled = true
+ if (reconnectTimer !== null) {
+ clearTimeout(reconnectTimer)
+ reconnectTimer = null
+ }
+ const ws = wsRef.current
+ wsRef.current = null
+ if (ws) {
+ // Detach handlers so the close we trigger here doesn't bounce
+ // into a reconnect attempt against an unmounted component.
+ ws.onopen = null
+ ws.onmessage = null
+ ws.onclose = null
+ ws.onerror = null
+ ws.close()
+ }
+ }
+ }, [mutate])
+
+ const onCancel = useCallback(
+ async (id: number) => {
+ try {
+ await cancelServerTransfer(id)
+ toast(t("Transfer.CancelRequested"))
+ mutate()
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : String(e)
+ toast(t("Error"), { description: msg })
+ }
+ },
+ [mutate, t],
+ )
+
+ const onRetry = useCallback(
+ async (id: number) => {
+ try {
+ await retryServerTransfer(id)
+ toast(t("Transfer.RetryRequested"))
+ mutate()
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : String(e)
+ toast(t("Error"), { description: msg })
+ }
+ },
+ [mutate, t],
+ )
+
+ const rows = useMemo(() => data ?? [], [data])
+
+ return (
+
+
{t("Transfer.Title")}
+
{t("Transfer.PageHint")}
+
+
+
+ ID
+ {t("ServerID")}
+ {t("Transfer.From")}
+ {t("Transfer.To")}
+ {t("Transfer.Initiator")}
+ {t("Status")}
+ {t("Transfer.LastError")}
+ {t("CreatedAt")}
+ {t("Actions")}
+
+
+
+ {rows.length === 0 ? (
+
+
+ {t("NoResults")}
+
+
+ ) : (
+ rows.map((row) => {
+ const meta = statusMeta[row.status]
+ const isPending = row.status === ModelServerTransferStatusPending
+ const isTerminalRevert =
+ row.status === ModelServerTransferStatusFailed ||
+ row.status === ModelServerTransferStatusTimeout ||
+ row.status === ModelServerTransferStatusCancelled
+ return (
+
+ {row.id}
+ {row.server_id}
+ {row.from_user_id}
+ {row.to_user_id}
+ {row.initiator_id}
+
+ {t(meta.key)}
+
+
+ {row.last_error || "-"}
+
+
+ {new Date(row.created_at).toLocaleString()}
+
+
+ {isPending &&
+ (isAdmin || row.from_user_id === callerID) && (
+
+ )}
+ {isTerminalRevert && isAdmin && (
+
+ )}
+
+
+ )
+ })
+ )}
+
+
+
+ )
+}
diff --git a/src/routes/user.tsx b/src/routes/user.tsx
index 6f4d62c..e79e33d 100644
--- a/src/routes/user.tsx
+++ b/src/routes/user.tsx
@@ -13,8 +13,9 @@ import {
TableRow,
} from "@/components/ui/table"
import { UserCard } from "@/components/user"
+import { selectableTableFeatures } from "@/lib/table"
import { ModelUser } from "@/types"
-import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
+import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@@ -34,7 +35,7 @@ export default function UserPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
- const columns: ColumnDef[] = [
+ const columns: ColumnDef[] = [
{
id: "select",
header: ({ table }) => (
@@ -54,7 +55,6 @@ export default function UserPage() {
aria-label="Select row"
/>
),
- enableSorting: false,
enableHiding: false,
},
{
@@ -105,10 +105,10 @@ export default function UserPage() {
return data ?? []
}, [data])
- const table = useReactTable({
+ const table = useTable({
+ features: selectableTableFeatures,
data: dataCache,
columns,
- getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
diff --git a/src/routes/waf.tsx b/src/routes/waf.tsx
index 7bd75d0..8125e96 100644
--- a/src/routes/waf.tsx
+++ b/src/routes/waf.tsx
@@ -22,13 +22,14 @@ import {
TableRow,
} from "@/components/ui/table"
import { useAuth } from "@/hooks/useAuth"
+import { selectableTableFeatures } from "@/lib/table"
import {
GithubComNezhahqNezhaModelValueArrayModelWAFApiMock,
ModelWAFApiMock,
wafBlockIdentifiers,
wafBlockReasons,
} from "@/types"
-import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"
+import { ColumnDef, flexRender, useTable } from "@tanstack/react-table"
import { useEffect, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { useSearchParams } from "react-router-dom"
@@ -61,7 +62,7 @@ export default function WAFPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error])
- let columns: ColumnDef[] = [
+ let columns: ColumnDef[] = [
{
id: "select",
header: ({ table }) => (
@@ -81,7 +82,6 @@ export default function WAFPage() {
aria-label="Select row"
/>
),
- enableSorting: false,
enableHiding: false,
},
{
@@ -147,10 +147,10 @@ export default function WAFPage() {
return data?.value ?? []
}, [data])
- const table = useReactTable({
+ const table = useTable({
+ features: selectableTableFeatures,
data: dataCache,
columns,
- getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
diff --git a/src/test/api-tokens-expiry.test.ts b/src/test/api-tokens-expiry.test.ts
new file mode 100644
index 0000000..4c5395a
--- /dev/null
+++ b/src/test/api-tokens-expiry.test.ts
@@ -0,0 +1,30 @@
+import { expect, test } from "vitest"
+
+import { parseExpiresInDaysInput } from "../api/api-tokens"
+
+test("parseExpiresInDaysInput treats blank as 'never expires' (undefined)", () => {
+ expect(parseExpiresInDaysInput("")).toEqual({ ok: true, value: undefined })
+ expect(parseExpiresInDaysInput(" ")).toEqual({ ok: true, value: undefined })
+})
+
+test("parseExpiresInDaysInput accepts whole-number days in range", () => {
+ expect(parseExpiresInDaysInput("30")).toEqual({ ok: true, value: 30 })
+ expect(parseExpiresInDaysInput(" 3650 ")).toEqual({ ok: true, value: 3650 })
+})
+
+test("parseExpiresInDaysInput maps 0 to undefined (never expires)", () => {
+ expect(parseExpiresInDaysInput("0")).toEqual({ ok: true, value: undefined })
+})
+
+// Backend model field is `ExpiresInDays int` (nezha/model/api_token.go); a
+// fractional value would fail JSON binding server-side, so the UI must reject
+// it locally rather than send 1.5 and surface a confusing backend error.
+test("parseExpiresInDaysInput rejects fractional days", () => {
+ expect(parseExpiresInDaysInput("1.5").ok).toBe(false)
+})
+
+test("parseExpiresInDaysInput rejects out-of-range and non-numeric input", () => {
+ expect(parseExpiresInDaysInput("-1").ok).toBe(false)
+ expect(parseExpiresInDaysInput("3651").ok).toBe(false)
+ expect(parseExpiresInDaysInput("abc").ok).toBe(false)
+})
diff --git a/src/test/api-tokens-server-ids.test.ts b/src/test/api-tokens-server-ids.test.ts
new file mode 100644
index 0000000..fa4aea3
--- /dev/null
+++ b/src/test/api-tokens-server-ids.test.ts
@@ -0,0 +1,26 @@
+import { expect, test } from "vitest"
+
+import { parseServerIDsInput } from "../api/api-tokens"
+
+test("parseServerIDsInput returns undefined for empty input", () => {
+ expect(parseServerIDsInput("")).toEqual({ ok: true, value: undefined })
+ expect(parseServerIDsInput(" ")).toEqual({ ok: true, value: undefined })
+})
+
+test("parseServerIDsInput parses valid comma-separated positive ints", () => {
+ expect(parseServerIDsInput("1,2,3")).toEqual({ ok: true, value: [1, 2, 3] })
+ expect(parseServerIDsInput(" 10 , 11 ")).toEqual({ ok: true, value: [10, 11] })
+})
+
+test("parseServerIDsInput rejects any non-numeric token rather than silently dropping it", () => {
+ // 历史 UI 把 "1,abc" 静默裁剪为 [1] 再上送,等价于把"输入完整接受"骗给用户。
+ // 这条契约要求:任一片段非法 → 整次解析失败,前端必须报错而不是吞掉。
+ const r = parseServerIDsInput("1,abc")
+ expect(r.ok).toBe(false)
+})
+
+test("parseServerIDsInput rejects non-positive ids", () => {
+ expect(parseServerIDsInput("0").ok).toBe(false)
+ expect(parseServerIDsInput("-3").ok).toBe(false)
+ expect(parseServerIDsInput("1.5").ok).toBe(false)
+})
diff --git a/src/test/api-tokens.test.tsx b/src/test/api-tokens.test.tsx
new file mode 100644
index 0000000..79a507f
--- /dev/null
+++ b/src/test/api-tokens.test.tsx
@@ -0,0 +1,138 @@
+import { afterEach, beforeEach, expect, test, vi } from "vitest"
+
+import { createApiToken, deleteApiToken, listApiTokens } from "../api/api-tokens"
+
+const realFetch = globalThis.fetch
+
+function mockFetch(payload: unknown, ok = true, success = true) {
+ globalThis.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()
+ globalThis.fetch = realFetch
+ vi.restoreAllMocks()
+})
+
+test("listApiTokens GETs /api/v1/api-tokens and returns parsed array", async () => {
+ const calls: Array<{ url: string; method: string }> = []
+ globalThis.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
+ globalThis.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.
+ globalThis.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 }> = []
+ globalThis.fetch = vi.fn(async (input: any, init?: any) => {
+ calls.push({ url: String(input), method: String(init?.method ?? "GET") })
+ return new Response(JSON.stringify({ success: true, data: null }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ })
+ }) as unknown as typeof fetch
+
+ await deleteApiToken(42)
+ expect(calls).toHaveLength(1)
+ expect(calls[0].method).toBe("DELETE")
+ expect(calls[0].url).toContain("/api/v1/api-tokens/42")
+})
diff --git a/src/test/auth-login-loading.test.tsx b/src/test/auth-login-loading.test.tsx
new file mode 100644
index 0000000..32e7014
--- /dev/null
+++ b/src/test/auth-login-loading.test.tsx
@@ -0,0 +1,150 @@
+import { act, render } from "@testing-library/react"
+import { useEffect } from "react"
+import { afterEach, beforeEach, expect, test, vi } from "vitest"
+
+let profileStore: { id: number; role: number } | undefined
+const setProfileSpy = vi.fn((p: any) => {
+ profileStore = p
+})
+
+vi.mock("./useMainStore", () => ({}))
+vi.mock("@/hooks/useMainStore", () => ({
+ useMainStore: (selector: any) => selector({ profile: profileStore, setProfile: setProfileSpy }),
+}))
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (k: string) => k }),
+}))
+
+vi.mock("sonner", () => ({ toast: () => {} }))
+
+const navigate = vi.fn()
+vi.mock("react-router-dom", () => ({
+ useNavigate: () => navigate,
+}))
+
+// Initial getProfile() stays pending forever so loading can only be cleared
+// by an explicit login/logout, which is exactly what these tests assert.
+let initialProfilePromise: Promise
+let getProfileCall = 0
+const loginRequest = vi.fn(async () => {})
+vi.mock("@/api/user", () => ({
+ getProfile: vi.fn(() => {
+ getProfileCall++
+ if (getProfileCall === 1) return initialProfilePromise
+ return Promise.resolve({ id: 42, role: 0 })
+ }),
+ login: () => loginRequest(),
+}))
+
+beforeEach(() => {
+ profileStore = undefined
+ getProfileCall = 0
+ initialProfilePromise = new Promise(() => {})
+ setProfileSpy.mockClear()
+ navigate.mockClear()
+})
+
+afterEach(() => {
+ document.body.innerHTML = ""
+ vi.clearAllMocks()
+})
+
+// AuthProvider starts with loading=true and only clears it in the initial
+// mount probe's finally{}. A user can log in while that probe is still in
+// flight (ProtectedRoute renders the login page during loading). If login()
+// does not clear loading itself, ProtectedRoute keeps returning null for
+// /dashboard and the freshly-authenticated user sees a blank screen until the
+// unrelated probe settles.
+test("login() clears loading even while the initial profile probe is still pending", async () => {
+ const { AuthProvider, useAuth } = await import("@/hooks/useAuth")
+
+ const captured: { auth?: ReturnType } = {}
+ function Capture() {
+ const auth = useAuth()
+ useEffect(() => {
+ captured.auth = auth
+ })
+ captured.auth = auth
+ return null
+ }
+
+ await act(async () => {
+ render(
+
+
+ ,
+ )
+ })
+
+ // Initial probe still pending -> loading must be true.
+ expect(captured.auth!.loading).toBe(true)
+
+ await act(async () => {
+ await captured.auth!.login("u", "p")
+ })
+
+ // Login succeeded; loading must be false so ProtectedRoute renders.
+ expect(profileStore).toEqual({ id: 42, role: 0 })
+ expect(captured.auth!.loading).toBe(false)
+})
+
+test("loginOauth2() clears loading even while the initial profile probe is still pending", async () => {
+ const { AuthProvider, useAuth } = await import("@/hooks/useAuth")
+
+ const captured: { auth?: ReturnType } = {}
+ function Capture() {
+ const auth = useAuth()
+ useEffect(() => {
+ captured.auth = auth
+ })
+ captured.auth = auth
+ return null
+ }
+
+ await act(async () => {
+ render(
+
+
+ ,
+ )
+ })
+
+ expect(captured.auth!.loading).toBe(true)
+
+ await act(async () => {
+ await captured.auth!.loginOauth2()
+ })
+
+ expect(captured.auth!.loading).toBe(false)
+})
+
+test("logout() clears loading even while the initial profile probe is still pending", async () => {
+ const { AuthProvider, useAuth } = await import("@/hooks/useAuth")
+
+ const captured: { auth?: ReturnType } = {}
+ function Capture() {
+ const auth = useAuth()
+ useEffect(() => {
+ captured.auth = auth
+ })
+ captured.auth = auth
+ return null
+ }
+
+ await act(async () => {
+ render(
+
+
+ ,
+ )
+ })
+
+ expect(captured.auth!.loading).toBe(true)
+
+ await act(async () => {
+ captured.auth!.logout()
+ })
+
+ expect(captured.auth!.loading).toBe(false)
+})
diff --git a/src/test/auth-login-race.test.tsx b/src/test/auth-login-race.test.tsx
new file mode 100644
index 0000000..abe58e2
--- /dev/null
+++ b/src/test/auth-login-race.test.tsx
@@ -0,0 +1,95 @@
+import { act, render } from "@testing-library/react"
+import { useEffect } from "react"
+import { afterEach, beforeEach, expect, test, vi } from "vitest"
+
+let profileStore: { id: number; role: number } | undefined
+const setProfileSpy = vi.fn((p: any) => {
+ profileStore = p
+})
+
+vi.mock("./useMainStore", () => ({}))
+vi.mock("@/hooks/useMainStore", () => ({
+ useMainStore: (selector: any) => selector({ profile: profileStore, setProfile: setProfileSpy }),
+}))
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (k: string) => k }),
+}))
+
+vi.mock("sonner", () => ({ toast: () => {} }))
+
+const navigate = vi.fn()
+vi.mock("react-router-dom", () => ({
+ useNavigate: () => navigate,
+}))
+
+// Deferred initial getProfile() so we can resolve/reject it AFTER login().
+let rejectInitial: (e: any) => void
+const initialProfilePromise = new Promise((_res, rej) => {
+ rejectInitial = rej
+})
+let getProfileCall = 0
+const loginRequest = vi.fn(async () => {})
+vi.mock("@/api/user", () => ({
+ getProfile: vi.fn(() => {
+ getProfileCall++
+ if (getProfileCall === 1) return initialProfilePromise
+ return Promise.resolve({ id: 42, role: 0 })
+ }),
+ login: () => loginRequest(),
+}))
+
+beforeEach(() => {
+ profileStore = undefined
+ getProfileCall = 0
+ setProfileSpy.mockClear()
+ navigate.mockClear()
+})
+
+afterEach(() => {
+ document.body.innerHTML = ""
+ vi.clearAllMocks()
+})
+
+// The AuthProvider fires getProfile() on mount. While that probe is in flight a
+// user can submit the login form (ProtectedRoute renders the login page during
+// loading). If the in-flight probe later REJECTS (e.g. it 401'd because the
+// user was not yet authenticated), its catch{} must NOT clobber the profile a
+// successful login() already set — otherwise the freshly-authenticated user is
+// bounced back to the login page.
+test("late-rejecting initial profile probe does not clobber a successful login", async () => {
+ const { AuthProvider, useAuth } = await import("@/hooks/useAuth")
+
+ const captured: { auth?: ReturnType } = {}
+ function Capture() {
+ const auth = useAuth()
+ useEffect(() => {
+ captured.auth = auth
+ })
+ return null
+ }
+
+ await act(async () => {
+ render(
+
+
+ ,
+ )
+ })
+
+ // User logs in while the initial probe is still pending.
+ await act(async () => {
+ await captured.auth!.login("u", "p")
+ })
+ expect(profileStore).toEqual({ id: 42, role: 0 })
+
+ // Now the stale initial probe rejects (it was a pre-auth 401).
+ await act(async () => {
+ rejectInitial(new Error("401"))
+ await initialProfilePromise.catch(() => {})
+ })
+
+ // The logged-in profile must survive.
+ expect(profileStore).toEqual({ id: 42, role: 0 })
+ expect(setProfileSpy).not.toHaveBeenLastCalledWith(undefined)
+})
diff --git a/src/test/batch-move-server-icon.test.tsx b/src/test/batch-move-server-icon.test.tsx
new file mode 100644
index 0000000..2a0e9b8
--- /dev/null
+++ b/src/test/batch-move-server-icon.test.tsx
@@ -0,0 +1,87 @@
+import { BatchMoveServerIcon } from "@/components/batch-move-server-icon"
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
+import { afterEach, beforeEach, expect, test, vi } from "vitest"
+
+const toastCalls: Array<{ title: string; description: string }> = []
+vi.mock("sonner", () => ({
+ toast: (title: string, opts?: { description?: string }) => {
+ toastCalls.push({ title, description: opts?.description ?? "" })
+ },
+}))
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string, vars?: Record) => {
+ if (vars && typeof vars.count === "number") {
+ return `${key}=${vars.count}`
+ }
+ return key
+ },
+ }),
+ initReactI18next: { type: "3rdParty", init: () => undefined },
+ Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
+}))
+
+const batchMoveServer = vi.fn()
+vi.mock("@/api/server", () => ({
+ batchMoveServer: (...args: unknown[]) => batchMoveServer(...args),
+}))
+
+beforeEach(() => {
+ toastCalls.length = 0
+ batchMoveServer.mockReset()
+})
+
+afterEach(() => {
+ document.body.innerHTML = ""
+})
+
+async function openDialogAndSubmit(serverIds: number[], toUser: number) {
+ render()
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button"))
+ })
+ const userInput = await screen.findByPlaceholderText("User ID")
+ await act(async () => {
+ fireEvent.change(userInput, { target: { value: String(toUser) } })
+ })
+ const submit = screen.getByRole("button", { name: "Move" })
+ await act(async () => {
+ fireEvent.click(submit)
+ })
+ await waitFor(() => expect(toastCalls.length).toBeGreaterThan(0))
+}
+
+test("BatchMoveServer toast surfaces agent_too_old count so operator sees the failure", async () => {
+ batchMoveServer.mockResolvedValueOnce([
+ { server_id: 1, status: "agent_too_old", error: "agent build older than v1.18.0" },
+ { server_id: 2, status: "pending", transfer_id: 99 },
+ ])
+
+ await openDialogAndSubmit([1, 2], 300)
+
+ expect(batchMoveServer).toHaveBeenCalledOnce()
+ const summary = toastCalls[toastCalls.length - 1]
+ expect(summary, "submission must surface a toast").toBeDefined()
+ expect(summary.description, "toast must include the agent_too_old count").toContain(
+ "Transfer.AgentTooOldCount=1",
+ )
+ expect(summary.description).toContain("Transfer.PendingCount=1")
+})
+
+test("BatchMoveServer toast does not show Done fallback when every server is agent_too_old", async () => {
+ batchMoveServer.mockResolvedValueOnce([
+ { server_id: 1, status: "agent_too_old", error: "older than v1.18.0" },
+ { server_id: 2, status: "agent_too_old", error: "older than v1.18.0" },
+ ])
+
+ await openDialogAndSubmit([1, 2], 300)
+
+ const summary = toastCalls[toastCalls.length - 1]
+ expect(summary).toBeDefined()
+ expect(
+ summary.description,
+ "all-failed batch must NOT collapse to a generic Done label — operator would think it succeeded",
+ ).toContain("Transfer.AgentTooOldCount=2")
+ expect(summary.description).not.toBe("Done")
+})
diff --git a/src/test/fetcher-csrf.test.ts b/src/test/fetcher-csrf.test.ts
new file mode 100644
index 0000000..c537a96
--- /dev/null
+++ b/src/test/fetcher-csrf.test.ts
@@ -0,0 +1,99 @@
+import { afterEach, beforeEach, expect, test, vi } from "vitest"
+
+import { FetcherMethod, fetcher } from "../api/api"
+
+const realFetch = globalThis.fetch
+
+function setCookie(value: string) {
+ Object.defineProperty(document, "cookie", { value, configurable: true })
+}
+
+beforeEach(() => {
+ setCookie("")
+})
+
+afterEach(() => {
+ globalThis.fetch = realFetch
+ vi.restoreAllMocks()
+})
+
+function jsonOk() {
+ return new Response(JSON.stringify({ success: true, data: null }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ })
+}
+
+function headerOf(init: RequestInit | undefined, name: string): string | null {
+ const h = init?.headers
+ if (!h) return null
+ if (h instanceof Headers) return h.get(name)
+ const rec = h as Record
+ const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase())
+ return key ? rec[key] : null
+}
+
+// Backend csrfMiddleware (nezha/cmd/dashboard/controller/csrf.go) enforces a
+// double-submit cookie on every cookie-auth unsafe method: it rejects the
+// request unless X-CSRF-Token header == nz-csrf cookie. The fetcher must mirror
+// the cookie into the header for POST/PATCH/PUT/DELETE.
+test("POST sends X-CSRF-Token mirrored from nz-csrf cookie", async () => {
+ setCookie("nz-csrf=abc123; other=1")
+ const seen: { init?: RequestInit }[] = []
+ globalThis.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 }[] = []
+ globalThis.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 }[] = []
+ globalThis.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 () => {
+ globalThis.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 () => {
+ globalThis.fetch = vi.fn(
+ async () => new Response("", { status: 200 }),
+ ) as unknown as typeof fetch
+
+ await expect(fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/9")).resolves.toBeUndefined()
+})
diff --git a/src/test/fetcher-delete.test.ts b/src/test/fetcher-delete.test.ts
new file mode 100644
index 0000000..7ce48bb
--- /dev/null
+++ b/src/test/fetcher-delete.test.ts
@@ -0,0 +1,35 @@
+import { afterEach, beforeEach, expect, test, vi } from "vitest"
+
+import { FetcherMethod, fetcher } from "../api/api"
+
+const realFetch = globalThis.fetch
+
+beforeEach(() => {
+ // Avoid the auto refresh-token branch interfering with assertions.
+ Object.defineProperty(document, "cookie", { value: "", configurable: true })
+})
+
+afterEach(() => {
+ globalThis.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 }[] = []
+ globalThis.fetch = vi.fn(async (input: any, init?: RequestInit) => {
+ seen.push({ url: String(input), init })
+ return new Response(JSON.stringify({ success: true, data: null }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ })
+ }) as unknown as typeof fetch
+
+ await fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/42")
+
+ expect(seen).toHaveLength(1)
+ expect(seen[0].init?.method).toBe("DELETE")
+ expect(seen[0].url).toContain("/api/v1/api-tokens/42")
+})
diff --git a/src/test/fetcher-refresh-selfheal.test.ts b/src/test/fetcher-refresh-selfheal.test.ts
new file mode 100644
index 0000000..e840335
--- /dev/null
+++ b/src/test/fetcher-refresh-selfheal.test.ts
@@ -0,0 +1,61 @@
+import { afterEach, beforeEach, expect, test, vi } from "vitest"
+
+const realFetch = globalThis.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(() => {
+ globalThis.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[] = []
+ globalThis.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[] = []
+ globalThis.fetch = vi.fn(async (input: any) => {
+ urls.push(String(input))
+ return jsonOk()
+ }) as unknown as typeof fetch
+
+ setCookie("nz-jwt=session") // first GET: no csrf, refresh skipped
+ await fetcher(FetcherMethod.GET, "/api/v1/server")
+ expect(urls.some((u) => u.includes("/api/v1/refresh-token"))).toBe(false)
+
+ setCookie("nz-jwt=session; nz-csrf=seeded") // backend seeded it
+ await fetcher(FetcherMethod.GET, "/api/v1/server")
+ expect(urls.some((u) => u.includes("/api/v1/refresh-token"))).toBe(true)
+})
diff --git a/src/test/fm.test.tsx b/src/test/fm.test.tsx
new file mode 100644
index 0000000..82d35ec
--- /dev/null
+++ b/src/test/fm.test.tsx
@@ -0,0 +1,249 @@
+import { act, render, waitFor } from "@testing-library/react"
+import { afterEach, beforeEach, expect, test, vi } from "vitest"
+
+import { FMComponent } from "../components/fm"
+
+type DivProps = React.ComponentPropsWithoutRef<"div"> & { asChild?: boolean }
+type ButtonProps = React.ComponentPropsWithoutRef<"button"> & {
+ asChild?: boolean
+ size?: string
+ variant?: string
+}
+type IconButtonProps = ButtonProps & { icon?: string }
+type SentWebSocketData = string | ArrayBufferLike | Blob | ArrayBufferView
+
+vi.mock("../components/ui/button", () => ({
+ Button: (props: ButtonProps) => {
+ const { asChild, size, variant, ...buttonProps } = props
+ void asChild
+ void size
+ void variant
+ return
+ },
+}))
+vi.mock("../components/ui/input", () => ({
+ Input: (props: React.ComponentPropsWithoutRef<"input">) => ,
+}))
+vi.mock("../components/ui/table", () => ({
+ Table: (props: React.ComponentPropsWithoutRef<"table">) => ,
+ TableHeader: (props: React.ComponentPropsWithoutRef<"thead">) => ,
+ TableBody: (props: React.ComponentPropsWithoutRef<"tbody">) => ,
+ TableRow: (props: React.ComponentPropsWithoutRef<"tr">) =>
,
+ TableCell: (props: React.ComponentPropsWithoutRef<"td">) => | ,
+ TableHead: (props: React.ComponentPropsWithoutRef<"th">) => | ,
+}))
+vi.mock("../components/ui/dropdown-menu", () => ({
+ DropdownMenu: (props: DivProps) => ,
+ DropdownMenuTrigger: (props: DivProps) => ,
+ DropdownMenuContent: (props: DivProps) => ,
+ DropdownMenuItem: (props: DivProps) => ,
+}))
+vi.mock("../components/ui/alert-dialog", () => ({
+ AlertDialog: (props: DivProps) => ,
+ AlertDialogTrigger: (props: DivProps) => ,
+ AlertDialogContent: (props: DivProps) => ,
+ AlertDialogHeader: (props: DivProps) => ,
+ AlertDialogFooter: (props: DivProps) => ,
+ AlertDialogTitle: (props: DivProps) => ,
+ AlertDialogDescription: (props: DivProps) => ,
+ AlertDialogCancel: (props: DivProps) => ,
+ AlertDialogAction: (props: DivProps) => ,
+}))
+vi.mock("../components/ui/drawer", () => ({
+ Drawer: (props: DivProps) => ,
+ DrawerContent: (props: DivProps) => ,
+ DrawerHeader: (props: DivProps) => ,
+ DrawerTitle: (props: DivProps) => ,
+ DrawerTrigger: (props: DivProps) => ,
+}))
+vi.mock("../components/xui/overlayless-sheet", () => ({
+ Sheet: (props: DivProps) => ,
+ SheetContent: (props: DivProps) => ,
+ SheetDescription: (props: DivProps) => ,
+ SheetHeader: (props: DivProps) => ,
+ SheetTitle: (props: DivProps) => ,
+ SheetTrigger: (props: DivProps) => ,
+}))
+vi.mock("../components/xui/filepath", () => ({
+ Filepath: () => ,
+}))
+vi.mock("../components/xui/icon-button", () => ({
+ IconButton: (props: IconButtonProps) => {
+ const { asChild, icon, size, variant, ...buttonProps } = props
+ void asChild
+ void icon
+ void size
+ void variant
+ return
+ },
+}))
+vi.mock("../components/xui/virtulized-data-table", () => ({
+ DataTable: () => ,
+}))
+vi.mock("lucide-react", () => ({
+ File: () => ,
+ Folder: () => ,
+}))
+
+const translate = (key: string) => key
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: translate }),
+ initReactI18next: { type: "3rdParty", init: () => {} },
+}))
+
+vi.mock("sonner", () => ({ toast: vi.fn() }))
+vi.mock("@/lib/utils", () => ({
+ copyToClipboard: vi.fn(),
+ fm: {
+ parseFMList: async (buf: ArrayBufferLike) => {
+ const view = new DataView(buf)
+ const pathLength = view.getUint32(4, false)
+ const pathBytes = new Uint8Array(buf, 8, pathLength)
+
+ return { path: new TextDecoder().decode(pathBytes), fmList: [] }
+ },
+ },
+ fmWorker: new Worker(""),
+ formatPath: (path: string) => path,
+}))
+
+const webSockets: MockWebSocket[] = []
+
+class MockWebSocket extends EventTarget implements WebSocket {
+ static readonly CONNECTING = 0
+ static readonly OPEN = 1
+ static readonly CLOSING = 2
+ static readonly CLOSED = 3
+
+ readonly CONNECTING = MockWebSocket.CONNECTING
+ readonly OPEN = MockWebSocket.OPEN
+ readonly CLOSING = MockWebSocket.CLOSING
+ readonly CLOSED = MockWebSocket.CLOSED
+ readonly bufferedAmount = 0
+ readonly extensions = ""
+ readonly protocol = ""
+ readonly url: string
+
+ binaryType: BinaryType = "arraybuffer"
+ onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null = null
+ onerror: ((this: WebSocket, ev: Event) => unknown) | null = null
+ onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null
+ onopen: ((this: WebSocket, ev: Event) => unknown) | null = null
+ readyState: 0 | 1 | 2 | 3 = MockWebSocket.CONNECTING
+ sent: SentWebSocketData[] = []
+
+ constructor(url: string | URL) {
+ super()
+ this.url = url.toString()
+ webSockets.push(this)
+ }
+
+ addEventListener(
+ type: K,
+ listener: (this: WebSocket, ev: WebSocketEventMap[K]) => unknown,
+ options?: boolean | AddEventListenerOptions,
+ ): void
+ addEventListener(
+ type: string,
+ listener: EventListenerOrEventListenerObject | null,
+ options?: boolean | AddEventListenerOptions,
+ ): void {
+ super.addEventListener(type, listener, options)
+ }
+
+ removeEventListener(
+ type: K,
+ listener: (this: WebSocket, ev: WebSocketEventMap[K]) => unknown,
+ options?: boolean | EventListenerOptions,
+ ): void
+ removeEventListener(
+ type: string,
+ listener: EventListenerOrEventListenerObject | null,
+ options?: boolean | EventListenerOptions,
+ ): void {
+ super.removeEventListener(type, listener, options)
+ }
+
+ close(): void {
+ this.readyState = MockWebSocket.CLOSED
+ }
+
+ open(): void {
+ this.readyState = MockWebSocket.OPEN
+ this.onopen?.call(this, new Event("open"))
+ }
+
+ send(data: SentWebSocketData): void {
+ this.sent.push(data)
+ }
+}
+
+beforeEach(() => {
+ webSockets.length = 0
+ globalThis.WebSocket = MockWebSocket
+})
+
+afterEach(() => {
+ vi.clearAllMocks()
+})
+
+const encodeFileNameMessage = (path: string) => {
+ const identifier = new Uint8Array([0x4e, 0x5a, 0x46, 0x4e])
+ const pathBytes = new TextEncoder().encode(path)
+ const payload = new Uint8Array(identifier.length + 4 + pathBytes.length)
+ payload.set(identifier, 0)
+ new DataView(payload.buffer).setUint32(identifier.length, pathBytes.length, false)
+ payload.set(pathBytes, identifier.length + 4)
+ return payload.buffer
+}
+
+const encodeCompleteMessage = () => new Uint8Array([0x4e, 0x5a, 0x55, 0x50]).buffer
+
+const decodeListPath = (data: SentWebSocketData) => {
+ if (!ArrayBuffer.isView(data)) return null
+
+ const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
+ if (bytes[0] !== 0) return null
+
+ return new TextDecoder().decode(bytes.slice(1))
+}
+
+test("FM websocket lifecycle reuses the socket on path changes", async () => {
+ const { unmount } = render()
+
+ await waitFor(() => {
+ expect(webSockets).toHaveLength(1)
+ })
+
+ const socket = webSockets[0]
+ act(() => {
+ socket.open()
+ })
+
+ await act(async () => {
+ await socket.onmessage?.call(
+ socket,
+ new MessageEvent("message", { data: encodeFileNameMessage("/new/path") }),
+ )
+ })
+
+ await waitFor(() => {
+ expect(webSockets).toHaveLength(1)
+ expect(decodeListPath(socket.sent[socket.sent.length - 1])).toBe("/new/path")
+ })
+
+ socket.sent.length = 0
+ await act(async () => {
+ await socket.onmessage?.call(
+ socket,
+ new MessageEvent("message", { data: encodeCompleteMessage() }),
+ )
+ })
+
+ expect(webSockets).toHaveLength(1)
+ expect(decodeListPath(socket.sent[socket.sent.length - 1])).toBe("/new/path")
+
+ unmount()
+ expect(socket.readyState).toBe(MockWebSocket.CLOSED)
+})
diff --git a/src/test/protect-auth-loading.test.tsx b/src/test/protect-auth-loading.test.tsx
new file mode 100644
index 0000000..926e18f
--- /dev/null
+++ b/src/test/protect-auth-loading.test.tsx
@@ -0,0 +1,95 @@
+import { act, render } from "@testing-library/react"
+import { MemoryRouter, Route, Routes } from "react-router-dom"
+import { afterEach, beforeEach, expect, test, vi } from "vitest"
+
+// ProtectedRoute must NOT mount its children while AuthProvider is still
+// running the initial getProfile() probe — except for the /dashboard/login
+// path itself. Mounting the protected subtree during the probe would fire
+// authenticated SWR fetches like /api/v1/setting before auth is confirmed.
+// Without this contract, a regression in protect.tsx silently re-introduces
+// pre-auth traffic and a flash of protected UI before redirect.
+
+let mockProfile: { id: number; role: number } | undefined
+let mockLoading = false
+vi.mock("@/hooks/useAuth", () => ({
+ useAuth: () => ({ profile: mockProfile, loading: mockLoading }),
+}))
+
+beforeEach(() => {
+ mockProfile = undefined
+ mockLoading = true
+})
+
+afterEach(() => {
+ document.body.innerHTML = ""
+})
+
+function renderAtPath(path: string, child: React.ReactNode) {
+ return import("@/routes/protect").then(({ default: ProtectedRoute }) => {
+ return act(async () => {
+ render(
+
+
+ {child}}
+ />
+ {child}}
+ />
+
+ ,
+ )
+ })
+ })
+}
+
+test("ProtectedRoute does not mount children for protected paths while auth is loading", async () => {
+ await renderAtPath("/dashboard", protected
)
+ expect(document.querySelector("[data-testid='protected-child']")).toBeNull()
+})
+
+test("ProtectedRoute renders children on the login page even while auth is loading", async () => {
+ await renderAtPath("/dashboard/login", login
)
+ expect(document.querySelector("[data-testid='login-child']")).not.toBeNull()
+})
+
+test("ProtectedRoute redirects unauthenticated users without mounting protected children", async () => {
+ mockLoading = false
+ mockProfile = undefined
+ const { default: ProtectedRoute } = await import("@/routes/protect")
+ await act(async () => {
+ render(
+
+
+
+ login
+
+ }
+ />
+
+ protected
+
+ }
+ />
+
+ ,
+ )
+ })
+ expect(document.querySelector("[data-testid='protected-child']")).toBeNull()
+ expect(document.querySelector("[data-testid='login-child']")).not.toBeNull()
+})
+
+test("ProtectedRoute renders children once an authenticated profile resolves", async () => {
+ mockLoading = false
+ mockProfile = { id: 1, role: 0 }
+ await renderAtPath("/dashboard", protected
)
+ expect(document.querySelector("[data-testid='protected-child']")).not.toBeNull()
+})
diff --git a/src/test/settings-auth-loading.test.tsx b/src/test/settings-auth-loading.test.tsx
new file mode 100644
index 0000000..34bf302
--- /dev/null
+++ b/src/test/settings-auth-loading.test.tsx
@@ -0,0 +1,74 @@
+import { act, render } from "@testing-library/react"
+import { afterEach, beforeEach, expect, test, vi } from "vitest"
+
+vi.mock("sonner", () => ({ toast: () => {} }))
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (k: string) => k,
+ i18n: { language: "en", changeLanguage: () => {} },
+ }),
+ initReactI18next: { type: "3rdParty", init: () => undefined },
+ Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
+}))
+
+let mockProfile: { id: number; role: number } | undefined
+let mockLoading = false
+vi.mock("@/hooks/useAuth", () => ({
+ useAuth: () => ({ profile: mockProfile, loading: mockLoading }),
+}))
+
+vi.mock("@/hooks/useSetting", () => ({
+ default: () => ({ data: undefined, mutate: () => {} }),
+}))
+
+vi.mock("@/hooks/useNotfication", () => ({
+ useNotification: () => ({ notifierGroup: [] }),
+}))
+
+vi.mock("@/api/settings", () => ({ updateSettings: vi.fn() }))
+
+const navigateRenders: string[] = []
+vi.mock("react-router-dom", async () => {
+ const actual = await vi.importActual("react-router-dom")
+ return {
+ ...actual,
+ Navigate: ({ to }: { to: string }) => {
+ navigateRenders.push(to)
+ return redirect:{to}
+ },
+ }
+})
+
+beforeEach(() => {
+ navigateRenders.length = 0
+ mockProfile = undefined
+ mockLoading = true
+})
+
+afterEach(() => {
+ document.body.innerHTML = ""
+})
+
+// SettingsPage 在 profile 还没 fetch 完成时不能就把用户当作非管理员重定向。
+// useAuth.loading=true 表示请求未回来;此时必须按"加载中"渲染,不能 Navigate
+// 到 /dashboard/settings/api-tokens,否则管理员每次直达 /dashboard/settings
+// 都会先闪一下到 api-tokens 页。
+test("SettingsPage waits for auth load before redirecting non-admin", async () => {
+ const { default: SettingsPage } = await import("@/routes/settings")
+ await act(async () => {
+ render()
+ })
+ expect(navigateRenders).toEqual([])
+})
+
+// 一旦 loading=false 且确认 profile 不是 admin,才允许跳转。
+test("SettingsPage redirects to api-tokens once auth resolves and user is not admin", async () => {
+ mockLoading = false
+ mockProfile = { id: 1, role: 1 }
+ const { default: SettingsPage } = await import("@/routes/settings")
+ await act(async () => {
+ render()
+ })
+ expect(navigateRenders).toContain("/dashboard/settings/api-tokens")
+})
diff --git a/src/test/settings-submit-error.test.tsx b/src/test/settings-submit-error.test.tsx
new file mode 100644
index 0000000..45f8704
--- /dev/null
+++ b/src/test/settings-submit-error.test.tsx
@@ -0,0 +1,109 @@
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
+import { MemoryRouter } from "react-router-dom"
+import { afterEach, beforeEach, expect, test, vi } from "vitest"
+
+const toastCalls: string[] = []
+vi.mock("sonner", () => ({
+ toast: (msg: string) => {
+ toastCalls.push(msg)
+ },
+}))
+
+const changeLanguageCalls: string[] = []
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (k: string) => k,
+ i18n: {
+ language: "en",
+ changeLanguage: (lng: string) => {
+ changeLanguageCalls.push(lng)
+ },
+ },
+ }),
+ initReactI18next: { type: "3rdParty", init: () => undefined },
+ Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
+}))
+
+vi.mock("@/hooks/useAuth", () => ({
+ useAuth: () => ({ profile: { id: 1, role: 0 }, loading: false }),
+}))
+
+const validConfig = {
+ config: {
+ site_name: "Nezha",
+ language: "zh-CN",
+ user_template: "user-dist",
+ cover: 1,
+ ip_change_notification_group_id: 0,
+ },
+ frontend_templates: [],
+}
+vi.mock("@/hooks/useSetting", () => ({
+ default: () => ({ data: validConfig, mutate: vi.fn() }),
+}))
+
+vi.mock("@/hooks/useNotfication", () => ({
+ useNotification: () => ({ notifierGroup: [] }),
+}))
+
+const updateSettings = vi.fn()
+vi.mock("@/api/settings", () => ({
+ updateSettings: (...args: unknown[]) => updateSettings(...args),
+}))
+
+beforeEach(() => {
+ toastCalls.length = 0
+ changeLanguageCalls.length = 0
+ updateSettings.mockReset()
+})
+
+afterEach(() => {
+ document.body.innerHTML = ""
+})
+
+test("SettingsPage does not toast Success when updateSettings rejects", async () => {
+ updateSettings.mockRejectedValue(new Error("boom"))
+ const { default: SettingsPage } = await import("@/routes/settings")
+
+ await act(async () => {
+ render(
+
+
+ ,
+ )
+ })
+
+ const submit = screen.getByRole("button", { name: /Confirm|Submit|Save/i })
+ await act(async () => {
+ fireEvent.click(submit)
+ })
+
+ await waitFor(() => expect(updateSettings).toHaveBeenCalled())
+
+ expect(toastCalls).toContain("Error")
+ expect(toastCalls).not.toContain("Success")
+ expect(changeLanguageCalls).toEqual([])
+})
+
+test("SettingsPage toasts Success when updateSettings resolves", async () => {
+ updateSettings.mockResolvedValue(undefined)
+ const { default: SettingsPage } = await import("@/routes/settings")
+
+ await act(async () => {
+ render(
+
+
+ ,
+ )
+ })
+
+ const submit = screen.getByRole("button", { name: /Confirm|Submit|Save/i })
+ await act(async () => {
+ fireEvent.click(submit)
+ })
+
+ await waitFor(() => expect(updateSettings).toHaveBeenCalled())
+
+ expect(toastCalls).toContain("Success")
+ expect(toastCalls).not.toContain("Error")
+})
diff --git a/src/test/setup.ts b/src/test/setup.ts
new file mode 100644
index 0000000..2f33a70
--- /dev/null
+++ b/src/test/setup.ts
@@ -0,0 +1,37 @@
+class TestWorker implements Worker {
+ onmessage: ((this: Worker, ev: MessageEvent) => unknown) | null = null
+ onmessageerror: ((this: Worker, ev: MessageEvent) => unknown) | null = null
+ onerror: ((this: AbstractWorker, ev: ErrorEvent) => unknown) | null = null
+
+ addEventListener(): void {}
+ removeEventListener(): void {}
+ dispatchEvent(): boolean {
+ return true
+ }
+ postMessage(): void {}
+ terminate(): void {}
+}
+
+class TestResizeObserver implements ResizeObserver {
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+}
+
+class TestIntersectionObserver implements IntersectionObserver {
+ readonly root: Element | Document | null = null
+ readonly rootMargin = ""
+ readonly scrollMargin = ""
+ readonly thresholds: ReadonlyArray = []
+
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+ takeRecords(): IntersectionObserverEntry[] {
+ return []
+ }
+}
+
+globalThis.Worker = TestWorker
+globalThis.ResizeObserver = TestResizeObserver
+globalThis.IntersectionObserver = TestIntersectionObserver
diff --git a/src/test/transfer.test.tsx b/src/test/transfer.test.tsx
new file mode 100644
index 0000000..e79fda8
--- /dev/null
+++ b/src/test/transfer.test.tsx
@@ -0,0 +1,135 @@
+import type { ModelServerTransfer } from "@/types"
+import { act, render, screen, waitFor } from "@testing-library/react"
+import { afterEach, beforeEach, expect, test, vi } from "vitest"
+
+const toastCalls: Array<{ title: string; description: string }> = []
+vi.mock("sonner", () => ({
+ toast: (title: string, opts?: { description?: string }) => {
+ toastCalls.push({ title, description: opts?.description ?? "" })
+ },
+}))
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+ initReactI18next: { type: "3rdParty", init: () => undefined },
+ Trans: ({ children }: { children?: React.ReactNode }) => children ?? null,
+}))
+
+const cancelServerTransfer = vi.fn()
+const retryServerTransfer = vi.fn()
+vi.mock("@/api/transfer", () => ({
+ cancelServerTransfer: (...args: unknown[]) => cancelServerTransfer(...args),
+ retryServerTransfer: (...args: unknown[]) => retryServerTransfer(...args),
+}))
+
+const swrFetcher = vi.fn()
+vi.mock("@/api/api", () => ({
+ swrFetcher: (...args: unknown[]) => swrFetcher(...args),
+}))
+
+vi.mock("swr", () => ({
+ default: (_key: string, _fetcher: unknown) => ({
+ data: mockedRows,
+ mutate: vi.fn(),
+ error: undefined,
+ }),
+}))
+
+let mockProfile: { id: number; role: number } | undefined
+vi.mock("@/hooks/useAuth", () => ({
+ useAuth: () => ({ profile: mockProfile }),
+}))
+vi.mock("@/hooks/useMainStore", () => ({
+ useMainStore: (selector?: (s: { profile?: { id: number; role: number } }) => unknown) => {
+ const store = { profile: mockProfile }
+ return selector ? selector(store) : store
+ },
+}))
+
+let mockedRows: ModelServerTransfer[] = []
+
+beforeEach(() => {
+ toastCalls.length = 0
+ cancelServerTransfer.mockReset()
+ retryServerTransfer.mockReset()
+ mockedRows = []
+ mockProfile = undefined
+})
+
+afterEach(() => {
+ document.body.innerHTML = ""
+})
+
+function makeRow(overrides: Partial): ModelServerTransfer {
+ return {
+ id: 1,
+ server_id: 10,
+ from_user_id: 100,
+ to_user_id: 200,
+ initiator_id: 100,
+ status: 0,
+ last_error: "",
+ acked_at: "",
+ created_at: "2024-01-01T00:00:00Z",
+ updated_at: "2024-01-01T00:00:00Z",
+ ...overrides,
+ } as ModelServerTransfer
+}
+
+async function renderPage() {
+ const { default: TransferPage } = await import("@/routes/transfer")
+ await act(async () => {
+ render()
+ })
+ await waitFor(() => {
+ expect(screen.getByText("Transfer.Title")).toBeTruthy()
+ })
+}
+
+test("non-admin member who is the FromUserID sees Cancel for pending rows but NOT Retry for terminal rows", async () => {
+ mockProfile = { id: 100, role: 1 }
+ mockedRows = [
+ makeRow({ id: 1, status: 0, from_user_id: 100 }),
+ makeRow({ id: 2, status: 2, from_user_id: 100 }),
+ makeRow({ id: 3, status: 4, from_user_id: 100 }),
+ ]
+
+ await renderPage()
+
+ expect(screen.queryAllByRole("button", { name: "Cancel" }).length).toBeGreaterThan(0)
+ expect(
+ screen.queryAllByRole("button", { name: "Transfer.Retry" }),
+ "Retry is admin-only on the backend; rendering it for members produces guaranteed permission_denied on click",
+ ).toHaveLength(0)
+})
+
+test("non-admin member who is only the ToUserID or InitiatorID sees neither Cancel nor Retry", async () => {
+ mockProfile = { id: 200, role: 1 }
+ mockedRows = [
+ makeRow({ id: 1, status: 0, from_user_id: 100, to_user_id: 200 }),
+ makeRow({ id: 2, status: 3, from_user_id: 100, to_user_id: 200 }),
+ ]
+
+ await renderPage()
+
+ expect(
+ screen.queryAllByRole("button", { name: "Cancel" }),
+ "backend cancelServerTransfer rejects non-admins that are not the FromUserID; UI must not pretend it works",
+ ).toHaveLength(0)
+ expect(
+ screen.queryAllByRole("button", { name: "Transfer.Retry" }),
+ "backend retryServerTransfer is admin-only; non-admin must not see the button",
+ ).toHaveLength(0)
+})
+
+test("admin sees both Cancel for pending and Retry for terminal", async () => {
+ mockProfile = { id: 1, role: 0 }
+ mockedRows = [makeRow({ id: 1, status: 0 }), makeRow({ id: 2, status: 2 })]
+
+ await renderPage()
+
+ expect(screen.queryAllByRole("button", { name: "Cancel" }).length).toBe(1)
+ expect(screen.queryAllByRole("button", { name: "Transfer.Retry" }).length).toBe(1)
+})
diff --git a/src/types/api.ts b/src/types/api.ts
index 273706d..88460e8 100644
--- a/src/types/api.ts
+++ b/src/types/api.ts
@@ -1,6 +1,4 @@
-/* eslint-disable */
/* tslint:disable */
-// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
@@ -10,166 +8,160 @@
* ---------------------------------------------------------------
*/
-/** @format int32 */
-export enum ModelRole {
- RoleAdmin = 0,
- RoleMember = 1,
-}
-
export interface GithubComNezhahqNezhaModelCommonResponseAny {
- data?: any
- error?: string
- success?: boolean
+ data: any
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseArrayModelAlertRule {
- data?: ModelAlertRule[]
- error?: string
- success?: boolean
+ data: ModelAlertRule[]
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseArrayModelCron {
- data?: ModelCron[]
- error?: string
- success?: boolean
+ data: ModelCron[]
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseArrayModelDDNSProfile {
- data?: ModelDDNSProfile[]
- error?: string
- success?: boolean
+ data: ModelDDNSProfile[]
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseArrayModelNAT {
- data?: ModelNAT[]
- error?: string
- success?: boolean
+ data: ModelNAT[]
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseArrayModelNotification {
- data?: ModelNotification[]
- error?: string
- success?: boolean
+ data: ModelNotification[]
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseArrayModelNotificationGroupResponseItem {
- data?: ModelNotificationGroupResponseItem[]
- error?: string
- success?: boolean
+ data: ModelNotificationGroupResponseItem[]
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseArrayModelServer {
- data?: ModelServer[]
- error?: string
- success?: boolean
+ data: ModelServer[]
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseArrayModelServerGroupResponseItem {
- data?: ModelServerGroupResponseItem[]
- error?: string
- success?: boolean
+ data: ModelServerGroupResponseItem[]
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseArrayModelService {
- data?: ModelService[]
- error?: string
- success?: boolean
+ data: ModelService[]
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseArrayModelServiceInfos {
- data?: ModelServiceInfos[]
- error?: string
- success?: boolean
+ data: ModelServiceInfos[]
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseArrayModelUser {
- data?: ModelUser[]
- error?: string
- success?: boolean
+ data: ModelUser[]
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseArrayString {
- data?: string[]
- error?: string
- success?: boolean
+ data: string[]
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseArrayUint64 {
- data?: number[]
- error?: string
- success?: boolean
+ data: number[]
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseModelLoginResponse {
- data?: ModelLoginResponse
- error?: string
- success?: boolean
+ data: ModelLoginResponse
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseModelProfile {
- data?: ModelProfile
- error?: string
- success?: boolean
+ data: ModelProfile
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseModelServerTaskResponse {
- data?: ModelServerTaskResponse
- error?: string
- success?: boolean
+ data: ModelServerTaskResponse
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseModelServiceResponse {
- data?: ModelServiceResponse
- error?: string
- success?: boolean
+ data: ModelServiceResponse
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseModelSettingResponse {
- data?: ModelSettingResponse
- error?: string
- success?: boolean
+ data: ModelSettingResponse
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseString {
- data?: string
- error?: string
- success?: boolean
+ data: string
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelCommonResponseUint64 {
- data?: number
- error?: string
- success?: boolean
+ data: number
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelPaginatedResponseArrayModelOnlineUserModelOnlineUser {
- data?: GithubComNezhahqNezhaModelValueArrayModelOnlineUser
- error?: string
- success?: boolean
+ data: GithubComNezhahqNezhaModelValueArrayModelOnlineUser
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelPaginatedResponseArrayModelWAFApiMockModelWAFApiMock {
- data?: GithubComNezhahqNezhaModelValueArrayModelWAFApiMock
- error?: string
- success?: boolean
+ data: GithubComNezhahqNezhaModelValueArrayModelWAFApiMock
+ error: string
+ success: boolean
}
export interface GithubComNezhahqNezhaModelValueArrayModelOnlineUser {
- pagination?: ModelPagination
- value?: ModelOnlineUser[]
+ pagination: ModelPagination
+ value: ModelOnlineUser[]
}
export interface GithubComNezhahqNezhaModelValueArrayModelWAFApiMock {
- pagination?: ModelPagination
- value?: ModelWAFApiMock[]
+ pagination: ModelPagination
+ value: ModelWAFApiMock[]
}
export interface ModelAlertRule {
- created_at?: string
- enable?: boolean
+ created_at: string
+ enable: boolean
/** 失败时执行的触发任务id */
fail_trigger_tasks: number[]
- id?: number
+ id: number
name: string
/** 该报警规则所在的通知组 */
notification_group_id: number
@@ -178,7 +170,7 @@ export interface ModelAlertRule {
rules: ModelRule[]
/** 触发模式: 0-始终触发(默认) 1-单次触发 */
trigger_mode: number
- updated_at?: string
+ updated_at: string
}
export interface ModelAlertRuleForm {
@@ -200,55 +192,95 @@ export interface ModelBatchMoveServerForm {
to_user: number
}
+// ModelServerTransferStatus mirrors model.ServerTransferStatus in nezha. The
+// values are stable iota numbers; do NOT renumber without coordinating with
+// the backend or every existing transfer row will be misread.
+export const ModelServerTransferStatusPending = 0
+export const ModelServerTransferStatusVerified = 1
+export const ModelServerTransferStatusFailed = 2
+export const ModelServerTransferStatusTimeout = 3
+export const ModelServerTransferStatusCancelled = 4
+export type ModelServerTransferStatus = 0 | 1 | 2 | 3 | 4
+
+// ModelBatchMoveServerResultStatus enumerates the per-server outcomes of
+// /batch-move/server. Mirrors model.BatchMoveServerResultStatus.
+export type ModelBatchMoveServerResultStatus =
+ | "pending"
+ | "permission_denied"
+ | "already_transferring"
+ | "server_not_found"
+ | "same_owner"
+ | "agent_too_old"
+
+export interface ModelBatchMoveServerResult {
+ server_id: number
+ status: ModelBatchMoveServerResultStatus
+ transfer_id?: number
+ error?: string
+}
+
+export interface ModelServerTransfer {
+ id: number
+ created_at: string
+ updated_at: string
+ server_id: number
+ from_user_id: number
+ to_user_id: number
+ initiator_id: number
+ status: ModelServerTransferStatus
+ last_error?: string
+ acked_at?: string
+}
+
export interface ModelCreateFMResponse {
- session_id?: string
+ session_id: string
}
export interface ModelCreateTerminalResponse {
- server_id?: number
- server_name?: string
- session_id?: string
+ server_id: number
+ server_name: string
+ session_id: string
}
export interface ModelCron {
- command?: string
+ command: string
/** 计划任务覆盖范围 (0:仅覆盖特定服务器 1:仅忽略特定服务器 2:由触发该计划任务的服务器执行) */
cover: number
- created_at?: string
- cron_job_id?: number
- id?: number
+ created_at: string
+ cron_job_id: number
+ id: number
/** 最后一次执行时间 */
- last_executed_at?: string
+ last_executed_at: string
/** 最后一次执行结果 */
- last_result?: boolean
+ last_result: boolean
name: string
/** 指定通知方式的分组 */
notification_group_id: number
/** 推送成功的通知 */
- push_successful?: boolean
+ push_successful: boolean
/** 分钟 小时 天 月 星期 */
scheduler: string
servers: number[]
/** 0:计划任务 1:触发任务 */
task_type: number
- updated_at?: string
+ updated_at: string
}
export interface ModelCronForm {
command?: string
/** @default 0 */
- cover?: number
+ cover: number
/** @minLength 1 */
- name?: string
- notification_group_id?: number
+ name: string
+ notification_group_id: number
push_successful?: boolean
- scheduler?: string
- servers?: number[]
+ scheduler: string
+ servers: number[]
/**
* 0:计划任务 1:触发任务
* @default 0
*/
- task_type?: number
+ task_type: number
}
export interface ModelCycleTransferStats {
@@ -256,23 +288,23 @@ export interface ModelCycleTransferStats {
max: number
min: number
name: string
- next_update?: Record
- server_name?: Record
+ next_update: Record
+ server_name: Record
to: string
- transfer?: Record
+ transfer: Record
}
export interface ModelDDNSForm {
access_id?: string
access_secret?: string
- domains?: string[]
+ domains: string[]
enable_ipv4?: boolean
enable_ipv6?: boolean
/** @default 3 */
- max_retries?: number
+ max_retries: number
/** @minLength 1 */
- name?: string
- provider?: string
+ name: string
+ provider: string
webhook_headers?: string
/** @default 1 */
webhook_method?: number
@@ -283,144 +315,139 @@ export interface ModelDDNSForm {
}
export interface ModelDDNSProfile {
- access_id?: string
- access_secret?: string
- created_at?: string
+ access_id: string
+ access_secret: string
+ created_at: string
domains: string[]
- enable_ipv4?: boolean
- enable_ipv6?: boolean
- id?: number
+ enable_ipv4: boolean
+ enable_ipv6: boolean
+ id: number
max_retries: number
name: string
provider: string
- updated_at?: string
- webhook_headers?: string
- webhook_method?: number
- webhook_request_body?: string
- webhook_request_type?: number
- webhook_url?: string
+ updated_at: string
+ webhook_headers: string
+ webhook_method: number
+ webhook_request_body: string
+ webhook_request_type: number
+ webhook_url: string
}
export interface ModelFrontendTemplate {
- author?: string
- is_admin?: boolean
- is_official?: boolean
- name?: string
- path?: string
- repository?: string
- version?: string
+ author: string
+ is_admin: boolean
+ is_official: boolean
+ name: string
+ path: string
+ repository: string
+ version: string
}
export interface ModelGeoIP {
- country_code?: string
- ip?: ModelIP
+ country_code: string
+ ip: ModelIP
}
export interface ModelHost {
- arch?: string
- boot_time?: number
- cpu?: string[]
- disk_total?: number
- gpu?: string[]
- mem_total?: number
- platform?: string
- platform_version?: string
- swap_total?: number
- version?: string
- virtualization?: string
+ arch: string
+ boot_time: number
+ cpu: string[]
+ disk_total: number
+ gpu: string[]
+ mem_total: number
+ platform: string
+ platform_version: string
+ swap_total: number
+ version: string
+ virtualization: string
}
export interface ModelHostState {
- cpu?: number
- disk_used?: number
- gpu?: number[]
- load_1?: number
- load_15?: number
- load_5?: number
- mem_used?: number
- net_in_speed?: number
- net_in_transfer?: number
- net_out_speed?: number
- net_out_transfer?: number
- process_count?: number
- swap_used?: number
- tcp_conn_count?: number
- temperatures?: ModelSensorTemperature[]
- udp_conn_count?: number
- uptime?: number
+ cpu: number
+ disk_used: number
+ gpu: number[]
+ load_1: number
+ load_15: number
+ load_5: number
+ mem_used: number
+ net_in_speed: number
+ net_in_transfer: number
+ net_out_speed: number
+ net_out_transfer: number
+ process_count: number
+ swap_used: number
+ tcp_conn_count: number
+ temperatures: ModelSensorTemperature[]
+ udp_conn_count: number
+ uptime: number
}
export interface ModelIP {
- ipv4_addr?: string
- ipv6_addr?: string
+ ipv4_addr: string
+ ipv6_addr: string
}
export interface ModelLoginRequest {
- password?: string
- username?: string
+ password: string
+ username: string
}
export interface ModelLoginResponse {
- expire?: string
- token?: string
+ expire: string
+ token: string
}
export interface ModelNAT {
- created_at?: string
+ created_at: string
domain: string
enabled: boolean
host: string
- id?: number
+ id: number
name: string
server_id: number
- updated_at?: string
+ updated_at: string
}
export interface ModelNATForm {
- domain?: string
- enabled?: boolean
- host?: string
+ domain: string
+ enabled: boolean
+ host: string
/** @minLength 1 */
- name?: string
- server_id?: number
+ name: string
+ server_id: number
}
export interface ModelNotification {
- created_at?: string
- id?: number
+ created_at: string
+ id: number
name: string
- type?: number
request_body: string
request_header: string
request_method: number
request_type: number
- updated_at?: string
+ updated_at: string
url: string
- verify_tls?: boolean
- skip_check?: boolean
- format_metric_units?: boolean
+ verify_tls: boolean
+ format_metric_units: boolean
}
export interface ModelNotificationForm {
/** @minLength 1 */
- name?: string
- type?: number
- request_body?: string
- request_header?: string
- request_method?: number
- request_type?: number
+ name: string
+ request_body: string
+ request_header: string
+ request_method: number
+ request_type: number
skip_check?: boolean
- url?: string
+ url: string
verify_tls?: boolean
- skip_check?: boolean
- format_metric_units?: boolean
}
export interface ModelNotificationGroup {
- created_at?: string
- id?: number
+ created_at: string
+ id: number
name: string
- updated_at?: string
+ updated_at: string
}
export interface ModelNotificationGroupForm {
@@ -435,52 +462,50 @@ export interface ModelNotificationGroupResponseItem {
}
export interface ModelOauth2LoginResponse {
- redirect?: string
+ redirect: string
}
export interface ModelOnlineUser {
- connected_at?: string
- ip?: string
- user_id?: number
+ connected_at: string
+ ip: string
+ user_id: number
+}
+
+export interface ModelOnlineUserApi {
+ value: ModelOnlineUser[]
+ pagination: {
+ total: number
+ offset: number
+ limit: number
+ }
}
export interface ModelPagination {
- limit?: number
- offset?: number
- total?: number
+ limit: number
+ offset: number
+ total: number
}
export interface ModelProfile {
- agent_secret?: string
- created_at?: string
- id?: number
- login_ip?: string
- oauth2_bind?: Record
- password?: string
- reject_password?: boolean
- role?: ModelRole
- updated_at?: string
- username?: string
+ agent_secret: string
+ created_at: string
+ id: number
+ login_ip: string
+ oauth2_bind: Record
+ password: string
+ reject_password: boolean
+ role: number
+ updated_at: string
+ username: string
}
export interface ModelProfileForm {
- new_password?: string
- new_username?: string
- original_password?: string
+ new_password: string
+ new_username: string
+ original_password: string
reject_password?: boolean
}
-export interface ModelBillingDataMod {
- registrar?: string
- registeredDate?: string
- endDate?: string
- renewalPrice?: string
- autoRenewal?: string
- notes?: string
- cycle?: string
- amount?: string
-}
-
export interface ModelRule {
/** 覆盖范围 RuleCoverAll/IgnoreAll */
cover: number
@@ -511,39 +536,49 @@ export interface ModelRule {
export interface ModelSensorTemperature {
name?: string
- /** @format float64 */
temperature?: number
}
+// ModelServerOwner mirrors model.ServerOwnerInfo from the backend. Server.id
+// is always present; username is omitted for the legacy uid=0 pseudo-owner
+// and for users that no longer exist in the User table.
+export interface ModelServerOwner {
+ id: number
+ username?: string
+}
+
export interface ModelServer {
- created_at?: string
+ created_at: string
/** DDNS配置 */
ddns_profiles?: number[]
/** 展示排序,越大越靠前 */
display_index: number
/** 启用DDNS */
- enable_ddns?: boolean
- geoip?: ModelGeoIP
+ enable_ddns: boolean
+ geoip: ModelGeoIP
/** 对游客隐藏 */
- hide_for_guest?: boolean
- host?: ModelHost
- id?: number
- last_active?: string
+ hide_for_guest: boolean
+ host: ModelHost
+ id: number
+ last_active: string
name: string
/** 管理员可见备注 */
- note?: string
+ note: string
override_ddns_domains?: Record
+ /** Owner identity from model.Server.MarshalJSON. Always present for
+ * current backends; optional here for compatibility with older
+ * dashboards that pre-date the owner field. */
+ owner?: ModelServerOwner
/** 公开备注 */
- public_note?: string
- state?: ModelHostState
- billing_data?: ModelBillingDataMod
- updated_at?: string
- uuid?: string
+ public_note: string
+ state: ModelHostState
+ updated_at: string
+ uuid: string
}
export interface ModelServerConfigForm {
- config?: string
- servers?: number[]
+ config: string
+ servers: number[]
}
export interface ModelServerForm {
@@ -553,25 +588,24 @@ export interface ModelServerForm {
* 展示排序,越大越靠前
* @default 0
*/
- display_index?: number
+ display_index: number
/** 启用DDNS */
enable_ddns?: boolean
/** 对游客隐藏 */
hide_for_guest?: boolean
- name?: string
+ name: string
/** 管理员可见备注 */
note?: string
override_ddns_domains?: Record
/** 公开备注 */
public_note?: string
- billing_data?: ModelBillingDataMod
}
export interface ModelServerGroup {
- created_at?: string
- id?: number
+ created_at: string
+ id: number
name: string
- updated_at?: string
+ updated_at: string
}
export interface ModelServerGroupForm {
@@ -593,48 +627,52 @@ export interface ModelServerTaskResponse {
export interface ModelService {
cover: number
- created_at?: string
+ created_at: string
+ /** 展示排序,越大越靠前 */
+ display_index: number
duration: number
- enable_show_in_service?: boolean
- enable_trigger_task?: boolean
+ enable_trigger_task: boolean
/** 失败时执行的触发任务id */
fail_trigger_tasks: number[]
- display_index: number
- id?: number
- latency_notify?: boolean
+ /** 对游客隐藏 */
+ hide_for_guest: boolean
+ id: number
+ latency_notify: boolean
max_latency: number
min_latency: number
name: string
/** 当前服务监控所属的通知组 ID */
notification_group_id: number
- notify?: boolean
+ notify: boolean
/** 恢复时执行的触发任务id */
recover_trigger_tasks: number[]
skip_servers: Record
target: string
type: number
- updated_at?: string
+ updated_at: string
}
export interface ModelServiceForm {
- cover?: number
- duration?: number
- enable_show_in_service?: boolean
+ cover: number
+ /** 展示排序,越大越靠前 */
+ display_index: number
+ duration: number
enable_trigger_task?: boolean
- fail_trigger_tasks?: number[]
+ fail_trigger_tasks: number[]
+ hide_for_guest?: boolean
latency_notify?: boolean
/** @default 0 */
- max_latency?: number
+ max_latency: number
/** @default 0 */
- min_latency?: number
+ min_latency: number
/** @minLength 1 */
- name?: string
- notification_group_id?: number
+ name: string
+ notification_group_id: number
notify?: boolean
- recover_trigger_tasks?: number[]
- skip_servers?: Record
- target?: string
- type?: number
+ recover_trigger_tasks: number[]
+ skip_servers: Record
+ target: string
+ type: number
}
export interface ModelServiceInfos {
@@ -647,151 +685,132 @@ export interface ModelServiceInfos {
}
export interface ModelServiceResponse {
- cycle_transfer_stats?: Record
- services?: Record
+ cycle_transfer_stats: Record
+ services: Record
}
export interface ModelServiceResponseItem {
current_down: number
current_up: number
- delay?: number[]
- down?: number[]
- service_name?: string
+ delay: number[]
+ down: number[]
+ service_name: string
total_down: number
total_up: number
- up?: number[]
+ up: number[]
}
export interface ModelSetting {
- admin_template?: string
+ admin_template: string
/** Agent真实IP */
- agent_real_ip_header?: string
+ agent_real_ip_header: string
/** 覆盖范围(0:提醒未被 IgnoredIPNotification 包含的所有服务器; 1:仅提醒被 IgnoredIPNotification 包含的服务器;) */
cover: number
- custom_code?: string
- custom_code_dashboard?: string
- dns_servers?: string
+ custom_code: string
+ custom_code_dashboard: string
+ dns_servers: string
/** IP变更提醒 */
- enable_ip_change_notification?: boolean
+ enable_ip_change_notification: boolean
/** 通知信息IP不打码 */
- enable_plain_ip_in_notification?: boolean
- expiry_notification_group_id?: number
+ enable_plain_ip_in_notification: boolean
+ /** 是否启用 MCP 入口(默认关闭) */
+ enable_mcp: boolean
/** 特定服务器IP(多个服务器用逗号分隔) */
- ignored_ip_notification?: string
- ignored_ip_notification_server_ids?: Record
- install_host?: string
+ ignored_ip_notification: string
+ ignored_ip_notification_server_ids: Record
+ install_host: string
+ dashboard_host: string
ip_change_notification_group_id: number
/** 系统语言,默认 zh_CN */
language: string
- oauth2_providers?: string[]
+ oauth2_providers: string[]
+ /** 反代部署下声明对外域名(逗号分隔),防止成员注册冲突 NAT 域名抢占路由 */
+ reserved_hosts: string
site_name: string
/** 用于前端判断生成的安装命令是否启用 TLS */
- tls?: boolean
- user_template?: string
- web_real_ip_header?: string
- custom_logo?: string
- custom_description?: string
- custom_links?: string
- background_image_day?: string
- background_image_night?: string
- telegram_bot_token?: string
- telegram_admin_chat_id?: string
- smtp_server?: string
- smtp_user?: string
- smtp_password?: string
- admin_email?: string
+ tls: boolean
+ user_template: string
+ /** 前端真实IP */
+ web_real_ip_header: string
}
export interface ModelSettingForm {
/** Agent真实IP */
agent_real_ip_header?: string
- cover?: number
+ cover: number
custom_code?: string
custom_code_dashboard?: string
dns_servers?: string
enable_ip_change_notification?: boolean
enable_plain_ip_in_notification?: boolean
- expiry_notification_group_id?: number
+ enable_mcp?: boolean
ignored_ip_notification?: string
install_host?: string
+ dashboard_host?: string
+ reserved_hosts?: string
/** IP变更提醒的通知组 */
- ip_change_notification_group_id?: number
+ ip_change_notification_group_id: number
/** @minLength 2 */
- language?: string
+ language: string
/** @minLength 1 */
- site_name?: string
+ site_name: string
tls?: boolean
user_template?: string
+ /** 前端真实IP */
web_real_ip_header?: string
- /** 自定义Logo链接 */
- custom_logo?: string
- /** 自定义描述/副标题 */
- custom_description?: string
- /** 自定义导航链接 (JSON) */
- custom_links?: string
- /** 白天背景图链接 */
- background_image_day?: string
- /** 夜间背景图链接 */
- background_image_night?: string
- telegram_bot_token?: string
- telegram_admin_chat_id?: string
- smtp_server?: string
- smtp_user?: string
- smtp_password?: string
- admin_email?: string
}
export interface ModelSettingResponse {
config: ModelSetting
- frontend_templates?: ModelFrontendTemplate[]
- version?: string
+ frontend_templates: ModelFrontendTemplate[]
+ version: string
}
export interface ModelStreamServer {
- country_code?: string
+ country_code: string
/** 展示排序,越大越靠前 */
- display_index?: number
- host?: ModelHost
- id?: number
- last_active?: string
- name?: string
+ display_index: number
+ host: ModelHost
+ id: number
+ last_active: string
+ name: string
/** 公开备注,只第一个数据包有值 */
- public_note?: string
- state?: ModelHostState
+ public_note: string
+ state: ModelHostState
}
export interface ModelStreamServerData {
- now?: number
- online?: number
- servers?: ModelStreamServer[]
+ now: number
+ online: number
+ servers: ModelStreamServer[]
}
export interface ModelTerminalForm {
- protocol?: string
- server_id?: number
+ protocol: string
+ server_id: number
}
export interface ModelUser {
- agent_secret?: string
- created_at?: string
- id?: number
- password?: string
- reject_password?: boolean
- role?: ModelRole
- updated_at?: string
- username?: string
+ agent_secret: string
+ created_at: string
+ id: number
+ password: string
+ reject_password: boolean
+ role: number
+ updated_at: string
+ username: string
}
export interface ModelUserForm {
- password?: string
- role?: ModelRole
- username?: string
+ password: string
+ role: number
+ username: string
}
export interface ModelWAFApiMock {
- block_identifier?: number
- block_reason?: number
- block_timestamp?: number
- count?: number
- ip?: string
+ block_identifier: number
+ block_reason: number
+ block_timestamp: number
+ count: number
+ ip: string
}
diff --git a/src/types/authContext.ts b/src/types/authContext.ts
index 53a8d99..13aee6d 100644
--- a/src/types/authContext.ts
+++ b/src/types/authContext.ts
@@ -2,6 +2,7 @@ import { ModelProfile } from "@/types"
export interface AuthContextProps {
profile: ModelProfile | undefined
+ loading: boolean
login: (username: string, password: string) => void
loginOauth2: () => void
logout: () => void
diff --git a/src/types/server.ts b/src/types/server.ts
index dfbf7bb..b06d159 100644
--- a/src/types/server.ts
+++ b/src/types/server.ts
@@ -16,7 +16,7 @@ export const AgentConfigSchema = z.object({
try {
JSON.parse(val)
return true
- } catch (e) {
+ } catch {
return false
}
},
@@ -33,7 +33,7 @@ export const AgentConfigSchema = z.object({
try {
JSON.parse(val)
return true
- } catch (e) {
+ } catch {
return false
}
},
diff --git a/src/types/settings.ts b/src/types/settings.ts
index 19dce00..ee75d53 100644
--- a/src/types/settings.ts
+++ b/src/types/settings.ts
@@ -16,6 +16,10 @@ export const nezhaLang: Record = {
"it-IT": "Italiano",
"fr-FR": "Français",
"id-ID": "Bahasa Indonesia",
+ "ja-JP": "日本語",
+ "ro-RO": "Română",
+ "uk-UA": "Українська",
+ "gl-ES": "Galego",
}
export const wafBlockReasons: Record = {
diff --git a/tests/e2e/agent-dashboard.spec.ts b/tests/e2e/agent-dashboard.spec.ts
new file mode 100644
index 0000000..8437879
--- /dev/null
+++ b/tests/e2e/agent-dashboard.spec.ts
@@ -0,0 +1,50 @@
+import { expect } from "@playwright/test"
+
+import { test } from "./fixtures"
+
+type AgentServer = {
+ host?: { platform?: string }
+ id: number
+ last_active?: string
+ name: string
+ uuid: string
+}
+
+test("a real agent reports to the dashboard and is selectable in the server table", async ({
+ adminPage: page,
+}) => {
+ const agentUUID = process.env.E2E_AGENT_UUID
+ expect(agentUUID, "E2E_AGENT_UUID must identify the Agent started by CI").toBeTruthy()
+
+ let agentServer: AgentServer | undefined
+ await expect
+ .poll(
+ async () => {
+ const response = await page.request.get("/api/v1/server")
+ if (!response.ok()) return false
+
+ const body = (await response.json()) as { data?: AgentServer[] }
+ agentServer = body.data?.find((server) => server.uuid === agentUUID)
+ if (!agentServer?.last_active || !agentServer.host?.platform) return false
+
+ return new Date(agentServer.last_active).getTime() > 0
+ },
+ {
+ message: "the real Agent must register and report live host state",
+ timeout: 30_000,
+ },
+ )
+ .toBe(true)
+
+ await page.goto("/dashboard")
+
+ const row = page.getByRole("row").filter({
+ has: page.getByText(agentServer!.name, { exact: true }),
+ })
+ await expect(row).toHaveCount(1)
+
+ const selection = row.getByRole("checkbox", { name: "Select row" })
+ await expect(selection).not.toBeChecked()
+ await selection.click()
+ await expect(selection).toBeChecked()
+})
diff --git a/tests/e2e/api-tokens.spec.ts b/tests/e2e/api-tokens.spec.ts
new file mode 100644
index 0000000..033fc6e
--- /dev/null
+++ b/tests/e2e/api-tokens.spec.ts
@@ -0,0 +1,139 @@
+import { expect } from "@playwright/test"
+
+import { csrfHeaders, 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 dialog.getByRole("button", { name: /Create API token|创建 API 令牌/ }).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}`, {
+ headers: await csrfHeaders(page),
+ })
+ 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", {
+ headers: await csrfHeaders(page),
+ 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)
+
+ // The list endpoint omits `data` entirely when the admin has zero tokens,
+ // so default to [] before searching for the revoked id.
+ const after = await page.request.get("/api/v1/api-tokens").then((r) => r.json())
+ const tokens: Array<{ id: number }> = after.data ?? []
+ expect(tokens.find((t) => t.id === tokenID)).toBeUndefined()
+})
+
+test("an API token can authenticate /mcp", async ({ page }) => {
+ await loginAs(page, defaultAdmin)
+ await page.goto("/dashboard/settings/api-tokens")
+
+ // EnableMCP defaults to false: snapshot + enable + restore so the test is hermetic.
+ const settingBefore = (await page.request.get("/api/v1/setting").then((r) => r.json())).data
+ ?.config as Record & {
+ site_name?: string
+ user_template?: string
+ enable_mcp?: boolean
+ }
+ const baseSettings: Record = {
+ ...settingBefore,
+ site_name: settingBefore?.site_name || "Nezha",
+ user_template: settingBefore?.user_template || "user-dist",
+ }
+ const enableResp = await page.request.patch("/api/v1/setting", {
+ headers: await csrfHeaders(page),
+ 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", {
+ headers: await csrfHeaders(page),
+ 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}`, {
+ headers: await csrfHeaders(page),
+ })
+ expect(delResp.ok()).toBeTruthy()
+ } finally {
+ await page.request.patch("/api/v1/setting", {
+ headers: await csrfHeaders(page),
+ data: { ...baseSettings, enable_mcp: !!settingBefore?.enable_mcp },
+ })
+ }
+})
diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts
new file mode 100644
index 0000000..17aca38
--- /dev/null
+++ b/tests/e2e/auth.spec.ts
@@ -0,0 +1,67 @@
+import { expect } from "@playwright/test"
+
+import {
+ csrfRequest,
+ defaultAdmin,
+ expectAuthenticated,
+ expectUnauthenticated,
+ loginAs,
+ test,
+} from "./fixtures"
+
+test("login persists session via cookie and getProfile succeeds", async ({ page }) => {
+ await loginAs(page, defaultAdmin)
+ await expectAuthenticated(page)
+})
+
+test("password change rotates TokenVersion and revokes existing session", async ({ page }) => {
+ await loginAs(page, defaultAdmin)
+
+ const originalCookies = await page.context().cookies()
+ const originalJWT = originalCookies.find((c) => c.name === "nz-jwt")?.value
+ expect(originalJWT, "login must set nz-jwt cookie").toBeTruthy()
+
+ const newPassword = `e2e-${Date.now().toString(36)}`
+ let isOnNewPassword = false
+ try {
+ const resp = await csrfRequest(page, "post", "/api/v1/profile", {
+ data: {
+ original_password: defaultAdmin.password,
+ new_password: newPassword,
+ new_username: defaultAdmin.username,
+ reject_password: false,
+ },
+ })
+ expect(resp.ok(), "profile update must succeed").toBeTruthy()
+ isOnNewPassword = true
+
+ await page.context().clearCookies()
+ if (originalJWT) {
+ await page.context().addCookies([
+ {
+ name: "nz-jwt",
+ value: originalJWT,
+ url: page.url() || "http://localhost:5173",
+ },
+ ])
+ }
+ await expectUnauthenticated(page)
+ } finally {
+ if (isOnNewPassword) {
+ await page.context().clearCookies()
+ await loginAs(page, { username: defaultAdmin.username, password: newPassword })
+ const restoreResp = await csrfRequest(page, "post", "/api/v1/profile", {
+ data: {
+ original_password: newPassword,
+ new_password: defaultAdmin.password,
+ new_username: defaultAdmin.username,
+ reject_password: false,
+ },
+ })
+ expect(
+ restoreResp.ok(),
+ "password restore must succeed so other suites can still log in",
+ ).toBeTruthy()
+ }
+ }
+})
diff --git a/tests/e2e/cron-csrf.spec.ts b/tests/e2e/cron-csrf.spec.ts
new file mode 100644
index 0000000..7c5f746
--- /dev/null
+++ b/tests/e2e/cron-csrf.spec.ts
@@ -0,0 +1,40 @@
+import { expect } from "@playwright/test"
+
+import { csrfRequest, test } from "./fixtures"
+
+test("manual cron trigger goes through POST, not GET", async ({ adminPage: page }) => {
+ const created = await csrfRequest(page, "post", "/api/v1/cron", {
+ data: {
+ name: `e2e-cron-csrf-${Date.now().toString(36)}`,
+ task_type: 0,
+ scheduler: "@every 1h",
+ command: "true",
+ servers: [],
+ cover: 0,
+ push_successful: false,
+ notification_group_id: 0,
+ },
+ })
+ expect(created.ok(), "create cron via POST must succeed").toBeTruthy()
+ const { data: cronID } = (await created.json()) as { data: number }
+ expect(typeof cronID).toBe("number")
+
+ try {
+ const getResp = await page.request.get(`/api/v1/cron/${cronID}/manual`, {
+ failOnStatusCode: false,
+ })
+ expect(
+ getResp.status() === 404 || getResp.status() === 405,
+ `GET must no longer be routable (got ${getResp.status()})`,
+ ).toBeTruthy()
+
+ const postResp = await csrfRequest(page, "post", `/api/v1/cron/${cronID}/manual`)
+ expect(postResp.ok(), `POST must succeed (got ${postResp.status()})`).toBeTruthy()
+ const body = await postResp.json()
+ expect(body.success).toBe(true)
+ } finally {
+ await csrfRequest(page, "post", "/api/v1/batch-delete/cron", {
+ data: [cronID],
+ })
+ }
+})
diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts
new file mode 100644
index 0000000..dfdbedc
--- /dev/null
+++ b/tests/e2e/fixtures.ts
@@ -0,0 +1,106 @@
+import { Page, Request, test as base, expect } from "@playwright/test"
+
+export type LoginContext = {
+ username: string
+ password: string
+}
+
+export const defaultAdmin: LoginContext = {
+ username: process.env.E2E_ADMIN_USER || "admin",
+ password: process.env.E2E_ADMIN_PASS || "admin",
+}
+
+export async function loginAs(page: Page, creds: LoginContext) {
+ await page.goto("/dashboard/login")
+ await page.locator('input[autocomplete="username"]').fill(creds.username)
+ await page.locator('input[autocomplete="current-password"]').fill(creds.password)
+ await page.locator('button[type="submit"]').click()
+ await page.waitForURL(/\/dashboard\/?(?:$|\?|#)/, { timeout: 10_000 })
+ // Block until the signed nz-csrf cookie is readable. csrfHeaders() reads it
+ // synchronously; without this wait a mutating request fired right after
+ // login can race the Set-Cookie and send no token, getting a 403.
+ await expect
+ .poll(async () => (await page.context().cookies()).some((c) => c.name === "nz-csrf"), {
+ timeout: 10_000,
+ })
+ .toBe(true)
+}
+
+export async function logout(page: Page) {
+ await page.context().clearCookies()
+}
+
+// csrfHeaders mirrors the signed nz-csrf cookie into the X-CSRF-Token header.
+// The backend's double-submit CSRF gate rejects unsafe methods unless the two
+// match; the SPA does this in api.ts, but page.request bypasses that JS, so
+// E2E mutating calls must replicate it or every POST/PATCH/DELETE gets 403.
+export async function csrfHeaders(page: Page): Promise> {
+ let value = ""
+ await expect
+ .poll(
+ async () => {
+ value =
+ (await page.context().cookies()).find((c) => c.name === "nz-csrf")?.value ?? ""
+ return value
+ },
+ { timeout: 10_000 },
+ )
+ .not.toBe("")
+ return { "X-CSRF-Token": value }
+}
+
+// csrfRequest issues a mutating request with the X-CSRF-Token header, retrying
+// once if the backend re-mints the nz-csrf cookie between read and send. A
+// password change triggers a refresh-token that rotates the cookie, so a single
+// read can race the new value and 403; re-reading on 403 closes that window.
+export async function csrfRequest(
+ page: Page,
+ method: "post" | "patch" | "delete" | "put",
+ url: string,
+ options: { data?: unknown; failOnStatusCode?: boolean } = {},
+): Promise {
+ let resp = await page.request[method](url, {
+ ...options,
+ headers: await csrfHeaders(page),
+ })
+ if (resp.status() === 403) {
+ resp = await page.request[method](url, {
+ ...options,
+ headers: await csrfHeaders(page),
+ })
+ }
+ return resp
+}
+
+export async function expectAuthenticated(page: Page) {
+ const resp = await page.request.get("/api/v1/profile")
+ expect(resp.status(), "profile must respond 2xx while authenticated").toBeLessThan(400)
+ const body = await resp.json()
+ expect(body.success, "profile.success must be true").toBe(true)
+ expect(body.data?.id, "profile.data.id must be present").toBeTruthy()
+}
+
+export async function expectUnauthenticated(page: Page) {
+ const resp = await page.request.get("/api/v1/profile")
+ const body = await resp.json()
+ expect(body.success, "profile must NOT be authorized after revoke").not.toBe(true)
+ expect(body.error, "profile must surface an error after revoke").toBeTruthy()
+}
+
+export async function findRequest(
+ page: Page,
+ matcher: (req: Request) => boolean,
+ trigger: () => Promise,
+ timeoutMs = 5000,
+): Promise {
+ const waiter = page.waitForRequest(matcher, { timeout: timeoutMs })
+ await trigger()
+ return await waiter
+}
+
+export const test = base.extend<{ adminPage: Page }>({
+ adminPage: async ({ page }, use) => {
+ await loginAs(page, defaultAdmin)
+ await use(page)
+ },
+})
diff --git a/tests/e2e/fm-csrf.spec.ts b/tests/e2e/fm-csrf.spec.ts
new file mode 100644
index 0000000..c894eb8
--- /dev/null
+++ b/tests/e2e/fm-csrf.spec.ts
@@ -0,0 +1,28 @@
+import { expect } from "@playwright/test"
+
+import { csrfRequest, test } from "./fixtures"
+
+test("file manager creation only accepts POST and reaches the connected Agent", async ({
+ adminPage: page,
+}) => {
+ const getResp = await page.request.get("/api/v1/file?id=1", {
+ failOnStatusCode: false,
+ })
+ expect(
+ getResp.status() === 404 || getResp.status() === 405,
+ `GET /api/v1/file must no longer be routable (got ${getResp.status()})`,
+ ).toBeTruthy()
+
+ const postResp = await csrfRequest(page, "post", "/api/v1/file?id=1", {
+ failOnStatusCode: false,
+ })
+ expect(postResp.status()).toBe(200)
+ const body = (await postResp.json()) as {
+ data?: { session_id?: string }
+ success?: boolean
+ }
+ expect(body.success, "POST must create a file-manager session through the real Agent").toBe(
+ true,
+ )
+ expect(body.data?.session_id).toBeTruthy()
+})
diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json
new file mode 100644
index 0000000..2700d6d
--- /dev/null
+++ b/tests/e2e/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2023", "DOM"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "isolatedModules": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": ["@playwright/test"]
+ },
+ "include": ["**/*.ts"]
+}
diff --git a/tests/e2e/visibility.spec.ts b/tests/e2e/visibility.spec.ts
new file mode 100644
index 0000000..36bb850
--- /dev/null
+++ b/tests/e2e/visibility.spec.ts
@@ -0,0 +1,68 @@
+import { expect } from "@playwright/test"
+
+import { csrfHeaders, test } from "./fixtures"
+
+test("server-group hides guest-empty groups from anonymous callers", async ({
+ adminPage: page,
+ browser,
+}) => {
+ const tag = Date.now().toString(36)
+ const visibleName = `e2e-visible-${tag}`
+ const hiddenName = `e2e-hidden-${tag}`
+
+ // Admin creates one group with no servers (will be guest-empty) and one with a public server.
+ // First make sure there's at least one public server visible to guests; if not, this whole
+ // scenario boils down to "no group is guest-visible", which the assertion below still covers.
+ const serversResp = await page.request.get("/api/v1/server")
+ expect(serversResp.ok()).toBeTruthy()
+ const serversBody = (await serversResp.json()) as {
+ data: Array<{ id: number; hide_for_guest?: boolean }>
+ }
+ const publicServer = serversBody.data?.find((s) => !s.hide_for_guest)
+
+ const createdGroupIDs: number[] = []
+ if (publicServer) {
+ const visibleResp = await page.request.post("/api/v1/server-group", {
+ headers: await csrfHeaders(page),
+ data: { name: visibleName, servers: [publicServer.id] },
+ })
+ expect(visibleResp.ok()).toBeTruthy()
+ createdGroupIDs.push(((await visibleResp.json()) as { data: number }).data)
+ }
+ const hiddenResp = await page.request.post("/api/v1/server-group", {
+ headers: await csrfHeaders(page),
+ data: { name: hiddenName, servers: [] },
+ })
+ expect(hiddenResp.ok()).toBeTruthy()
+ createdGroupIDs.push(((await hiddenResp.json()) as { data: number }).data)
+
+ try {
+ const guestCtx = await browser.newContext()
+ try {
+ const guestResp = await guestCtx.request.get("/api/v1/server-group")
+ expect(guestResp.ok()).toBeTruthy()
+ const guestBody = (await guestResp.json()) as {
+ data: Array<{ group: { name: string }; servers: number[] }>
+ }
+ const names = (guestBody.data || []).map((it) => it.group.name)
+ expect(names, "guest must NOT see groups with zero visible servers").not.toContain(
+ hiddenName,
+ )
+ if (publicServer) {
+ expect(
+ names,
+ "guest still sees groups that contain a guest-visible server",
+ ).toContain(visibleName)
+ }
+ } finally {
+ await guestCtx.close()
+ }
+ } finally {
+ if (createdGroupIDs.length > 0) {
+ await page.request.post("/api/v1/batch-delete/server-group", {
+ headers: await csrfHeaders(page),
+ data: createdGroupIDs,
+ })
+ }
+ }
+})
diff --git a/tsconfig.app.json b/tsconfig.app.json
index bd905d3..59c9a1a 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -1,6 +1,7 @@
{
"compilerOptions": {
- "baseUrl": ".",
+ // TypeScript 7 resolves paths relative to this config; baseUrl was removed.
+
"paths": {
"@/*": ["./src/*"]
},
@@ -24,5 +25,8 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
- "include": ["src"]
+ "include": ["src"],
+ "exclude": ["src/test"]
}
+
+
diff --git a/tsconfig.json b/tsconfig.json
index d82983e..3f66b48 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -9,7 +9,8 @@
}
],
"compilerOptions": {
- "baseUrl": ".",
+ // TypeScript 7 resolves paths relative to this config; baseUrl was removed.
+
"paths": {
"@/*": ["./src/*"]
}
diff --git a/vite.config.ts b/vite.config.ts
index 8d3179c..97319c1 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -6,14 +6,22 @@ export default defineConfig({
base: "/dashboard",
plugins: [react()],
server: {
+ // Bind the dev server to loopback so an in-browser CSRF or LAN
+ // scan cannot reach a developer's dashboard token. Override with
+ // `bun run dev -- --host 0.0.0.0` for Docker / remote dev only.
+ host: "127.0.0.1",
proxy: {
"^/api/v1/ws/.*": {
- target: "ws://localhost:8008",
+ target: "ws://127.0.0.1:8008",
changeOrigin: true,
ws: true,
},
"/api": {
- target: "http://localhost:8008",
+ target: "http://127.0.0.1:8008",
+ changeOrigin: true,
+ },
+ "/mcp": {
+ target: "http://127.0.0.1:8008",
changeOrigin: true,
},
},
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..9974b93
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig, mergeConfig } from "vitest/config"
+
+import viteConfig from "./vite.config.ts"
+
+export default mergeConfig(
+ viteConfig,
+ defineConfig({
+ test: {
+ environment: "jsdom",
+ exclude: ["tests/e2e/**", "node_modules/**", "dist/**"],
+ globals: true,
+ setupFiles: ["./src/test/setup.ts"],
+ },
+ }),
+)