Make session visibility preference-driven and keep system-channel ACL separate.

Super/fallback can toggle view-all (default on); admin/user stay own-only while still seeing unowned system sessions. Add restore/prune helpers so empty-shell cleanup is explicit and recoverable.
This commit is contained in:
oliver 2026-09-14 20:48:15 +08:00
parent c26dc68f77
commit ad0cc98b38
17 changed files with 898 additions and 93 deletions

View file

@ -188,6 +188,27 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
})
}
async function setViewAllSessions(ctx) {
if (!requirePermission(ctx, 'canToggleViewAllSessions')) {
return fail(ctx, 'forbidden_view_all_sessions', 403)
}
const body = await readBody(ctx.req)
const enabled = !!(body && body.enabled)
try {
rolesStore.setViewAllSessions(ctx.empNo, enabled)
if (typeof rolesStore.flush === 'function') {
await rolesStore.flush()
}
ctx.permissions = rolesStore.resolvePermissions(ctx.empNo, ctx.role)
await ok(ctx, enabled ? 'view_all_sessions_on' : 'view_all_sessions_off', null, {
permissions: ctx.permissions,
viewAllSessions: enabled,
})
} catch (err) {
await mapThrown(ctx, err, 403)
}
}
return {
logout,
getCurrentUser,
@ -198,6 +219,7 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
setFallbackPassword,
clearFallbackPassword,
fallbackStatus,
setViewAllSessions,
}
}

View file

