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:
+24
-1
@@ -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={
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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 };
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
export type PermissionKey =
|
||||
| 'project:create' // 创建新项目/立项
|
||||
| 'project:assign_lead' // 设置/变更文档责任人(编制/审核/批准)
|
||||
| 'project:manage_members' // 管理项目成员(添加/调整/移出)
|
||||
| 'perm:manage' // 系统权限与角色管理配置 (进入管理中心增删改角色)
|
||||
| 'workflow:config_visibility' // 配置工作流默认可见性
|
||||
| 'doc:override_visibility' // 配置单文档可见性覆盖
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user