fix(web): align settings CAS, PDF preview, and collaboration UX guards

Wire expected_revision, blob PDF framing, mustChangePassword session rewrite,
client_key project create, no-project send disable, and right-rail persistence.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-20 07:05:06 +00:00
co-authored by Cursor
parent 5cd22c7327
commit c8dbefe8a1
20 changed files with 193 additions and 43 deletions
+24 -1
View File
@@ -84,6 +84,7 @@ import {
loadProjects,
filterProjectSessionsForUser,
getProjectMembership,
resetWorkbenchStorage,
} from './data/storage';
import { useChatWindow } from './hooks/useChatWindow';
import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync';
@@ -232,6 +233,7 @@ export default function App() {
);
const isCurrentProjectExited = currentMembership?.status === 'exited';
const chatBlockedNoProject = !currentProjectId || currentProjectId <= 0 || projects.length === 0;
// 当前用户专属的项目会话列表 (基于项目成员身份与退出留痕过滤)
const visibleSessions = useMemo(() => {
@@ -355,6 +357,7 @@ export default function App() {
const canManageProjectMembers = useMemo(() => {
if (!currentProject) return false;
if (hasPermission(currentUser, roles, 'perm:manage', capabilities)) return true;
if (hasPermission(currentUser, roles, 'project:manage_members', capabilities)) return true;
if (currentUser.id === currentProject.creatorId) return true;
const membership = currentProject.members.find((m) => m.userId === currentUser.id);
return membership?.role === 'creator' || membership?.role === 'owner';
@@ -939,9 +942,26 @@ export default function App() {
useEffect(() => {
if (!recoverableRunKey) return;
let cancelled = false;
const skipped = new Set<string>();
const poll = async () => {
const activeIds = recoverableRunIds.filter((id) => !skipped.has(id));
if (activeIds.length === 0) return;
const records = await Promise.all(
recoverableRunIds.map((runId) => getTaskRun(runId).catch(() => null)),
activeIds.map(async (runId) => {
try {
return await getTaskRun(runId);
} catch (err) {
const status =
typeof err === 'object' && err !== null && ('httpStatus' in err || 'status' in err)
? Number((err as { httpStatus?: number; status?: number }).httpStatus ?? (err as { status?: number }).status)
: undefined;
// Stop hammering forbidden/missing runs (console 403 spam).
if (status === 403 || status === 404) {
skipped.add(runId);
}
return null;
}
}),
);
if (cancelled) return;
if (records.some((record) => record && record.status !== 'running')) {
@@ -1189,6 +1209,8 @@ export default function App() {
setSession(null);
setCapabilities([]);
setSessionValidated(false);
setProjects([]);
resetWorkbenchStorage();
});
};
@@ -1547,6 +1569,7 @@ export default function App() {
activeQuotes={activeQuotes}
handleRemoveQuote={handleRemoveQuote}
isCurrentProjectExited={isCurrentProjectExited}
chatBlockedNoProject={chatBlockedNoProject}
/>
}
docContent={
+14 -17
View File
@@ -39,28 +39,25 @@ test('mapUserAccount: prefers snake_case when both present', () => {
assert.equal(u.employeeId, 'SYS-000');
});
test('mapLoginResult + mapMeResult map nested user and top-level must_change_password', () => {
const login = mapLoginResult({
token: 'tok-1',
user: { ...snakeAdminUser, must_change_password: true },
must_change_password: true,
env: 'dev',
});
assert.equal(login.user.roleId, 'role-admin');
assert.equal(login.user.employeeId, 'SYS-000');
assert.equal(login.mustChangePassword, true);
assert.equal(login.user.mustChangePassword, true);
test('mapLoginResult/mapMeResult use top-level must_change_password as sole authority', () => {
// Nested true must not revive when top-level is false (post password-change cache).
const me = mapMeResult({
user: snakeAdminUser,
user: { ...snakeAdminUser, must_change_password: true },
must_change_password: false,
env: 'dev',
capabilities: ['admin:manage_users'],
capabilities: [],
});
assert.equal(me.user.roleId, 'role-admin');
assert.equal(me.user.employeeId, 'SYS-000');
assert.equal(me.mustChangePassword, false);
assert.deepEqual(me.capabilities, ['admin:manage_users']);
assert.equal(me.user.mustChangePassword, false);
const login = mapLoginResult({
token: 'tok-2',
user: { ...snakeAdminUser, must_change_password: true },
must_change_password: false,
env: 'dev',
});
assert.equal(login.mustChangePassword, false);
assert.equal(login.user.mustChangePassword, false);
});
test('mapUserAccountList maps admin users list', () => {
+16 -4
View File
@@ -58,6 +58,20 @@ export function mapUserAccountList(raw: unknown): UserAccount[] {
return raw.map((item) => mapUserAccount(item));
}
/**
* Top-level must_change_password is the sole authority when present.
* Never OR-merge with nested user.mustChangePassword — that revived a cleared
* flag after FirstLoginPasswordModal only patched the top-level field.
*/
function resolveMustChangePassword(
o: Record<string, unknown>,
nestedUser: { mustChangePassword?: boolean },
): boolean {
if (typeof o.must_change_password === 'boolean') return o.must_change_password;
if (typeof o.mustChangePassword === 'boolean') return o.mustChangePassword as boolean;
return !!nestedUser.mustChangePassword;
}
/** 映射 /auth/login 响应(token + user + must_change_password + env)。 */
export function mapLoginResult(raw: unknown): LoginResult {
if (!raw || typeof raw !== 'object') {
@@ -65,8 +79,7 @@ export function mapLoginResult(raw: unknown): LoginResult {
}
const o = raw as Record<string, unknown>;
const user = mapUserAccount(o.user);
const mustChangePassword =
pickBool(o, 'must_change_password', 'mustChangePassword') || !!user.mustChangePassword;
const mustChangePassword = resolveMustChangePassword(o, user);
const capabilities = Array.isArray(o.capabilities)
? (o.capabilities as unknown[]).map(String)
: undefined;
@@ -86,8 +99,7 @@ export function mapMeResult(raw: unknown): MeResult {
}
const o = raw as Record<string, unknown>;
const user = mapUserAccount(o.user);
const mustChangePassword =
pickBool(o, 'must_change_password', 'mustChangePassword') || !!user.mustChangePassword;
const mustChangePassword = resolveMustChangePassword(o, user);
return {
user: { ...user, mustChangePassword },
mustChangePassword,
+6
View File
@@ -14,6 +14,10 @@ export class ProjectSettingsApiError extends Error {
readonly status: number;
readonly code: string;
readonly issues: ProjectSettingsIssue[];
/** Alias for consumers that read httpStatus (e.g. useTaskRunAttempts). */
readonly httpStatus: number;
/** Alias for consumers that read errorCode (e.g. useTaskRunAttempts). */
readonly errorCode: string;
constructor(input: {
status: number;
@@ -26,6 +30,8 @@ export class ProjectSettingsApiError extends Error {
this.status = input.status;
this.code = input.code;
this.issues = input.issues ?? [];
this.httpStatus = input.status;
this.errorCode = input.code;
}
}
+6 -1
View File
@@ -48,11 +48,16 @@ export async function fetchSystemSettings(): Promise<SystemSetting[]> {
export async function updateSystemSetting(
key: string,
value: SystemConfigValue,
expectedRevision?: number,
): Promise<SystemSetting> {
const body: { value: SystemConfigValue; expected_revision?: number } = { value };
if (expectedRevision !== undefined) {
body.expected_revision = expectedRevision;
}
const resp = await authorizedFetch(`${BASE}/settings/${encodeURIComponent(key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
body: JSON.stringify(body),
});
if (!resp.ok) throw await readError(resp, '保存设置失败');
const data = (await resp.json()) as { setting?: SystemSetting } & SystemSetting;
+7 -1
View File
@@ -153,7 +153,13 @@ export async function getTaskRun(runId: string) {
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
throw new Error(`get task run failed: HTTP ${res.status}`);
const err = new Error(`get task run failed: HTTP ${res.status}`) as Error & {
httpStatus?: number;
status?: number;
};
err.httpStatus = res.status;
err.status = res.status;
throw err;
}
return res.json();
}
@@ -22,6 +22,12 @@ const ALL_PERMISSION_ITEMS: {
desc: '允许在文档治理抽屉为交付物指派编制人、审核人与批准人',
category: '项目与流程',
},
{
key: 'project:manage_members',
label: '管理项目成员',
desc: '允许添加、调整角色或移出项目成员(非项目 owner 时需单独授予)',
category: '项目与流程',
},
{
key: 'perm:manage',
label: '系统权限与角色管理',
@@ -49,7 +49,11 @@ export const SettingsSection: React.FC = () => {
const handleSave = async (setting: SystemSetting) => {
setSavingKey(setting.key);
try {
const updated = await updateSystemSetting(setting.key, drafts[setting.key]);
const updated = await updateSystemSetting(
setting.key,
drafts[setting.key],
typeof setting.revision === 'number' ? setting.revision : undefined,
);
setSettings((prev) => prev.map((s) => (s.key === updated.key ? updated : s)));
setDrafts((prev) => ({ ...prev, [updated.key]: updated.value }));
showTip(
@@ -57,7 +61,13 @@ export const SettingsSection: React.FC = () => {
updated.requiresRestart ? `已保存【${updated.label || updated.key}】,重启服务后生效` : `已保存【${updated.label || updated.key}】`,
);
} catch (err) {
showTip('error', err instanceof Error ? err.message : '保存设置失败');
const status = (err as Error & { status?: number }).status;
if (status === 409) {
showTip('error', '设置版本冲突,请刷新后重试');
void load();
} else {
showTip('error', err instanceof Error ? err.message : '保存设置失败');
}
} finally {
setSavingKey(null);
}
+9 -2
View File
@@ -50,6 +50,8 @@ export interface ChatColumnProps {
activeQuotes: QuotedMessage[];
handleRemoveQuote: (index: number) => void;
isCurrentProjectExited: boolean;
/** When true, user has no project context and cannot send chat. */
chatBlockedNoProject?: boolean;
}
export function ChatColumn({
@@ -87,6 +89,7 @@ export function ChatColumn({
activeQuotes,
handleRemoveQuote,
isCurrentProjectExited,
chatBlockedNoProject = false,
}: ChatColumnProps) {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
@@ -234,8 +237,12 @@ export function ChatColumn({
onStopStreaming={handleStopStreaming}
quotes={activeQuotes}
onRemoveQuote={handleRemoveQuote}
disabled={isCurrentProjectExited}
disabledMessage="您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。"
disabled={isCurrentProjectExited || chatBlockedNoProject}
disabledMessage={
chatBlockedNoProject
? '请先创建或加入项目后再开始对话'
: '您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。'
}
/>
</div>
);
@@ -7,38 +7,68 @@ interface PdfViewerProps {
fileName?: string;
}
export const PdfViewer: React.FC<PdfViewerProps> = ({ fileRaw, fileUrl }) => {
export const PdfViewer: React.FC<PdfViewerProps> = ({ fileRaw, fileUrl, fileName }) => {
const [activeUrl, setActiveUrl] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let createdUrl: string | null = null;
let cancelled = false;
async function loadPdf() {
setLoading(true);
setError(null);
setActiveUrl('');
if (fileUrl) {
setActiveUrl(fileUrl);
setLoading(false);
} else if (fileRaw) {
try {
createdUrl = URL.createObjectURL(fileRaw);
let blob: Blob | null = null;
if (fileRaw) {
blob = fileRaw;
} else if (fileUrl) {
// Always fetch → blob → createObjectURL so iframe never loads a
// same-origin URL that carries X-Frame-Options: DENY / frame-ancestors 'none'.
const res = await fetch(fileUrl);
if (!res.ok) {
throw new Error(`无法获取 PDF 网络资源: ${res.statusText || res.status}`);
}
blob = await res.blob();
}
if (cancelled) return;
if (!blob) {
if (fileName) {
setError(
`文件「${fileName}」的本地内容已释放(页面刷新后浏览器会清除内存中的 File 对象)。如需预览,请重新上传该文件。`,
);
} else {
setError('未检测到有效的 PDF 文件资源');
}
setLoading(false);
return;
}
createdUrl = URL.createObjectURL(blob);
setActiveUrl(createdUrl);
setLoading(false);
} catch (err: any) {
} catch (err: unknown) {
if (cancelled) return;
console.error('无法创建 PDF 预览链接:', err);
setError('生成 PDF 预览地址失败');
setError(err instanceof Error ? err.message : '生成 PDF 预览地址失败');
setLoading(false);
}
} else {
setError('未检测到有效的 PDF 文件资源');
setLoading(false);
}
void loadPdf();
return () => {
cancelled = true;
if (createdUrl && typeof window !== 'undefined' && typeof window.URL?.revokeObjectURL === 'function') {
window.URL.revokeObjectURL(createdUrl);
}
};
}, [fileRaw, fileUrl]);
}, [fileRaw, fileUrl, fileName]);
if (error) {
return (
+12 -2
View File
@@ -6,6 +6,7 @@ import { ChatColumn } from './ChatColumn';
import { RightDocRail } from './RightDocRail';
import { ProjectSession } from '../../types/workbench';
import { UserRole, UserProfile } from '../../types/docgov';
import { loadRightRailCollapsed, saveRightRailCollapsed } from '../../data/storage';
interface AppShellProps {
sessions: ProjectSession[];
@@ -62,12 +63,13 @@ export const AppShell: React.FC<AppShellProps> = ({
const [leftWidth, setLeftWidth] = useState<number>(280);
const [leftCollapsed, setLeftCollapsed] = useState<boolean>(false);
const [rightWidth, setRightWidth] = useState<number>(500);
const [rightCollapsed, setRightCollapsed] = useState<boolean>(false);
const [rightCollapsed, setRightCollapsed] = useState<boolean>(() => loadRightRailCollapsed(true));
useEffect(() => {
const onResize = () => {
const narrow = window.innerWidth <= 880;
if (narrow) {
setLeftCollapsed(true);
// Narrow-screen force-collapse must not overwrite the persisted preference.
setRightCollapsed(true);
}
};
@@ -76,6 +78,14 @@ export const AppShell: React.FC<AppShellProps> = ({
return () => window.removeEventListener('resize', onResize);
}, []);
const toggleRightCollapsed = () => {
setRightCollapsed((prev) => {
const next = !prev;
saveRightRailCollapsed(next);
return next;
});
};
const currentSession =
sessions.find((s) => s.sessionId === activeSessionId) || sessions[0] || {
sessionId: 'sess-default',
@@ -126,7 +136,7 @@ export const AppShell: React.FC<AppShellProps> = ({
leftCollapsed={leftCollapsed}
onToggleLeft={() => setLeftCollapsed(!leftCollapsed)}
rightCollapsed={rightCollapsed}
onToggleRight={() => setRightCollapsed(!rightCollapsed)}
onToggleRight={toggleRightCollapsed}
activeWorkflowKey={activeWorkflowKey}
workflowVersion={workflowVersion}
lifecycleStatus={lifecycleStatus}
+5 -1
View File
@@ -213,7 +213,11 @@ export function WorkbenchView({
user={currentUser}
onPasswordChanged={() => {
if (!session) return;
const updated = { ...session, mustChangePassword: false };
const updated = {
...session,
mustChangePassword: false,
user: { ...session.user, mustChangePassword: false },
};
setSession(updated);
authClient.saveSession(updated);
}}
@@ -32,6 +32,7 @@ export interface UserSettingsModalProps {
const ALL_PERMISSION_DESCRIPTIONS: { key: string; name: string; desc: string; category: string }[] = [
{ key: 'project:create', name: '创建新项目/立项', desc: '具备在协同工作台开启全新研发项目并指派阶段模板的权限', category: '项目协作' },
{ key: 'project:assign_lead', name: '设置文档责任人', desc: '在文档治理面板为交付物指派编制人、审核人与批准人', category: '项目协作' },
{ key: 'project:manage_members', name: '管理项目成员', desc: '添加、调整角色或移出项目成员(非项目创建人时需单独授予)', category: '项目协作' },
{ key: 'perm:manage', name: '系统权限与角色管理', desc: '增删改全局职位角色定义与权限矩阵 (管理员独占)', category: '权限安全' },
{ key: 'workflow:config_visibility', name: '配置工作流默认可见性', desc: '调整业务阶段中各受控交付物的基线查阅准入边界', category: '权限安全' },
{ key: 'doc:override_visibility', name: '配置单文档可见性覆盖', desc: '针对特定核心机密交付物设定独占白名单或责任人收紧', category: '权限安全' },
+1
View File
@@ -1,6 +1,7 @@
import { ProjectInfo, ProjectMember, ProjectSession } from '../types/workbench';
import { DEFAULT_PROJECTS } from '@demo-seeds';
/** Must stay in sync with STORAGE_KEYS.PROJECTS in storage.ts */
const STORAGE_KEY_PROJECTS = 'smdf_agent_workbench_v6_projects';
export { DEFAULT_PROJECTS };
+11
View File
@@ -32,6 +32,8 @@ export const STORAGE_KEYS = {
DOC_CACHE: `${STORAGE_PREFIX}doc_cache`,
ROLES: `${STORAGE_PREFIX}roles`,
USERS: `${STORAGE_PREFIX}users_v7`,
PROJECTS: `${STORAGE_PREFIX}projects`,
RIGHT_RAIL_COLLAPSED: `${STORAGE_PREFIX}right_rail_collapsed`,
};
export {
@@ -361,3 +363,12 @@ export function resetWorkbenchStorage(): void {
Object.values(STORAGE_KEYS).forEach((k) => window.localStorage.removeItem(k));
window.localStorage.removeItem(DEMO_SEED_PURGE_KEY);
}
/** Default true = collapsed (user preference for right doc rail). */
export function loadRightRailCollapsed(fallback = true): boolean {
return safeGetItem<boolean>(STORAGE_KEYS.RIGHT_RAIL_COLLAPSED, fallback);
}
export function saveRightRailCollapsed(collapsed: boolean): void {
safeSetItem(STORAGE_KEYS.RIGHT_RAIL_COLLAPSED, collapsed);
}
@@ -73,6 +73,17 @@ export function useAuthenticatedWorkspaceSync(input: AuthenticatedWorkspaceSyncI
.then((me) => {
setCapabilities(me.capabilities ?? []);
setCurrentUser(mergeSessionUser(me.user, match));
// Rewrite session from /auth/me so a polluted local mustChangePassword
// (nested user flag) cannot survive refresh after a successful change.
const nextSession = {
...saved,
user: { ...saved.user, ...me.user, mustChangePassword: me.mustChangePassword },
mustChangePassword: me.mustChangePassword,
env: me.env || saved.env,
capabilities: me.capabilities ?? saved.capabilities ?? [],
};
setSession(nextSession);
authClient.saveSession(nextSession);
setSessionValidated(true);
})
.catch(() => {
+5
View File
@@ -78,8 +78,13 @@ export function useCreateProjectConfirm(input: CreateProjectConfirmInput) {
const ensured = await ensureCreatedProjectWithMembers({
bootstrapProjectId: createProjectBootstrapIdRef.current,
createProject: async () => {
const clientKey =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? `proj-${crypto.randomUUID()}`
: `proj-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const created = await createProject({
name: data.title,
client_key: clientKey,
device_type: data.deviceType || undefined,
intended_use: data.productDomain === 'non_medical_software' ? undefined : data.description,
product_domain: data.productDomain,
+2
View File
@@ -72,6 +72,8 @@ export interface CreateProjectInput {
problem_statement?: string;
target_users?: string;
target_outcome?: string;
/** Idempotency key; same creator+key returns the existing project. */
client_key?: string;
/** @deprecated Accepted by API for catalog checks only; create dialog must not send this. */
initial_workflow_key?: string;
}
+1
View File
@@ -1,6 +1,7 @@
export type PermissionKey =
| 'project:create' // 创建新项目/立项
| 'project:assign_lead' // 设置/变更文档责任人(编制/审核/批准)
| 'project:manage_members' // 管理项目成员(添加/调整/移出)
| 'perm:manage' // 系统权限与角色管理配置 (进入管理中心增删改角色)
| 'workflow:config_visibility' // 配置工作流默认可见性
| 'doc:override_visibility' // 配置单文档可见性覆盖
+2
View File
@@ -49,6 +49,8 @@ export interface SystemSetting {
writable: boolean;
requiresRestart: boolean;
description: string;
/** CAS revision for versioned settings (e.g. workflow.allow_author_only_approval). */
revision?: number;
updatedAt: string;
updatedBy: string;
}