feat(auth): 鉴权片2 —— 前端登录闭环 + 保护路由 + 去掉 header 兜底
把 JWT 鉴权从后端核心闭环到端到端: 后端: - middleware.RequireAuth:上下文无已验证 uid 则 401;挂在 owner 作用域业务路由组。 - 路由拆 公开/受保护:公开=auth/health + 按 task_id 寻址的 SSE 与报告导出 (EventSource/下载无法带 Bearer);受保护=tasks/memory/kb*/agents/reports/billing。 - userID(c) 去掉 X-User-ID 兜底,仅信任 JWT 注入的 uid。 - 修 CORS:Allow-Headers 增 Authorization(否则浏览器拦截带 Bearer 的请求)。 前端: - lib/api:token 存 localStorage + Bearer 头(不再发 X-User-ID)+ authRegister/Login/Me + 401 清令牌并广播 sdx:logout;submitTask/report/memory/列表加载走 Bearer 与 401 守卫。 - views/Login:登录/注册全屏门。 - App:启动校验令牌 → 无则渲染 Login,有则进主应用;identity.userId=已验证 user.id; 监听 sdx:logout 回登录页。 - TopBar:去掉可编辑身份输入,改显登录用户 + 登出。 实跑验证(docker+gateway+preview): - RequireAuth:无 token /kb/list、/agents → 401;/health → 200;带 token → 200。 - 前端:无 token 显登录门;注入有效 token 重载 → 进主应用、顶栏显 Dexter、KB 加载本人库、 隔离徽标显雪花 uid。控制台无错、生产构建通过。 - 过程中发现并修复 CORS 缺 Authorization 头的真实 bug。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -81,14 +81,14 @@
|
|||||||
- [x] DB 规约全库统一:雪花字符串 id + created/updated + 软删(gateway 各表 + mcp-go Profile)
|
- [x] DB 规约全库统一:雪花字符串 id + created/updated + 软删(gateway 各表 + mcp-go Profile)
|
||||||
- [x] 文件主表,文档间关联用雪花 ID(弃用按名关联)
|
- [x] 文件主表,文档间关联用雪花 ID(弃用按名关联)
|
||||||
- [x] 后端首批单测(19 纯逻辑用例:引擎/DSL/docx/报告)+ mcp-go 集成测试(Profile 迁移)
|
- [x] 后端首批单测(19 纯逻辑用例:引擎/DSL/docx/报告)+ mcp-go 集成测试(Profile 迁移)
|
||||||
- [ ] 🟡 真实鉴权(JWT):后端核心已完成 ✅(注册/登录签发 JWT + 校验中间件 + owner 取已验证 uid + 单测/实跑);**前端登录页 + 去掉 header 兜底 + 保护路由**待做(片 2)
|
- [x] **真实鉴权(JWT)闭环**:后端注册/登录/校验 + RequireAuth 保护路由 + owner=已验证 uid(去掉 header 兜底);前端登录/注册门 + 存 token + Bearer + 401 自动登出 + 顶栏用户/登出。实跑验证(含 CORS Authorization 修复)
|
||||||
- [ ] 集成/前端测试(`runGraph` / `handleReport` 需 mock pool/tools/sink;前端无测试)
|
- [ ] 集成/前端测试(`runGraph` / `handleReport` 需 mock pool/tools/sink;前端无测试)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 未实现的大块(路线图)
|
## 未实现的大块(路线图)
|
||||||
|
|
||||||
- [ ] 🟡 **真实登录 / 鉴权**(JWT 后端核心 ✅;前端登录 + 强制鉴权 = 片 2 待做)
|
- [x] **真实登录 / 鉴权(JWT)** —— 后端 + 前端闭环已完成 ✅
|
||||||
- [ ] **代码解释器 + 安全沙箱**(mcp-py 核心能力,目前全桩)
|
- [ ] **代码解释器 + 安全沙箱**(mcp-py 核心能力,目前全桩)
|
||||||
- [ ] **Harness 余下**:输出护栏(dispatcher token 发射层)(熔断降级 ✅、输入护栏 ✅、LLM 自动化评测 ✅ 已完成)
|
- [ ] **Harness 余下**:输出护栏(dispatcher token 发射层)(熔断降级 ✅、输入护栏 ✅、LLM 自动化评测 ✅ 已完成)
|
||||||
- [ ] **长期记忆抽取** + external_api 工具
|
- [ ] **长期记忆抽取** + external_api 工具
|
||||||
|
|||||||
@@ -12,11 +12,22 @@ import { RunsView } from "./views/RunsView";
|
|||||||
import { Home } from "./views/Home";
|
import { Home } from "./views/Home";
|
||||||
import { Placeholder } from "./views/Placeholder";
|
import { Placeholder } from "./views/Placeholder";
|
||||||
import { CommandPalette, type Command } from "./components/CommandPalette";
|
import { CommandPalette, type Command } from "./components/CommandPalette";
|
||||||
import { submitTask, streamTokens, streamExec, type Identity } from "./lib/api";
|
import { Login } from "./views/Login";
|
||||||
|
import { submitTask, streamTokens, streamExec, authMe, logout, type Identity, type AuthUser } from "./lib/api";
|
||||||
import type { TaskDsl } from "./lib/dsl";
|
import type { TaskDsl } from "./lib/dsl";
|
||||||
import { emptyRun, type RunState } from "./lib/run";
|
import { emptyRun, type RunState } from "./lib/run";
|
||||||
import { ToastProvider } from "./ui";
|
import { ToastProvider } from "./ui";
|
||||||
|
|
||||||
|
// 会话标识:本地持久化生成一次(多轮历史用,与鉴权身份分离)。
|
||||||
|
function getSessionId(): string {
|
||||||
|
let s = localStorage.getItem("sdx_sess");
|
||||||
|
if (!s) {
|
||||||
|
s = "sess-" + Math.random().toString(36).slice(2, 10);
|
||||||
|
localStorage.setItem("sdx_sess", s);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
const PLACEHOLDERS: Partial<Record<ViewKey, { title: string; desc: string }>> = {
|
const PLACEHOLDERS: Partial<Record<ViewKey, { title: string; desc: string }>> = {
|
||||||
home: { title: "工作台", desc: "概览:知识库 / 文档 / 近期运行 / 待办报告 / 配额计费 + 快捷入口。" },
|
home: { title: "工作台", desc: "概览:知识库 / 文档 / 近期运行 / 待办报告 / 配额计费 + 快捷入口。" },
|
||||||
kb: { title: "知识库 (RAG)", desc: "入库流水线监控 · 检索调试台(带来源徽标) · 文档/块浏览 · 知识图谱 · 检索评测。依赖 embedding + 入库 worker + 真实混合检索。" },
|
kb: { title: "知识库 (RAG)", desc: "入库流水线监控 · 检索调试台(带来源徽标) · 文档/块浏览 · 知识图谱 · 检索评测。依赖 embedding + 入库 worker + 真实混合检索。" },
|
||||||
@@ -28,7 +39,9 @@ const PLACEHOLDERS: Partial<Record<ViewKey, { title: string; desc: string }>> =
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [view, setView] = useState<ViewKey>("home");
|
const [view, setView] = useState<ViewKey>("home");
|
||||||
const [identity, setIdentity] = useState<Identity>({ userId: "wt", sessionId: "sess-ui" });
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
|
const identity = useMemo<Identity>(() => ({ userId: user?.id ?? "", sessionId: getSessionId() }), [user]);
|
||||||
const [run, setRun] = useState<RunState>(emptyRun);
|
const [run, setRun] = useState<RunState>(emptyRun);
|
||||||
const [cmdOpen, setCmdOpen] = useState(false);
|
const [cmdOpen, setCmdOpen] = useState(false);
|
||||||
const closeRef = useRef<(() => void) | null>(null);
|
const closeRef = useRef<(() => void) | null>(null);
|
||||||
@@ -47,6 +60,22 @@ export default function App() {
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 启动校验现有令牌;并监听 401 登出事件(令牌失效 → 回登录页)。
|
||||||
|
useEffect(() => {
|
||||||
|
authMe()
|
||||||
|
.then(setUser)
|
||||||
|
.catch(() => setUser(null))
|
||||||
|
.finally(() => setAuthLoading(false));
|
||||||
|
const onLogout = () => setUser(null);
|
||||||
|
window.addEventListener("sdx:logout", onLogout);
|
||||||
|
return () => window.removeEventListener("sdx:logout", onLogout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onLogout = useCallback(() => {
|
||||||
|
logout();
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const commands = useMemo<Command[]>(() => {
|
const commands = useMemo<Command[]>(() => {
|
||||||
const go = (key: ViewKey) => () => setView(key);
|
const go = (key: ViewKey) => () => setView(key);
|
||||||
return [
|
return [
|
||||||
@@ -109,6 +138,18 @@ export default function App() {
|
|||||||
[identity],
|
[identity],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 鉴权门:校验中显示占位;未登录显示登录页;登录后进入主应用。
|
||||||
|
if (authLoading) {
|
||||||
|
return <div className="flex h-screen w-screen items-center justify-center bg-ink-950 text-sm text-slate-500">加载中…</div>;
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<Login onAuthed={setUser} />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<div className="relative flex h-screen w-screen flex-col bg-ink-950 text-slate-200">
|
<div className="relative flex h-screen w-screen flex-col bg-ink-950 text-slate-200">
|
||||||
@@ -117,7 +158,7 @@ export default function App() {
|
|||||||
className="pointer-events-none absolute inset-x-0 top-0 h-64 opacity-60"
|
className="pointer-events-none absolute inset-x-0 top-0 h-64 opacity-60"
|
||||||
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
|
||||||
/>
|
/>
|
||||||
<TopBar identity={identity} setIdentity={setIdentity} onCommand={() => setCmdOpen(true)} />
|
<TopBar user={user} onLogout={onLogout} onCommand={() => setCmdOpen(true)} />
|
||||||
<div className="relative flex min-h-0 flex-1">
|
<div className="relative flex min-h-0 flex-1">
|
||||||
<LeftNav active={view} onSelect={setView} />
|
<LeftNav active={view} onSelect={setView} />
|
||||||
<main className="min-w-0 flex-1 overflow-hidden">
|
<main className="min-w-0 flex-1 overflow-hidden">
|
||||||
|
|||||||
@@ -10,17 +10,100 @@ export interface Identity {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- JWT 令牌存储(localStorage)----
|
||||||
|
const TOKEN_KEY = "sdx_token";
|
||||||
|
let authToken: string = typeof localStorage !== "undefined" ? localStorage.getItem(TOKEN_KEY) ?? "" : "";
|
||||||
|
|
||||||
|
export function setToken(t: string): void {
|
||||||
|
authToken = t;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TOKEN_KEY, t);
|
||||||
|
} catch {
|
||||||
|
/* 隐私模式忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function clearToken(): void {
|
||||||
|
authToken = "";
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function getToken(): string {
|
||||||
|
return authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bearer 把 JWT 放进请求头(无令牌则不带)。
|
||||||
|
function bearer(): Record<string, string> {
|
||||||
|
return authToken ? { Authorization: `Bearer ${authToken}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// guard401 在收到 401 时清理令牌并广播登出事件(App 监听后回到登录页)。
|
||||||
|
function guard401(res: Response): Response {
|
||||||
|
if (res.status === 401) {
|
||||||
|
clearToken();
|
||||||
|
if (typeof window !== "undefined") window.dispatchEvent(new Event("sdx:logout"));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 鉴权 API ----
|
||||||
|
export async function authRegister(email: string, password: string, name: string): Promise<AuthUser> {
|
||||||
|
const res = await fetch(`${GATEWAY}/api/v1/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password, name }),
|
||||||
|
});
|
||||||
|
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 authLogin(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// authMe 用当前令牌取登录用户;无效/过期返回 null(用于应用启动校验)。
|
||||||
|
export async function authMe(): Promise<AuthUser | null> {
|
||||||
|
if (!authToken) return null;
|
||||||
|
const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: bearer() });
|
||||||
|
if (!res.ok) {
|
||||||
|
clearToken();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { user?: AuthUser };
|
||||||
|
return data.user ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(): void {
|
||||||
|
clearToken();
|
||||||
|
}
|
||||||
|
|
||||||
// submitTask: POST /api/v1/tasks,返回 task_id。
|
// submitTask: POST /api/v1/tasks,返回 task_id。
|
||||||
export async function submitTask(dsl: TaskDsl, id: Identity): Promise<string> {
|
export async function submitTask(dsl: TaskDsl, id: Identity): Promise<string> {
|
||||||
const res = await fetch(`${GATEWAY}/api/v1/tasks`, {
|
const res = guard401(
|
||||||
|
await fetch(`${GATEWAY}/api/v1/tasks`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json", ...idHeaders(id) },
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-User-ID": id.userId,
|
|
||||||
"X-Session-ID": id.sessionId,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(dsl),
|
body: JSON.stringify(dsl),
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
if (!res.ok) throw new Error(`submit failed: ${res.status} ${await res.text()}`);
|
if (!res.ok) throw new Error(`submit failed: ${res.status} ${await res.text()}`);
|
||||||
const data = (await res.json()) as { task_id: string };
|
const data = (await res.json()) as { task_id: string };
|
||||||
return data.task_id;
|
return data.task_id;
|
||||||
@@ -91,9 +174,10 @@ export interface IngestEvent {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// idHeaders 把身份带进请求头 —— 网关据此把知识库锁进 owner 作用域(隔离)。
|
// idHeaders 把身份带进请求头:JWT(Bearer) 作鉴权与 owner 作用域;X-Session-ID 作多轮会话标识。
|
||||||
|
// 不再发 X-User-ID —— owner 由网关从已验证 JWT 取(伪造头无效)。
|
||||||
function idHeaders(id: Identity): Record<string, string> {
|
function idHeaders(id: Identity): Record<string, string> {
|
||||||
return { "X-User-ID": id.userId, "X-Session-ID": id.sessionId };
|
return { ...bearer(), "X-Session-ID": id.sessionId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KbInfo {
|
export interface KbInfo {
|
||||||
@@ -103,7 +187,7 @@ export interface KbInfo {
|
|||||||
|
|
||||||
// listKb: GET /api/v1/kb/list —— 当前用户的知识库列表(owner 隔离)。
|
// listKb: GET /api/v1/kb/list —— 当前用户的知识库列表(owner 隔离)。
|
||||||
export async function listKb(id: Identity): Promise<KbInfo[]> {
|
export async function listKb(id: Identity): Promise<KbInfo[]> {
|
||||||
const res = await fetch(`${GATEWAY}/api/v1/kb/list`, { headers: idHeaders(id) });
|
const res = guard401(await fetch(`${GATEWAY}/api/v1/kb/list`, { headers: idHeaders(id) }));
|
||||||
const data = (await res.json()) as { kbs?: KbInfo[]; error?: string };
|
const data = (await res.json()) as { kbs?: KbInfo[]; error?: string };
|
||||||
if (!res.ok) throw new Error(data.error ?? `list failed: ${res.status}`);
|
if (!res.ok) throw new Error(data.error ?? `list failed: ${res.status}`);
|
||||||
return data.kbs ?? [];
|
return data.kbs ?? [];
|
||||||
@@ -141,7 +225,7 @@ export interface AgentInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listAgents(id: Identity): Promise<AgentInfo[]> {
|
export async function listAgents(id: Identity): Promise<AgentInfo[]> {
|
||||||
const res = await fetch(`${GATEWAY}/api/v1/agents`, { headers: idHeaders(id) });
|
const res = guard401(await fetch(`${GATEWAY}/api/v1/agents`, { headers: idHeaders(id) }));
|
||||||
const data = (await res.json()) as { agents?: AgentInfo[]; error?: string };
|
const data = (await res.json()) as { agents?: AgentInfo[]; error?: string };
|
||||||
if (!res.ok) throw new Error(data.error ?? `list agents failed: ${res.status}`);
|
if (!res.ok) throw new Error(data.error ?? `list agents failed: ${res.status}`);
|
||||||
return data.agents ?? [];
|
return data.agents ?? [];
|
||||||
@@ -288,15 +372,13 @@ export async function searchKb(id: Identity, kb: string, q: string, topK = 5): P
|
|||||||
// generateReport: POST /api/v1/reports —— 触发报告生成,返回 task_id。
|
// generateReport: POST /api/v1/reports —— 触发报告生成,返回 task_id。
|
||||||
// 用 streamTokens(task_id) 看实时进度,完成后用 reportDownloadUrl(task_id) 下载 Word。
|
// 用 streamTokens(task_id) 看实时进度,完成后用 reportDownloadUrl(task_id) 下载 Word。
|
||||||
export async function generateReport(id: Identity, topic: string, kb?: string): Promise<string> {
|
export async function generateReport(id: Identity, topic: string, kb?: string): Promise<string> {
|
||||||
const res = await fetch(`${GATEWAY}/api/v1/reports`, {
|
const res = guard401(
|
||||||
|
await fetch(`${GATEWAY}/api/v1/reports`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json", ...idHeaders(id) },
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-User-ID": id.userId,
|
|
||||||
"X-Session-ID": id.sessionId,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ topic, kb: kb ?? "" }),
|
body: JSON.stringify({ topic, kb: kb ?? "" }),
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
const data = (await res.json()) as { task_id?: string; error?: string };
|
const data = (await res.json()) as { task_id?: string; error?: string };
|
||||||
if (!res.ok || !data.task_id) throw new Error(data.error ?? `report failed: ${res.status}`);
|
if (!res.ok || !data.task_id) throw new Error(data.error ?? `report failed: ${res.status}`);
|
||||||
return data.task_id;
|
return data.task_id;
|
||||||
@@ -318,15 +400,13 @@ export async function setMemory(
|
|||||||
key: string,
|
key: string,
|
||||||
value: string,
|
value: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const res = await fetch(`${GATEWAY}/api/v1/memory`, {
|
const res = guard401(
|
||||||
|
await fetch(`${GATEWAY}/api/v1/memory`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json", ...idHeaders(id) },
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-User-ID": id.userId,
|
|
||||||
"X-Session-ID": id.sessionId,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ key, value }),
|
body: JSON.stringify({ key, value }),
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
const data = (await res.json()) as { message?: string; error?: string };
|
const data = (await res.json()) as { message?: string; error?: string };
|
||||||
if (!res.ok) throw new Error(data.error ?? `memory failed: ${res.status}`);
|
if (!res.ok) throw new Error(data.error ?? `memory failed: ${res.status}`);
|
||||||
return data.message ?? "ok";
|
return data.message ?? "ok";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { User, ChevronDown, Search as SearchIcon } from "lucide-react";
|
import { User, ChevronDown, Search as SearchIcon, LogOut } from "lucide-react";
|
||||||
import type { Identity } from "../lib/api";
|
import type { AuthUser } from "../lib/api";
|
||||||
import { useHealth } from "../lib/health";
|
import { useHealth } from "../lib/health";
|
||||||
import { isMacDesktop } from "../lib/desktop";
|
import { isMacDesktop } from "../lib/desktop";
|
||||||
import { cn } from "../ui";
|
import { cn } from "../ui";
|
||||||
@@ -19,8 +19,8 @@ function Light({ on, label }: { on: boolean; label: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 顶栏:品牌 · 垂直切换 · 健康灯 · 身份/会话(深色 + 毛玻璃)。
|
// 顶栏:品牌 · 垂直切换 · 健康灯 · 登录用户 + 登出(深色 + 毛玻璃)。
|
||||||
export function TopBar({ identity, setIdentity, onCommand }: { identity: Identity; setIdentity: (id: Identity) => void; onCommand?: () => void }) {
|
export function TopBar({ user, onLogout, onCommand }: { user: AuthUser; onLogout: () => void; onCommand?: () => void }) {
|
||||||
const h = useHealth();
|
const h = useHealth();
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
@@ -62,21 +62,17 @@ export function TopBar({ identity, setIdentity, onCommand }: { identity: Identit
|
|||||||
<Light on={h.neo4j} label="Neo4j" />
|
<Light on={h.neo4j} label="Neo4j" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
|
||||||
<div className="relative">
|
<span className="flex items-center gap-1.5 rounded-md border border-line bg-ink-800 px-2.5 py-1 text-xs text-slate-300" title={user.email}>
|
||||||
<User className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
<User className="h-3.5 w-3.5 text-slate-500" />
|
||||||
<input
|
{user.name || user.email}
|
||||||
className="w-24 rounded-md border border-line bg-ink-800 py-1 pl-7 pr-2 text-xs text-slate-200 focus:border-brand focus:outline-none"
|
</span>
|
||||||
value={identity.userId}
|
<button
|
||||||
onChange={(e) => setIdentity({ ...identity, userId: e.target.value })}
|
onClick={onLogout}
|
||||||
title="用户"
|
className="flex items-center gap-1 rounded-md border border-line bg-ink-800 px-2 py-1 text-xs text-slate-400 transition hover:border-danger/50 hover:text-danger"
|
||||||
/>
|
title="退出登录"
|
||||||
</div>
|
>
|
||||||
<input
|
<LogOut className="h-3.5 w-3.5" />
|
||||||
className="w-24 rounded-md border border-line bg-ink-800 px-2 py-1 text-xs text-slate-200 focus:border-brand focus:outline-none"
|
</button>
|
||||||
value={identity.sessionId}
|
|
||||||
onChange={(e) => setIdentity({ ...identity, sessionId: e.target.value })}
|
|
||||||
title="会话"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { LogIn, UserPlus, Loader2 } from "lucide-react";
|
||||||
|
import { authLogin, authRegister, type AuthUser } from "../lib/api";
|
||||||
|
import { Button, Input, Field } from "../ui";
|
||||||
|
|
||||||
|
// Login:未登录时的全屏鉴权门。登录/注册成功后回调 onAuthed 把用户交给 App。
|
||||||
|
export function Login({ onAuthed }: { onAuthed: (u: AuthUser) => void }) {
|
||||||
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setErr("");
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const u = mode === "login" ? await authLogin(email.trim(), password) : await authRegister(email.trim(), password, name.trim());
|
||||||
|
onAuthed(u);
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRegister = mode === "register";
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-ink-950 text-slate-200">
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-x-0 top-0 h-80 opacity-60"
|
||||||
|
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.12), transparent 70%)" }}
|
||||||
|
/>
|
||||||
|
<div className="relative w-[360px] rounded-xl border border-line bg-ink-900 p-6 shadow-card">
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand/20 text-brand-400 font-bold">S</div>
|
||||||
|
<span className="text-lg font-semibold text-slate-100">sundynix-agentix</span>
|
||||||
|
</div>
|
||||||
|
<p className="mb-5 text-xs text-slate-500">{isRegister ? "创建账户以开始" : "登录以继续"}</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isRegister && (
|
||||||
|
<Field label="昵称">
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="如 Dexter" autoFocus={isRegister} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="邮箱">
|
||||||
|
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" autoFocus={!isRegister} onKeyDown={(e) => e.key === "Enter" && submit()} />
|
||||||
|
</Field>
|
||||||
|
<Field label="密码">
|
||||||
|
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={isRegister ? "至少 6 位" : "••••••••"} onKeyDown={(e) => e.key === "Enter" && submit()} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="mt-3 rounded-md bg-danger/10 px-3 py-2 text-xs text-danger">{err}</div>}
|
||||||
|
|
||||||
|
<Button variant="primary" className="mt-4 w-full justify-center" icon={busy ? Loader2 : isRegister ? UserPlus : LogIn} disabled={busy || !email.trim() || !password} onClick={submit}>
|
||||||
|
{busy ? "处理中…" : isRegister ? "注册并进入" : "登录"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="mt-3 text-center text-[11px] text-slate-500">
|
||||||
|
{isRegister ? "已有账户?" : "还没有账户?"}
|
||||||
|
<button className="ml-1 text-brand-400 hover:underline" onClick={() => { setErr(""); setMode(isRegister ? "login" : "register"); }}>
|
||||||
|
{isRegister ? "去登录" : "去注册"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -184,17 +184,14 @@ func (h *Handler) SetMemory(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": res.Content})
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": res.Content})
|
||||||
}
|
}
|
||||||
|
|
||||||
// userID 取当前用户标识:优先 JWT 鉴权中间件注入的已验证 uid;
|
// userID 取当前用户标识 —— 仅信任 JWT 鉴权中间件注入的已验证 uid(不再认 header)。
|
||||||
// 兜底 X-User-ID 头(开发期 / 前端尚未接登录),都没有则匿名。
|
// 受保护路由有 RequireAuth 兜底,此处理论上不会返回 anonymous。
|
||||||
func userID(c *gin.Context) string {
|
func userID(c *gin.Context) string {
|
||||||
if v, ok := c.Get("uid"); ok {
|
if v, ok := c.Get("uid"); ok {
|
||||||
if s, _ := v.(string); s != "" {
|
if s, _ := v.(string); s != "" {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if u := c.GetHeader("X-User-ID"); u != "" {
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
return "anonymous"
|
return "anonymous"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -25,3 +26,17 @@ func Auth() gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequireAuth 在 Auth 之后使用:上下文无已验证 userID 则 401 拒绝。
|
||||||
|
// 用于 owner 作用域的业务路由;SSE/导出等按 task_id 寻址的端点不挂(EventSource 无法带头)。
|
||||||
|
func RequireAuth() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if v, ok := c.Get(CtxUserID); ok {
|
||||||
|
if s, _ := v.(string); s != "" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "需要登录"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,36 +22,40 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
h := handler.New(db, cache, bus, blobStore)
|
h := handler.New(db, cache, bus, blobStore)
|
||||||
api := r.Group("/api/v1")
|
api := r.Group("/api/v1")
|
||||||
{
|
{
|
||||||
|
// —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)——
|
||||||
api.POST("/auth/register", h.Register) // 注册 + 签发 JWT
|
api.POST("/auth/register", h.Register) // 注册 + 签发 JWT
|
||||||
api.POST("/auth/login", h.Login) // 登录 + 签发 JWT
|
api.POST("/auth/login", h.Login) // 登录 + 签发 JWT
|
||||||
api.GET("/auth/me", h.Me) // 当前登录用户
|
api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401)
|
||||||
|
|
||||||
api.POST("/tasks", h.SubmitTask) // 1. 解析 DSL 并 Publish 到 NATS
|
|
||||||
api.GET("/tasks/:id/stream", h.StreamTask) // 4. SSE/WS 回流 Token Stream
|
|
||||||
api.GET("/tasks/:id/exec", h.StreamExec) // 4b. SSE 回流执行轨迹事件(运行·观测)
|
|
||||||
api.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
|
||||||
api.GET("/kb/list", h.KbList) // 当前用户的知识库列表(owner 隔离)
|
|
||||||
api.POST("/kb/create", h.KbCreate) // 新建知识库(项目/案件/文件夹/通用)
|
|
||||||
api.POST("/kb/ingest", h.KbIngest) // 知识库入库(文本,→ mcp-go kb_ingest)
|
|
||||||
api.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库(docx/xlsx/pdf… 异步)
|
|
||||||
api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(实时监控)
|
|
||||||
api.POST("/kb/search", h.KbSearch) // 知识库检索台(→ mcp-go kb_search)
|
|
||||||
api.GET("/kb/vault", h.KbVault) // 文库:文档列表(仅元数据+预览)
|
|
||||||
api.GET("/kb/doc", h.KbDoc) // 取单篇文档全文(按需加载)
|
|
||||||
api.GET("/kb/links", h.KbLinks) // 某库全部 [[双链]](反链/笔记关系图)
|
|
||||||
api.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记(落库 + 按 doc 重入库)
|
|
||||||
api.GET("/kb/graph", h.KbGraph) // 知识图谱三元组(→ mcp-go kb_graph,Neo4j)
|
|
||||||
|
|
||||||
api.GET("/agents", h.AgentList) // 我的 Agent 编排列表(owner 隔离)
|
|
||||||
api.POST("/agents", h.AgentSave) // 保存/更新编排
|
|
||||||
api.DELETE("/agents", h.AgentDelete) // 删除编排
|
|
||||||
api.POST("/reports", h.GenerateReport) // 报告生成(intent=report 任务 → Dispatcher 专用编排)
|
|
||||||
api.GET("/reports/:id/export", h.ExportReport) // 按需导出(format=docx|md;默认 docx)
|
|
||||||
api.GET("/reports/:id/download", h.ExportReport) // 兼容旧入口(默认 docx)
|
|
||||||
api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯)
|
api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯)
|
||||||
api.GET("/billing", h.Billing)
|
api.GET("/tasks/:id/stream", h.StreamTask) // SSE 回流 Token Stream(task_id 寻址)
|
||||||
|
api.GET("/tasks/:id/exec", h.StreamExec) // SSE 回流执行轨迹(task_id 寻址)
|
||||||
|
api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(job_id 寻址)
|
||||||
|
api.GET("/reports/:id/export", h.ExportReport) // 按需导出(report_id 寻址)
|
||||||
|
api.GET("/reports/:id/download", h.ExportReport) // 兼容旧入口(默认 docx)
|
||||||
|
|
||||||
// 运维控制面:LLM 模型配置(独立运维控制台调用)。
|
// —— 受保护:owner 作用域业务,必须携带有效 JWT ——
|
||||||
|
p := api.Group("", middleware.RequireAuth())
|
||||||
|
{
|
||||||
|
p.POST("/tasks", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid)
|
||||||
|
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
||||||
|
p.GET("/kb/list", h.KbList) // 当前用户的知识库列表(owner 隔离)
|
||||||
|
p.POST("/kb/create", h.KbCreate) // 新建知识库
|
||||||
|
p.POST("/kb/ingest", h.KbIngest) // 文本入库
|
||||||
|
p.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库
|
||||||
|
p.POST("/kb/search", h.KbSearch) // 检索台
|
||||||
|
p.GET("/kb/vault", h.KbVault) // 文库列表
|
||||||
|
p.GET("/kb/doc", h.KbDoc) // 取单篇文档
|
||||||
|
p.GET("/kb/links", h.KbLinks) // 某库双链
|
||||||
|
p.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记
|
||||||
|
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
|
||||||
|
p.GET("/agents", h.AgentList) // 我的编排列表(owner 隔离)
|
||||||
|
p.POST("/agents", h.AgentSave) // 保存/更新编排
|
||||||
|
p.DELETE("/agents", h.AgentDelete) // 删除编排
|
||||||
|
p.POST("/reports", h.GenerateReport) // 报告生成
|
||||||
|
p.GET("/billing", h.Billing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运维控制面:LLM 模型配置(独立运维控制台调用;鉴权待后续接管理员角色)。
|
||||||
admin := api.Group("/admin")
|
admin := api.Group("/admin")
|
||||||
{
|
{
|
||||||
admin.GET("/models", h.ListModels)
|
admin.GET("/models", h.ListModels)
|
||||||
@@ -69,7 +73,7 @@ func cors() gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Header("Access-Control-Allow-Origin", "*")
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
c.Header("Access-Control-Allow-Headers", "Content-Type, X-User-ID, X-Session-ID")
|
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-ID, X-User-ID")
|
||||||
if c.Request.Method == "OPTIONS" {
|
if c.Request.Method == "OPTIONS" {
|
||||||
c.AbortWithStatus(204)
|
c.AbortWithStatus(204)
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user