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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,9 +45,15 @@ export function AppShell() {
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-auto flex items-center gap-2 border-t p-4 text-[11px] text-gray-500">
|
||||
<span className={`h-2 w-2 rounded-full ${online ? "bg-emerald-500" : "bg-rose-500"}`} />
|
||||
Gateway {online ? "在线" : "离线"}
|
||||
<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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user