feat(admin): 管理端接登录 + Bearer 鉴权 + 修雪花ID/探活(适配硬化后的网关)

/admin 加 RequireAdmin 后管理端原来无鉴权会 401。本次打通:
- api.ts:JWT token 存 localStorage + 所有 /admin 调用带 Bearer + 401 清令牌广播登出;
  login/me;gatewayOnline 改用公开 /healthz(原 /billing 已转受保护会误判离线)。
- 修类型:Model.id number → string(模型 id 早已迁雪花字符串)。
- Login 登录门 + App 鉴权门(启动校验 me,无则登录页)+ AppShell 显示用户/登出。

实测(硬化网关):无 token /admin/models → 401;登录拿 token → 200 返回模型(string id)。
dev 未配 ADMIN_USER_IDS 时任意登录账号放行;生产须在白名单。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-19 11:09:02 +08:00
parent 8f619c2a62
commit 030dcda9b4
4 changed files with 185 additions and 28 deletions
+29 -1
View File
@@ -1,11 +1,39 @@
import { useEffect, useState } from "react";
import { HashRouter } from "react-router-dom";
import { AppShell } from "./shell/AppShell";
import { Login } from "./Login";
import { me, clearToken, type AuthUser } from "./api";
// 用 HashRouter:纯静态托管/桌面内嵌都能深链,无需服务端路由配置。
export default function App() {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
me()
.then(setUser)
.catch(() => setUser(null))
.finally(() => setLoading(false));
const onLogout = () => setUser(null);
window.addEventListener("sdx:logout", onLogout);
return () => window.removeEventListener("sdx:logout", onLogout);
}, []);
if (loading) {
return <div className="flex h-screen w-screen items-center justify-center text-sm text-gray-400"></div>;
}
if (!user) {
return <Login onAuthed={setUser} />;
}
return (
<HashRouter>
<AppShell />
<AppShell
user={user}
onLogout={() => {
clearToken();
setUser(null);
}}
/>
</HashRouter>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useState } from "react";
import { login, type AuthUser } from "./api";
// 管理端登录门:登录成功回调 onAuthed。需管理员账号(生产须在 ADMIN_USER_IDS 白名单)。
export function Login({ onAuthed }: { onAuthed: (u: AuthUser) => void }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const submit = async () => {
if (busy || !email.trim() || !password) return;
setErr("");
setBusy(true);
try {
onAuthed(await login(email.trim(), password));
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
};
return (
<div className="flex h-screen w-screen items-center justify-center bg-gray-50 text-gray-900">
<div className="w-[340px] rounded-xl border bg-white p-6 shadow-sm">
<div className="text-sm font-bold text-gray-800">sundynix-agentix</div>
<div className="mb-5 text-[11px] text-gray-400"> · </div>
<label className="mb-1 block text-xs text-gray-500"></label>
<input
className="mb-3 w-full rounded border px-3 py-2 text-sm focus:border-violet-500 focus:outline-none"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@example.com"
autoFocus
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
<label className="mb-1 block text-xs text-gray-500"></label>
<input
className="mb-4 w-full rounded border px-3 py-2 text-sm focus:border-violet-500 focus:outline-none"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
{err && <div className="mb-3 rounded bg-rose-50 px-3 py-2 text-xs text-rose-600">{err}</div>}
<button
className="w-full rounded bg-violet-600 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:opacity-50"
disabled={busy || !email.trim() || !password}
onClick={submit}
>
{busy ? "登录中…" : "登录"}
</button>
<p className="mt-3 text-[10px] leading-relaxed text-gray-400">
ADMIN_USER_IDS
</p>
</div>
</div>
);
}
+83 -22
View File
@@ -1,12 +1,80 @@
// 运维控制台 → Gateway 控制面 API。
// 运维控制台 → Gateway 控制面 API(带 JWT 鉴权;/admin 受 RequireAdmin 保护)
export const GATEWAY: string =
(import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080";
const ADMIN = `${GATEWAY}/api/v1/admin`;
// ---- 鉴权(JWT,存 localStorage----
const TOKEN_KEY = "sdx_admin_token";
let token = typeof localStorage !== "undefined" ? localStorage.getItem(TOKEN_KEY) ?? "" : "";
export function setToken(t: string): void {
token = t;
try {
localStorage.setItem(TOKEN_KEY, t);
} catch {
/* ignore */
}
}
export function clearToken(): void {
token = "";
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
/* ignore */
}
}
export function getToken(): string {
return token;
}
function authHeaders(json = false): Record<string, string> {
const h: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
if (json) h["Content-Type"] = "application/json";
return h;
}
// guard 在 401(未登录) 时清令牌并广播登出(403=已登录但非管理员,照常抛错)。
function guard(res: Response): Response {
if (res.status === 401) {
clearToken();
if (typeof window !== "undefined") window.dispatchEvent(new Event("sdx:logout"));
}
return res;
}
export interface AuthUser {
id: string;
email: string;
name?: string;
}
export async function login(email: string, password: string): Promise<AuthUser> {
const res = await fetch(`${GATEWAY}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = (await res.json()) as { token?: string; user?: AuthUser; error?: string };
if (!res.ok || !data.token || !data.user) throw new Error(data.error ?? `登录失败: ${res.status}`);
setToken(data.token);
return data.user;
}
export async function me(): Promise<AuthUser | null> {
if (!token) return null;
const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: authHeaders() });
if (!res.ok) {
clearToken();
return null;
}
return ((await res.json()) as { user?: AuthUser }).user ?? null;
}
// ---- 模型配置(id 为雪花字符串)----
export type Kind = "chat" | "embedding";
export interface Model {
id: number;
id: string;
kind: Kind;
provider: string;
base_url: string;
@@ -16,7 +84,7 @@ export interface Model {
}
export interface ModelInput {
id?: number;
id?: string;
kind: Kind;
provider: string;
base_url: string;
@@ -25,44 +93,37 @@ export interface ModelInput {
}
export async function listModels(kind: Kind): Promise<Model[]> {
const res = await fetch(`${ADMIN}/models?kind=${kind}`);
const res = guard(await fetch(`${ADMIN}/models?kind=${kind}`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`list failed: ${res.status}`);
return ((await res.json()) as { models: Model[] }).models;
}
export async function saveModel(m: ModelInput): Promise<number> {
const res = await fetch(`${ADMIN}/models`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(m),
});
const data = (await res.json()) as { id?: number; error?: string };
export async function saveModel(m: ModelInput): Promise<string> {
const res = guard(await fetch(`${ADMIN}/models`, { method: "POST", headers: authHeaders(true), body: JSON.stringify(m) }));
const data = (await res.json()) as { id?: string; error?: string };
if (!res.ok) throw new Error(data.error ?? `save failed: ${res.status}`);
return data.id ?? 0;
return data.id ?? "";
}
export async function setActive(id: number): Promise<void> {
const res = await fetch(`${ADMIN}/models/${id}/active`, { method: "POST" });
export async function setActive(id: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/models/${id}/active`, { method: "POST", headers: authHeaders() }));
if (!res.ok) throw new Error(`activate failed: ${res.status}`);
}
export async function deleteModel(id: number): Promise<void> {
const res = await fetch(`${ADMIN}/models/${id}`, { method: "DELETE" });
export async function deleteModel(id: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/models/${id}`, { method: "DELETE", headers: authHeaders() }));
if (!res.ok) throw new Error(`delete failed: ${res.status}`);
}
export async function testModel(m: ModelInput): Promise<{ ok: boolean; message: string }> {
const res = await fetch(`${ADMIN}/models/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(m),
});
const res = guard(await fetch(`${ADMIN}/models/test`, { method: "POST", headers: authHeaders(true), body: JSON.stringify(m) }));
return (await res.json()) as { ok: boolean; message: string };
}
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
export async function gatewayOnline(): Promise<boolean> {
try {
const res = await fetch(`${GATEWAY}/api/v1/billing`);
const res = await fetch(`${GATEWAY}/healthz`);
return res.ok;
} catch {
return false;
+9 -3
View File
@@ -2,10 +2,10 @@ import { Suspense, useEffect, useState } from "react";
import { NavLink, Routes, Route, Navigate, useLocation } from "react-router-dom";
import { routes, navGroups, defaultPath } from "../routes";
import { gatewayOnline } from "../api";
import { gatewayOnline, type AuthUser } from "../api";
// 控制台外壳:导航与内容均由路由注册表派生(动态路由)。
export function AppShell() {
export function AppShell({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
const [online, setOnline] = useState(false);
const loc = useLocation();
const current = routes.find((r) => r.path === loc.pathname);
@@ -45,10 +45,16 @@ export function AppShell() {
</div>
))}
</nav>
<div className="mt-auto flex items-center gap-2 border-t p-4 text-[11px] text-gray-500">
<div className="mt-auto border-t p-4 text-[11px] text-gray-500">
<div className="mb-2 flex items-center justify-between">
<span className="truncate text-gray-600" title={user.email}>{user.name || user.email}</span>
<button onClick={onLogout} className="text-gray-400 hover:text-rose-600"></button>
</div>
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${online ? "bg-emerald-500" : "bg-rose-500"}`} />
Gateway {online ? "在线" : "离线"}
</div>
</div>
</aside>
<main className="flex-1 overflow-auto p-6">