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 { HashRouter } from "react-router-dom";
|
||||||
import { AppShell } from "./shell/AppShell";
|
import { AppShell } from "./shell/AppShell";
|
||||||
|
import { Login } from "./Login";
|
||||||
|
import { me, clearToken, type AuthUser } from "./api";
|
||||||
|
|
||||||
// 用 HashRouter:纯静态托管/桌面内嵌都能深链,无需服务端路由配置。
|
// 用 HashRouter:纯静态托管/桌面内嵌都能深链,无需服务端路由配置。
|
||||||
export default function App() {
|
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 (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<AppShell />
|
<AppShell
|
||||||
|
user={user}
|
||||||
|
onLogout={() => {
|
||||||
|
clearToken();
|
||||||
|
setUser(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</HashRouter>
|
</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 =
|
export const GATEWAY: string =
|
||||||
(import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080";
|
(import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080";
|
||||||
const ADMIN = `${GATEWAY}/api/v1/admin`;
|
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 type Kind = "chat" | "embedding";
|
||||||
|
|
||||||
export interface Model {
|
export interface Model {
|
||||||
id: number;
|
id: string;
|
||||||
kind: Kind;
|
kind: Kind;
|
||||||
provider: string;
|
provider: string;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
@@ -16,7 +84,7 @@ export interface Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelInput {
|
export interface ModelInput {
|
||||||
id?: number;
|
id?: string;
|
||||||
kind: Kind;
|
kind: Kind;
|
||||||
provider: string;
|
provider: string;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
@@ -25,44 +93,37 @@ export interface ModelInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listModels(kind: Kind): Promise<Model[]> {
|
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}`);
|
if (!res.ok) throw new Error(`list failed: ${res.status}`);
|
||||||
return ((await res.json()) as { models: Model[] }).models;
|
return ((await res.json()) as { models: Model[] }).models;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveModel(m: ModelInput): Promise<number> {
|
export async function saveModel(m: ModelInput): Promise<string> {
|
||||||
const res = await fetch(`${ADMIN}/models`, {
|
const res = guard(await fetch(`${ADMIN}/models`, { method: "POST", headers: authHeaders(true), body: JSON.stringify(m) }));
|
||||||
method: "POST",
|
const data = (await res.json()) as { id?: string; error?: string };
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(m),
|
|
||||||
});
|
|
||||||
const data = (await res.json()) as { id?: number; error?: string };
|
|
||||||
if (!res.ok) throw new Error(data.error ?? `save failed: ${res.status}`);
|
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> {
|
export async function setActive(id: string): Promise<void> {
|
||||||
const res = await fetch(`${ADMIN}/models/${id}/active`, { method: "POST" });
|
const res = guard(await fetch(`${ADMIN}/models/${id}/active`, { method: "POST", headers: authHeaders() }));
|
||||||
if (!res.ok) throw new Error(`activate failed: ${res.status}`);
|
if (!res.ok) throw new Error(`activate failed: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteModel(id: number): Promise<void> {
|
export async function deleteModel(id: string): Promise<void> {
|
||||||
const res = await fetch(`${ADMIN}/models/${id}`, { method: "DELETE" });
|
const res = guard(await fetch(`${ADMIN}/models/${id}`, { method: "DELETE", headers: authHeaders() }));
|
||||||
if (!res.ok) throw new Error(`delete failed: ${res.status}`);
|
if (!res.ok) throw new Error(`delete failed: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testModel(m: ModelInput): Promise<{ ok: boolean; message: string }> {
|
export async function testModel(m: ModelInput): Promise<{ ok: boolean; message: string }> {
|
||||||
const res = await fetch(`${ADMIN}/models/test`, {
|
const res = guard(await fetch(`${ADMIN}/models/test`, { method: "POST", headers: authHeaders(true), body: JSON.stringify(m) }));
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(m),
|
|
||||||
});
|
|
||||||
return (await res.json()) as { ok: boolean; message: string };
|
return (await res.json()) as { ok: boolean; message: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
|
||||||
export async function gatewayOnline(): Promise<boolean> {
|
export async function gatewayOnline(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${GATEWAY}/api/v1/billing`);
|
const res = await fetch(`${GATEWAY}/healthz`);
|
||||||
return res.ok;
|
return res.ok;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { Suspense, useEffect, useState } from "react";
|
|||||||
import { NavLink, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
import { NavLink, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { routes, navGroups, defaultPath } from "../routes";
|
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 [online, setOnline] = useState(false);
|
||||||
const loc = useLocation();
|
const loc = useLocation();
|
||||||
const current = routes.find((r) => r.path === loc.pathname);
|
const current = routes.find((r) => r.path === loc.pathname);
|
||||||
@@ -45,9 +45,15 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</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">
|
||||||
<span className={`h-2 w-2 rounded-full ${online ? "bg-emerald-500" : "bg-rose-500"}`} />
|
<div className="mb-2 flex items-center justify-between">
|
||||||
Gateway {online ? "在线" : "离线"}
|
<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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user