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
+6 -59
View File
@@ -46,7 +46,6 @@ import { FilePreviewPanel } from './components/doc/preview/FilePreviewPanel';
import { ShieldAlert } from 'lucide-react';
import { LoginView } from './components/auth/LoginView';
import { FirstLoginPasswordModal } from './components/auth/FirstLoginPasswordModal';
import { DevRoleSwitcher } from './components/dev/DevRoleSwitcher';
import { authClient } from './api/auth';
import { streamChat, mapErrorInfo } from './api/chatStream';
import { streamTask, buildTaskRequest, buildResumeRequest } from './api/taskStream';
@@ -120,7 +119,6 @@ import {
saveUsers,
migrateAndClearLegacyMessages,
resolveActiveSessionForUser,
ensurePrivateSessionsForUser,
loadProjects,
saveProjects,
filterProjectSessionsForUser,
@@ -156,7 +154,6 @@ import { subscribeTaskRunEvents, createTaskRun, cancelTaskRun, getTaskRun } from
async function readAttachmentsAsMaterials(
attachments: { name: string; type: string; docId?: string; fileRaw?: File | Blob; assetId?: number }[],
projectId?: number,
userId?: number,
): Promise<TaskRunMaterial[]> {
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
const results: TaskRunMaterial[] = [];
@@ -172,7 +169,7 @@ async function readAttachmentsAsMaterials(
if (!projectId || projectId <= 0) {
throw new Error(`附件 ${att.name} 需要先登记为项目资产(缺少 project_id)`);
}
const uploaded = await uploadProjectAsset(projectId, att.fileRaw as Blob, att.name, userId);
const uploaded = await uploadProjectAsset(projectId, att.fileRaw as Blob, att.name);
results.push({ filename: uploaded.filename || att.name, asset_id: uploaded.asset_id });
continue;
}
@@ -643,7 +640,7 @@ export default function App() {
// 认证与会话状态
const [session, setSession] = useState<LoginResult | null>(() => authClient.loadSavedSession());
const [systemEnv, setSystemEnv] = useState<string>('dev');
const [systemEnv, setSystemEnv] = useState<string>('');
const [systemAuthConfig, setSystemAuthConfig] = useState<SystemAuthConfig>({
allowRegistration: true,
requireApproval: false,
@@ -1249,7 +1246,7 @@ export default function App() {
}
if (id.startsWith('doc-free-') || targetDoc.governanceMode === 'freeform') {
fetchFreeformDoc(id, currentUser.id)
fetchFreeformDoc(id)
.then((detail) => {
if (detail && detail.content_markdown) {
const fullAST = markdownToBlockAST(detail.content_markdown, {
@@ -1382,7 +1379,6 @@ export default function App() {
currentProjectId,
undefined,
message.contentSha256,
currentUser.id
);
resData = resp.data;
} catch (serverErr: any) {
@@ -1460,8 +1456,8 @@ export default function App() {
const fetchDocs = async () => {
try {
const [privateSummaries, sharedSummaries] = await Promise.all([
listFreeformDocs('my_private', currentProjectId, currentUser.id).catch(() => []),
listFreeformDocs('project_shared', currentProjectId, currentUser.id).catch(() => []),
listFreeformDocs('my_private', currentProjectId).catch(() => []),
listFreeformDocs('project_shared', currentProjectId).catch(() => []),
]);
if (!active) return;
const allSummaries = [...privateSummaries, ...sharedSummaries];
@@ -2617,7 +2613,6 @@ export default function App() {
const materials = await readAttachmentsAsMaterials(
processedAttachments || [],
baseTask.projectId,
baseTask.userId,
);
const task: TaskRunContext = materials.length > 0 ? { ...baseTask, materials } : baseTask;
runTaskAttempt(task, asstId, 'run', 0);
@@ -3673,7 +3668,7 @@ export default function App() {
}
/>
{/* 用户在当前激活项目中的法定职责与权限矩阵弹窗 (支持快速模拟账号切换) */}
{/* 用户在当前激活项目中的法定职责与权限矩阵弹窗 */}
<UserProjectRightsModal
isOpen={rightsModalOpen}
onClose={() => setRightsModalOpen(false)}
@@ -3681,22 +3676,7 @@ export default function App() {
currentProjectTitle={sessions.find((s) => s.sessionId === activeSessionId)?.title || '当前项目'}
deliverables={visibleDeliverables}
activeDocId={activeDocId}
allUsers={allUsers}
canAssignLead={canAssignLead}
allowDevSwitch={systemEnv === 'dev'}
onSwitchUser={(u) => {
const fresh = allUsers.find((x) => x.id === u.id) || u;
setCurrentUser(fresh);
const updatedSession: LoginResult = {
token: session?.token || `dev-token-${fresh.id}`,
user: fresh as any,
mustChangePassword: false,
env: session?.env || systemEnv,
allowDevSwitch: session?.allowDevSwitch ?? (systemEnv === 'dev'),
};
setSession(updatedSession);
authClient.saveSession(updatedSession);
}}
onSelectDoc={handleSelectDoc}
onRoleChangeForDoc={handleRoleChangeForDoc}
/>
@@ -3758,39 +3738,6 @@ export default function App() {
env={systemEnv}
/>
{/* 研发调试快捷身份切换浮窗 (仅在 DEV 环境生效,生产与测试环境彻底剥离) */}
<DevRoleSwitcher
env={systemEnv}
currentUser={currentUser}
allUsers={allUsers}
onSwitchUser={(newUser) => {
const fresh = allUsers.find((u) => u.id === newUser.id) || newUser;
setCurrentUser(fresh);
const updatedSession: LoginResult = {
token: session?.token || `dev-token-${fresh.id}`,
user: fresh as any,
mustChangePassword: false,
env: session?.env || systemEnv,
allowDevSwitch: session?.allowDevSwitch ?? (systemEnv === 'dev'),
};
setSession(updatedSession);
authClient.saveSession(updatedSession);
// 确保新用户对已知项目具有独立私聊会话
const updatedSessions = ensurePrivateSessionsForUser(sessions, fresh.id);
setSessions(updatedSessions);
// 切换到目标用户在当前项目的私聊会话
const nextActiveId = resolveActiveSessionForUser(
updatedSessions,
fresh.id,
currentProjectId,
activeSessionId
);
if (nextActiveId) {
setActiveSessionId(nextActiveId);
}
}}
/>
{/* 单文档可见性覆盖弹窗 */}
<DocVisibilityModal
isOpen={docVisibilityModalOpen}
+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) {
-137
View File
@@ -1,137 +0,0 @@
import React, { useState } from 'react';
import { Sparkles, ChevronUp, ChevronDown, Check, UserCheck } from 'lucide-react';
import { UserProfile } from '../../types/docgov';
interface DevRoleSwitcherProps {
env?: string;
currentUser: UserProfile;
allUsers: UserProfile[];
onSwitchUser: (user: UserProfile) => void;
}
export const DevRoleSwitcher: React.FC<DevRoleSwitcherProps> = ({
env = 'dev',
currentUser,
allUsers,
onSwitchUser,
}) => {
const [expanded, setExpanded] = useState(false);
// 严格环境红线:非 dev 环境绝对不渲染任何 DOM 内容
if (env !== 'dev') {
return null;
}
return (
<div
style={{
position: 'fixed',
bottom: '16px',
right: '16px',
zIndex: 50,
fontFamily: 'var(--font-sans)',
userSelect: 'none',
}}
>
<div
style={{
width: '280px',
backgroundColor: 'var(--bg-panel, #131820)',
borderRadius: '12px',
border: '1px solid var(--border-strong)',
boxShadow: 'var(--shadow-lg)',
overflow: 'hidden',
backdropFilter: 'blur(8px)',
}}
>
{/* 折叠收起顶栏 */}
<div
onClick={() => setExpanded(!expanded)}
style={{
padding: '8px 12px',
backgroundColor: 'var(--bg-subtle, #1A212C)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
borderBottom: expanded ? '1px solid var(--border)' : 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Sparkles size={14} style={{ color: 'var(--amber-500)' }} />
<span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-strong)' }}>
DEV 调试切人
</span>
<span
style={{
fontSize: '10px',
padding: '1px 5px',
borderRadius: '4px',
backgroundColor: 'rgba(242, 169, 59, 0.15)',
color: 'var(--amber-500)',
fontWeight: 600,
}}
>
DEV ONLY
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '11px', color: 'var(--text-muted)' }}>
<span style={{ color: 'var(--brand-500)', fontWeight: 600 }}>{currentUser.name}</span>
{expanded ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</div>
</div>
{/* 展开的快速切换列表 */}
{expanded && (
<div
style={{
padding: '8px',
display: 'flex',
flexDirection: 'column',
gap: '4px',
maxHeight: '220px',
overflowY: 'auto',
backgroundColor: 'var(--bg-card, #151B25)',
}}
>
<div style={{ fontSize: '10px', color: 'var(--text-faint)', padding: '2px 4px' }}>
点击一键切换身份,实测立项、指派与文档权限:
</div>
{allUsers.map((u) => {
const isCurrent = u.id === currentUser.id;
return (
<button
key={u.id}
type="button"
onClick={() => onSwitchUser(u)}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 10px',
borderRadius: '6px',
fontSize: '12px',
backgroundColor: isCurrent ? 'rgba(79, 107, 255, 0.12)' : 'transparent',
border: isCurrent ? '1px solid rgba(79, 107, 255, 0.3)' : '1px solid transparent',
color: isCurrent ? 'var(--brand-500)' : 'var(--text)',
cursor: 'pointer',
textAlign: 'left',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<UserCheck size={13} style={{ color: isCurrent ? 'var(--brand-500)' : 'var(--text-muted)' }} />
<span style={{ fontWeight: isCurrent ? 600 : 400 }}>{u.name}</span>
<span style={{ fontSize: '10px', color: 'var(--text-faint)' }}>({u.position})</span>
</div>
{isCurrent && <Check size={13} style={{ color: 'var(--brand-500)' }} />}
</button>
);
})}
</div>
)}
</div>
</div>
);
};
+3 -30
View File
@@ -6,7 +6,6 @@ import {
Plus,
Trash2,
Check,
UserCheck,
Users,
Key,
Info,
@@ -23,7 +22,6 @@ export interface SettingsModalProps {
users: UserProfile[];
currentUser: UserProfile;
onUpdateUserRole: (userId: number, roleId: string) => void;
onSwitchUser: (user: UserProfile) => void;
}
const ALL_PERMISSION_ITEMS: {
@@ -84,7 +82,6 @@ export const SettingsModal: FC<SettingsModalProps> = ({
users,
currentUser,
onUpdateUserRole,
onSwitchUser,
}) => {
const [activeTab, setActiveTab] = useState<'roles' | 'users'>('roles');
const [selectedRoleId, setSelectedRoleId] = useState<string>(roles[0]?.id || 'role-admin');
@@ -779,35 +776,11 @@ export const SettingsModal: FC<SettingsModalProps> = ({
</select>
</div>
{/* 切换登录人按钮 */}
{!isCurrent ? (
<button
onClick={() => {
onSwitchUser(u);
showSavedNotification();
}}
style={{
padding: '6px 14px',
borderRadius: '6px',
border: '1px solid var(--brand-500)',
backgroundColor: 'var(--brand-500)',
color: '#fff',
fontSize: '12px',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<UserCheck size={13} />
<span>切换为此人登录</span>
</button>
) : (
{isCurrent ? (
<span style={{ fontSize: '12px', color: 'var(--brand-500)', fontWeight: 600, width: '120px', textAlign: 'center' }}>
✓ 正在以此身份交互
✓ 当前登录身份
</span>
)}
) : null}
</div>
</div>
);
@@ -17,10 +17,7 @@ interface UserProjectRightsModalProps {
currentProjectTitle: string;
deliverables: DeliverableItem[];
activeDocId: string;
allUsers?: UserProfile[];
canAssignLead?: boolean;
onSwitchUser?: (user: UserProfile) => void;
allowDevSwitch?: boolean;
onSelectDoc: (docId: string) => void;
onRoleChangeForDoc?: (docId: string, role: DocPermissionRole) => void;
}
@@ -32,10 +29,7 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
currentProjectTitle,
deliverables,
activeDocId,
allUsers,
canAssignLead = true,
onSwitchUser,
allowDevSwitch = true,
onSelectDoc,
onRoleChangeForDoc,
}) => {
@@ -567,33 +561,7 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
justifyContent: 'space-between',
}}
>
{allowDevSwitch && allUsers && allUsers.length > 0 ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontSize: '11px', color: 'var(--amber-400)', fontWeight: 600 }}>[DEV 调试切人]:</span>
{allUsers.map((u) => {
const isCurrent = u.id === currentUser.id;
return (
<button
key={u.id}
onClick={() => onSwitchUser?.(u)}
style={{
padding: '4px 10px',
borderRadius: '4px',
border: isCurrent ? '1px solid var(--brand-500)' : '1px solid var(--border)',
backgroundColor: isCurrent ? 'var(--brand-500)' : 'var(--bg-panel, #131820)',
color: isCurrent ? '#fff' : 'var(--text)',
fontSize: '11px',
fontWeight: isCurrent ? 600 : 400,
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
{u.name} ({u.position.slice(0, 4)})
</button>
);
})}
</div>
) : <div />}
<div />
<button
onClick={onClose}
+2 -8
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createInMemoryAuthClient } from '../api/auth';
import { createInMemoryAuthClient } from '../api/auth.local';
import { DEFAULT_ROLES, hasPermission } from './rbac';
import { UserProfile } from '../types/docgov';
@@ -122,22 +122,16 @@ test('全链路场景 3: 管理员开通账号首次强制改密闭环', async (
assert.equal(freshSession.mustChangePassword, false);
});
test('全链路场景 4: 环境隔离守卫(dev 允许切人,test/main 彻底禁用)', async () => {
// DEV 环境
test('全链路场景 4: 环境标识随登录会话返回(dev/test/main)', async () => {
const devClient = createInMemoryAuthClient({ env: 'dev' });
const devSession = await devClient.login({ username: 'admin', password: 'admin-secret-password-123' });
assert.equal(devSession.env, 'dev');
assert.equal(devSession.allowDevSwitch, true);
// TEST 环境
const testClient = createInMemoryAuthClient({ env: 'test' });
const testSession = await testClient.login({ username: 'admin', password: 'admin-secret-password-123' });
assert.equal(testSession.env, 'test');
assert.equal(testSession.allowDevSwitch, false);
// MAIN 生产环境
const prodClient = createInMemoryAuthClient({ env: 'main' });
const prodSession = await prodClient.login({ username: 'admin', password: 'admin-secret-password-123' });
assert.equal(prodSession.env, 'main');
assert.equal(prodSession.allowDevSwitch, false);
});
-1
View File
@@ -26,7 +26,6 @@ export interface LoginResult {
user: UserAccount;
mustChangePassword?: boolean;
env: string;
allowDevSwitch: boolean;
}
export interface RegisterPayload {