diff --git a/web/src/App.tsx b/web/src/App.tsx index 40ff42b3..fa5d6f48 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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(); 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={ diff --git a/web/src/api/authUserMap.test.ts b/web/src/api/authUserMap.test.ts index 1efe73e4..263ca95f 100644 --- a/web/src/api/authUserMap.test.ts +++ b/web/src/api/authUserMap.test.ts @@ -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', () => { diff --git a/web/src/api/authUserMap.ts b/web/src/api/authUserMap.ts index 357be526..79959871 100644 --- a/web/src/api/authUserMap.ts +++ b/web/src/api/authUserMap.ts @@ -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, + 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; 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; 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, diff --git a/web/src/api/projectSettingsError.ts b/web/src/api/projectSettingsError.ts index cc0aa75a..030cfa36 100644 --- a/web/src/api/projectSettingsError.ts +++ b/web/src/api/projectSettingsError.ts @@ -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; } } diff --git a/web/src/api/systemAdmin.ts b/web/src/api/systemAdmin.ts index c8591ccc..1e334aab 100644 --- a/web/src/api/systemAdmin.ts +++ b/web/src/api/systemAdmin.ts @@ -48,11 +48,16 @@ export async function fetchSystemSettings(): Promise { export async function updateSystemSetting( key: string, value: SystemConfigValue, + expectedRevision?: number, ): Promise { + 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; diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index 406c0717..a8755dbb 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -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(); } diff --git a/web/src/components/admin/console/sections/RolesSection.tsx b/web/src/components/admin/console/sections/RolesSection.tsx index 122aa3e9..cb3e50e2 100644 --- a/web/src/components/admin/console/sections/RolesSection.tsx +++ b/web/src/components/admin/console/sections/RolesSection.tsx @@ -22,6 +22,12 @@ const ALL_PERMISSION_ITEMS: { desc: '允许在文档治理抽屉为交付物指派编制人、审核人与批准人', category: '项目与流程', }, + { + key: 'project:manage_members', + label: '管理项目成员', + desc: '允许添加、调整角色或移出项目成员(非项目 owner 时需单独授予)', + category: '项目与流程', + }, { key: 'perm:manage', label: '系统权限与角色管理', diff --git a/web/src/components/admin/console/sections/SettingsSection.tsx b/web/src/components/admin/console/sections/SettingsSection.tsx index c93ba25d..d9d4fc92 100644 --- a/web/src/components/admin/console/sections/SettingsSection.tsx +++ b/web/src/components/admin/console/sections/SettingsSection.tsx @@ -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); } diff --git a/web/src/components/chat/ChatColumn.tsx b/web/src/components/chat/ChatColumn.tsx index 03a35d00..d7970729 100644 --- a/web/src/components/chat/ChatColumn.tsx +++ b/web/src/components/chat/ChatColumn.tsx @@ -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 (
@@ -234,8 +237,12 @@ export function ChatColumn({ onStopStreaming={handleStopStreaming} quotes={activeQuotes} onRemoveQuote={handleRemoveQuote} - disabled={isCurrentProjectExited} - disabledMessage="您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。" + disabled={isCurrentProjectExited || chatBlockedNoProject} + disabledMessage={ + chatBlockedNoProject + ? '请先创建或加入项目后再开始对话' + : '您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。' + } />
); diff --git a/web/src/components/doc/preview/renderers/PdfViewer.tsx b/web/src/components/doc/preview/renderers/PdfViewer.tsx index c8bec3b2..d1973e1c 100644 --- a/web/src/components/doc/preview/renderers/PdfViewer.tsx +++ b/web/src/components/doc/preview/renderers/PdfViewer.tsx @@ -7,38 +7,68 @@ interface PdfViewerProps { fileName?: string; } -export const PdfViewer: React.FC = ({ fileRaw, fileUrl }) => { +export const PdfViewer: React.FC = ({ fileRaw, fileUrl, fileName }) => { const [activeUrl, setActiveUrl] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 ( diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 601d7164..49306e75 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -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 = ({ const [leftWidth, setLeftWidth] = useState(280); const [leftCollapsed, setLeftCollapsed] = useState(false); const [rightWidth, setRightWidth] = useState(500); - const [rightCollapsed, setRightCollapsed] = useState(false); + const [rightCollapsed, setRightCollapsed] = useState(() => 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 = ({ 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 = ({ leftCollapsed={leftCollapsed} onToggleLeft={() => setLeftCollapsed(!leftCollapsed)} rightCollapsed={rightCollapsed} - onToggleRight={() => setRightCollapsed(!rightCollapsed)} + onToggleRight={toggleRightCollapsed} activeWorkflowKey={activeWorkflowKey} workflowVersion={workflowVersion} lifecycleStatus={lifecycleStatus} diff --git a/web/src/components/layout/WorkbenchView.tsx b/web/src/components/layout/WorkbenchView.tsx index 6ef9e05c..3c67f185 100644 --- a/web/src/components/layout/WorkbenchView.tsx +++ b/web/src/components/layout/WorkbenchView.tsx @@ -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); }} diff --git a/web/src/components/user/UserSettingsModal.tsx b/web/src/components/user/UserSettingsModal.tsx index ad242a26..72556caf 100644 --- a/web/src/components/user/UserSettingsModal.tsx +++ b/web/src/components/user/UserSettingsModal.tsx @@ -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: '权限安全' }, diff --git a/web/src/data/projectsStorage.ts b/web/src/data/projectsStorage.ts index 162977b6..1e682460 100644 --- a/web/src/data/projectsStorage.ts +++ b/web/src/data/projectsStorage.ts @@ -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 }; diff --git a/web/src/data/storage.ts b/web/src/data/storage.ts index 32a9ef85..b3c1a5fb 100644 --- a/web/src/data/storage.ts +++ b/web/src/data/storage.ts @@ -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(STORAGE_KEYS.RIGHT_RAIL_COLLAPSED, fallback); +} + +export function saveRightRailCollapsed(collapsed: boolean): void { + safeSetItem(STORAGE_KEYS.RIGHT_RAIL_COLLAPSED, collapsed); +} diff --git a/web/src/hooks/useAuthenticatedWorkspaceSync.ts b/web/src/hooks/useAuthenticatedWorkspaceSync.ts index f03a8c6d..976c3a5c 100644 --- a/web/src/hooks/useAuthenticatedWorkspaceSync.ts +++ b/web/src/hooks/useAuthenticatedWorkspaceSync.ts @@ -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(() => { diff --git a/web/src/hooks/useCreateProjectConfirm.ts b/web/src/hooks/useCreateProjectConfirm.ts index 293bc1fe..a7bda0df 100644 --- a/web/src/hooks/useCreateProjectConfirm.ts +++ b/web/src/hooks/useCreateProjectConfirm.ts @@ -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, diff --git a/web/src/types/projectWorkflow.ts b/web/src/types/projectWorkflow.ts index a807b34b..eb0d7d3d 100644 --- a/web/src/types/projectWorkflow.ts +++ b/web/src/types/projectWorkflow.ts @@ -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; } diff --git a/web/src/types/rbac.ts b/web/src/types/rbac.ts index c0d5e87a..ba3439e6 100644 --- a/web/src/types/rbac.ts +++ b/web/src/types/rbac.ts @@ -1,6 +1,7 @@ export type PermissionKey = | 'project:create' // 创建新项目/立项 | 'project:assign_lead' // 设置/变更文档责任人(编制/审核/批准) + | 'project:manage_members' // 管理项目成员(添加/调整/移出) | 'perm:manage' // 系统权限与角色管理配置 (进入管理中心增删改角色) | 'workflow:config_visibility' // 配置工作流默认可见性 | 'doc:override_visibility' // 配置单文档可见性覆盖 diff --git a/web/src/types/systemAdmin.ts b/web/src/types/systemAdmin.ts index 5c2272ae..e9cf1595 100644 --- a/web/src/types/systemAdmin.ts +++ b/web/src/types/systemAdmin.ts @@ -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; }