mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-16 03:10:47 +08:00
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:
parent
c26dc68f77
commit
ad0cc98b38
17 changed files with 898 additions and 93 deletions
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue