oclaw/uds-auth/lib/client.js
oliver 060500360d Stop infinite page refresh from auth remount gates.
Remove directory-flow reload, only shadow the picker while anonymous, and stop clearing login attrs on AuthBadge unmount so flat↔workspace observers cannot loop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 22:01:22 +08:00

2116 lines
98 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* uds-auth browser half — sidebar.footer.action + force foot row with Settings (side by side) + settings.section.
* Auth: empNo cookie + token verified server-side via user-info.
* Fallback: username/password when UAC/QR unavailable.
*/
window.__ModuleLoader__.load({
id: 'uds-auth',
factory: (require) => {
const module = { exports: {} }
const exports = module.exports
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
const React = require('react')
const h = React.createElement
const { useCallback, useEffect, useLayoutEffect, useRef, useState } = React
const name = 'uds-auth'
const inject = ['slots', 'locale']
const PAGE_SIZE = 50
const LOCALE_NS = 'uds-auth'
/* uds-auth-i18n-begin — keep in sync with lib/i18n.js */
const UDS_I18N_MESSAGES = {
"zh": {
"role.super_admin": "超级管理员",
"role.admin": "管理员",
"role.user": "普通用户",
"role.fallback_admin": "应急管理员",
"role.badge.super_admin": "超管",
"role.badge.admin": "管理员",
"role.badge.fallback_admin": "应急",
"role.badge.user": "",
"ui.settingsTitle": "UAC 认证",
"ui.settingsIntro": "工号+token 双校验;UAC 挂死时用应急账号 administrator 密码登录。",
"ui.loginRequiredPage": "请先登录后查看此页",
"ui.roleHint": "当前角色:{role}。首位扫码登录且 roles.json 为空时会自动成为超管;普通 admin 需超管在「用户管理」提权后再扫码登录。应急账号 administrator 需超管先设密码,再在登录面板用账密登录。",
"ui.deployConfig": "部署配置",
"ui.userSearchUrl": "用户搜索 URL(token 校验)",
"ui.workspaceRoot": "工作区根目录(空=$DSH_HOME/user-workspaces)",
"ui.saveConfig": "保存配置",
"ui.saving": "保存中...",
"ui.configSaved": "配置已保存",
"ui.saveFailed": "保存失败",
"ui.fallbackTitle": "应急登录(UAC 不可用)",
"ui.fallbackStatus": "状态:{status}。可在此改密或关闭。仅在扫码不可用时从登录面板切换。",
"ui.enabled": "已启用",
"ui.disabled": "未启用",
"ui.fallbackPassword": "应急密码(至少 6 位)",
"ui.saveFallbackPassword": "保存应急密码",
"ui.fallbackPasswordSet": "应急密码已设置",
"ui.confirmClearFallback": "确认清除应急密码?",
"ui.clear": "清除",
"ui.userManagement": "用户管理",
"ui.searchEmpNo": "搜索工号",
"ui.search": "搜索",
"ui.loading": "加载中...",
"ui.loadFailed": "加载失败",
"ui.empNo": "工号",
"ui.role": "角色",
"ui.actions": "操作",
"ui.noUsers": "暂无用户",
"ui.delete": "删除",
"ui.confirmDelete": "确认删除 {empNo}?",
"ui.pager": "共 {total} 人,第 {page} / {totalPages} 页",
"ui.prevPage": "上一页",
"ui.nextPage": "下一页",
"ui.add": "添加",
"ui.department": "部门",
"ui.viewAllSessionsTitle": "查看全部会话",
"ui.viewAllSessionsIntro": "超级管理员默认可见全部会话(含渠道)。关闭后仅看自己的;侧栏与 @ 提及规则相同。",
"ui.viewAllSessionsToggle": "显示所有人的会话",
"ui.viewAllSessionsOn": "已开启:可见全部会话",
"ui.viewAllSessionsOff": "已关闭:仅可见自己的会话",
"ui.notLoggedIn": "未登录",
"ui.pleaseScan": "请使用 iCenter 扫码登录",
"ui.refreshQr": "刷新二维码",
"ui.fallbackLink": "UAC 不可用?应急账号登录",
"ui.fallbackLogin": "应急登录",
"ui.fallbackDetail": "UAC / 扫码不可用时使用",
"ui.localKeyLink": "本机密钥解锁",
"ui.localKeyLogin": "本机密钥解锁",
"ui.localKeyDetail": "用密封盒口令解密后登录(仅设环境变量不会自动登录)",
"ui.localKey": "解密密钥",
"ui.username": "用户名",
"ui.password": "密码",
"ui.login": "登录",
"ui.loggingIn": "登录中...",
"ui.backToQr": "返回扫码登录",
"ui.logout": "退出登录",
"ui.userPrefix": "用户",
"ui.qrGenerating": "正在生成二维码...",
"ui.qrScanPrompt": "请使用 iCenter 扫码登录...",
"ui.userInfoFailed": "用户信息查询失败",
"ui.loginSuccess": "登录成功!",
"ui.missingToken": "缺少 token,无法完成校验",
"ui.waitingScan": "等待扫码...",
"ui.qrExpired": "二维码已失效",
"ui.qrExpiredHint": "请刷新后重新扫描",
"ui.loginFailed": "登录失败",
"ui.networkError": "网络错误...",
"ui.qrGenerateFailed": "生成二维码失败",
"ui.cronLoginRequired": "登录后才能使用定时任务",
"ui.workspaceCreateForbidden": "只有管理员可以创建工作区",
"ui.workspaceLoginRequired": "登录后才能使用工作区",
"err.not_logged_in": "未登录",
"err.forbidden_settings": "当前账号无设置权限",
"err.forbidden_manage_users": "只有超级管理员可以管理用户",
"err.forbidden_list_users": "只有超级管理员可以查看用户列表",
"err.forbidden_set_role": "只有超级管理员可以修改角色",
"err.forbidden_add_user": "只有超级管理员可以添加用户",
"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": "用户名和密码必填",
"err.invalid_credentials": "用户名或密码错误",
"err.last_super_admin_demote": "系统至少需要 1 个超级管理员,不能降级最后一个",
"err.last_super_admin_delete": "系统至少需要 1 个超级管理员,不能删除最后一个",
"err.password_too_short": "密码至少 6 位",
"err.local_admin_not_configured": "未配置本机管理员密封盒",
"err.key_required": "请输入解密密钥",
"err.decrypt_failed": "密钥无法解密,登录失败",
"err.rate_limited": "尝试过多,请稍后再试",
"err.config_not_ready": "配置未初始化",
"err.request_failed": "请求失败",
"err.method_not_allowed": "方法不允许",
"err.missing_qr_params": "缺少 qrCodeKey 或 qrCodeValue",
"err.missing_emp_token": "缺少 empNo 或 token",
"err.user_search_failed": "用户搜索失败",
"err.not_found": "未找到",
"err.internal": "内部错误",
"err.login_required_cron": "登录后才能使用定时任务",
"err.login_required_session": "登录后才能访问会话",
"err.login_required_create_session": "登录后才能创建会话",
"err.login_required_workspace": "登录后才能使用工作区",
"err.session_forbidden": "无权访问该会话",
"err.session_workspace_only": "只能在自己的工作区创建会话",
"err.workspace_path_only": "只能打开自己的工作区路径",
"err.workspace_create_forbidden": "只有管理员可以创建工作区",
"err.no_skill_credentials": "请先完成 UAC 扫码登录",
"err.loopback_only_credentials": "agent-credentials 仅允许本机访问",
"err.loopback_only_outbound": "outbound 仅允许本机访问",
"err.url_required": "缺少 url",
"err.invalid_url": "无效 url",
"err.unsupported_protocol": "不支持的协议",
"err.host_not_allowed": "主机不在白名单",
"err.upstream_failed": "上游请求失败",
"err.skill_credentials_not_ready": "skill 凭证未就绪",
"err.outbound_not_ready": "outbound 未就绪",
"ok.logged_out": "已退出登录",
"ok.config_saved": "配置已保存",
"ok.role_updated": "{empNo} 角色已更新为 {role}",
"ok.user_added": "{empNo} 已添加为 {role}",
"ok.user_removed": "{empNo} 已删除",
"ok.fallback_password_set": "应急管理员密码已设置",
"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",
"role.admin": "Admin",
"role.user": "User",
"role.fallback_admin": "Emergency admin",
"role.badge.super_admin": "Super",
"role.badge.admin": "Admin",
"role.badge.fallback_admin": "Emergency",
"role.badge.user": "",
"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.deployConfig": "Deploy config",
"ui.userSearchUrl": "User search URL (token verify)",
"ui.workspaceRoot": "Workspace root (empty=$DSH_HOME/user-workspaces)",
"ui.saveConfig": "Save config",
"ui.saving": "Saving...",
"ui.configSaved": "Config saved",
"ui.saveFailed": "Save failed",
"ui.fallbackTitle": "Emergency login (UAC unavailable)",
"ui.fallbackStatus": "Status: {status}. Change or disable here. Switch from the login panel only when QR is unavailable.",
"ui.enabled": "Enabled",
"ui.disabled": "Disabled",
"ui.fallbackPassword": "Emergency password (min 6 chars)",
"ui.saveFallbackPassword": "Save emergency password",
"ui.fallbackPasswordSet": "Emergency password set",
"ui.confirmClearFallback": "Clear emergency password?",
"ui.clear": "Clear",
"ui.userManagement": "User management",
"ui.searchEmpNo": "Search empNo",
"ui.search": "Search",
"ui.loading": "Loading...",
"ui.loadFailed": "Load failed",
"ui.empNo": "EmpNo",
"ui.role": "Role",
"ui.actions": "Actions",
"ui.noUsers": "No users",
"ui.delete": "Delete",
"ui.confirmDelete": "Delete {empNo}?",
"ui.pager": "{total} users, page {page} / {totalPages}",
"ui.prevPage": "Previous",
"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",
"ui.fallbackLink": "UAC down? Emergency account",
"ui.fallbackLogin": "Emergency login",
"ui.fallbackDetail": "Use when UAC / QR is unavailable",
"ui.localKeyLink": "Unlock with local key",
"ui.localKeyLogin": "Local key unlock",
"ui.localKeyDetail": "Decrypt the sealed box with your passphrase (env alone does nothing)",
"ui.localKey": "Decryption key",
"ui.username": "Username",
"ui.password": "Password",
"ui.login": "Sign in",
"ui.loggingIn": "Signing in...",
"ui.backToQr": "Back to QR login",
"ui.logout": "Sign out",
"ui.userPrefix": "User",
"ui.qrGenerating": "Generating QR...",
"ui.qrScanPrompt": "Scan with iCenter to sign in...",
"ui.userInfoFailed": "User info lookup failed",
"ui.loginSuccess": "Signed in!",
"ui.missingToken": "Missing token; cannot verify",
"ui.waitingScan": "Waiting for scan...",
"ui.qrExpired": "QR code expired",
"ui.qrExpiredHint": "Refresh and scan again",
"ui.loginFailed": "Sign-in failed",
"ui.networkError": "Network error...",
"ui.qrGenerateFailed": "Failed to generate QR",
"ui.cronLoginRequired": "Sign in to use scheduled tasks",
"ui.workspaceCreateForbidden": "Only admins can create workspaces",
"ui.workspaceLoginRequired": "Sign in to use workspaces",
"err.not_logged_in": "Not signed in",
"err.forbidden_settings": "No settings permission",
"err.forbidden_manage_users": "Only super admins can manage users",
"err.forbidden_list_users": "Only super admins can list users",
"err.forbidden_set_role": "Only super admins can change roles",
"err.forbidden_add_user": "Only super admins can add users",
"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",
"err.invalid_credentials": "Invalid username or password",
"err.last_super_admin_demote": "At least one super admin is required; cannot demote the last one",
"err.last_super_admin_delete": "At least one super admin is required; cannot delete the last one",
"err.password_too_short": "Password must be at least 6 characters",
"err.local_admin_not_configured": "Local admin sealed box is not configured",
"err.key_required": "Decryption key required",
"err.decrypt_failed": "Key could not decrypt — sign-in failed",
"err.rate_limited": "Too many attempts, try later",
"err.config_not_ready": "Config not initialized",
"err.request_failed": "Request failed",
"err.method_not_allowed": "Method not allowed",
"err.missing_qr_params": "Missing qrCodeKey or qrCodeValue",
"err.missing_emp_token": "Missing empNo or token",
"err.user_search_failed": "User search failed",
"err.not_found": "Not found",
"err.internal": "Internal error",
"err.login_required_cron": "Sign in to use scheduled tasks",
"err.login_required_session": "Sign in to access sessions",
"err.login_required_create_session": "Sign in to create a session",
"err.login_required_workspace": "Sign in to use workspaces",
"err.session_forbidden": "No access to this session",
"err.session_workspace_only": "Sessions can only be created in your own workspace",
"err.workspace_path_only": "You can only open your own workspace path",
"err.workspace_create_forbidden": "Only admins can create workspaces",
"err.no_skill_credentials": "Complete UAC QR sign-in first",
"err.loopback_only_credentials": "agent-credentials is loopback-only",
"err.loopback_only_outbound": "outbound is loopback-only",
"err.url_required": "url required",
"err.invalid_url": "invalid url",
"err.unsupported_protocol": "unsupported protocol",
"err.host_not_allowed": "host not allowed",
"err.upstream_failed": "upstream failed",
"err.skill_credentials_not_ready": "skill credentials not ready",
"err.outbound_not_ready": "outbound not ready",
"ok.logged_out": "Signed out",
"ok.config_saved": "Config saved",
"ok.role_updated": "{empNo} role updated to {role}",
"ok.user_added": "{empNo} added as {role}",
"ok.user_removed": "{empNo} removed",
"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.view_all_sessions_on": "View-all sessions enabled",
"ok.view_all_sessions_off": "View-all sessions disabled"
}
}
const UDS_HOST_ARIA = {
"addWorkspace": [
"添加工作区",
"Add workspace"
],
"chooseWorkspace": [
"选择工作区",
"Choose workspace"
],
"sessions": [
"会话",
"Sessions"
]
}
/** Prefer DSH host locale over portal SSO language cookies. */
let localeHost = null
let translate = null
function normalizeLocale(lang) {
const raw = String(lang || '').trim().toLowerCase()
if (raw.startsWith('zh')) return 'zh'
return 'en'
}
function getUiLocale() {
try {
const snap = typeof localeHost?.locale?.getSnapshot === 'function'
? localeHost.locale.getSnapshot()
: null
const raw = snap?.active
|| snap?.locale
|| snap?.preference
|| localeHost?.locale?.active
|| (document.documentElement && document.documentElement.lang)
|| (typeof navigator !== 'undefined' && (navigator.language || navigator.userLanguage))
|| ''
return normalizeLocale(raw)
} catch {
return 'en'
}
}
function applyVars(text, vars) {
let out = String(text)
if (vars && typeof vars === 'object') {
for (const k of Object.keys(vars)) {
out = out.split('{' + k + '}').join(String(vars[k]))
}
}
return out
}
function t(key, vars) {
if (typeof translate === 'function') {
try {
const out = translate(key, vars)
if (out != null && out !== key) return applyVars(out, vars)
} catch { /* fall through */ }
}
const loc = getUiLocale()
const table = UDS_I18N_MESSAGES[loc] || UDS_I18N_MESSAGES.zh
const text = (table && table[key])
|| (UDS_I18N_MESSAGES.en && UDS_I18N_MESSAGES.en[key])
|| (UDS_I18N_MESSAGES.zh && UDS_I18N_MESSAGES.zh[key])
|| key
return applyVars(text, vars)
}
function makeTranslator(ctx) {
if (typeof ctx?.locale?.bind === 'function') {
try {
const bound = ctx.locale.bind(LOCALE_NS)
if (typeof bound === 'function') {
return (key, params) => {
try {
const out = bound(key, params)
if (out != null && out !== key) return out
} catch { /* fall through */ }
const dict = getUiLocale() === 'zh' ? UDS_I18N_MESSAGES.zh : UDS_I18N_MESSAGES.en
return (dict && dict[key]) || UDS_I18N_MESSAGES.en[key] || UDS_I18N_MESSAGES.zh[key] || key
}
}
} catch { /* fall through */ }
}
return (key) => {
const dict = getUiLocale() === 'zh' ? UDS_I18N_MESSAGES.zh : UDS_I18N_MESSAGES.en
return (dict && dict[key]) || UDS_I18N_MESSAGES.en[key] || UDS_I18N_MESSAGES.zh[key] || key
}
}
function roleLabel(role) {
return t('role.badge.' + String(role || 'user')) || t('role.' + String(role || 'user'))
}
function apiMessage(err) {
const data = err && err.data
const code = data && data.error
if (code) {
const key = String(code).startsWith('err.') ? code : 'err.' + code
const translated = t(key)
if (translated && translated !== key) return translated
}
return (data && (data.message || data.error)) || (err && err.message) || t('err.request_failed')
}
function hostAriaList(key) {
return (UDS_HOST_ARIA && UDS_HOST_ARIA[key]) || []
}
function hostButtonAriaSel(key) {
return hostAriaList(key).map((l) => 'button[aria-label="' + l + '"]').join(',')
}
function hostAriaSel(key) {
return hostAriaList(key).map((l) => '[aria-label="' + l + '"]').join(',')
}
/* uds-auth-i18n-end */
const CSS = [
'.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-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}',
'.uds-auth-role{font-size:10px;padding:1px 6px;border-radius:10px;font-weight:600;flex-shrink:0}',
'.uds-auth-role-super_admin{background:rgba(213,73,65,.12);color:var(--dsw-alias-state-error-primary,#d54941)}',
'.uds-auth-role-admin{background:rgba(217,119,6,.12);color:var(--dsw-alias-state-warn-primary,#d97706)}',
'.uds-auth-role-fallback_admin{background:rgba(51,112,255,.12);color:var(--dsw-alias-state-business-primary,#3370ff)}',
'.uds-auth-panel{position:fixed;z-index:1200;width:min(300px,calc(100vw - 24px));max-height:min(70vh,560px);overflow:auto;top:auto;background:var(--dsw-alias-bg-layer-1,#fff);border-radius:8px;border:1px solid var(--dsw-alias-border-l2,#dee0e3);box-shadow:0 8px 28px rgba(0,0,0,.12)}',
'.uds-auth-info{padding:14px 16px;border-bottom:1px solid var(--dsw-alias-border-l1,#eef0f3)}',
'.uds-auth-info-name{font-weight:600;margin-bottom:4px;color:var(--dsw-alias-label-primary,#1f2329);font-size:14px}',
'.uds-auth-info-detail{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);margin-top:2px;display:flex;justify-content:space-between;gap:8px}',
'.uds-auth-info-detail-label{color:var(--dsw-alias-label-tertiary,#8f959e)}',
'.uds-auth-qr{padding:16px;text-align:center}',
'.uds-auth-qr-frame{position:relative;width:160px;height:160px;margin:0 auto;background:var(--dsw-alias-bg-layer-1,#fff);box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2,#dee0e3);border-radius:4px;overflow:hidden}',
'.uds-auth-qr-frame img{display:block;width:100%;height:100%;object-fit:contain}',
'.uds-auth-qr-frame.is-expired img{opacity:.18;filter:grayscale(1)}',
'.uds-auth-qr-overlay{position:absolute;inset:0;z-index:3;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;padding:10px;box-sizing:border-box;background:color-mix(in srgb, var(--dsw-alias-bg-layer-1,#fff) 82%, transparent)}',
'.uds-auth-qr-overlay-title{font-size:15px;font-weight:700;line-height:1.3;color:var(--dsw-alias-label-primary,#1f2329)}',
'.uds-auth-qr-overlay-hint{font-size:12px;line-height:1.3;color:var(--dsw-alias-label-secondary,#646a73);margin-bottom:2px}',
'.uds-auth-qr-refresh{width:40px;height:40px;border:none;border-radius:50%;background:var(--dsw-alias-state-business-primary,#3370ff);color:#fff;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0;flex:none}',
'.uds-auth-qr-refresh:hover{opacity:.9}',
'.uds-auth-qr-refresh:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#3370ff);outline-offset:2px}',
'.uds-auth-qr-status{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);margin-top:8px}',
'.uds-auth-btn{display:block;width:calc(100% - 32px);margin:8px 16px 0;padding:8px 12px;border-radius:4px;cursor:pointer;font-size:13px;text-align:left;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:var(--dsw-alias-label-primary,#1f2329)}',
'.uds-auth-btn:hover{background:var(--dsw-alias-interactive-bg-hover,#f7f8fa)}',
'.uds-auth-btn-primary{background:var(--dsw-alias-state-business-primary,#3370ff);color:#fff;border-color:transparent;text-align:center}',
'.uds-auth-btn-primary:hover{opacity:.9}',
'.uds-auth-btn-danger{margin-bottom:16px;background:rgba(213,73,65,.08);color:var(--dsw-alias-state-error-primary,#d54941);border-color:rgba(213,73,65,.2)}',
'.uds-auth-btn-link{background:transparent;border:none;color:var(--dsw-alias-state-business-primary,#3370ff);text-align:center;padding:4px 12px;margin:4px 16px 12px;width:calc(100% - 32px);font-size:12px;cursor:pointer}',
'.uds-auth-fallback{padding:12px 16px 16px}',
'.uds-auth-fallback label{display:block;font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);margin:8px 0 4px}',
'.uds-auth-fallback input{width:100%;box-sizing:border-box;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);border-radius:6px;font-size:13px}',
'.uds-auth-fallback-hint{font-size:11px;color:var(--dsw-alias-label-tertiary,#8f959e);margin:8px 0 0;line-height:1.4}',
'.uds-auth-table{width:100%;border-collapse:collapse;font-size:12px}',
'.uds-auth-table th,.uds-auth-table td{padding:6px 8px;text-align:left;border-bottom:1px solid var(--dsw-alias-border-l1,#eef0f3)}',
'.uds-auth-add{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}',
'.uds-auth-add input,.uds-auth-add select{padding:6px 8px;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);border-radius:4px;font-size:12px}',
'.uds-auth-add input{flex:1;min-width:120px}',
'.uds-auth-del{padding:3px 8px;background:rgba(213,73,65,.1);color:var(--dsw-alias-state-error-primary,#d54941);border:1px solid rgba(213,73,65,.2);border-radius:3px;cursor:pointer;font-size:11px}',
'.uds-auth-pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:12px;font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);flex-wrap:wrap}',
'.uds-auth-pager button{padding:4px 10px;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);border-radius:4px;background:var(--dsw-alias-bg-module-platform,#f4f5f7);cursor:pointer;font-size:12px}',
'.uds-auth-pager button:disabled{opacity:.4;cursor:not-allowed}',
'.uds-auth-search{display:flex;gap:8px;margin-bottom:12px}',
'.uds-auth-search input{flex:1;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);border-radius:6px;font-size:13px}',
'.uds-auth-settings{box-sizing:border-box;display:flex;flex-direction:column;gap:20px;min-height:0;padding:4px 4px 24px;color:var(--dsw-alias-label-primary,#1f2329)}',
'.uds-auth-settings h2{margin:0;font-size:20px;font-weight:600;line-height:28px}',
'.uds-auth-settings-intro{margin:4px 0 0;font-size:13px;color:var(--dsw-alias-label-secondary,#646a73)}',
'.uds-auth-settings-card{border:1px solid var(--dsw-alias-border-l2,#dee0e3);border-radius:12px;padding:16px;background:var(--dsw-alias-bg-layer-1,transparent)}',
'.uds-auth-settings-card h3{margin:0 0 12px;font-size:15px;font-weight:600}',
'.uds-auth-settings-field{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}',
'.uds-auth-settings-field label{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)}',
'.uds-auth-settings-field input{padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);border-radius:6px;font-size:13px;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:inherit}',
'.uds-auth-settings-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
'.uds-auth-settings-msg{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)}',
'.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) {
const prefix = cookieName + '='
const parts = String(document.cookie || '').split(';')
for (const part of parts) {
const v = part.trim()
if (v.startsWith(prefix)) return decodeURIComponent(v.slice(prefix.length))
}
return null
}
function setCookie(cookieName, value, days) {
const d = new Date()
d.setTime(d.getTime() + days * 86400000)
document.cookie = encodeURIComponent(cookieName) + '=' + encodeURIComponent(value) + ';expires=' + d.toUTCString() + ';path=/'
}
function clearAuthCookies() {
const names = ['PORTALSSOUser', 'PORTALSSOCookie', 'ZTEDPGSSOUser', 'ZTEDPGSSOCookie', 'UDS_FALLBACK_USER', 'UDS_FALLBACK_UI', 'UDS_LOCAL_ADMIN']
for (const key of names) {
// Match both Secure and non-Secure variants; HttpOnly ones need server clear.
document.cookie = encodeURIComponent(key) + '=; Max-Age=0; Path=/; SameSite=Lax'
document.cookie = encodeURIComponent(key) + '=; Max-Age=0; Path=/; SameSite=Lax; Secure'
document.cookie = encodeURIComponent(key) + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'
}
}
function getEmpNo() {
return getCookie('PORTALSSOUser') || getCookie('ZTEDPGSSOUser') || getCookie('UDS_FALLBACK_USER') || getCookie('UDS_FALLBACK_UI') || null
}
/** Prefer /api/me-driven attr: fallback login cookie is HttpOnly and invisible to document.cookie. */
function isUdsAuthReady() {
return document.documentElement.getAttribute('data-uds-auth-ready') === '1'
}
/** True only after /api/me confirmed a user — portal empNo cookie alone does not count. */
function isLoggedInInUi() {
return document.documentElement.getAttribute('data-uds-logged-in') === '1'
}
function clearSessionIfAnonymous(sessions) {
// Until /api/me confirms login, treat as anonymous (AuthBadge refreshes me on mount).
if (isLoggedInInUi()) return
try {
if (sessions && typeof sessions.clear === 'function') sessions.clear()
} catch { /* ignore */ }
try {
window.localStorage.removeItem('dsh.sessions.current')
} catch { /* ignore */ }
// Navigation may auto-reopen; clear again on next ticks while still anonymous.
try {
const snap = sessions && sessions.list && typeof sessions.list.getSnapshot === 'function'
? sessions.list.getSnapshot()
: null
if (snap && snap.current != null) {
setTimeout(() => {
if (isLoggedInInUi()) return
try { sessions.clear() } catch { /* ignore */ }
try { window.localStorage.removeItem('dsh.sessions.current') } catch { /* ignore */ }
}, 0)
setTimeout(() => {
if (isLoggedInInUi()) return
try { sessions.clear() } catch { /* ignore */ }
}, 350)
}
} catch { /* ignore */ }
}
const VIEW_STORE_KEY = 'dsh.workspace.view.v5'
const FORCED_FLAT_KEY = 'uds-auth-forced-flat'
function readViewState() {
try {
const raw = window.localStorage.getItem(VIEW_STORE_KEY)
const state = raw ? JSON.parse(raw) : null
if (state && typeof state === 'object') return state
} catch { /* ignore */ }
return {
groupBy: 'workspace',
orderBy: 'updated',
groupExpansion: {},
sessionOrderByAccount: {},
sessionUpdatedAtByAccount: {},
}
}
function writeViewState(state) {
window.localStorage.setItem(VIEW_STORE_KEY, JSON.stringify(state))
}
/** user/admin only — wait until /api/me set can-create-ws explicitly to 0. */
function ensureFlatSessionSidebar() {
try {
const root = document.documentElement
if (root.getAttribute('data-uds-auth-ready') !== '1') return false
if (root.getAttribute('data-uds-logged-in') !== '1') return false
// Must be explicit 0 — missing/default must not force flat for supers.
if (root.getAttribute('data-uds-can-create-ws') !== '0') return false
const state = readViewState()
if (state.groupBy === 'flat') {
try { window.localStorage.setItem(FORCED_FLAT_KEY, '1') } catch { /* ignore */ }
return false
}
state.groupBy = 'flat'
writeViewState(state)
try { window.localStorage.setItem(FORCED_FLAT_KEY, '1') } catch { /* ignore */ }
return true
} catch {
return false
}
}
/** super/fallback: restore workspace partitions after a forced flat. */
function ensureWorkspaceGroupedSidebar() {
try {
const root = document.documentElement
if (root.getAttribute('data-uds-auth-ready') !== '1') return false
if (root.getAttribute('data-uds-logged-in') !== '1') return false
if (root.getAttribute('data-uds-can-create-ws') !== '1') return false
const state = readViewState()
if (state.groupBy === 'workspace') {
try { window.localStorage.removeItem(FORCED_FLAT_KEY) } catch { /* ignore */ }
return false
}
state.groupBy = 'workspace'
writeViewState(state)
try { window.localStorage.removeItem(FORCED_FLAT_KEY) } catch { /* ignore */ }
return true
} catch {
return false
}
}
function expandHiddenWorkspaceGroups() {
try {
if (document.documentElement.getAttribute('data-uds-auth-ready') !== '1') return
if (document.documentElement.getAttribute('data-uds-logged-in') !== '1') return
if (document.documentElement.getAttribute('data-uds-can-create-ws') !== '0') return
document.querySelectorAll('[class*="projectRow"][aria-expanded="false"]:not([class*="dsh-ct-project"])').forEach((el) => {
try { el.click() } catch { /* ignore */ }
})
} catch { /* ignore */ }
}
function reloadAfterLogin() {
// Login Set-Cookie must land before WS upgrade — hard reload is required.
try { ensureFlatSessionSidebar() } catch { /* ignore */ }
try { window.location.reload() } catch { /* ignore */ }
}
/** Soft WS reconnect (logout / auth flip). Prefer this over full reload. */
function softReconnectAuth() {
try {
if (typeof window.__udsAuthReconnect === 'function') {
window.__udsAuthReconnect()
return
}
} catch { /* fall through */ }
try { window.location.reload() } catch { /* ignore */ }
}
// Back-compat alias used by older call sites in this file.
function reconnectAfterLogin() {
reloadAfterLogin()
}
function getAuthToken() {
return getCookie('PORTALSSOCookie') || getCookie('ZTEDPGSSOCookie') || null
}
async function fetchJson(url, options) {
const res = await fetch(url, options)
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const err = new Error(data.message || data.error || res.statusText || t('err.request_failed'))
err.status = res.status
err.data = data
throw err
}
return data
}
function useLocaleTick() {
const [tick, setTick] = useState(0)
useEffect(() => {
const bump = () => setTick((n) => n + 1)
const offs = []
try {
const off = localeHost?.on?.('locale/change', bump)
if (typeof off === 'function') offs.push(off)
} catch { /* ignore */ }
try {
const off = localeHost?.locale?.subscribe?.(bump)
if (typeof off === 'function') offs.push(off)
} catch { /* ignore */ }
let last = typeof document !== 'undefined' ? document.documentElement?.lang : ''
const timer = setInterval(() => {
const next = document.documentElement?.lang || ''
if (next !== last) {
last = next
bump()
}
}, 800)
return () => {
for (const off of offs) {
try { off() } catch { /* ignore */ }
}
clearInterval(timer)
}
}, [])
return tick
}
function useOutsideClose(ref, open, setOpen) {
useEffect(() => {
if (!open) return undefined
const onPointer = (event) => {
if (ref.current && !ref.current.contains(event.target)) setOpen(false)
}
document.addEventListener('pointerdown', onPointer, true)
return () => document.removeEventListener('pointerdown', onPointer, true)
}, [open, ref, setOpen])
}
function UserManagementPanel() {
useLocaleTick()
const [users, setUsers] = useState([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [q, setQ] = useState('')
const [qDraft, setQDraft] = useState('')
const [newEmpNo, setNewEmpNo] = useState('')
const [newRole, setNewRole] = useState('user')
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const reload = useCallback(async () => {
setLoading(true)
setError('')
try {
const qs = new URLSearchParams({
page: String(page),
pageSize: String(PAGE_SIZE),
})
if (q) qs.set('q', q)
const data = await fetchJson('/uds-auth/api/users?' + qs.toString())
setUsers(data.users || [])
setTotal(data.total || 0)
setTotalPages(data.totalPages || 1)
} catch (err) {
setError(apiMessage(err) || t('ui.loadFailed'))
setUsers([])
} finally {
setLoading(false)
}
}, [page, q])
useEffect(() => { reload() }, [reload])
return h('div', { className: 'uds-auth-settings-card' },
h('h3', null, t('ui.userManagement')),
h('div', { className: 'uds-auth-search' },
h('input', {
value: qDraft,
placeholder: t('ui.searchEmpNo'),
onChange: (e) => setQDraft(e.target.value),
onKeyDown: (e) => {
if (e.key === 'Enter') { setPage(1); setQ(qDraft.trim()) }
},
}),
h('button', {
type: 'button',
className: 'uds-auth-btn uds-auth-btn-primary',
style: { width: 'auto', margin: 0 },
onClick: () => { setPage(1); setQ(qDraft.trim()) },
}, t('ui.search')),
),
error && h('div', { className: 'uds-auth-settings-msg err', role: 'alert' }, error),
loading
? h('div', { className: 'uds-auth-settings-empty' }, t('ui.loading'))
: h('table', { className: 'uds-auth-table' },
h('thead', null, h('tr', null, h('th', null, t('ui.empNo')), h('th', null, t('ui.role')), h('th', null, t('ui.actions')))),
h('tbody', null, users.length === 0
? h('tr', null, h('td', { colSpan: 3 }, t('ui.noUsers')))
: users.map((u) => h('tr', { key: u.empNo },
h('td', null, u.empNo),
h('td', null,
h('select', {
value: u.role,
onChange: async (e) => {
try {
await fetchJson('/uds-auth/api/users/role', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ empNo: u.empNo, role: e.target.value }),
})
reload()
} catch (err) { window.alert(apiMessage(err)) }
},
},
h('option', { value: 'user' }, t('role.user')),
h('option', { value: 'admin' }, t('role.admin')),
h('option', { value: 'super_admin' }, t('role.super_admin')),
),
),
h('td', null,
h('button', {
type: 'button', className: 'uds-auth-del',
onClick: async () => {
if (!window.confirm(t('ui.confirmDelete', { empNo: u.empNo }))) return
try {
await fetchJson('/uds-auth/api/users/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ empNo: u.empNo }),
})
reload()
} catch (err) { window.alert(apiMessage(err)) }
},
}, t('ui.delete')),
),
)),
),
),
h('div', { className: 'uds-auth-pager' },
h('span', null, t('ui.pager', { total, page, totalPages })),
h('span', null,
h('button', {
type: 'button', disabled: page <= 1 || loading,
onClick: () => setPage((p) => Math.max(1, p - 1)),
}, t('ui.prevPage')),
' ',
h('button', {
type: 'button', disabled: page >= totalPages || loading,
onClick: () => setPage((p) => p + 1),
}, t('ui.nextPage')),
),
),
h('div', { className: 'uds-auth-add' },
h('input', { value: newEmpNo, placeholder: t('ui.empNo'), onChange: (e) => setNewEmpNo(e.target.value) }),
h('select', { value: newRole, onChange: (e) => setNewRole(e.target.value) },
h('option', { value: 'user' }, t('role.user')),
h('option', { value: 'admin' }, t('role.admin')),
h('option', { value: 'super_admin' }, t('role.super_admin')),
),
h('button', {
type: 'button', className: 'uds-auth-btn uds-auth-btn-primary', style: { width: 'auto', margin: 0 },
onClick: async () => {
const empNo = newEmpNo.trim()
if (!empNo) return
try {
await fetchJson('/uds-auth/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ empNo, role: newRole }),
})
setNewEmpNo('')
reload()
} catch (err) { window.alert(apiMessage(err)) }
},
}, t('ui.add')),
),
)
}
function AuthSettingsSection() {
useLocaleTick()
const [me, setMe] = useState(null)
const [form, setForm] = useState({
uacBaseUrl: '',
userSearchUrl: '',
loginSystemCode: '',
originSystemCode: '',
workspaceRoot: '',
})
const [fallbackEnabled, setFallbackEnabled] = useState(false)
const [fallbackPwd, setFallbackPwd] = useState('')
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
;(async () => {
try {
const data = await fetchJson('/uds-auth/api/me')
if (!cancelled) setMe(data?.data || null)
} catch {
if (!cancelled) setMe(null)
}
try {
const cfg = await fetchJson('/uds-auth/config.get')
if (!cancelled && cfg?.value) setForm((prev) => ({ ...prev, ...cfg.value }))
} catch { /* ignore */ }
try {
const st = await fetchJson('/uds-auth/api/fallback/status')
if (!cancelled) setFallbackEnabled(!!st.enabled)
} catch { /* ignore */ }
})()
return () => { cancelled = true }
}, [])
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)
setMsg('')
try {
await fetchJson('/uds-auth/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
setMsgKind('ok')
setMsg(t('ui.configSaved'))
} catch (err) {
setMsgKind('err')
setMsg(apiMessage(err) || t('ui.saveFailed'))
} finally {
setBusy(false)
}
}
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', {
id: 'uds-auth-' + key,
value: form[key] || '',
disabled: !canSettings || busy,
onChange: (e) => setForm((prev) => ({ ...prev, [key]: e.target.value })),
}),
)
return h('section', { className: 'uds-auth-settings', 'aria-label': t('ui.settingsTitle') },
h('header', null,
h('h2', null, t('ui.settingsTitle')),
h('p', { className: 'uds-auth-settings-intro' }, t('ui.settingsIntro')),
),
!me && h('div', { className: 'uds-auth-settings-empty' }, t('ui.loginRequiredPage')),
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'),
field('userSearchUrl', t('ui.userSearchUrl')),
field('loginSystemCode', 'loginSystemCode'),
field('originSystemCode', 'originSystemCode'),
field('workspaceRoot', t('ui.workspaceRoot')),
h('div', { className: 'uds-auth-settings-actions' },
h('button', {
type: 'button',
className: 'uds-auth-btn uds-auth-btn-primary',
style: { width: 'auto', margin: 0 },
disabled: busy,
onClick: saveConfig,
}, busy ? t('ui.saving') : t('ui.saveConfig')),
msg && h('span', { className: 'uds-auth-settings-msg ' + msgKind }, msg),
),
),
canManage && h('div', { className: 'uds-auth-settings-card' },
h('h3', null, t('ui.fallbackTitle')),
h('p', { className: 'uds-auth-settings-intro' },
t('ui.fallbackStatus', { status: fallbackEnabled ? t('ui.enabled') : t('ui.disabled') })),
h('div', { className: 'uds-auth-settings-field' },
h('label', { htmlFor: 'uds-auth-fallback-pwd' }, t('ui.fallbackPassword')),
h('input', {
id: 'uds-auth-fallback-pwd',
type: 'password',
value: fallbackPwd,
onChange: (e) => setFallbackPwd(e.target.value),
autoComplete: 'new-password',
}),
),
h('div', { className: 'uds-auth-settings-actions' },
h('button', {
type: 'button',
className: 'uds-auth-btn uds-auth-btn-primary',
style: { width: 'auto', margin: 0 },
onClick: async () => {
try {
await fetchJson('/uds-auth/api/fallback/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: fallbackPwd }),
})
setFallbackPwd('')
setFallbackEnabled(true)
window.alert(t('ui.fallbackPasswordSet'))
} catch (err) { window.alert(apiMessage(err)) }
},
}, t('ui.saveFallbackPassword')),
fallbackEnabled && h('button', {
type: 'button',
className: 'uds-auth-btn uds-auth-btn-danger',
style: { width: 'auto', margin: 0 },
onClick: async () => {
if (!window.confirm(t('ui.confirmClearFallback'))) return
try {
await fetchJson('/uds-auth/api/fallback/clear', { method: 'POST' })
setFallbackEnabled(false)
} catch (err) { window.alert(apiMessage(err)) }
},
}, t('ui.clear')),
),
),
canManage && h(UserManagementPanel, null),
)
}
function AuthBadge(props = {}) {
useLocaleTick()
const wide = props.wide !== false
const rootRef = useRef(null)
useLayoutEffect(() => {
const host = rootRef.current
if (!host) return undefined
// Climb to the official footArea that holds footer.action + sidebar.settings.
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) || 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')
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)
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(false)
const [config, setConfig] = useState({ loginSystemCode: '100000455558', originSystemCode: '' })
const [qrStatus, setQrStatus] = useState('')
const [qrImg, setQrImg] = useState('')
const [qrExpired, setQrExpired] = useState(false)
const [qrFailed, setQrFailed] = useState(false)
const [loginMode, setLoginMode] = useState('qr')
// Default true (server enables fallback by default). If status fetch fails
// while QR is also down, keep the emergency link visible so admins can still sign in.
const [fallbackEnabled, setFallbackEnabled] = useState(true)
const [localKeyEnabled, setLocalKeyEnabled] = useState(false)
const [fbUser, setFbUser] = useState('administrator')
const [fbPass, setFbPass] = useState('')
const [localKey, setLocalKey] = useState('')
const [fbBusy, setFbBusy] = useState(false)
const [fbErr, setFbErr] = useState('')
const qrRef = useRef({ key: null, value: null, timer: null, timeout: null, deadline: 0 })
const QR_TIMEOUT_MS = 60 * 1000
useEffect(() => {
const perms = user?.permissions && !Array.isArray(user.permissions)
? user.permissions
: {}
const role = typeof user?.role === 'string' ? user.role : ''
const canSettings = user ? !!perms.canAccessSettings : false
// Prefer permissions; also trust admin-class roles if /api/me omitted the flag.
const canCreateWs = user ? !!(
perms.canCreateWorkspace
|| role === 'super_admin'
|| role === 'fallback_admin'
|| role === 'admin'
) : false
const prev = document.documentElement.getAttribute('data-uds-logged-in')
document.documentElement.setAttribute('data-uds-logged-in', user ? '1' : '0')
document.documentElement.setAttribute('data-uds-can-settings', canSettings ? '1' : '0')
document.documentElement.setAttribute('data-uds-can-create-ws', canCreateWs ? '1' : '0')
// Drop stale remote.mux identity after logout so workspace names disappear.
if (prev === '1' && !user) {
clearSessionIfAnonymous(window.__udsAuthSessions)
try { softReconnectAuth() } catch { /* ignore */ }
}
// Do not reset auth attrs in the effect cleanup: React remount/strict-mode
// would briefly look logged-out and trigger flat↔workspace reload loops.
}, [user]) /* uds-auth: logout-reconnect-on-null */
const stopQr = useCallback(() => {
if (qrRef.current.timer) { clearInterval(qrRef.current.timer); qrRef.current.timer = null }
if (qrRef.current.timeout) { clearTimeout(qrRef.current.timeout); qrRef.current.timeout = null }
qrRef.current.key = null
qrRef.current.value = null
qrRef.current.deadline = 0
}, [])
const markQrExpired = useCallback(() => {
stopQr()
setQrFailed(false)
setQrExpired(true)
setQrStatus(t('ui.qrExpired'))
}, [stopQr])
const markQrFailed = useCallback((message) => {
stopQr()
setQrExpired(false)
setQrFailed(true)
setQrImg('')
setQrStatus(message || t('ui.qrGenerateFailed'))
}, [stopQr])
const refreshUser = useCallback(async () => {
try {
const me = await fetchJson('/uds-auth/api/me')
if (!me?.authenticated || !me?.data) {
setUser(null)
window.dispatchEvent(new Event('uds-auth-changed'))
return false
}
const d = me.data
const rawPerms = d.permissions
const permissions = rawPerms && typeof rawPerms === 'object' && !Array.isArray(rawPerms)
? rawPerms
: {}
setUser({
empNo: d.empNo || d.userId,
userName: d.displayName || d.username || d.userName || d.empNo,
department: d.department || '',
email: d.email || '',
phone: d.phone || '',
role: d.role || 'user',
permissions,
})
window.dispatchEvent(new Event('uds-auth-changed'))
return true
} catch {
setUser(null)
window.dispatchEvent(new Event('uds-auth-changed'))
return false
} finally {
document.documentElement.setAttribute('data-uds-auth-ready', '1')
setLoading(false)
}
}, [])
const startQr = useCallback(async () => {
stopQr()
setQrExpired(false)
setQrFailed(false)
setQrStatus(t('ui.qrGenerating'))
setQrImg('')
// Me check in background — never block QR paint on /api/me.
void refreshUser().then((loggedIn) => {
if (!loggedIn) return
stopQr()
setQrStatus('')
setQrImg('')
setOpen(false)
}).catch(() => {})
try {
const started = await fetchJson('/uds-auth/qr-start')
// Already signed in while QR was starting — drop the code.
if (document.documentElement.getAttribute('data-uds-logged-in') === '1') {
stopQr()
setQrStatus('')
setQrImg('')
return
}
const { qrCodeStr, qrCodeKey, qrCodeValue, loginSystemCode, originSystemCode } = started
qrRef.current.key = qrCodeKey
qrRef.current.value = qrCodeValue
qrRef.current.deadline = Date.now() + QR_TIMEOUT_MS
setQrImg('/uds-auth/qr?data=' + encodeURIComponent(qrCodeStr))
setQrStatus(t('ui.qrScanPrompt'))
qrRef.current.timeout = setTimeout(() => {
if (!qrRef.current.key) return
markQrExpired()
}, QR_TIMEOUT_MS)
qrRef.current.timer = setInterval(async () => {
if (!qrRef.current.key) return
if (qrRef.current.deadline && Date.now() >= qrRef.current.deadline) {
markQrExpired()
return
}
try {
const verify = await fetchJson(
'/uds-auth/verify-code?qrCodeKey=' + encodeURIComponent(qrRef.current.key)
+ '&qrCodeValue=' + encodeURIComponent(qrRef.current.value)
+ '&loginClientIp=' + encodeURIComponent('127.0.0.1')
+ '&loginSystemCode=' + encodeURIComponent(loginSystemCode || config.loginSystemCode)
+ '&originSystemCode=' + encodeURIComponent(originSystemCode || config.originSystemCode || ''),
)
const result = await fetchJson('/uds-auth/qr-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
qrCodeKey: qrRef.current.key,
qrCodeValue: qrRef.current.value,
loginClientIp: '127.0.0.1',
originSystemCode: originSystemCode || config.originSystemCode || '',
loginSystemCode: loginSystemCode || config.loginSystemCode,
verifyCode: verify.verifyCode,
}),
})
const codeCode = result.code?.code || ''
const boCode = result.bo?.code || ''
if (codeCode === '0000' && boCode === '0000') {
stopQr()
setQrExpired(false)
setQrFailed(false)
const other = result.other || {}
const empNo = other.account || other.empNo || ''
const token = other.token || other.authValue || ''
if (empNo && token) {
setCookie('PORTALSSOUser', empNo, 7)
setCookie('PORTALSSOCookie', token, 7)
try {
const info = await fetchJson(
'/uds-auth/user-info?empNo=' + encodeURIComponent(empNo)
+ '&token=' + encodeURIComponent(token),
)
const ic = info?.code?.code ?? info?.code
const list = Array.isArray(info?.bo) ? info.bo
: Array.isArray(info?.bo?.rows) ? info.bo.rows
: Array.isArray(info?.bo?.list) ? info.bo.list
: []
if ((ic !== '0000' && ic !== 0 && ic !== '0') || !list.length) {
setQrStatus(t('ui.userInfoFailed'))
console.warn('[uds-auth] user-info after QR failed', info)
return
}
} catch (err) {
setQrStatus(t('ui.userInfoFailed') + ': ' + (err.message || err))
return
}
setQrStatus(t('ui.loginSuccess'))
await refreshUser()
setOpen(false)
reconnectAfterLogin()
} else {
setQrStatus(t('ui.missingToken'))
}
return
}
if (codeCode === '0000' && boCode === '4002') {
setQrStatus(t('ui.waitingScan'))
return
}
if (codeCode === '0000' && boCode === '1002') {
markQrExpired()
return
}
stopQr()
setQrExpired(false)
setQrFailed(false)
setQrStatus(result.bo?.msg || boCode || t('ui.loginFailed'))
} catch {
setQrStatus(t('ui.networkError'))
}
}, 2000)
} catch (err) {
markQrFailed(err.message || t('ui.qrGenerateFailed'))
}
}, [config.loginSystemCode, config.originSystemCode, markQrExpired, markQrFailed, refreshUser, stopQr])
const submitFallback = useCallback(async () => {
setFbBusy(true)
setFbErr('')
try {
await fetchJson('/uds-auth/api/fallback/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: fbUser.trim(), password: fbPass }),
})
setFbPass('')
await refreshUser()
setOpen(false)
reconnectAfterLogin()
} catch (err) {
setFbErr(apiMessage(err) || t('ui.loginFailed'))
} finally {
setFbBusy(false)
}
}, [fbUser, fbPass, refreshUser])
const submitLocalKey = useCallback(async () => {
setFbBusy(true)
setFbErr('')
try {
await fetchJson('/uds-auth/api/local-admin/unlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: localKey }),
})
setLocalKey('')
await refreshUser()
setOpen(false)
reconnectAfterLogin()
} catch (err) {
setFbErr(apiMessage(err) || t('err.decrypt_failed'))
} finally {
setFbBusy(false)
}
}, [localKey, refreshUser])
useEffect(() => {
// Restore session after login reload / refresh — cookie alone does not set the badge.
// (Previously me ran only inside startQr, so a click on「未登录」was required.)
void refreshUser()
fetchJson('/uds-auth/config.get')
.then((data) => { if (data?.value) setConfig((prev) => ({ ...prev, ...data.value })) })
.catch(() => {})
fetchJson('/uds-auth/api/fallback/status')
.then((st) => setFallbackEnabled(!!st.enabled))
.catch(() => { /* keep default true — do not hide emergency login */ })
fetchJson('/uds-auth/api/local-admin/status')
.then((st) => setLocalKeyEnabled(!!st.enabled))
.catch(() => { setLocalKeyEnabled(false) })
return () => { stopQr() }
}, [refreshUser, stopQr])
useEffect(() => {
if (!open || user) return undefined
// Refresh status each time the panel opens (mount may have raced/failed).
let cancelled = false
fetchJson('/uds-auth/api/fallback/status')
.then((st) => { if (!cancelled) setFallbackEnabled(!!st.enabled) })
.catch(() => {})
return () => { cancelled = true }
}, [open, user])
useEffect(() => {
if (open && !user && loginMode === 'qr') startQr()
if (!open || loginMode !== 'qr') stopQr()
}, [open, user, loginMode, startQr, stopQr])
useLayoutEffect(() => {
if (!open) { setAnchor(null); return undefined }
const place = () => {
const rect = rootRef.current?.getBoundingClientRect()
if (!rect) return
const width = Math.min(300, window.innerWidth - 24)
// Keep panel aligned to the badge (footer), not mid-sidebar.
const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))
const gap = 8
const bottom = Math.max(8, window.innerHeight - rect.top + gap)
setAnchor({ left, bottom, width })
}
place()
window.addEventListener('resize', place)
window.addEventListener('scroll', place, true)
return () => {
window.removeEventListener('resize', place)
window.removeEventListener('scroll', place, true)
}
}, [open])
useOutsideClose(rootRef, open, setOpen)
const displayName = user
? (user.userName || user.name || (t('ui.userPrefix') + user.empNo))
: t('ui.notLoggedIn')
const initials = String(displayName).slice(0, 2).toUpperCase()
const label = roleLabel(user?.role)
const perms = user?.permissions || {}
const qrOverlay = qrExpired || qrFailed
const qrOverlayTitle = qrExpired
? t('ui.qrExpired')
: (qrStatus || t('ui.qrGenerateFailed'))
const qrOverlayHint = t('ui.qrExpiredHint')
return h('div', {
ref: rootRef,
className: 'uds-auth-host' + (wide ? '' : ' is-rail'),
'data-uds-auth-host': 'sidebar-footer',
},
open && anchor && h('section', {
className: 'uds-auth-panel',
style: {
left: anchor.left,
bottom: anchor.bottom,
top: 'auto',
width: anchor.width,
},
'aria-label': 'UAC',
},
!user
? (loginMode === 'qr'
? h(React.Fragment, null,
h('div', { className: 'uds-auth-info' },
h('div', { className: 'uds-auth-info-name' }, qrOverlay ? qrOverlayTitle : t('ui.notLoggedIn')),
h('div', { className: 'uds-auth-info-detail' }, qrOverlay ? qrOverlayHint : t('ui.pleaseScan')),
),
h('div', { className: 'uds-auth-qr' },
(qrImg || qrOverlay) && h('div', {
className: 'uds-auth-qr-frame' + (qrOverlay ? ' is-expired' : ''),
},
qrImg ? h('img', { src: qrImg, alt: 'QR' }) : h('div', {
style: {
width: '100%',
height: '100%',
background: 'var(--dsw-alias-bg-module-platform, #f4f5f7)',
},
}),
qrOverlay && h('div', { className: 'uds-auth-qr-overlay' },
h('div', { className: 'uds-auth-qr-overlay-title' }, qrOverlayTitle),
h('div', { className: 'uds-auth-qr-overlay-hint' }, qrOverlayHint),
h('button', {
type: 'button',
className: 'uds-auth-qr-refresh',
'aria-label': t('ui.refreshQr'),
title: t('ui.refreshQr'),
onClick: startQr,
},
h('svg', {
width: 22,
height: 22,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2.4,
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': 'true',
},
h('path', { d: 'M21 12a9 9 0 1 1-2.6-6.2' }),
h('polyline', { points: '21 3 21 9 15 9' }),
),
),
),
),
!qrOverlay && h('div', { className: 'uds-auth-qr-status' }, qrStatus || t('ui.loading')),
),
// Always offer emergency login when QR failed/expired; otherwise only if enabled.
(fallbackEnabled || qrFailed || qrExpired) && h('button', {
type: 'button',
className: 'uds-auth-btn-link',
onClick: () => { stopQr(); setLoginMode('fallback'); setFbErr('') },
}, t('ui.fallbackLink')),
localKeyEnabled && h('button', {
type: 'button',
className: 'uds-auth-btn-link',
onClick: () => { stopQr(); setLoginMode('localKey'); setFbErr(''); setLocalKey('') },
}, t('ui.localKeyLink')),
)
: loginMode === 'localKey'
? h(React.Fragment, null,
h('div', { className: 'uds-auth-info' },
h('div', { className: 'uds-auth-info-name' }, t('ui.localKeyLogin')),
h('div', { className: 'uds-auth-info-detail' }, t('ui.localKeyDetail')),
),
h('div', { className: 'uds-auth-fallback' },
h('label', { htmlFor: 'uds-local-key' }, t('ui.localKey')),
h('input', {
id: 'uds-local-key',
type: 'password',
value: localKey,
onChange: (e) => setLocalKey(e.target.value),
autoComplete: 'current-password',
onKeyDown: (e) => { if (e.key === 'Enter') submitLocalKey() },
}),
fbErr && h('div', { className: 'uds-auth-settings-msg err' }, fbErr),
h('button', {
type: 'button',
className: 'uds-auth-btn uds-auth-btn-primary',
style: { width: '100%', margin: '12px 0 0' },
disabled: fbBusy || !localKey,
onClick: submitLocalKey,
}, fbBusy ? t('ui.loggingIn') : t('ui.login')),
),
h('button', {
type: 'button',
className: 'uds-auth-btn-link',
onClick: () => setLoginMode('qr'),
}, t('ui.backToQr')),
)
: h(React.Fragment, null,
h('div', { className: 'uds-auth-info' },
h('div', { className: 'uds-auth-info-name' }, t('ui.fallbackLogin')),
h('div', { className: 'uds-auth-info-detail' }, t('ui.fallbackDetail')),
),
h('div', { className: 'uds-auth-fallback' },
h('label', { htmlFor: 'uds-fb-user' }, t('ui.username')),
h('input', {
id: 'uds-fb-user',
value: fbUser,
onChange: (e) => setFbUser(e.target.value),
autoComplete: 'username',
}),
h('label', { htmlFor: 'uds-fb-pass' }, t('ui.password')),
h('input', {
id: 'uds-fb-pass',
type: 'password',
value: fbPass,
onChange: (e) => setFbPass(e.target.value),
autoComplete: 'current-password',
onKeyDown: (e) => { if (e.key === 'Enter') submitFallback() },
}),
fbErr && h('div', { className: 'uds-auth-settings-msg err' }, fbErr),
h('button', {
type: 'button',
className: 'uds-auth-btn uds-auth-btn-primary',
style: { width: '100%', margin: '12px 0 0' },
disabled: fbBusy,
onClick: submitFallback,
}, fbBusy ? t('ui.loggingIn') : t('ui.login')),
),
h('button', {
type: 'button',
className: 'uds-auth-btn-link',
onClick: () => setLoginMode('qr'),
}, t('ui.backToQr')),
))
: h(React.Fragment, null,
h('div', { className: 'uds-auth-info' },
h('div', { className: 'uds-auth-info-name' }, displayName),
h('div', { className: 'uds-auth-info-detail' }, h('span', { className: 'uds-auth-info-detail-label' }, t('ui.empNo')), user.empNo || '-'),
h('div', { className: 'uds-auth-info-detail' }, h('span', { className: 'uds-auth-info-detail-label' }, t('ui.role')), label || user.role || '-'),
user.department && h('div', { className: 'uds-auth-info-detail' }, h('span', { className: 'uds-auth-info-detail-label' }, t('ui.department')), user.department),
),
h('button', {
type: 'button', className: 'uds-auth-btn uds-auth-btn-danger',
onClick: async () => {
try { await fetchJson('/uds-auth/api/logout', { method: 'POST' }) } catch { /* ignore */ }
clearAuthCookies()
setUser(null)
setOpen(false)
document.documentElement.setAttribute('data-uds-logged-in', '0')
document.documentElement.setAttribute('data-uds-can-settings', '0')
document.documentElement.setAttribute('data-uds-can-create-ws', '0')
clearSessionIfAnonymous(window.__udsAuthSessions)
window.dispatchEvent(new Event('uds-auth-changed'))
try { softReconnectAuth() } catch { /* ignore */ }
},
}, t('ui.logout')),
),
),
h('button', {
type: 'button',
className: 'uds-auth-badge' + (user ? '' : ' uds-auth-badge-unauth'),
'aria-expanded': open,
'aria-label': displayName || 'UAC',
title: displayName || 'UAC',
onClick: () => setOpen((v) => !v),
},
h('span', { className: 'uds-auth-badge-label' }, displayName),
),
)
}
function apply(ctx) {
localeHost = ctx
translate = makeTranslator(ctx)
// --- Critical path: login badge must paint first ---
// Sync ACL + CSS before any React mount so 「未登录」is styled and locked immediately.
{
const root = document.documentElement
if (!root.getAttribute('data-uds-logged-in')) root.setAttribute('data-uds-logged-in', '0')
if (!root.getAttribute('data-uds-auth-ready')) root.setAttribute('data-uds-auth-ready', '0')
if (!root.getAttribute('data-uds-can-create-ws')) root.setAttribute('data-uds-can-create-ws', '0')
if (!root.getAttribute('data-uds-can-settings')) root.setAttribute('data-uds-can-settings', '0')
}
if (!document.getElementById('uds-auth-client-css')) {
const tag = document.createElement('style')
tag.id = 'uds-auth-client-css'
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({
name: 'sidebar.footer.action',
id: 'uds-auth-login',
order: 100,
label: 'UAC',
}, AuthBadge))
ctx.effect(() => {
if (!ctx.locale || typeof ctx.locale.register !== 'function') return undefined
try {
return ctx.locale.register(LOCALE_NS, {
zh: UDS_I18N_MESSAGES.zh,
en: UDS_I18N_MESSAGES.en,
})
} catch {
try {
const offZh = ctx.locale.register(LOCALE_NS, 'zh', UDS_I18N_MESSAGES.zh)
const offEn = ctx.locale.register(LOCALE_NS, 'en', UDS_I18N_MESSAGES.en)
return () => { offZh && offZh(); offEn && offEn() }
} catch {
return undefined
}
}
}, 'uds-auth: locale')
try {
ctx.inject(['connection'], (cctx) => {
window.__udsAuthReconnect = () => {
try { cctx.connection.reconnect() } catch { window.location.reload() }
}
})
} catch { /* connection may be unavailable */ }
// Settings gear: CSS hides it when data-uds-can-settings=0 (no slot override —
// registering sidebar.settings early used to crash / delay the whole plugin).
// Settings page section — after login badge; not on the critical path for first paint.
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'uds-auth',
order: 22,
label: () => t('ui.settingsTitle'),
locale: LOCALE_NS,
icon: 'users',
}, AuthSettingsSection))
// Defense in depth: never show Host session rows while UDS cookies are absent.
// Covers ALS misses on /api and broadcast api-session/added leaks.
// Use ctx.get (optional) — sandboxed clients cannot ctx.inject undeclared services.
ctx.effect(() => {
let onAuthChanged = null
let installedRpc = false
let installedSessions = false
const install = () => {
// Block anonymous calls to dsh-ops-cron (定时任务) HTTP API.
if (!window.__udsAuthCronFetchGate) {
window.__udsAuthCronFetchGate = true
const origFetch = window.fetch.bind(window)
window.fetch = async function udsAuthFetch(input, init) {
const url = typeof input === 'string' ? input : (input && input.url) || ''
if (!isLoggedInInUi() && String(url).includes('/dsh-ops-cron') && !String(url).includes('/dsh-ops-cron/health')) {
return new Response(JSON.stringify({
ok: false,
error: 'login_required',
message: t('ui.cronLoginRequired'),
}), { status: 401, headers: { 'content-type': 'application/json' } })
}
return origFetch(input, init)
}
}
const connection = ctx.get('connection')
const rpc = connection && connection.rpc
if (!installedRpc && rpc && typeof rpc.call === 'function' && !rpc.__udsAuthListGate) {
installedRpc = true
rpc.__udsAuthListGate = true
const origCall = rpc.call.bind(rpc)
rpc.call = async function udsAuthRpcCall(channel, endpoint, payload, signal) {
const result = await origCall(channel, endpoint, payload, signal)
if (!isLoggedInInUi()) {
if (channel === '/api') {
if (endpoint === 'session/list') return { ok: true, value: { items: [] } }
if (endpoint === 'session/search') return { ok: true, value: { items: [], hasMore: false } }
}
}
if (
channel === '/api'
&& (
endpoint === 'directoryPicker/pick'
|| endpoint === 'directoryPicker/list'
|| endpoint === 'directoryPicker/createDirectory'
)
&& document.documentElement.getAttribute('data-uds-can-create-ws') !== '1'
) {
return {
ok: false,
error: {
code: 'gateway/forbidden',
message: getEmpNo() ? t('ui.workspaceCreateForbidden') : t('ui.workspaceLoginRequired'),
},
}
}
return result
}
}
const sessions = ctx.get('sessions')
if (!installedSessions && sessions && !sessions.__udsAuthListGate) {
installedSessions = true
sessions.__udsAuthListGate = true
const origAdded = sessions.handleSessionAdded && sessions.handleSessionAdded.bind(sessions)
if (origAdded) {
sessions.handleSessionAdded = (summary) => {
if (!isLoggedInInUi()) return
return origAdded(summary)
}
}
const origActivity = sessions.handleSessionActivity && sessions.handleSessionActivity.bind(sessions)
if (origActivity) {
sessions.handleSessionActivity = (sessionId, updatedAt) => {
if (!isLoggedInInUi()) return
return origActivity(sessionId, updatedAt)
}
}
const refresh = () => {
if (typeof sessions.refresh === 'function') void sessions.refresh()
}
refresh()
onAuthChanged = refresh
window.addEventListener('uds-auth-changed', onAuthChanged)
}
return installedRpc && installedSessions
}
install()
const timer = installedRpc && installedSessions ? null : setInterval(() => {
if (install() && timer) clearInterval(timer)
}, 300)
return () => {
if (timer) clearInterval(timer)
if (onAuthChanged) window.removeEventListener('uds-auth-changed', onAuthChanged)
}
}, 'uds-auth: session-list-gate')
ctx.effect(() => {
let sessions = null
let unsub = null
const bind = () => {
try { sessions = ctx.get('sessions') || sessions } catch { /* ignore */ }
window.__udsAuthSessions = sessions
if (unsub) { try { unsub() } catch { /* ignore */ } unsub = null }
if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') {
unsub = sessions.list.subscribe(() => { clearSessionIfAnonymous(sessions) })
}
clearSessionIfAnonymous(sessions)
}
bind()
const onAuth = () => { bind() }
window.addEventListener('uds-auth-changed', onAuth)
const mo = new MutationObserver(() => { clearSessionIfAnonymous(window.__udsAuthSessions) })
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-uds-logged-in', 'data-uds-auth-ready'],
})
const timer = setInterval(bind, 500)
setTimeout(() => clearInterval(timer), 20000)
return () => {
clearInterval(timer)
mo.disconnect()
window.removeEventListener('uds-auth-changed', onAuth)
if (unsub) { try { unsub() } catch { /* ignore */ } }
}
}, 'uds-auth: clear-session-when-anonymous')
ctx.effect(() => {
let disposers = []
const Gate = function UdsAuthDirectoryFlowGate(props) {
React.useEffect(() => {
if (props && props.open) {
try { props.onCancel && props.onCancel() } catch { /* ignore */ }
}
}, [props && props.open])
return null
}
const installGate = () => {
if (disposers.length) return
for (const slotName of [
'sidebar.workspaces.directoryFlow',
'conversation.hero.workspace.directoryFlow',
]) {
try {
disposers.push(ctx.slots.register({
name: slotName,
id: 'uds-auth-dir-gate',
order: 9999,
}, Gate))
} catch { /* slot may be undeclared briefly */ }
}
}
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() })
} 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'],
})
return () => {
window.removeEventListener('uds-auth-changed', onAuth)
mo.disconnect()
clearGate()
for (const off of injectOffs) {
try { if (typeof off === 'function') off() } catch { /* ignore */ }
}
}
}, 'uds-auth: directory-flow-gate')
ctx.effect(() => {
// Open/choose workspace: admin / super_admin / fallback_admin (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.
const TRIGGER_CARD = '[class*="cardWorkspaceTrigger"]'
const SURFACE = CHOOSER + ', ' + TRIGGER_CARD
const canOpenWorkspace = () => document.documentElement.getAttribute('data-uds-can-create-ws') === '1'
const isChooser = (node) => {
if (!node || !node.closest) return null
return node.closest(SURFACE)
}
const unlockEl = (el) => {
if (el.dataset.udsWsLocked !== '1') return
el.style.display = ''
el.style.pointerEvents = ''
el.style.opacity = ''
el.style.cursor = ''
if (el.tagName === 'BUTTON' || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
el.removeAttribute('disabled')
}
el.removeAttribute('aria-disabled')
el.removeAttribute('tabindex')
delete el.dataset.udsWsLocked
}
const lockEl = (el, hide) => {
el.dataset.udsWsLocked = '1'
if (hide) el.style.display = 'none'
el.style.pointerEvents = 'none'
el.style.opacity = hide ? '' : '0.45'
el.style.cursor = 'not-allowed'
el.setAttribute('aria-disabled', 'true')
el.setAttribute('tabindex', '-1')
if (el.tagName === 'BUTTON' || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
try { el.setAttribute('disabled', 'true') } catch { /* ignore */ }
}
}
const freezeChoosers = () => {
const loggedIn = document.documentElement.getAttribute('data-uds-logged-in') === '1'
const canCreate = canOpenWorkspace()
// Clear stale locks left on the composer card after it leaves trigger mode.
document.querySelectorAll('[data-uds-ws-locked="1"]').forEach((el) => {
const stillChooser = el.matches && (el.matches(CHOOSER) || el.matches(TRIGGER_CARD))
if (canCreate || !stillChooser || (loggedIn && el.matches(TRIGGER_CARD))) unlockEl(el)
})
if (canCreate) {
document.querySelectorAll(SURFACE).forEach(unlockEl)
return
}
// Non-creators: hide labeled chooser chips only.
document.querySelectorAll(CHOOSER).forEach((el) => lockEl(el, true))
// Lock inert trigger card only while anonymous; logged-in users
// auto-bind a personal workspace and must not keep pointer-events:none.
if (!loggedIn) {
document.querySelectorAll(TRIGGER_CARD).forEach((el) => lockEl(el, false))
}
}
const block = (event) => {
if (canOpenWorkspace()) return
const hit = isChooser(event.target)
if (!hit) return
const loggedIn = document.documentElement.getAttribute('data-uds-logged-in') === '1'
// Logged-in non-creators: still block labeled "选择工作区" / trigger clicks
// that open the picker; do not leave the card permanently disabled.
if (loggedIn && hit.matches && hit.matches(TRIGGER_CARD) && !hit.matches(CHOOSER)) {
event.preventDefault()
event.stopPropagation()
if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation()
return
}
event.preventDefault()
event.stopPropagation()
if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation()
}
document.addEventListener('click', block, true)
document.addEventListener('pointerdown', block, true)
document.addEventListener('mousedown', block, true)
document.addEventListener('keydown', (event) => {
if (canOpenWorkspace()) return
if (event.key !== 'Enter' && event.key !== ' ' && event.key !== 'Spacebar') return
if (!isChooser(event.target)) return
event.preventDefault()
event.stopPropagation()
}, true)
let workspaceDispose = null
const LockedWorkspace = function UdsAuthLockedWorkspace(props) {
React.useEffect(() => {
if (props && props.open) {
try { props.onClose && props.onClose() } catch { /* ignore */ }
}
}, [props && props.open])
return null
}
const installWorkspaceLock = () => {
if (workspaceDispose) return
try {
workspaceDispose = ctx.slots.register({
name: 'conversation.hero.workspace',
id: 'uds-auth-workspace-lock',
order: 9999,
}, LockedWorkspace)
} catch { /* slot may be undeclared briefly */ }
}
const clearWorkspaceLock = () => {
if (workspaceDispose) {
try { workspaceDispose() } catch { /* ignore */ }
workspaceDispose = null
}
}
// Deny open-workspace by default until AuthBadge /api/me proves canCreateWorkspace.
freezeChoosers()
const syncWorkspaceSlot = () => {
// Use AuthBadge DOM attrs only — no default /api/me.
const canCreate = document.documentElement.getAttribute('data-uds-can-create-ws') === '1'
if (canCreate) clearWorkspaceLock()
else installWorkspaceLock()
freezeChoosers()
}
let workspaceInjectOff = null
try {
workspaceInjectOff = ctx.slots.inject('conversation.hero.workspace', () => {
syncWorkspaceSlot()
})
} catch { /* ignore */ }
syncWorkspaceSlot()
const onAuth = () => { syncWorkspaceSlot() }
window.addEventListener('uds-auth-changed', onAuth)
const mo = new MutationObserver(() => { freezeChoosers(); syncWorkspaceSlot() })
mo.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-uds-can-create-ws', 'data-uds-logged-in', 'aria-label', 'class'],
})
return () => {
mo.disconnect()
document.removeEventListener('click', block, true)
document.removeEventListener('pointerdown', block, true)
document.removeEventListener('mousedown', block, true)
window.removeEventListener('uds-auth-changed', onAuth)
clearWorkspaceLock()
try { if (typeof workspaceInjectOff === 'function') workspaceInjectOff() } catch { /* ignore */ }
}
}, 'uds-auth: workspace-click-lock')
ctx.effect(() => {
let busy = false
let tries = 0
const bindPersonalWorkspace = async () => {
if (busy) return
if (document.documentElement.getAttribute('data-uds-logged-in') !== '1') return
if (document.documentElement.getAttribute('data-uds-auth-ready') !== '1') return
// Super/fallback keep full workspace browser — do not force personal workspace.
if (document.documentElement.getAttribute('data-uds-can-create-ws') === '1') return
if (document.documentElement.getAttribute('data-uds-can-create-ws') !== '0') return
let sessions = null
try { sessions = ctx.get('sessions') } catch { sessions = null }
if (!sessions || typeof sessions.create !== 'function') return
try {
const snap = sessions.list && sessions.list.getSnapshot && sessions.list.getSnapshot()
if (snap && snap.current != null) return
} catch { /* ignore */ }
busy = true
try {
const me = await fetchJson('/uds-auth/api/me')
const wsId = me && me.data && me.data.workspaceId
if (!me?.authenticated || !wsId) return
const sessionId = await sessions.create({ workspaceId: wsId })
if (sessionId && typeof sessions.open === 'function') sessions.open(sessionId)
} catch (err) {
console.warn('[uds-auth] auto-bind personal workspace failed:', err && err.message ? err.message : err)
} finally {
busy = false
}
}
const tick = () => {
if (tries++ > 40) return
void bindPersonalWorkspace()
}
tick()
window.addEventListener('uds-auth-changed', tick)
const timer = setInterval(tick, 1500)
setTimeout(() => clearInterval(timer), 60000)
return () => {
clearInterval(timer)
window.removeEventListener('uds-auth-changed', tick)
}
}, 'uds-auth: auto-bind-personal-workspace')
ctx.effect(() => {
const sync = () => {
const root = document.documentElement
if (root.getAttribute('data-uds-auth-ready') !== '1') return
if (root.getAttribute('data-uds-logged-in') !== '1') {
try { window.sessionStorage.removeItem('uds-auth-flat-reloaded') } catch { /* ignore */ }
try { window.sessionStorage.removeItem('uds-auth-ws-reloaded') } catch { /* ignore */ }
return
}
// Super/fallback: restore workspace partitions (undo forced flat).
if (root.getAttribute('data-uds-can-create-ws') === '1') {
const restored = ensureWorkspaceGroupedSidebar()
if (restored && !window.sessionStorage.getItem('uds-auth-ws-reloaded')) {
try {
window.sessionStorage.setItem('uds-auth-ws-reloaded', '1')
window.location.reload()
} catch { /* ignore */ }
}
try { window.sessionStorage.removeItem('uds-auth-flat-reloaded') } catch { /* ignore */ }
return
}
if (root.getAttribute('data-uds-can-create-ws') !== '0') return
const switched = ensureFlatSessionSidebar()
expandHiddenWorkspaceGroups()
if (switched && !window.sessionStorage.getItem('uds-auth-flat-reloaded')) {
try {
window.sessionStorage.setItem('uds-auth-flat-reloaded', '1')
window.location.reload()
} catch { /* ignore */ }
}
}
sync()
const onAuth = () => { sync() }
window.addEventListener('uds-auth-changed', onAuth)
const moAttrs = new MutationObserver(() => { sync() })
moAttrs.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-uds-logged-in', 'data-uds-can-create-ws', 'data-uds-auth-ready'],
})
const mo = new MutationObserver(() => { expandHiddenWorkspaceGroups() })
mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-expanded', 'class'] })
const timer = setInterval(expandHiddenWorkspaceGroups, 2000)
setTimeout(() => clearInterval(timer), 30000)
return () => {
clearInterval(timer)
mo.disconnect()
moAttrs.disconnect()
window.removeEventListener('uds-auth-changed', onAuth)
}
}, 'uds-auth: session-only-sidebar')
}
exports.name = name
exports.inject = inject
exports.apply = apply
return module.exports
},
})