From 91d251520588dee53ed0578edf88eb361d0511f0 Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 16 Sep 2026 22:46:45 +0800 Subject: [PATCH] Unify admin-class workspace create and restore Add-workspace UI. Give admin the same permissions as super/fallback, occupy directoryFlow at priority 1 so the button renders without colliding with the native picker, and inject uiWorkspace for pickDirectory. Co-authored-by: Cursor --- uds-auth/.gitignore | 6 + uds-auth/lib/client.js | 122 +++++++++++++-------- uds-auth/lib/dsh-acl.js | 8 +- uds-auth/lib/i18n.js | 4 +- uds-auth/lib/middleware/auth-middleware.js | 5 +- uds-auth/lib/roles.js | 106 ++++++++---------- uds-auth/lib/session-acl.js | 5 +- uds-auth/package.json | 5 +- uds-auth/test/session-access.test.js | 96 ++++++++++------ 9 files changed, 211 insertions(+), 146 deletions(-) diff --git a/uds-auth/.gitignore b/uds-auth/.gitignore index 7a6d7df8..ec6f9b44 100644 --- a/uds-auth/.gitignore +++ b/uds-auth/.gitignore @@ -7,5 +7,11 @@ config.runtime.json .DS_Store Thumbs.db session-owners.json +session-owners.json.bak* user-workspaces.json skill-credentials.json + +# Local one-off probes / scratch (do not commit) +scripts/find-dsh-process.ps1 +scripts/inspect-session-sample.cjs +scripts/merge-sessions-into-workspace.ps1 diff --git a/uds-auth/lib/client.js b/uds-auth/lib/client.js index 4e86aff4..bf216434 100644 --- a/uds-auth/lib/client.js +++ b/uds-auth/lib/client.js @@ -15,7 +15,7 @@ window.__ModuleLoader__.load({ const { useCallback, useEffect, useLayoutEffect, useRef, useState } = React const name = 'uds-auth' - const inject = ['slots', 'locale'] + const inject = ['slots', 'locale', 'uiWorkspace'] const PAGE_SIZE = 50 const LOCALE_NS = 'uds-auth' @@ -33,7 +33,7 @@ window.__ModuleLoader__.load({ "ui.settingsTitle": "UAC 认证", "ui.settingsIntro": "工号+token 双校验;UAC 挂死时用应急账号 administrator 密码登录。", "ui.loginRequiredPage": "请先登录后查看此页", - "ui.roleHint": "当前角色:{role}。首位扫码登录且 roles.json 为空时会自动成为超管;普通 admin 需超管在「用户管理」提权后再扫码登录。应急账号 administrator 需超管先设密码,再在登录面板用账密登录。", + "ui.roleHint": "当前角色:{role}。超管只是身份标签,与管理员权限相同;首位扫码登录且 roles.json 为空时自动获得超管身份,也可在「用户管理」设为管理员。应急账号 administrator 需先设密码,再在登录面板用账密登录。", "ui.deployConfig": "部署配置", "ui.userSearchUrl": "用户搜索 URL(token 校验)", "ui.workspaceRoot": "工作区根目录(空=$DSH_HOME/user-workspaces)", @@ -173,7 +173,7 @@ window.__ModuleLoader__.load({ "ui.settingsTitle": "UAC Auth", "ui.settingsIntro": "EmpNo + token verification; when UAC is down, sign in with emergency account administrator.", "ui.loginRequiredPage": "Sign in to view this page", - "ui.roleHint": "Current role: {role}. The first QR login with an empty roles.json becomes super admin; grant admin in User management then re-scan. Set the emergency password before using administrator on the login panel.", + "ui.roleHint": "Current role: {role}. Super admin is only an identity label with the same permissions as admin; the first QR login with an empty roles.json gets that identity, or grant admin in User management. Set the emergency password before using administrator on the login panel.", "ui.deployConfig": "Deploy config", "ui.userSearchUrl": "User search URL (token verify)", "ui.workspaceRoot": "Workspace root (empty=$DSH_HOME/user-workspaces)", @@ -1793,17 +1793,71 @@ function reloadAfterLogin() { + // Fallback directoryFlow at priority 1 (NOT 0). + // Single-slot cells collide only on the exact priority; the shipped + // native/browse picker owns 0 ("lowest renders"). We occupy priority 1 so + // entries(hole).length > 0 → Add workspace button renders even when the + // official picker failed to mount, without crashing Loader. ctx.effect(() => { - let disposers = [] - const Gate = function UdsAuthDirectoryFlowGate(props) { - React.useEffect(() => { - if (props && props.open) { - try { props.onCancel && props.onCancel() } catch { /* ignore */ } + const Flow = function UdsAuthDirectoryFlow(props) { + const open = !!(props && props.open) + const armed = useRef(false) + const outcome = useRef(props) + outcome.current = props + const alive = useRef(true) + useEffect(() => { + alive.current = true + return () => { alive.current = false } + }, []) + useEffect(() => { + if (!open) { + armed.current = false + return } - }, [props && props.open]) + if (armed.current) return + armed.current = true + const can = document.documentElement.getAttribute('data-uds-can-create-ws') === '1' + if (!can) { + try { outcome.current.onCancel && outcome.current.onCancel() } catch { /* ignore */ } + return + } + let pickPromise + try { + const ui = ctx.uiWorkspace + if (ui && typeof ui.pickDirectory === 'function') { + pickPromise = ui.pickDirectory() + } else { + pickPromise = Promise.reject(new Error('directory picker unavailable')) + } + } catch (err) { + pickPromise = Promise.reject(err) + } + pickPromise.then( + (path) => { + if (!alive.current) return + if (path == null) { + try { outcome.current.onCancel && outcome.current.onCancel() } catch { /* ignore */ } + } else { + try { outcome.current.onPicked && outcome.current.onPicked(path) } catch { /* ignore */ } + } + }, + (reason) => { + if (!alive.current) return + const msg = reason instanceof Error ? reason.message : String(reason) + try { outcome.current.onError && outcome.current.onError(msg) } catch { /* ignore */ } + }, + ) + }, [open]) return null } - const installGate = () => { + + const disposers = [] + const clear = () => { + for (const d of disposers.splice(0)) { + try { d() } catch { /* ignore */ } + } + } + const install = () => { if (disposers.length) return for (const slotName of [ 'sidebar.workspaces.directoryFlow', @@ -1812,61 +1866,37 @@ function reloadAfterLogin() { try { disposers.push(ctx.slots.register({ name: slotName, - id: 'uds-auth-dir-gate', - order: 9999, - }, Gate)) - } catch { /* slot may be undeclared briefly */ } + id: 'uds-auth-dir-flow', + // Must differ from shipped picker priority 0. + priority: 1, + }, Flow)) + } catch { /* slot undeclared, or priority already taken — ignore */ } } } - const clearGate = () => { - for (const d of disposers) { - try { d() } catch { /* ignore */ } - } - disposers = [] - } - // Only shadow directoryFlow while anonymous. Logged-in users (including - // non-creators) keep the native/browse occupant so "Add workspace" still - // renders; CSS + RPC + Host ACL deny create for users without permission. - // Never location.reload() here — remount attr flips caused infinite refresh. - const sync = () => { - const loggedIn = document.documentElement.getAttribute('data-uds-logged-in') === '1' - if (loggedIn) { - clearGate() - return - } - installGate() - } + const injectOffs = [ 'sidebar.workspaces.directoryFlow', 'conversation.hero.workspace.directoryFlow', ].map((slotName) => { try { - return ctx.slots.inject(slotName, () => { sync() }) + return ctx.slots.inject(slotName, () => { install() }) } catch { return null } }) - sync() - const onAuth = () => { sync() } - window.addEventListener('uds-auth-changed', onAuth) - const mo = new MutationObserver(sync) - mo.observe(document.documentElement, { - attributes: true, - attributeFilter: ['data-uds-can-create-ws', 'data-uds-logged-in', 'data-uds-auth-ready'], - }) + install() return () => { - window.removeEventListener('uds-auth-changed', onAuth) - mo.disconnect() - clearGate() + clear() for (const off of injectOffs) { try { if (typeof off === 'function') off() } catch { /* ignore */ } } } - }, 'uds-auth: directory-flow-gate') + }, 'uds-auth: directory-flow-fallback') + // Anonymous / non-creators: CSS (data-uds-can-create-ws) + click lock + Host ACL. ctx.effect(() => { - // Open/choose workspace: admin / super_admin / fallback_admin (canCreateWorkspace). + // Open/choose workspace: admin-class (canCreateWorkspace). // Everyone else uses the auto-provisioned per-user workspace and must not open the picker. const CHOOSER = hostAriaSel('chooseWorkspace') // Inert composer: onClick lives on the card (cardWorkspaceTrigger), not the labeled node. diff --git a/uds-auth/lib/dsh-acl.js b/uds-auth/lib/dsh-acl.js index d0289353..0672688b 100644 --- a/uds-auth/lib/dsh-acl.js +++ b/uds-auth/lib/dsh-acl.js @@ -411,9 +411,13 @@ export function createSessionAccess({ return !!store.resolvePermissions(empNo).canViewAllSessions } if (identity.permissions?.canViewAllSessions) return true - // No store (tests / misconfig): keep legacy super visibility. + // No store (tests / misconfig): admin-class sees all by default. + // If permissions were supplied and view-all is off, respect that. + if (identity.permissions && Object.prototype.hasOwnProperty.call(identity.permissions, 'canViewAllSessions')) { + return !!identity.permissions.canViewAllSessions + } const role = identity.role || identity.userContext?.role - return role === 'super_admin' || role === 'fallback_admin' + return role === 'super_admin' || role === 'fallback_admin' || role === 'admin' || String(empNo) === 'administrator' } diff --git a/uds-auth/lib/i18n.js b/uds-auth/lib/i18n.js index 38d10d42..a52a2445 100644 --- a/uds-auth/lib/i18n.js +++ b/uds-auth/lib/i18n.js @@ -26,7 +26,7 @@ export const MESSAGES = { 'ui.settingsTitle': 'UAC 认证', 'ui.settingsIntro': '工号+token 双校验;UAC 挂死时用应急账号 administrator 密码登录。', 'ui.loginRequiredPage': '请先登录后查看此页', - 'ui.roleHint': '当前角色:{role}。首位扫码登录且 roles.json 为空时会自动成为超管;普通 admin 需超管在「用户管理」提权后再扫码登录。应急账号 administrator 需超管先设密码,再在登录面板用账密登录。', + 'ui.roleHint': '当前角色:{role}。超管只是身份标签,与管理员权限相同;首位扫码登录且 roles.json 为空时自动获得超管身份,也可在「用户管理」设为管理员。应急账号 administrator 需先设密码,再在登录面板用账密登录。', 'ui.deployConfig': '部署配置', 'ui.userSearchUrl': '用户搜索 URL(token 校验)', 'ui.workspaceRoot': '工作区根目录(空=$DSH_HOME/user-workspaces)', @@ -179,7 +179,7 @@ export const MESSAGES = { 'ui.settingsTitle': 'UAC Auth', 'ui.settingsIntro': 'EmpNo + token verification; when UAC is down, sign in with emergency account administrator.', 'ui.loginRequiredPage': 'Sign in to view this page', - 'ui.roleHint': 'Current role: {role}. The first QR login with an empty roles.json becomes super admin; grant admin in User management then re-scan. Set the emergency password before using administrator on the login panel.', + 'ui.roleHint': 'Current role: {role}. Super admin is only an identity label with the same permissions as admin; the first QR login with an empty roles.json gets that identity, or grant admin in User management. Set the emergency password before using administrator on the login panel.', 'ui.deployConfig': 'Deploy config', 'ui.userSearchUrl': 'User search URL (token verify)', 'ui.workspaceRoot': 'Workspace root (empty=$DSH_HOME/user-workspaces)', diff --git a/uds-auth/lib/middleware/auth-middleware.js b/uds-auth/lib/middleware/auth-middleware.js index 3782281c..9a7969b9 100644 --- a/uds-auth/lib/middleware/auth-middleware.js +++ b/uds-auth/lib/middleware/auth-middleware.js @@ -219,8 +219,11 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = { export function requirePermission(ctx, permission) { if (!ctx?.permissions) return false + // Legacy alias: "super_admin" means admin-class identity (超管 / 应急 / 管理员). if (permission === 'super_admin') { - return ctx.role === ROLES.SUPER_ADMIN || ctx.role === ROLES.FALLBACK_ADMIN + return ctx.role === ROLES.SUPER_ADMIN + || ctx.role === ROLES.FALLBACK_ADMIN + || ctx.role === ROLES.ADMIN } return !!ctx.permissions[permission] } diff --git a/uds-auth/lib/roles.js b/uds-auth/lib/roles.js index ffb96a7b..53b1134a 100644 --- a/uds-auth/lib/roles.js +++ b/uds-auth/lib/roles.js @@ -1,15 +1,15 @@ /** * uds-auth 角色存储 + 权限管理 * - * 角色: - * super_admin 所有权限 + 用户管理;默认可见全部会话(可在设置中关闭) - * fallback_admin 等同 super_admin(兜底 administrator) - * admin 无设置齿轮;仅看自己会话(含 @);可见渠道/系统会话;可创建工作区 - * user 仅看自己会话,无设置 + * 角色(身份标签;admin 级权限相同): + * super_admin 身份:首位扫码 bootstrap / 显式提权;权限 = admin 级 + * fallback_admin 身份:应急账号 administrator;权限 = admin 级 + * admin 身份:用户管理中提权;权限 = admin 级 + * user 仅看自己会话,无设置 / 不可建工作区 * - * 超管/应急默认全览开启;prefs.viewAllSessions === false 时关闭。 - * 设置齿轮仅超管/应急(canAccessSettings)。 - * 创建工作区:super_admin / fallback_admin / admin(扫码不可用时现场通常只有 admin)。 + * admin 级(isAdminClass)权限相同:用户管理、设置、建工作区、默认可看全部会话。 + * 超管只是身份;默认可持有该身份的包括 admin,以及首位扫码加入者(bootstrap)。 + * prefs.viewAllSessions === false 时关闭全览。 * 持久化: roles.json (roles + prefs + fallbackPasswordHash) */ import { createHash, randomBytes } from 'node:crypto' @@ -33,58 +33,44 @@ export const ROLES = { /** zh labels for list/search; UI should translate via i18n role.* keys. */ export const ROLE_LABELS = ROLE_LABELS_ZH +/** admin 级身份:超管 / 应急 / 管理员(权限相同,仅身份标签不同) */ +export function isAdminClass(role) { + return role === ROLES.SUPER_ADMIN + || role === ROLES.FALLBACK_ADMIN + || role === ROLES.ADMIN +} + /** * 计算角色权限 (纯函数) * @param {string} role - * @param {{ viewAllSessions?: boolean }} [opts] 个人偏好;超管默认可见全部 + * @param {{ viewAllSessions?: boolean }} [opts] 个人偏好;admin 级默认可见全部 */ export function computePermissions(role, opts = {}) { const viewAll = !!opts.viewAllSessions - switch (role) { - case ROLES.SUPER_ADMIN: - return { - canManageUsers: true, - canAccessSettings: true, - canToggleViewAllSessions: true, - canViewAllSessions: viewAll, - canViewSystemSessions: true, - canCreateWorkspace: true, - } - case ROLES.FALLBACK_ADMIN: - return { - canManageUsers: true, - canAccessSettings: true, - canToggleViewAllSessions: true, - canViewAllSessions: viewAll, - canViewSystemSessions: true, - canCreateWorkspace: true, - } - case ROLES.ADMIN: - return { - canManageUsers: false, - // 设置齿轮仅超管/应急;admin 仍可看渠道/系统会话,并可创建工作区 - canAccessSettings: false, - canToggleViewAllSessions: false, - canViewAllSessions: false, - canViewSystemSessions: true, - canCreateWorkspace: true, - } - default: // user / undefined - return { - canManageUsers: false, - canAccessSettings: false, - canToggleViewAllSessions: false, - canViewAllSessions: false, - canViewSystemSessions: false, - canCreateWorkspace: false, - } + if (isAdminClass(role)) { + return { + canManageUsers: true, + canAccessSettings: true, + canToggleViewAllSessions: true, + canViewAllSessions: viewAll, + canViewSystemSessions: true, + canCreateWorkspace: true, + } + } + // user / undefined + return { + canManageUsers: false, + canAccessSettings: false, + canToggleViewAllSessions: false, + canViewAllSessions: false, + canViewSystemSessions: false, + canCreateWorkspace: false, } } -/** 角色是否允许开启「查看全部会话」(仅超管 / 应急) */ +/** 角色是否允许开启「查看全部会话」(admin 级) */ export function canToggleViewAllSessions(role) { - return role === ROLES.SUPER_ADMIN - || role === ROLES.FALLBACK_ADMIN + return isAdminClass(role) } function hashPassword(password) { @@ -226,8 +212,8 @@ export class RolesStore { } /** - * 个人偏好:超管/应急默认开启查看全部;显式 false 才关闭。 - * admin/user 不会走到这里(resolvePermissions 里 allowToggle=false)。 + * 个人偏好:admin 级默认开启查看全部;显式 false 才关闭。 + * user 不会走到这里(resolvePermissions 里 allowToggle=false)。 */ isViewAllSessionsEnabled(empNo) { if (!empNo) return false @@ -318,18 +304,18 @@ export class RolesStore { return n } - // === 角色管理 (super_admin only) === + // === 角色管理 (admin 级) === /** * 设置用户角色 - * 保护性 invariant: 至少保留 1 个 super_admin + * 保护性 invariant: 至少保留 1 个 super_admin 身份(若表中曾有) */ async setRole(empNo, newRole, currentAdminRole) { - if (currentAdminRole !== ROLES.SUPER_ADMIN) { + if (!isAdminClass(currentAdminRole)) { throw codedError('forbidden_set_role') } - // invariant: 不能让系统变成 0 个 super_admin + // invariant: 不能让系统变成 0 个 super_admin(身份仍保留) const currentRole = this._roles.get(empNo) if (currentRole === ROLES.SUPER_ADMIN && newRole !== ROLES.SUPER_ADMIN) { const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN) @@ -345,7 +331,7 @@ export class RolesStore { /** 删除用户 */ async removeUser(empNo, currentAdminRole) { - if (currentAdminRole !== ROLES.SUPER_ADMIN) { + if (!isAdminClass(currentAdminRole)) { throw codedError('forbidden_remove_user') } const currentRole = this._roles.get(empNo) @@ -363,7 +349,7 @@ export class RolesStore { /** 确保用户存在 (如果不存在设为 user) */ ensureUser(empNo, currentAdminRole) { - if (currentAdminRole !== ROLES.SUPER_ADMIN) { + if (!isAdminClass(currentAdminRole)) { throw codedError('forbidden_add_user') } if (!this._roles.has(empNo)) { @@ -376,7 +362,7 @@ export class RolesStore { // === Fallback Administrator === setFallbackPassword(password, currentAdminRole) { - if (currentAdminRole !== ROLES.SUPER_ADMIN && currentAdminRole !== ROLES.FALLBACK_ADMIN) { + if (!isAdminClass(currentAdminRole)) { throw codedError('forbidden_set_fallback') } if (!password || password.length < 6) { @@ -388,7 +374,7 @@ export class RolesStore { } clearFallbackPassword(currentAdminRole) { - if (currentAdminRole !== ROLES.SUPER_ADMIN && currentAdminRole !== ROLES.FALLBACK_ADMIN) { + if (!isAdminClass(currentAdminRole)) { throw codedError('forbidden_clear_fallback') } this._fallbackPasswordHash = null diff --git a/uds-auth/lib/session-acl.js b/uds-auth/lib/session-acl.js index 0719c55b..aade0871 100644 --- a/uds-auth/lib/session-acl.js +++ b/uds-auth/lib/session-acl.js @@ -111,6 +111,9 @@ export function identityFromAls(rolesStore) { } } +/** @deprecated prefer isAdminClass — 超管只是身份,与 admin / 应急同权 */ export function isSuperLike(role) { - return role === ROLES.SUPER_ADMIN || role === ROLES.FALLBACK_ADMIN + return role === ROLES.SUPER_ADMIN + || role === ROLES.FALLBACK_ADMIN + || role === ROLES.ADMIN } diff --git a/uds-auth/package.json b/uds-auth/package.json index 418334f1..c04bda51 100644 --- a/uds-auth/package.json +++ b/uds-auth/package.json @@ -1,6 +1,6 @@ { "name": "uds-auth", - "version": "0.2.6", + "version": "0.2.11", "description": "UDS authentication plugin for DeepSeek Harness with multi-tenant session isolation", "type": "module", "main": "lib/index.js", @@ -65,7 +65,8 @@ "immediately": true, "inject": [ "@deepseek-ai/dsh-client-ui-slots", - "@deepseek-ai/dsh-client-locale" + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-workspace" ] } } diff --git a/uds-auth/test/session-access.test.js b/uds-auth/test/session-access.test.js index 49bf6c89..ccb36fa0 100644 --- a/uds-auth/test/session-access.test.js +++ b/uds-auth/test/session-access.test.js @@ -60,7 +60,7 @@ describe('createSessionAccess', () => { assert.equal(canAccessSession('s-other', superAdmin), false) }) - it('admin and user only see owned or own-workspace sessions by default', () => { + it('admin-class sees all by default; user only owned or own-workspace sessions', () => { const { canAccessSession, canSeeAll } = makeAccess({ 's-owned': 'u1', 's-peer': 'u2', @@ -68,39 +68,48 @@ describe('createSessionAccess', () => { const admin = { empNo: 'u1', role: 'admin', - permissions: computePermissions('admin'), + permissions: computePermissions('admin', { viewAllSessions: true }), } const user = { empNo: 'u1', role: 'user', permissions: computePermissions('user'), } - assert.equal(canSeeAll(admin), false) + assert.equal(canSeeAll(admin), true) assert.equal(canSeeAll(user), false) - assert.equal(admin.permissions.canToggleViewAllSessions, false) + assert.equal(admin.permissions.canToggleViewAllSessions, true) assert.equal(user.permissions.canToggleViewAllSessions, false) assert.equal(canAccessSession('s-owned', admin), true) + assert.equal(canAccessSession('s-peer', admin), true) assert.equal(canAccessSession('s-in-u1', user), true) assert.equal(canAccessSession('s-cwd', user, { cwd: '/ws/u1/project' }), true) - assert.equal(canAccessSession('s-peer', admin), false) assert.equal(canAccessSession('s-in-u2', user), false) assert.equal(canAccessSession('s-shared', user), false) assert.equal(canAccessSession('s-cwd-peer', user, { cwd: '/ws/u2/x' }), false) }) - it('admin cannot enable view-all even if preference flag is passed', () => { - const { canAccessSession, canSeeAll } = makeAccess({ 's-peer': 'u2' }) + it('admin can turn off view-all via preference (same as super_admin)', () => { + const store = new RolesStore() + store._roles.set('op', ROLES.ADMIN) + store.setViewAllSessions('op', false) + const ownersMap = new Map([['s-peer', 'u2']]) + const access = createSessionAccess({ + sessionAcl: { getOwner: (id) => ownersMap.get(String(id)) || null }, + userWorkspaces: { get: () => null, isUserPath: () => false }, + getWorkspaceRoot: () => '/ws', + getWorkspaceRegistry: () => ({ list: () => [] }), + rolesStore: store, + }) const admin = { - empNo: 'u1', + empNo: 'op', role: 'admin', permissions: computePermissions('admin', { viewAllSessions: true }), } - assert.equal(admin.permissions.canViewAllSessions, false) - assert.equal(admin.permissions.canToggleViewAllSessions, false) - assert.equal(canSeeAll(admin), false) - assert.equal(canAccessSession('s-peer', admin), false) + assert.equal(access.canSeeAll(admin), false) + assert.equal(access.canAccessSession('s-peer', admin), false) + assert.equal(store.resolvePermissions('op').canToggleViewAllSessions, true) }) it('missing owner does not deny when cwd is under user path', () => { @@ -111,26 +120,45 @@ describe('createSessionAccess', () => { }) it('admin can see unowned channel/system sessions outside user-workspaces', () => { - const { canAccessSession, isVisibleWorkspace } = makeAccess({}) + const store = new RolesStore() + store._roles.set('u1', ROLES.ADMIN) + store.setViewAllSessions('u1', false) + const accessOff = createSessionAccess({ + sessionAcl: { getOwner: () => null }, + userWorkspaces: { + get: () => null, + isUserPath: () => false, + }, + getWorkspaceRoot: () => '/ws', + getWorkspaceRegistry: () => ({ list: () => [] }), + rolesStore: store, + }) const admin = { empNo: 'u1', role: 'admin', - permissions: computePermissions('admin'), + permissions: computePermissions('admin', { viewAllSessions: false }), } const user = { empNo: 'u1', role: 'user', permissions: computePermissions('user'), } - assert.equal(canAccessSession('ch-1', admin, { cwd: '/bots/whatsapp' }), true) - assert.equal(canAccessSession('ch-1', user, { cwd: '/bots/whatsapp' }), false) - assert.equal(canAccessSession('s-peer', admin, { cwd: '/ws/u2/x' }), false) + assert.equal(accessOff.canAccessSession('ch-1', admin, { cwd: '/bots/whatsapp' }), true) + assert.equal(accessOff.canAccessSession('ch-1', user, { cwd: '/bots/whatsapp' }), false) + assert.equal(accessOff.canAccessSession('s-peer', admin, { cwd: '/ws/u2/x' }), false) - const access = makeAccess({ 'ch-owned': 'u2' }) - assert.equal(access.canAccessSession('ch-owned', admin, { cwd: '/bots/wa' }), false) + const ownersMap = new Map([['ch-owned', 'u2']]) + const accessOwned = createSessionAccess({ + sessionAcl: { getOwner: (id) => ownersMap.get(String(id)) || null }, + userWorkspaces: { get: () => null, isUserPath: () => false }, + getWorkspaceRoot: () => '/ws', + getWorkspaceRegistry: () => ({ list: () => [] }), + rolesStore: store, + }) + assert.equal(accessOwned.canAccessSession('ch-owned', admin, { cwd: '/bots/wa' }), false) - assert.equal(isVisibleWorkspace(admin, { id: 'bot-ws', path: '/bots/whatsapp' }), true) - assert.equal(isVisibleWorkspace(user, { id: 'bot-ws', path: '/bots/whatsapp' }), false) + assert.equal(accessOff.isVisibleWorkspace(admin, { id: 'bot-ws', path: '/bots/whatsapp' }), true) + assert.equal(accessOff.isVisibleWorkspace(user, { id: 'bot-ws', path: '/bots/whatsapp' }), false) }) it('live rolesStore prefs override stale identity.permissions', () => { @@ -163,7 +191,7 @@ describe('createSessionAccess', () => { }) describe('RolesStore view-all prefs', () => { - it('defaults on for super_admin and can be turned off', () => { + it('defaults on for admin-class and can be turned off', () => { const store = new RolesStore() store._roles.set('boss', ROLES.SUPER_ADMIN) store._roles.set('op', ROLES.ADMIN) @@ -179,24 +207,28 @@ describe('RolesStore view-all prefs', () => { store.setViewAllSessions('boss', true) assert.equal(store.resolvePermissions('boss').canViewAllSessions, true) - assert.throws(() => store.setViewAllSessions('op', true), (err) => err.code === 'forbidden_view_all_sessions') - store._prefs.set('op', { viewAllSessions: true }) + assert.equal(store.resolvePermissions('op').canToggleViewAllSessions, true) + assert.equal(store.resolvePermissions('op').canViewAllSessions, true) + store.setViewAllSessions('op', false) assert.equal(store.resolvePermissions('op').canViewAllSessions, false) - assert.equal(store.resolvePermissions('op').canToggleViewAllSessions, false) }) - it('rejects view-all toggle for ordinary users and admins', () => { + it('rejects view-all toggle for ordinary users', () => { const store = new RolesStore() store._roles.set('u1', ROLES.USER) - store._roles.set('a1', ROLES.ADMIN) assert.throws(() => store.setViewAllSessions('u1', true), (err) => err.code === 'forbidden_view_all_sessions') - assert.throws(() => store.setViewAllSessions('a1', true), (err) => err.code === 'forbidden_view_all_sessions') }) - it('lets admin, super_admin, and fallback_admin create workspaces', () => { - assert.equal(computePermissions(ROLES.SUPER_ADMIN).canCreateWorkspace, true) - assert.equal(computePermissions(ROLES.FALLBACK_ADMIN).canCreateWorkspace, true) - assert.equal(computePermissions(ROLES.ADMIN).canCreateWorkspace, true) + it('admin-class roles share create-workspace and manage-users permissions', () => { + for (const role of [ROLES.SUPER_ADMIN, ROLES.FALLBACK_ADMIN, ROLES.ADMIN]) { + const p = computePermissions(role, { viewAllSessions: true }) + assert.equal(p.canCreateWorkspace, true) + assert.equal(p.canManageUsers, true) + assert.equal(p.canAccessSettings, true) + assert.equal(p.canToggleViewAllSessions, true) + assert.equal(p.canViewAllSessions, true) + } assert.equal(computePermissions(ROLES.USER).canCreateWorkspace, false) + assert.equal(computePermissions(ROLES.USER).canManageUsers, false) }) })