fix(web): remove identity simulation and stop sending X-User-ID

Product UI no longer switches sessions locally; API clients attach Bearer
tokens only and LocalAuthClient stays test-only outside the prod bundle.
This commit is contained in:
weichen
2026-09-13 02:50:22 +00:00
committed by weichen
parent 886cbb7913
commit d612c1193e
14 changed files with 259 additions and 543 deletions
+184
View File
@@ -0,0 +1,184 @@
/**
* 测试专用本地鉴权客户端 — 仅由单元测试 import,不进入生产 bundle 主路径。
*/
import {
UserAccount,
SystemAuthConfig,
LoginPayload,
LoginResult,
RegisterPayload,
RegisterResult,
CreateAdminUserPayload,
AccountStatus,
} from '../types/auth';
import type { AuthClientOptions } from './auth';
const AUTH_STORAGE_KEY_SESSION = 'smdf_auth_session';
export class LocalAuthClient {
private users: Map<number, UserAccount & { password?: string }> = new Map();
private nextId = 10;
private config: SystemAuthConfig = { allowRegistration: true, requireApproval: false };
private env = 'dev';
private adminUsername = 'admin';
private adminPassword = 'admin-secret-password-123';
constructor(options?: AuthClientOptions) {
if (options?.env) this.env = options.env;
if (options?.adminUsername) this.adminUsername = options.adminUsername;
if (options?.adminPassword) this.adminPassword = options.adminPassword;
if (options?.allowRegistration !== undefined) this.config.allowRegistration = options.allowRegistration;
if (options?.requireApproval !== undefined) this.config.requireApproval = options.requireApproval;
this.initDefaultUsers();
}
private initDefaultUsers() {
const now = new Date().toISOString();
this.users.set(1, {
id: 1,
username: this.adminUsername,
password: this.adminPassword,
name: '系统管理员',
roleId: 'role-admin',
position: '系统管理员',
department: '系统运维部',
employeeId: 'SYS-000',
status: 'active',
mustChangePassword: false,
createdAt: now,
updatedAt: now,
});
}
async login(payload: LoginPayload): Promise<LoginResult> {
const trimmedUser = payload.username.trim();
let matched: (UserAccount & { password?: string }) | undefined;
for (const u of this.users.values()) {
if (u.username.toLowerCase() === trimmedUser.toLowerCase()) {
matched = u;
break;
}
}
if (!matched || matched.password !== payload.password) {
throw new Error('用户名或密码错误');
}
if (matched.status === 'pending_approval') {
throw new Error('账号正在等待管理员审核中,暂无法登录');
}
const { password: _, ...safeUser } = matched;
const result: LoginResult = {
token: `token-${Date.now()}-${safeUser.id}`,
user: safeUser,
mustChangePassword: !!safeUser.mustChangePassword,
env: this.env,
};
this.saveSession(result);
return result;
}
async register(payload: RegisterPayload): Promise<RegisterResult> {
if (!this.config.allowRegistration) {
throw new Error('系统当前已暂停自主注册,请联系系统管理员分配账号');
}
const trimmedUser = payload.username.trim();
if (!trimmedUser || !payload.password || !payload.name?.trim()) {
throw new Error('用户名、密码与姓名为必填项');
}
for (const u of this.users.values()) {
if (u.username.toLowerCase() === trimmedUser.toLowerCase()) {
throw new Error(`用户名已存在: ${trimmedUser}`);
}
}
const now = new Date().toISOString();
const status: AccountStatus = this.config.requireApproval ? 'pending_approval' : 'active';
const newId = this.nextId++;
this.users.set(newId, {
id: newId,
username: trimmedUser,
password: payload.password,
name: payload.name.trim(),
roleId: 'role-guest',
position: '访客/待指派',
department: payload.department || '待分配部门',
employeeId: payload.employeeId || `EXT-${newId}`,
status,
mustChangePassword: false,
createdAt: now,
updatedAt: now,
});
return {
status,
message: status === 'pending_approval' ? '注册成功,等待审核' : '注册成功',
userId: newId,
};
}
async changePassword(userId: number, oldPass: string, newPass: string): Promise<boolean> {
const user = this.users.get(userId);
if (!user || user.password !== oldPass) throw new Error('原密码不正确');
user.password = newPass;
user.mustChangePassword = false;
return true;
}
async updateSystemAuthConfig(newConfig: SystemAuthConfig): Promise<SystemAuthConfig> {
this.config = { ...newConfig };
return { ...this.config };
}
async updateUserStatus(userId: number, status: AccountStatus, roleId?: string, position?: string): Promise<UserAccount> {
const user = this.users.get(userId);
if (!user) throw new Error('用户不存在');
user.status = status;
if (roleId) user.roleId = roleId;
if (position) user.position = position;
const { password: _, ...safe } = user;
return safe;
}
async createAdminUser(payload: CreateAdminUserPayload): Promise<UserAccount> {
const now = new Date().toISOString();
const newId = this.nextId++;
const user: UserAccount & { password?: string } = {
id: newId,
username: payload.username.trim(),
password: payload.initialPassword,
name: payload.name.trim(),
roleId: payload.roleId,
position: payload.position,
department: payload.department || '研发中心',
employeeId: payload.employeeId || `EMP-${newId}`,
status: 'active',
mustChangePassword: true,
createdAt: now,
updatedAt: now,
};
this.users.set(newId, user);
const { password: _, ...safe } = user;
return safe;
}
logout() {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.removeItem(AUTH_STORAGE_KEY_SESSION);
}
}
saveSession(session: LoginResult) {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.setItem(AUTH_STORAGE_KEY_SESSION, JSON.stringify(session));
}
}
loadSavedSession(): LoginResult | null {
return null;
}
getSessionToken(): string | null {
return null;
}
}
export function createInMemoryAuthClient(options?: AuthClientOptions): LocalAuthClient {
return new LocalAuthClient({ ...options, localFallback: true });
}
+2 -4
View File
@@ -1,8 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createInMemoryAuthClient,
} from './auth';
import { createInMemoryAuthClient } from './auth.local';
test('AuthClient: 基础登录与 .env 超级管理员鉴权', async () => {
const client = createInMemoryAuthClient({
@@ -23,7 +21,7 @@ test('AuthClient: 基础登录与 .env 超级管理员鉴权', async () => {
const session = await client.login({ username: 'admin', password: 'admin-password-123' });
assert.equal(session.user.username, 'admin');
assert.equal(session.user.roleId, 'role-admin');
assert.equal(session.allowDevSwitch, true);
assert.equal(session.env, 'dev');
assert.ok(session.token.length > 0);
});
-170
View File
@@ -208,174 +208,4 @@ export class AuthClient {
}
}
/** 测试专用本地鉴权客户端(不用于生产构建默认路径) */
export class LocalAuthClient {
private users: Map<number, UserAccount & { password?: string }> = new Map();
private nextId = 10;
private config: SystemAuthConfig = { allowRegistration: true, requireApproval: false };
private env = 'dev';
private adminUsername = 'admin';
private adminPassword = 'admin-secret-password-123';
constructor(options?: AuthClientOptions) {
if (options?.env) this.env = options.env;
if (options?.adminUsername) this.adminUsername = options.adminUsername;
if (options?.adminPassword) this.adminPassword = options.adminPassword;
if (options?.allowRegistration !== undefined) this.config.allowRegistration = options.allowRegistration;
if (options?.requireApproval !== undefined) this.config.requireApproval = options.requireApproval;
this.initDefaultUsers();
}
private initDefaultUsers() {
const now = new Date().toISOString();
this.users.set(1, {
id: 1,
username: this.adminUsername,
password: this.adminPassword,
name: '系统管理员',
roleId: 'role-admin',
position: '系统管理员',
department: '系统运维部',
employeeId: 'SYS-000',
status: 'active',
mustChangePassword: false,
createdAt: now,
updatedAt: now,
});
}
async login(payload: LoginPayload): Promise<LoginResult> {
const trimmedUser = payload.username.trim();
let matched: (UserAccount & { password?: string }) | undefined;
for (const u of this.users.values()) {
if (u.username.toLowerCase() === trimmedUser.toLowerCase()) {
matched = u;
break;
}
}
if (!matched || matched.password !== payload.password) {
throw new Error('用户名或密码错误');
}
if (matched.status === 'pending_approval') {
throw new Error('账号正在等待管理员审核中,暂无法登录');
}
const { password, ...safeUser } = matched;
const result: LoginResult = {
token: `token-${Date.now()}-${safeUser.id}`,
user: safeUser,
mustChangePassword: !!safeUser.mustChangePassword,
env: this.env,
allowDevSwitch: this.env === 'dev',
};
this.saveSession(result);
return result;
}
async register(payload: RegisterPayload): Promise<RegisterResult> {
if (!this.config.allowRegistration) {
throw new Error('系统当前已暂停自主注册,请联系系统管理员分配账号');
}
const trimmedUser = payload.username.trim();
if (!trimmedUser || !payload.password || !payload.name?.trim()) {
throw new Error('用户名、密码与姓名为必填项');
}
for (const u of this.users.values()) {
if (u.username.toLowerCase() === trimmedUser.toLowerCase()) {
throw new Error(`用户名已存在: ${trimmedUser}`);
}
}
const now = new Date().toISOString();
const status: AccountStatus = this.config.requireApproval ? 'pending_approval' : 'active';
const newId = this.nextId++;
this.users.set(newId, {
id: newId,
username: trimmedUser,
password: payload.password,
name: payload.name.trim(),
roleId: 'role-guest',
position: '访客/待指派',
department: payload.department || '待分配部门',
employeeId: payload.employeeId || `EXT-${newId}`,
status,
mustChangePassword: false,
createdAt: now,
updatedAt: now,
});
return {
status,
message: status === 'pending_approval' ? '注册成功,等待审核' : '注册成功',
userId: newId,
};
}
async changePassword(userId: number, oldPass: string, newPass: string): Promise<boolean> {
const user = this.users.get(userId);
if (!user || user.password !== oldPass) throw new Error('原密码不正确');
user.password = newPass;
user.mustChangePassword = false;
return true;
}
async updateSystemAuthConfig(newConfig: SystemAuthConfig): Promise<SystemAuthConfig> {
this.config = { ...newConfig };
return { ...this.config };
}
async updateUserStatus(userId: number, status: AccountStatus, roleId?: string, position?: string): Promise<UserAccount> {
const user = this.users.get(userId);
if (!user) throw new Error('用户不存在');
user.status = status;
if (roleId) user.roleId = roleId;
if (position) user.position = position;
const { password, ...safe } = user;
return safe;
}
async createAdminUser(payload: CreateAdminUserPayload): Promise<UserAccount> {
const now = new Date().toISOString();
const newId = this.nextId++;
const user: UserAccount & { password?: string } = {
id: newId,
username: payload.username.trim(),
password: payload.initialPassword,
name: payload.name.trim(),
roleId: payload.roleId,
position: payload.position,
department: payload.department || '研发中心',
employeeId: payload.employeeId || `EMP-${newId}`,
status: 'active',
mustChangePassword: true,
createdAt: now,
updatedAt: now,
};
this.users.set(newId, user);
const { password, ...safe } = user;
return safe;
}
logout() {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.removeItem(AUTH_STORAGE_KEY_SESSION);
}
}
saveSession(session: LoginResult) {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.setItem(AUTH_STORAGE_KEY_SESSION, JSON.stringify(session));
}
}
loadSavedSession(): LoginResult | null {
return null;
}
getSessionToken(): string | null {
return null;
}
}
export function createInMemoryAuthClient(options?: AuthClientOptions): LocalAuthClient {
return new LocalAuthClient({ ...options, localFallback: true });
}
export const authClient = new AuthClient();
+25
View File
@@ -0,0 +1,25 @@
const AUTH_STORAGE_KEY_SESSION = 'smdf_auth_session';
/** Bearer token from persisted login session; no X-User-ID (Phase 0). */
export function getBearerAuthHeaders(contentType?: string): Record<string, string> {
const headers: Record<string, string> = {};
if (contentType) {
headers['Content-Type'] = contentType;
}
if (typeof window === 'undefined' || !window.localStorage) {
return headers;
}
try {
const raw = window.localStorage.getItem(AUTH_STORAGE_KEY_SESSION);
if (!raw) {
return headers;
}
const session = JSON.parse(raw) as { token?: string };
if (session.token) {
headers['Authorization'] = `Bearer ${session.token}`;
}
} catch {
// ignore corrupt session
}
return headers;
}
+4 -25
View File
@@ -1,3 +1,5 @@
import { getBearerAuthHeaders } from './authHeaders';
export interface ChatSessionSummary {
session_id: string;
title: string;
@@ -18,29 +20,6 @@ export interface UpsertChatSessionInput {
const API_BASE = '';
function getAuthHeaders(): HeadersInit {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (typeof window !== 'undefined' && window.localStorage) {
try {
const raw = window.localStorage.getItem('smdf_auth_session');
if (raw) {
const session = JSON.parse(raw);
if (session.token) {
headers['Authorization'] = `Bearer ${session.token}`;
}
if (session.user?.id) {
headers['X-User-ID'] = String(session.user.id);
}
}
} catch {
// ignore
}
}
return headers;
}
export async function fetchChatSessions(
userId: number,
projectId?: number,
@@ -54,7 +33,7 @@ export async function fetchChatSessions(
params.set('project_id', String(projectId));
}
const res = await fetch(`${API_BASE}/api/v1/chat/sessions?${params.toString()}`, {
headers: getAuthHeaders(),
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
throw new Error(`获取会话列表失败: ${res.status}`);
@@ -65,7 +44,7 @@ export async function fetchChatSessions(
export async function upsertChatSession(input: UpsertChatSessionInput): Promise<ChatSessionSummary> {
const res = await fetch(`${API_BASE}/api/v1/chat/sessions`, {
method: 'POST',
headers: getAuthHeaders(),
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify(input),
});
if (!res.ok) {
+15 -36
View File
@@ -3,6 +3,8 @@
* 提供单条回复保存、私密列表、详情获取、编辑保存、共享流转及回收站接口
*/
import { getBearerAuthHeaders } from './authHeaders';
export interface FreeformDocSummary {
doc_id: string;
project_id: number;
@@ -49,14 +51,10 @@ export async function saveMessageToPrivate(
projectId: number = 1,
title?: string,
expectedSha256?: string,
userId?: number
): Promise<CreateFromMessageResponse> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (userId) headers['X-User-ID'] = String(userId);
const res = await fetch('/api/v1/freeform-documents/from-message', {
method: 'POST',
headers,
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify({
message_id: messageId,
project_id: projectId,
@@ -79,17 +77,13 @@ export async function saveMessageToPrivate(
export async function listFreeformDocs(
space: 'my_private' | 'project_shared' | 'trash' = 'my_private',
projectId?: number,
userId?: number
): Promise<FreeformDocSummary[]> {
let url = `/api/v1/freeform-documents?space=${encodeURIComponent(space)}`;
if (projectId) {
url += `&project_id=${projectId}`;
}
const headers: Record<string, string> = {};
if (userId) headers['X-User-ID'] = String(userId);
const res = await fetch(url, { headers });
const res = await fetch(url, { headers: getBearerAuthHeaders() });
if (!res.ok) {
throw new Error(`获取列表失败 (${res.status})`);
}
@@ -100,11 +94,10 @@ export async function listFreeformDocs(
/**
* 获取自由文档详情与 Markdown 正文
*/
export async function fetchFreeformDoc(docId: string, userId?: number): Promise<FreeformDocDetail> {
const headers: Record<string, string> = {};
if (userId) headers['X-User-ID'] = String(userId);
const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, { headers });
export async function fetchFreeformDoc(docId: string): Promise<FreeformDocDetail> {
const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, {
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
if (res.status === 403) {
throw new Error('该文档为他人私密文档,严格禁止跨用户访问');
@@ -129,13 +122,9 @@ export async function updateFreeformDoc(
changeSummary?: string;
expectedVersion?: number;
expectedSha256?: string;
userId?: number;
}
): Promise<FreeformDocDetail> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (payload.userId) {
headers['X-User-ID'] = String(payload.userId);
}
const headers = getBearerAuthHeaders('application/json');
if (payload.expectedVersion !== undefined) {
headers['If-Match'] = `"${payload.expectedVersion}"`;
} else if (payload.expectedSha256) {
@@ -168,13 +157,10 @@ export async function updateFreeformDoc(
/**
* 一键共享至项目参考库
*/
export async function shareFreeformDoc(docId: string, userId?: number): Promise<void> {
const headers: Record<string, string> = {};
if (userId) headers['X-User-ID'] = String(userId);
export async function shareFreeformDoc(docId: string): Promise<void> {
const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}/share`, {
method: 'POST',
headers,
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
@@ -185,13 +171,10 @@ export async function shareFreeformDoc(docId: string, userId?: number): Promise<
/**
* 撤回共享,恢复为个人私密
*/
export async function unshareFreeformDoc(docId: string, userId?: number): Promise<void> {
const headers: Record<string, string> = {};
if (userId) headers['X-User-ID'] = String(userId);
export async function unshareFreeformDoc(docId: string): Promise<void> {
const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}/unshare`, {
method: 'POST',
headers,
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
@@ -202,17 +185,13 @@ export async function unshareFreeformDoc(docId: string, userId?: number): Promis
/**
* 软删除文档移至回收站
*/
export async function deleteFreeformDoc(docId: string, userId?: number): Promise<void> {
const headers: Record<string, string> = {};
if (userId) headers['X-User-ID'] = String(userId);
export async function deleteFreeformDoc(docId: string): Promise<void> {
const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, {
method: 'DELETE',
headers,
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.message || `删除失败 (${res.status})`);
}
}
+6 -7
View File
@@ -1,3 +1,5 @@
import { getBearerAuthHeaders } from './authHeaders';
export type ProjectAsset = {
asset_id: number;
filename: string;
@@ -11,17 +13,12 @@ export async function uploadProjectAsset(
projectId: number,
file: File | Blob,
filename: string,
userId?: number,
): Promise<ProjectAsset> {
const form = new FormData();
form.append('file', file, filename);
const headers: Record<string, string> = {};
if (userId && userId > 0) {
headers['X-User-ID'] = String(userId);
}
const res = await fetch(`/api/v1/projects/${projectId}/assets`, {
method: 'POST',
headers,
headers: getBearerAuthHeaders(),
body: form,
});
if (!res.ok) {
@@ -31,7 +28,9 @@ export async function uploadProjectAsset(
}
export async function listProjectAssets(projectId: number): Promise<ProjectAsset[]> {
const res = await fetch(`/api/v1/projects/${projectId}/assets`);
const res = await fetch(`/api/v1/projects/${projectId}/assets`, {
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
throw new Error(`list assets failed: HTTP ${res.status}`);
}
+11 -33
View File
@@ -7,35 +7,13 @@ import {
ActivateWorkflowInput,
ActivateWorkflowResult,
} from '../types/projectWorkflow';
import { getBearerAuthHeaders } from './authHeaders';
const API_BASE = '';
function getAuthHeaders(): HeadersInit {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (typeof window !== 'undefined' && window.localStorage) {
try {
const raw = window.localStorage.getItem('smdf_auth_session');
if (raw) {
const session = JSON.parse(raw);
if (session.token) {
headers['Authorization'] = `Bearer ${session.token}`;
}
if (session.user?.id) {
headers['X-User-ID'] = String(session.user.id);
}
}
} catch {
// ignore
}
}
return headers;
}
export async function fetchProjects(): Promise<Project[]> {
const res = await fetch(`${API_BASE}/api/v1/projects`, {
headers: getAuthHeaders(),
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
throw new Error(`获取项目列表失败: ${res.status}`);
@@ -46,7 +24,7 @@ export async function fetchProjects(): Promise<Project[]> {
export async function createProject(data: CreateProjectInput): Promise<Project> {
const res = await fetch(`${API_BASE}/api/v1/projects`, {
method: 'POST',
headers: getAuthHeaders(),
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify(data),
});
if (!res.ok) {
@@ -58,7 +36,7 @@ export async function createProject(data: CreateProjectInput): Promise<Project>
export async function getProject(id: number): Promise<Project> {
const res = await fetch(`${API_BASE}/api/v1/projects/${id}`, {
headers: getAuthHeaders(),
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
throw new Error(`获取项目详情失败: ${res.status}`);
@@ -68,7 +46,7 @@ export async function getProject(id: number): Promise<Project> {
export async function fetchProjectWorkflows(projectId: number): Promise<ProjectWorkflow[]> {
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflows`, {
headers: getAuthHeaders(),
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
throw new Error(`获取项目工作流清单失败: ${res.status}`);
@@ -82,7 +60,7 @@ export async function activateWorkflow(
): Promise<ActivateWorkflowResult> {
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/active-workflow`, {
method: 'PUT',
headers: getAuthHeaders(),
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify(input),
});
if (!res.ok) {
@@ -101,7 +79,7 @@ export async function activateWorkflow(
export async function fetchWorkflowCatalog(): Promise<WorkflowDefinition[]> {
const res = await fetch(`${API_BASE}/api/v1/workflows`, {
headers: getAuthHeaders(),
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
throw new Error(`获取工作流规范目录失败: ${res.status}`);
@@ -117,7 +95,7 @@ export async function fetchProjectLineage(
? `${API_BASE}/api/v1/projects/${projectId}/lineage?doc_id=${encodeURIComponent(docId)}`
: `${API_BASE}/api/v1/projects/${projectId}/lineage`;
const res = await fetch(url, {
headers: getAuthHeaders(),
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
throw new Error(`获取文档血缘失败: ${res.status}`);
@@ -128,11 +106,11 @@ export async function fetchProjectLineage(
export async function runProjectWorkflow(
projectId: number,
workflowKey: string,
payload: Record<string, any>
): Promise<any> {
payload: Record<string, unknown>
): Promise<unknown> {
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflows/${workflowKey}/run`, {
method: 'POST',
headers: getAuthHeaders(),
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify(payload),
});
if (!res.ok) {