@ -66,6 +66,11 @@ window.__ModuleLoader__.load({
"ui.nextPage": "下一页",
"ui.add": "添加",
"ui.department": "部门",
"ui.viewAllSessionsTitle": "查看全部会话",
"ui.viewAllSessionsIntro": "超级管理员默认可见全部会话(含渠道)。关闭后仅看自己的;侧栏与 @ 提及规则相同。",
"ui.viewAllSessionsToggle": "显示所有人的会话",
"ui.viewAllSessionsOn": "已开启:可见全部会话",
"ui.viewAllSessionsOff": "已关闭:仅可见自己的会话",
"ui.notLoggedIn": "未登录",
"ui.pleaseScan": "请使用 iCenter 扫码登录",
"ui.refreshQr": "刷新二维码",
@ -106,6 +111,7 @@ window.__ModuleLoader__.load({
"err.forbidden_remove_user": "只有超级管理员可以删除用户",
"err.forbidden_set_fallback": "只有超级管理员可以设置应急密码",
"err.forbidden_clear_fallback": "只有超级管理员可以清除应急密码",
"err.forbidden_view_all_sessions": "当前角色不能开启查看全部会话",
"err.invalid_role_params": "参数错误: empNo 和 role 必填",
"err.emp_no_required": "empNo 必填",
"err.username_password_required": "用户名和密码必填",
@ -151,7 +157,9 @@ window.__ModuleLoader__.load({
"ok.fallback_password_set": "应急管理员密码已设置",
"ok.fallback_password_cleared": "应急管理员密码已清除",
"ok.fallback_login": "应急管理员登录成功",
"ok.local_admin_unlock": "本机密钥解锁成功"
"ok.local_admin_unlock": "本机密钥解锁成功",
"ok.view_all_sessions_on": "已开启查看全部会话",
"ok.view_all_sessions_off": "已关闭查看全部会话"
},
"en": {
"role.super_admin": "Super admin",
@ -198,6 +206,11 @@ window.__ModuleLoader__.load({
"ui.nextPage": "Next",
"ui.add": "Add",
"ui.department": "Department",
"ui.viewAllSessionsTitle": "View all sessions",
"ui.viewAllSessionsIntro": "Super admins see all sessions by default (including channels). Turn off to only see your own; sidebar and @ mentions share the same rule.",
"ui.viewAllSessionsToggle": "Show everyone’s sessions",
"ui.viewAllSessionsOn": "On: all sessions visible",
"ui.viewAllSessionsOff": "Off: only your own sessions",
"ui.notLoggedIn": "Not signed in",
"ui.pleaseScan": "Scan with iCenter to sign in",
"ui.refreshQr": "Refresh QR",
@ -238,6 +251,7 @@ window.__ModuleLoader__.load({
"err.forbidden_remove_user": "Only super admins can remove users",
"err.forbidden_set_fallback": "Only super admins can set the emergency password",
"err.forbidden_clear_fallback": "Only super admins can clear the emergency password",
"err.forbidden_view_all_sessions": "Your role cannot enable view-all sessions",
"err.invalid_role_params": "Invalid params: empNo and role required",
"err.emp_no_required": "empNo required",
"err.username_password_required": "Username and password required",
@ -283,7 +297,9 @@ window.__ModuleLoader__.load({
"ok.fallback_password_set": "Emergency admin password set",
"ok.fallback_password_cleared": "Emergency admin password cleared",
"ok.fallback_login": "Emergency admin signed in",
"ok.local_admin_unlock": "Local admin unlocked"
"ok.local_admin_unlock": "Local admin unlocked",
"ok.view_all_sessions_on": "View-all sessions enabled",
"ok.view_all_sessions_off": "View-all sessions disabled"
}
}
const UDS_HOST_ARIA = {
@ -396,14 +412,21 @@ window.__ModuleLoader__.load({
const CSS = [
'.uds-auth-host{position:relative;display:inline-flex;align-items:center;height:32px;margin:0;flex-shrink:0;pointer-events:auto}.uds-auth-host.is-rail{justify-content:center;width:100%}[data-uds-auth-foot="row"]{display:flex!important;flex-direction:row!important;align-items:center!important;gap:8px;width:100%}[data-uds-auth-foot="row"]>*:nth-child(1){order:2;flex:none!important;width:auto!important;min-width:0;margin-left:auto!important}[data-uds-auth-foot="row"]>*:nth-child(2){order:1;flex:none!important;width:auto!important;min-width:0}',
'html[data-uds-can-settings="0"] [data-uds-auth-foot="row"]>*:not(:has([data-uds-auth-host])){display:none!important}html[data-uds-can-create-ws="0"] button[aria-label="添加工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Add workspace"]{display:none!important}html[data-uds-logged-in="0"] [role="tree"][aria-label="会话"],html[data-uds-logged-in="0"] [role="tree"][aria-label="Sessions"],html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] .dsh-ct-entry,html[data-uds-logged-in="0"] .dsh-ct-region,html[data-uds-logged-in="0"] .dsh-ct-main,html[data-uds-logged-in="0"] [data-dsh-ct-mode="on"] .dsh-ct-region{display:none!important}html[data-uds-can-create-ws="0"] button[aria-label="选择工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Choose workspace"],html[data-uds-can-create-ws="0"] [aria-label="选择工作区"],html[data-uds-can-create-ws="0"] [aria-label="Choose workspace"]{display:none!important}html[data-uds-logged-in="0"] [class*="cardWorkspaceTrigger"],html[data-uds-logged-in="0"] [data-composer-card][class*="cardWorkspaceTrigger"]{pointer-events:none!important;opacity:.45!important;cursor:not-allowed!important}/* uds-anon-hide-workspaces *//* uds-anon-hide-conversation:removed */html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceRow"],html[data-uds-logged-in="0"] [class*="WorkspaceRow"]{display:none!important}',
'.uds-auth-host{position:relative;display:inline-flex!important;align-items:center;height:32px;margin:0;flex-shrink:0;pointer-events:auto;visibility:visible!important;opacity:1!important}.uds-auth-host.is-rail{justify-content:center;width:100%}',
/* Foot: Settings left, login right. Marked via data-uds-foot-slot by AuthBadge. */
'[data-uds-auth-foot="row"]{display:flex!important;flex-direction:row!important;flex-wrap:nowrap!important;align-items:center!important;gap:8px;width:100%}',
'[data-uds-auth-foot="row"]>[data-uds-foot-slot="settings"]{order:1;flex:none!important;width:auto!important;min-width:0}',
'[data-uds-auth-foot="row"]>[data-uds-foot-slot="login"]{order:2;flex:none!important;width:auto!important;min-width:0;margin-left:auto!important}',
/* Hide settings when logged out or no settings permission (super/fallback only). */
'html[data-uds-can-settings="0"] [data-uds-foot-slot="settings"],html[data-uds-logged-in="0"] [data-uds-foot-slot="settings"]{display:none!important}',
'html[data-uds-can-settings="0"] button[aria-label="设置"],html[data-uds-can-settings="0"] button[aria-label="Settings"],html[data-uds-logged-in="0"] button[aria-label="设置"],html[data-uds-logged-in="0"] button[aria-label="Settings"]{display:none!important}',
'html[data-uds-can-create-ws="0"] button[aria-label="添加工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Add workspace"]{display:none!important}html[data-uds-logged-in="0"] [role="tree"][aria-label="会话"],html[data-uds-logged-in="0"] [role="tree"][aria-label="Sessions"],html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] .dsh-ct-entry,html[data-uds-logged-in="0"] .dsh-ct-region,html[data-uds-logged-in="0"] .dsh-ct-main,html[data-uds-logged-in="0"] [data-dsh-ct-mode="on"] .dsh-ct-region{display:none!important}html[data-uds-can-create-ws="0"] button[aria-label="选择工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Choose workspace"],html[data-uds-can-create-ws="0"] [aria-label="选择工作区"],html[data-uds-can-create-ws="0"] [aria-label="Choose workspace"]{display:none!important}html[data-uds-logged-in="0"] [class*="cardWorkspaceTrigger"],html[data-uds-logged-in="0"] [data-composer-card][class*="cardWorkspaceTrigger"]{pointer-events:none!important;opacity:.45!important;cursor:not-allowed!important}/* uds-anon-hide-workspaces *//* uds-anon-hide-conversation:removed */html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceRow"],html[data-uds-logged-in="0"] [class*="WorkspaceRow"]{display:none!important}',
/* uds-session-only-sidebar */
'html[data-uds-can-create-ws="0"][data-uds-logged-in="1"] [class*="projectRow"]:not([class*="dsh-ct-project"]),html[data-uds-can-create-ws="0"][data-uds-logged-in="1"] [class*="ProjectRow"]:not([class*="dsh-ct-project"]){display:none!important}',
'.uds-auth-badge{display:inline-flex;align-items:center;justify-content:flex-start;gap:0;max-width:min(160px,42vw);min-width:0;height:32px;padding:0 8px;box-sizing:border-box;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;font-weight:400;line-height:20px;cursor:pointer;overflow:hidden}',
'.uds-auth-badge:hover{background:var(--dsw-alias-interactive-bg-hover)}',
'.uds-auth-host.is-rail .uds-auth-badge{width:auto;max-width:100%;height:32px;padding:0 6px;border-radius:8px}',
'.uds-auth-badge-unauth{color:var(--dsw-alias-label-tertiary,#8f959e)}',
'.uds-auth-badge-unauth{color:var(--dsw-alias-label-primary,#e8eaed)}',
'.uds-auth-avatar{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:50%;flex:none;font-size:10px;line-height:1;color:var(--dsw-alias-label-secondary,#646a73);background:transparent;border:none}',
'.uds-auth-badge-unauth .uds-auth-avatar{background:var(--dsw-alias-bg-module-platform,rgba(242,243,245,1));color:var(--dsw-alias-label-tertiary,#8f959e)}',
'.uds-auth-badge-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
@ -461,6 +484,10 @@ window.__ModuleLoader__.load({
'.uds-auth-settings-msg.ok{color:var(--dsw-alias-state-success-primary,#20a162)}',
'.uds-auth-settings-msg.err{color:var(--dsw-alias-state-error-primary,#d54941)}',
'.uds-auth-settings-empty{padding:24px;text-align:center;color:var(--dsw-alias-label-tertiary,#8f959e);font-size:13px}',
'.uds-auth-settings-toggle{display:flex;align-items:flex-start;gap:10px;margin:8px 0 4px;cursor:pointer;user-select:none}',
'.uds-auth-settings-toggle input{margin-top:3px;flex-shrink:0}',
'.uds-auth-settings-toggle span{font-size:13px;line-height:1.4;color:var(--dsw-alias-label-primary,#1f2329)}',
'.uds-auth-settings-toggle-status{margin:4px 0 0;font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)}',
].join('')
function getCookie(cookieName) {
@ -844,6 +871,8 @@ function reloadAfterLogin() {
const [msg, setMsg] = useState('')
const [msgKind, setMsgKind] = useState('')
const [busy, setBusy] = useState(false)
const [viewAllBusy, setViewAllBusy] = useState(false)
const [viewAllMsg, setViewAllMsg] = useState('')
useEffect(() => {
let cancelled = false
@ -869,6 +898,8 @@ function reloadAfterLogin() {
const perms = me?.permissions || {}
const canManage = !!perms.canManageUsers
const canSettings = !!perms.canAccessSettings
const canToggleViewAll = !!perms.canToggleViewAllSessions
const viewAllOn = !!perms.canViewAllSessions
const saveConfig = async () => {
setBusy(true)
@ -889,6 +920,31 @@ function reloadAfterLogin() {
}
}
const setViewAllSessions = async (enabled) => {
setViewAllBusy(true)
setViewAllMsg('')
try {
const res = await fetchJson('/uds-auth/api/me/view-all-sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !!enabled }),
})
setMe((prev) => prev ? {
...prev,
permissions: res.permissions || {
...prev.permissions,
canViewAllSessions: !!enabled,
},
} : prev)
setViewAllMsg(res.message || (enabled ? t('ui.viewAllSessionsOn') : t('ui.viewAllSessionsOff')))
try { softReconnectAuth() } catch { /* ignore */ }
} catch (err) {
setViewAllMsg(apiMessage(err) || t('ui.saveFailed'))
} finally {
setViewAllBusy(false)
}
}
const field = (key, label) => h('div', { className: 'uds-auth-settings-field' },
h('label', { htmlFor: 'uds-auth-' + key }, label),
h('input', {
@ -905,9 +961,25 @@ function reloadAfterLogin() {
h('p', { className: 'uds-auth-settings-intro' }, t('ui.settingsIntro')),
),
!me && h('div', { className: 'uds-auth-settings-empty' }, t('ui.loginRequiredPage')),
me && !canSettings && !canManage && h('div', { className: 'uds-auth-settings-empty' },
me && !canSettings && !canManage && !canToggleViewAll && h('div', { className: 'uds-auth-settings-empty' },
t('ui.roleHint', { role: me.role || 'user' })
),
canToggleViewAll && h('div', { className: 'uds-auth-settings-card' },
h('h3', null, t('ui.viewAllSessionsTitle')),
h('p', { className: 'uds-auth-settings-intro' }, t('ui.viewAllSessionsIntro')),
h('label', { className: 'uds-auth-settings-toggle' },
h('input', {
type: 'checkbox',
checked: viewAllOn,
disabled: viewAllBusy,
onChange: (e) => setViewAllSessions(e.target.checked),
}),
h('span', null, t('ui.viewAllSessionsToggle')),
),
h('p', { className: 'uds-auth-settings-toggle-status' },
viewAllMsg || (viewAllOn ? t('ui.viewAllSessionsOn') : t('ui.viewAllSessionsOff')),
),
),
canSettings && h('div', { className: 'uds-auth-settings-card' },
h('h3', null, t('ui.deployConfig')),
field('uacBaseUrl', 'UAC Base URL'),
@ -987,13 +1059,23 @@ function reloadAfterLogin() {
let footArea = null
for (let el = host.parentElement; el && el !== document.body; el = el.parentElement) {
if (el.childElementCount < 2) continue
const mine = [...el.children].some((c) => c.contains(host))
const other = [...el.children].some((c) => !c.contains(host))
const mine = [...el.children].some((c) => c.contains(host) || c === host)
const other = [...el.children].some((c) => !(c.contains(host) || c === host))
if (mine && other) { footArea = el; break }
}
if (!footArea) return undefined
footArea.setAttribute('data-uds-auth-foot', 'row')
return () => { footArea.removeAttribute('data-uds-auth-foot') }
const marked = []
for (const child of footArea.children) {
const isLogin = child === host || child.contains(host)
const slot = isLogin ? 'login' : 'settings'
child.setAttribute('data-uds-foot-slot', slot)
marked.push(child)
}
return () => {
footArea.removeAttribute('data-uds-auth-foot')
for (const child of marked) child.removeAttribute('data-uds-foot-slot')
}
}, [])
const [open, setOpen] = useState(false)
const [anchor, setAnchor] = useState(null)
@ -1517,6 +1599,9 @@ function reloadAfterLogin() {
tag.setAttribute('data-plugin', name)
tag.textContent = CSS
document.head.appendChild(tag)
} else {
// Hot reload / plugin update: refresh rules (e.g. login-host visibility fix).
document.getElementById('uds-auth-client-css').textContent = CSS
}
// Register login entry before heavy gates / settings section.
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({

View file

@ -2,12 +2,25 @@
* Bridge UDS cookies → AsyncLocalStorage and wrap DSH session/workspace/settings.
*/
import { createRequire } from 'node:module'
import { resolve as resolvePath, sep as pathSep } from 'node:path'
import { withUserContext, getUserContext, runWithUserContext } from './context.js'
import { computePermissions, ROLES } from './roles.js'
import { resolveLocale, t } from './i18n.js'
const require = createRequire(import.meta.url)
/** True when path is outside per-user workspace root (channel/bot/harness cwd). */
export function isOutsideUserWorkspaceRoot(candidatePath, workspaceRoot) {
if (!candidatePath) return true
if (!workspaceRoot) return true
try {
const base = resolvePath(String(workspaceRoot))
const cand = resolvePath(String(candidatePath))
return cand !== base && !cand.startsWith(base + pathSep)
} catch {
return true
}
}
function parseCookie(header, name) {
if (!header || typeof header !== 'string') return null
for (const part of header.split(';')) {
@ -174,7 +187,7 @@ export function resolveIdentityFromRequestSync(req, deps) {
return {
empNo: String(empNo),
role,
permissions: computePermissions(role),
permissions: rolesStore.resolvePermissions(empNo, role),
userContext: {
empNo: String(empNo),
userId: String(empNo),
@ -229,7 +242,7 @@ export async function resolveIdentityFromRequest(req, deps) {
} catch { /* keep getRole */ }
}
const permissions = computePermissions(role)
const permissions = rolesStore.resolvePermissions(empNo, role)
return {
empNo: String(empNo),
role,
@ -372,7 +385,11 @@ function throwForbidden(code) {
/**
* Shared session visibility helpers (sidebar + @ mention + query reads).
* Visibility: super_admin / fallback_admin see all; others see owner OR own workspace.
* Visibility:
* - canViewAllSessions (super/fallback toggle): see all
* - else: own owner stamp OR own user-workspace path
* - canViewSystemSessions (admin+): also see system/channel sessions whose cwd
* is outside the per-user workspace root (IM bots, harness cwd, unstamped)
*/
export function createSessionAccess({
sessionAcl,
@ -380,18 +397,26 @@ export function createSessionAccess({
getWorkspaceRoot,
getWorkspaceRegistry,
resolveLiveCwd,
rolesStore,
}) {
const empOf = (identity) => identity?.empNo || identity?.userContext?.empNo || null
/** Always re-read prefs from live rolesStore — never trust frozen WS identity.permissions. */
const canSeeAll = (identity) => {
if (!identity) return false
const empNo = empOf(identity)
const store = typeof rolesStore === 'function' ? rolesStore() : rolesStore
if (empNo && store && typeof store.resolvePermissions === 'function') {
// Do not pass identity.role — store.getRole(empNo) is source of truth.
return !!store.resolvePermissions(empNo).canViewAllSessions
}
if (identity.permissions?.canViewAllSessions) return true
// No store (tests / misconfig): keep legacy super visibility.
const role = identity.role || identity.userContext?.role
if (role === 'fallback_admin' || role === 'super_admin') return true
if (String(identity.empNo || identity.userContext?.empNo || '') === 'administrator') return true
return false
return role === 'super_admin' || role === 'fallback_admin'
|| String(empNo) === 'administrator'
}
const empOf = (identity) => identity?.empNo || identity?.userContext?.empNo || null
const resolveRegistry = () => {
try {
if (typeof getWorkspaceRegistry === 'function') {
@ -436,14 +461,21 @@ export function createSessionAccess({
const root = getWorkspaceRoot()
const wid = ws.id ?? ws.workspaceId
const path = ws.path
return userWorkspaces.isUserPath(empNo, path, root)
if (userWorkspaces.isUserPath(empNo, path, root)
|| (userWorkspaces.get(empNo)?.workspaceId
&& String(userWorkspaces.get(empNo).workspaceId) === String(wid))
&& String(userWorkspaces.get(empNo).workspaceId) === String(wid))) {
return true
}
// Channel / bot / shared harness workspaces live outside user-workspaces.
if (identity.permissions?.canViewSystemSessions && isOutsideUserWorkspaceRoot(path, root)) {
return true
}
return false
}
/**
* Owner stamp OR cwd/workspace under the caller's provisioned path.
* Missing owner alone does not deny (legacy sessions rely on workspace/cwd).
* Settings roles also see unowned system/channel sessions (cwd outside user-workspaces).
*/
const canAccessSession = (sessionId, identity, rowHint) => {
if (!identity || !empOf(identity)) return false
@ -457,12 +489,23 @@ export function createSessionAccess({
const cwd = resolveSessionCwd(sessionId, rowHint)
if (cwd && userWorkspaces.isUserPath(empNo, cwd, root)) return true
const foreignOwner = !!(owner && String(owner) !== String(empNo))
// IM/channel (and other host-internal) sessions: often unstamped + bot cwd.
// Let admin+ see those; never leak another user's stamped private session.
if (!foreignOwner && !owner && identity.permissions?.canViewSystemSessions
&& isOutsideUserWorkspaceRoot(cwd, root)) {
return true
}
const registry = resolveRegistry()
if (!registry || typeof registry.list !== 'function') return false
let workspaces = []
try { workspaces = registry.list() || [] } catch { return false }
for (const ws of workspaces) {
if (!isVisibleWorkspace(identity, ws)) continue
// Foreign-owned sessions must not become visible via channel/system workspaces.
if (foreignOwner && isOutsideUserWorkspaceRoot(ws?.path, root)) continue
if (workspaceContainsSession(ws, sessionId)) return true
}
return false
@ -480,47 +523,66 @@ export function createSessionAccess({
/**
* Install Host ACL wrappers.
* `getRolesStore` / `rolesStore` is read live on every ACL check so plugin reload
* and preference toggles take effect without re-wrapping Host controllers.
*/
export function installDshAcl(ctx, {
sessionAcl,
userWorkspaces,
getWorkspaceRoot,
rolesStore,
getRolesStore,
ensureUserWorkspace,
getWorkspaceRegistry,
}) {
const disposers = []
const resolveRolesStore = () => {
if (typeof getRolesStore === 'function') {
try { return getRolesStore() } catch { return null }
}
if (typeof rolesStore === 'function') {
try { return rolesStore() } catch { return null }
}
return rolesStore || null
}
const access = createSessionAccess({
sessionAcl,
userWorkspaces,
getWorkspaceRoot,
getWorkspaceRegistry: () => {
try {
if (typeof getWorkspaceRegistry === 'function') {
const r = getWorkspaceRegistry()
if (r) return r
// Shared live bag: re-install updates this even when Host controllers are already wrapped.
const live = installDshAcl._live || (installDshAcl._live = { access: null })
const rebuildAccess = () => {
live.access = createSessionAccess({
sessionAcl,
userWorkspaces,
getWorkspaceRoot,
rolesStore: resolveRolesStore,
getWorkspaceRegistry: () => {
try {
if (typeof getWorkspaceRegistry === 'function') {
const r = getWorkspaceRegistry()
if (r) return r
}
} catch { /* ignore */ }
try { return ctx.get('workspaceRegistry') } catch { return null }
},
resolveLiveCwd: (sessionId) => {
try {
const agents = ctx.get('agents')
const agent = agents?.get?.(sessionId)
return agent?.session?.header?.cwd || null
} catch {
return null
}
} catch { /* ignore */ }
try { return ctx.get('workspaceRegistry') } catch { return null }
},
resolveLiveCwd: (sessionId) => {
try {
const agents = ctx.get('agents')
const agent = agents?.get?.(sessionId)
return agent?.session?.header?.cwd || null
} catch {
return null
}
},
})
const {
canSeeAll,
empOf,
resolveRegistry,
isVisibleWorkspace,
canAccessSession,
} = access
},
})
}
rebuildAccess()
const canSeeAll = (identity) => live.access.canSeeAll(identity)
const empOf = (identity) => live.access.empOf(identity)
const resolveRegistry = () => live.access.resolveRegistry()
const isVisibleWorkspace = (identity, ws) => live.access.isVisibleWorkspace(identity, ws)
const canAccessSession = (sessionId, identity, rowHint) => (
live.access.canAccessSession(sessionId, identity, rowHint)
)
// Stamp owner on session create
const offCreated = ctx.on('session/created', (session) => {
@ -880,12 +942,7 @@ ctx.inject(['workspaceController'], (wctx) => {
// the ungrouped bucket).
const canSeeAllWorkspaces = (identity) => {
if (!identity) return false
if (identity.permissions?.canViewAllSessions) return true
const role = identity.role || identity.userContext?.role
if (role === 'fallback_admin' || role === 'super_admin') return true
// Fallback cookie user is always administrator
if (String(identity.empNo || identity.userContext?.empNo || '') === 'administrator') return true
return false
return canSeeAll(identity)
}
const allowWorkspace = (identity, ws) => {
@ -894,9 +951,16 @@ ctx.inject(['workspaceController'], (wctx) => {
const empNo = identity.empNo || identity.userContext?.empNo
const root = getWorkspaceRoot()
const wid = ws?.workspaceId ?? ws?.id
return userWorkspaces.isUserPath(empNo, ws?.path, root)
if (userWorkspaces.isUserPath(empNo, ws?.path, root)
|| (userWorkspaces.get(empNo)?.workspaceId
&& String(userWorkspaces.get(empNo).workspaceId) === String(wid))
&& String(userWorkspaces.get(empNo).workspaceId) === String(wid))) {
return true
}
if (identity.permissions?.canViewSystemSessions
&& isOutsideUserWorkspaceRoot(ws?.path, root)) {
return true
}
return false
}
const filterBaseline = (identity, baseline) => {
@ -921,6 +985,20 @@ ctx.inject(['workspaceController'], (wctx) => {
const allowed = new Set()
const mapped = userWorkspaces.get(empNo)?.workspaceId
if (mapped != null) allowed.add(String(mapped))
// Keep channel/bot workspaces for admin+ (same rule as allowWorkspace).
if (identity?.permissions?.canViewSystemSessions) {
try {
const root = getWorkspaceRoot()
const registry = resolveRegistry()
const list = typeof registry?.list === 'function' ? (registry.list() || []) : []
for (const ws of list) {
const id = ws?.id ?? ws?.workspaceId
if (id != null && isOutsideUserWorkspaceRoot(ws?.path, root)) {
allowed.add(String(id))
}
}
} catch { /* ignore */ }
}
return {
...frame,
workspaceIds: (frame.workspaceIds || []).filter((id) => allowed.has(String(id))),

View file

@ -60,6 +60,13 @@ export const MESSAGES = {
'ui.add': '添加',
'ui.department': '部门',
// privacy / session visibility
'ui.viewAllSessionsTitle': '查看全部会话',
'ui.viewAllSessionsIntro': '超级管理员默认可见全部会话(含渠道)。关闭后仅看自己的;侧栏与 @ 提及规则相同。',
'ui.viewAllSessionsToggle': '显示所有人的会话',
'ui.viewAllSessionsOn': '已开启:可见全部会话',
'ui.viewAllSessionsOff': '已关闭:仅可见自己的会话',
// login panel
'ui.notLoggedIn': '未登录',
'ui.pleaseScan': '请使用 iCenter 扫码登录',
@ -107,6 +114,7 @@ export const MESSAGES = {
'err.forbidden_remove_user': '只有超级管理员可以删除用户',
'err.forbidden_set_fallback': '只有超级管理员可以设置应急密码',
'err.forbidden_clear_fallback': '只有超级管理员可以清除应急密码',
'err.forbidden_view_all_sessions': '当前角色不能开启查看全部会话',
'err.invalid_role_params': '参数错误: empNo 和 role 必填',
'err.emp_no_required': 'empNo 必填',
'err.username_password_required': '用户名和密码必填',
@ -155,6 +163,8 @@ export const MESSAGES = {
'ok.fallback_password_cleared': '应急管理员密码已清除',
'ok.fallback_login': '应急管理员登录成功',
'ok.local_admin_unlock': '本机密钥解锁成功',
'ok.view_all_sessions_on': '已开启查看全部会话',
'ok.view_all_sessions_off': '已关闭查看全部会话',
},
en: {
'role.super_admin': 'Super admin',
@ -203,6 +213,12 @@ export const MESSAGES = {
'ui.add': 'Add',
'ui.department': 'Department',
'ui.viewAllSessionsTitle': 'View all sessions',
'ui.viewAllSessionsIntro': 'Super admins see all sessions by default (including channels). Turn off to only see your own; sidebar and @ mentions share the same rule.',
'ui.viewAllSessionsToggle': 'Show everyone’s sessions',
'ui.viewAllSessionsOn': 'On: all sessions visible',
'ui.viewAllSessionsOff': 'Off: only your own sessions',
'ui.notLoggedIn': 'Not signed in',
'ui.pleaseScan': 'Scan with iCenter to sign in',
'ui.refreshQr': 'Refresh QR',
@ -246,6 +262,7 @@ export const MESSAGES = {
'err.forbidden_remove_user': 'Only super admins can remove users',
'err.forbidden_set_fallback': 'Only super admins can set the emergency password',
'err.forbidden_clear_fallback': 'Only super admins can clear the emergency password',
'err.forbidden_view_all_sessions': 'Your role cannot enable view-all sessions',
'err.invalid_role_params': 'Invalid params: empNo and role required',
'err.emp_no_required': 'empNo required',
'err.username_password_required': 'Username and password required',
@ -293,6 +310,8 @@ export const MESSAGES = {
'ok.fallback_password_cleared': 'Emergency admin password cleared',
'ok.fallback_login': 'Emergency admin signed in',
'ok.local_admin_unlock': 'Local admin unlocked',
'ok.view_all_sessions_on': 'View-all sessions enabled',
'ok.view_all_sessions_off': 'View-all sessions disabled',
},
}

View file

@ -768,6 +768,9 @@ function handleRequest(req, res) {
if (url === '/api/fallback/clear' && method === 'POST') {
await _apiHandlers.clearFallbackPassword(ctx2); return
}
if (url === '/api/me/view-all-sessions' && method === 'POST') {
await _apiHandlers.setViewAllSessions(ctx2); return
}
// 配置端点 (admin / super_admin:canAccessSettings)
if (url === '/api/config' && method === 'GET') {
@ -1100,7 +1103,7 @@ async function initServices(ctx, config) {
sessionAcl: _sessionAcl,
userWorkspaces: _userWorkspaces,
getWorkspaceRoot: () => _currentConfig?.workspaceRoot,
rolesStore: _rolesStore,
getRolesStore: () => _rolesStore,
ensureUserWorkspace,
getWorkspaceRegistry: () => _workspaceRegistry,
})
@ -1141,6 +1144,10 @@ async function initServices(ctx, config) {
}
},
canViewAllJobs(identity) {
const empNo = identity?.empNo || identity?.userContext?.empNo
if (empNo && _rolesStore?.resolvePermissions) {
return !!_rolesStore.resolvePermissions(empNo, identity?.role).canViewAllSessions
}
return !!identity?.permissions?.canViewAllSessions
},
getRole(empNo) {
@ -1156,7 +1163,8 @@ async function initServices(ctx, config) {
const id = String(empNo || '').trim()
if (!id || id.startsWith('__')) return null
const role = _rolesStore?.getRole?.(id) || 'user'
const permissions = computePermissions(role)
const permissions = _rolesStore?.resolvePermissions?.(id, role)
|| computePermissions(role)
const workspacePath = (() => {
try {
const row = _userWorkspaces?.get(id)

View file

@ -189,10 +189,13 @@ export function buildLocalAdminUserContext() {
export function buildLocalAdminIdentity(rolesStore) {
const empNo = DEFAULT_FALLBACK_USERNAME
const role = rolesStore?.getRole?.(empNo) || ROLES.FALLBACK_ADMIN
const permissions = typeof rolesStore?.resolvePermissions === 'function'
? rolesStore.resolvePermissions(empNo, role)
: computePermissions(role)
return {
empNo,
role,
permissions: computePermissions(role),
permissions,
userContext: buildLocalAdminUserContext(),
kind: 'fallback',
}

View file

@ -1,6 +1,6 @@
import { UdsClient } from '../uds/client.js'
import { UdsValidator } from '../uds/validator.js'
import { ROLES, computePermissions } from '../roles.js'
import { ROLES } from '../roles.js'
import { searchUserByEmpNoToken } from '../uds/user-search.js'
/**
@ -108,7 +108,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
ctx.userContext = userContext
ctx.empNo = empNo
ctx.role = role
ctx.permissions = computePermissions(role)
ctx.permissions = rolesStore.resolvePermissions(empNo, role)
}
async function authMiddleware(ctx, next) {
@ -207,7 +207,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = {
ctx.userContext = userContext
ctx.empNo = profile.empNo
ctx.role = role
ctx.permissions = computePermissions(role)
ctx.permissions = rolesStore.resolvePermissions(profile.empNo, role)
return next()
}

View file

@ -1,13 +1,15 @@
/**
* uds-auth 角色存储 + 权限管理
*
*
* 角色:
* super_admin 所有权限 + 用户管理 + 可见全部会话(含 @)
* super_admin 所有权限 + 用户管理;默认可见全部会话(可在设置中关闭)
* fallback_admin 等同 super_admin(兜底 administrator)
* admin 设置权限 + 仅看自己会话(含 @)
* admin 无设置齿轮;仅看自己会话(含 @);可见渠道/系统会话
* user 仅看自己会话,无设置
*
* 持久化: roles.json (单实例文件) + MemoryStore 同步
* 超管/应急默认全览开启;prefs.viewAllSessions === false 时关闭。
* 设置齿轮仅超管/应急(canAccessSettings)。
* 持久化: roles.json (roles + prefs + fallbackPasswordHash)
*/
import { createHash, randomBytes } from 'node:crypto'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
@ -30,40 +32,60 @@ export const ROLES = {
/** zh labels for list/search; UI should translate via i18n role.* keys. */
export const ROLE_LABELS = ROLE_LABELS_ZH
/** 计算角色权限 (纯函数) */
export function computePermissions(role) {
/**
* 计算角色权限 (纯函数)
* @param {string} role
* @param {{ viewAllSessions?: boolean }} [opts] 个人偏好;超管默认可见全部
*/
export function computePermissions(role, opts = {}) {
const viewAll = !!opts.viewAllSessions
switch (role) {
case ROLES.SUPER_ADMIN:
return {
canManageUsers: true,
canAccessSettings: true,
canViewAllSessions: true,
canToggleViewAllSessions: true,
canViewAllSessions: viewAll,
canViewSystemSessions: true,
canCreateWorkspace: true,
}
case ROLES.FALLBACK_ADMIN:
return {
canManageUsers: true,
canAccessSettings: true,
canViewAllSessions: true,
canToggleViewAllSessions: true,
canViewAllSessions: viewAll,
canViewSystemSessions: true,
canCreateWorkspace: true,
}
case ROLES.ADMIN:
return {
canManageUsers: false,
canAccessSettings: true,
// 设置齿轮仅超管/应急;admin 仍可看渠道/系统会话
canAccessSettings: false,
canToggleViewAllSessions: false,
canViewAllSessions: false,
canViewSystemSessions: true,
canCreateWorkspace: false,
}
default: // user / undefined
return {
canManageUsers: false,
canAccessSettings: false,
canToggleViewAllSessions: false,
canViewAllSessions: false,
canViewSystemSessions: false,
canCreateWorkspace: false,
}
}
}
/** 角色是否允许开启「查看全部会话」(仅超管 / 应急) */
export function canToggleViewAllSessions(role) {
return role === ROLES.SUPER_ADMIN
|| role === ROLES.FALLBACK_ADMIN
}
function hashPassword(password) {
return createHash('sha256').update(password).digest('hex')
}
@ -82,6 +104,7 @@ export const DEFAULT_FALLBACK_USERNAME = 'administrator'
export class RolesStore {
constructor(options = {}) {
this._roles = new Map() // empNo → role
this._prefs = new Map() // empNo → { viewAllSessions?: boolean }
this._firstBootLock = Promise.resolve()
this._rolesFile = options.rolesFile ? resolve(options.rolesFile) : null
this._fallbackPasswordHash = null // SHA-256 hex,null = 未启用
@ -111,6 +134,11 @@ export class RolesStore {
for (const [empNo, role] of Object.entries(data.roles || {})) {
this._roles.set(empNo, role)
}
for (const [empNo, prefs] of Object.entries(data.prefs || {})) {
if (prefs && typeof prefs === 'object') {
this._prefs.set(String(empNo), { ...prefs })
}
}
if (Object.prototype.hasOwnProperty.call(data, 'fallbackPasswordHash')) {
loadedHash = data.fallbackPasswordHash || null
if (loadedHash) this._fallbackPasswordHash = loadedHash
@ -132,7 +160,16 @@ export class RolesStore {
_markDirty() {
this._dirty = true
if (this._saveTimer) return
this._saveTimer = setTimeout(() => this._save(), 2000)
this._saveTimer = setTimeout(() => { void this._save() }, 2000)
}
/** Flush pending roles/prefs to disk immediately (e.g. view-all toggle). */
async flush() {
if (this._saveTimer) {
clearTimeout(this._saveTimer)
this._saveTimer = null
}
await this._save()
}
async _save() {
@ -142,14 +179,18 @@ export class RolesStore {
try {
const data = {
roles: Object.fromEntries(this._roles),
prefs: Object.fromEntries(this._prefs),
fallbackPasswordHash: this._fallbackPasswordHash,
savedAt: new Date().toISOString(),
}
await mkdir(dirname(this._rolesFile), { recursive: true })
await writeFile(this._rolesFile, JSON.stringify(data, null, 2), 'utf-8')
} catch (err) {
this._dirty = true
console.warn('[uds-auth:RolesStore] Failed to save roles file:', err.message)
}
// Changes during await writeFile — schedule another save.
if (this._dirty) this._markDirty()
}
// === 首次部署 bootstrap ===
@ -183,6 +224,49 @@ export class RolesStore {
return this._roles.get(empNo) || ROLES.USER
}
/**
* 个人偏好:超管/应急默认开启查看全部;显式 false 才关闭。
* admin/user 不会走到这里(resolvePermissions 里 allowToggle=false)。
*/
isViewAllSessionsEnabled(empNo) {
if (!empNo) return false
const prefs = this._prefs.get(String(empNo))
if (prefs && Object.prototype.hasOwnProperty.call(prefs, 'viewAllSessions')) {
return !!prefs.viewAllSessions
}
return true
}
/**
* 设置「查看全部会话」偏好(调用方需校验 canToggleViewAllSessions)
* @param {string} empNo
* @param {boolean} enabled
*/
setViewAllSessions(empNo, enabled) {
const key = String(empNo || '').trim()
if (!key) throw codedError('emp_no_required')
const role = this.getRole(key)
if (!canToggleViewAllSessions(role)) {
throw codedError('forbidden_view_all_sessions')
}
const cur = { ...(this._prefs.get(key) || {}) }
// Persist explicit true/false — deleting the key would fall back to default-on.
cur.viewAllSessions = !!enabled
this._prefs.set(key, cur)
this._markDirty()
return true
}
/** 角色 + 个人偏好 → 有效权限 */
resolvePermissions(empNo, role) {
const id = empNo != null ? String(empNo) : ''
const r = role || this.getRole(id)
const allowToggle = canToggleViewAllSessions(r)
return computePermissions(r, {
viewAllSessions: allowToggle && this.isViewAllSessionsEnabled(id),
})
}
hasRole(empNo) {
return this._roles.has(empNo)
}
@ -271,6 +355,7 @@ export class RolesStore {
}
}
this._roles.delete(empNo)
this._prefs.delete(empNo)
this._markDirty()
return true
}

View file

@ -104,7 +104,10 @@ export function identityFromAls(rolesStore) {
return {
empNo: ctx.empNo,
role,
permissions: ctx.permissions || computePermissions(role),
// Prefer live prefs — ALS identity.permissions may be frozen at WS connect.
permissions: typeof rolesStore.resolvePermissions === 'function'
? rolesStore.resolvePermissions(ctx.empNo, role)
: (ctx.permissions || computePermissions(role)),
}
}