oclaw/uds-auth/lib/client.js
oliver 08a5520791
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run
fix(uds-auth): surface real cause of QR login network errors (0.3.16)
The QR poll showed a generic "network error" for any failure, and the
server collapsed every upstream failure into an opaque 502 with no log.

- Classify upstream failures (DNS, timeout, unreachable, TLS cert, HTTP
  status, bad response) and return host-specific localized messages.
- Report why the post-scan user lookup failed instead of a generic
  invalid-credentials error.
- Log the real errno/message server-side for every qr-proxy failure.
- Show the server message in the QR poll UI; keep "network error" only
  for failed local fetches.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 13:34:23 +08:00

2608 lines
120 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', 'uiWorkspace']
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}。超管只是身份标签,与管理员权限相同;显式配置的初始管理员在首次登录时获得超管身份,也可在「用户管理」设为管理员。应急账号默认账号 administrator、密码 Admin@123,首次登录后请修改。",
"ui.deployConfig": "部署配置",
"ui.userSearchUrl": "用户搜索 URL(token 校验)",
"ui.workspaceRoot": "工作区根目录(空=$DSH_HOME/user-workspaces)",
"ui.allowRawAgentToken": "允许 Skill 获取原始 UDS token",
"ui.allowRawAgentTokenHint": "开启后,具备当前任务 credentials scope 的本机 Skill 可获取工号和 token。只在业务确实需要原始 token 时开启。",
"ui.saveConfig": "保存配置",
"ui.saving": "保存中...",
"ui.configSaved": "配置已保存",
"ui.saveFailed": "保存失败",
"ui.fallbackTitle": "应急登录(UAC 不可用)",
"ui.fallbackStatus": "状态:{status}。可在此改密或关闭。仅在扫码不可用时从登录面板切换。",
"ui.enabled": "已启用",
"ui.disabled": "未启用",
"ui.fallbackPassword": "应急密码(至少 10 位)",
"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.fallbackDisabledLink": "应急登录(未启用)",
"ui.fallbackDisabledHint": "应急账号已关闭。请用已登录的管理员在「账户 / 权限」中重新设置并启用。",
"ui.fallbackDisabledDetail": "当前未启用应急账号。用已授权管理员登录后,在设置中重新设置密码即可恢复此入口。",
"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": "密码至少 10 位",
"err.password_too_common": "不能使用已知默认口令",
"err.account_disabled": "账号已停用",
"err.local_admin_not_configured": "未配置本机管理员密封盒",
"err.key_required": "请输入解密密钥",
"err.decrypt_failed": "密钥无法解密,登录失败",
"err.rate_limited": "尝试过多,请稍后再试",
"err.config_not_ready": "配置未初始化",
"err.request_failed": "请求失败",
"err.upstream_dns": "无法解析 {host}(DNS 失败),请确认本机已接入公司内网 / VPN",
"err.upstream_timeout": "连接 {host} 超时,请确认本机已接入公司内网 / VPN,且未被防火墙拦截",
"err.upstream_unreachable": "无法连接 {host}(连接被拒绝或中断),请检查网络、防火墙与代理",
"err.upstream_cert": "与 {host} 的 TLS 证书校验失败:Node 不读取系统证书库,请设置 NODE_OPTIONS=--use-system-ca 或 NODE_EXTRA_CA_CERTS 后重启",
"err.upstream_error": "访问 {host} 失败,详见服务端日志",
"err.upstream_http_status": "{host} 返回 HTTP {status}",
"err.invalid_upstream_response": "{host} 返回了无法识别的响应",
"err.profile_http_status": "用户信息服务 {host} 返回 HTTP {status}(该服务需内网直连,不能经代理)",
"err.profile_rejected": "扫码成功,但用户信息校验未通过({reason})",
"err.login_finalize_failed": "扫码成功,但本机创建会话失败({detail}),详见服务端日志",
"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}. Super admin is only an identity label with the same permissions as admin; the explicitly configured initial administrator receives that identity on first login, or grant admin in User management. The emergency account defaults to administrator / Admin@123; change it after first sign-in.",
"ui.deployConfig": "Deploy config",
"ui.userSearchUrl": "User search URL (token verify)",
"ui.workspaceRoot": "Workspace root (empty=$DSH_HOME/user-workspaces)",
"ui.allowRawAgentToken": "Allow Skills to read the raw UDS token",
"ui.allowRawAgentTokenHint": "Enables local Skills with the current task credentials scope to read the employee number and token. Turn this on only when a workflow needs the raw token.",
"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 10 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": "All sessions are visible by default (including channels and shared workspaces). Turn off to only see sessions you own and your personal workspace.",
"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.fallbackDisabledLink": "Emergency login (disabled)",
"ui.fallbackDisabledHint": "The emergency account is disabled. Ask a signed-in admin to set a new password and enable it under Account / Permissions.",
"ui.fallbackDisabledDetail": "The emergency account is off. An authorized admin can set its password again in settings to restore this entry.",
"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 10 characters",
"err.password_too_common": "Cannot use the known default password",
"err.account_disabled": "Account is disabled",
"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.upstream_dns": "Cannot resolve {host} (DNS failed). Make sure this machine is on the corporate network / VPN",
"err.upstream_timeout": "Connection to {host} timed out. Make sure this machine is on the corporate network / VPN and not firewalled",
"err.upstream_unreachable": "Cannot connect to {host} (refused or reset). Check network, firewall and proxy",
"err.upstream_cert": "TLS certificate check for {host} failed. Node ignores the OS cert store: set NODE_OPTIONS=--use-system-ca or NODE_EXTRA_CA_CERTS and restart",
"err.upstream_error": "Request to {host} failed; see server logs",
"err.upstream_http_status": "{host} returned HTTP {status}",
"err.invalid_upstream_response": "{host} returned an unrecognized response",
"err.profile_http_status": "User lookup service {host} returned HTTP {status} (needs direct intranet access, no proxy)",
"err.profile_rejected": "Scan succeeded but user lookup was rejected ({reason})",
"err.login_finalize_failed": "Scan succeeded but creating the local session failed ({detail}); see server logs",
"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}',
/* Session-only sidebar: hide workspace project rows only after we force flat
(data-uds-session-only). Do not key on can-create-ws alone — a race to 0
would hide named workspaces and leave only「未分组」visible. */
'html[data-uds-session-only="1"][data-uds-logged-in="1"] [class*="projectRow"]:not([class*="dsh-ct-project"]),html[data-uds-session-only="1"][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=/'
}
}
/** Desktop dsh-app:// drops cookies — persist verified bridge token in localStorage. */
const BRIDGE_STORAGE_KEY = 'uds-auth.session-bridge'
function readBridge() {
try {
const raw = window.localStorage.getItem(BRIDGE_STORAGE_KEY)
if (!raw) return null
const o = JSON.parse(raw)
if (o && o.empNo && o.token) return o
} catch { /* ignore */ }
return null
}
function writeBridge(bridge) {
try {
if (!bridge || !bridge.empNo || !bridge.token) {
window.localStorage.removeItem(BRIDGE_STORAGE_KEY)
return
}
window.localStorage.setItem(BRIDGE_STORAGE_KEY, JSON.stringify({
empNo: String(bridge.empNo),
token: String(bridge.token),
kind: String(bridge.kind || 'fallback'),
exp: bridge.exp || null,
}))
} catch { /* ignore */ }
}
function clearBridge() {
writeBridge(null)
}
/** Adopt server-issued bridge; still try cookies for http hosts. */
function adoptAuthSession(data) {
if (data?.bridge?.empNo && data?.bridge?.token) {
writeBridge(data.bridge)
}
const emp = data?.bridge?.empNo || data?.empNo
const kind = data?.bridge?.kind || (emp === 'administrator' ? 'fallback' : 'uds')
if (emp && kind === 'fallback') {
try { setCookie('UDS_FALLBACK_UI', emp, 7) } catch { /* Desktop may ignore */ }
}
// Prefetch WS tickets then soft-reconnect — sync XHR mint is unreliable on Desktop.
try {
window.__udsAuthBridgeMuxPrimed = false
setTimeout(() => {
try { void reconnectWithWsTicket() } catch { /* ignore */ }
}, 50)
} catch { /* ignore */ }
}
function bridgeAuthHeaders() {
const b = readBridge()
if (!b) return {}
return {
'X-UDS-Bridge-EmpNo': b.empNo,
'X-UDS-Bridge-Token': b.token,
'X-UDS-Bridge-Kind': b.kind || 'fallback',
}
}
/** Resolve fetch/WS input to absolute URL (R01). */
function resolveTransportUrl(input) {
try {
if (typeof Request !== 'undefined' && input instanceof Request) {
return new URL(input.url)
}
const base = typeof location !== 'undefined' ? location.href : 'http://localhost/'
return new URL(String(input), base)
} catch {
return null
}
}
function defaultPort(protocol) {
if (protocol === 'https:' || protocol === 'wss:') return '443'
if (protocol === 'http:' || protocol === 'ws:') return '80'
return ''
}
function hostnameIsLoopback(hostname) {
const h = String(hostname || '').toLowerCase()
const bare = h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h
return bare === 'localhost' || bare === '127.0.0.1' || bare === '::1'
}
/** Desktop boot exposes the real Host HTTP origin for mux/WS. */
function getDesktopTransportBase() {
try {
const base = globalThis.__DSH_TRANSPORT__?.streamBaseUrl
if (!base) return null
return new URL(base)
} catch {
return null
}
}
function sameHostAuthority(a, b) {
if (!a || !b) return false
const aSecure = a.protocol === 'https:' || a.protocol === 'wss:'
const bSecure = b.protocol === 'https:' || b.protocol === 'wss:'
if (aSecure !== bSecure) return false
return a.hostname === b.hostname
&& String(a.port || defaultPort(a.protocol)) === String(b.port || defaultPort(b.protocol))
}
/**
* A05/D01/D02: attach bridge only to the current Host authority.
* Desktop dsh-app:// maps relative Host routes + streamBaseUrl / loopback Gateway.
* HTTP↔WS and HTTPS↔WSS map as the same Host; never HTTPS→WS.
*/
function isTrustedHostUrl(input) {
if (typeof location === 'undefined') return false
const parsed = resolveTransportUrl(input)
if (!parsed) return false
const urlProto = parsed.protocol
// Desktop renderer: document is dsh-app://app — Host is a separate HTTP origin.
// 0.3.9 behavior (regression in 0.3.10): when streamBaseUrl is not ready yet,
// still trust loopback Gateway so WS tickets attach; otherwise workspace follow
// stays anonymous and every session lands under「未分组」.
if (location.protocol === 'dsh-app:') {
if (location.hostname !== 'app') return false
if (urlProto === 'dsh-app:') return parsed.hostname === 'app' && !parsed.port
if (!['http:', 'https:', 'ws:', 'wss:'].includes(urlProto)) return false
const transport = getDesktopTransportBase()
if (transport) {
return sameHostAuthority(parsed, {
protocol: transport.protocol,
hostname: transport.hostname,
port: transport.port,
})
}
return hostnameIsLoopback(parsed.hostname)
}
const locProto = location.protocol
const locSecure = locProto === 'https:' || locProto === 'wss:'
const urlSecure = urlProto === 'https:' || urlProto === 'wss:'
if (locSecure !== urlSecure) return false
if (!['http:', 'https:', 'ws:', 'wss:'].includes(urlProto)) return false
return parsed.hostname === location.hostname
&& String(parsed.port || defaultPort(urlProto))
=== String(location.port || defaultPort(locProto))
}
/**
* T01: one-time WS tickets. WebSocket ctor is sync, so we keep a small
* prefetched cache filled by async fetch (same path as login /api/me).
*
* Desktop regression: sync XHR to absolute streamBaseUrl trips CORS
* (custom bridge headers + OPTIONS→401), and relative sync XHR often
* returns an empty body on dsh-app://. Mux then upgrades anonymously;
* workspace follow cannot “wait” its way into an identity →「未分组」.
*/
const _wsTicketCache = []
let _wsTicketPrefetch = null
function wsTicketRequestUrls() {
const urls = ['/uds-auth/api/ws-ticket']
try {
if (typeof location !== 'undefined' && location.protocol === 'dsh-app:') {
const transport = getDesktopTransportBase()
// Absolute only as async-fetch fallback (never sync XHR — CORS).
if (transport) urls.push(new URL('/uds-auth/api/ws-ticket', transport).toString())
}
} catch { /* ignore */ }
return urls
}
async function prefetchWsTickets(count = 2) {
const b = readBridge()
if (!b?.empNo || !b?.token) return 0
const need = Math.max(0, Number(count) || 0)
if (need <= 0) return 0
if (_wsTicketPrefetch) {
try { await _wsTicketPrefetch } catch { /* ignore */ }
}
let minted = 0
const run = (async () => {
for (let i = 0; i < need; i++) {
let got = null
for (const ticketUrl of wsTicketRequestUrls()) {
try {
const res = await fetch(ticketUrl, {
method: 'POST',
credentials: 'include',
redirect: 'error',
headers: {
'Content-Type': 'application/json',
'X-UDS-Bridge-EmpNo': b.empNo,
'X-UDS-Bridge-Token': b.token,
...(b.kind ? { 'X-UDS-Bridge-Kind': b.kind } : {}),
},
body: '{}',
})
if (!res.ok) continue
const data = await res.json().catch(() => ({}))
if (data?.ticket) {
got = String(data.ticket)
break
}
} catch {
/* try next URL */
}
}
if (!got) break
_wsTicketCache.push(got)
minted++
}
})()
_wsTicketPrefetch = run.finally(() => {
if (_wsTicketPrefetch === run) _wsTicketPrefetch = null
})
try { await _wsTicketPrefetch } catch { /* ignore */ }
return minted
}
function mintWsTicketSync() {
if (_wsTicketCache.length) {
const ticket = _wsTicketCache.shift()
if (_wsTicketCache.length < 1) {
try { void prefetchWsTickets(2) } catch { /* ignore */ }
}
return ticket
}
const b = readBridge()
if (!b?.empNo || !b?.token) return null
// Last resort: same-origin sync XHR only (unit harness / http Host UI).
// Skip absolute Host URLs here — custom headers require CORS preflight.
try {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/uds-auth/api/ws-ticket', false)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.setRequestHeader('X-UDS-Bridge-EmpNo', b.empNo)
xhr.setRequestHeader('X-UDS-Bridge-Token', b.token)
if (b.kind) xhr.setRequestHeader('X-UDS-Bridge-Kind', b.kind || 'fallback')
xhr.send('{}')
if (xhr.status >= 200 && xhr.status < 300) {
const data = JSON.parse(xhr.responseText || '{}')
if (data.ticket) return String(data.ticket)
}
} catch { /* ignore */ }
try {
console.warn('[uds-auth] WS ticket mint failed — workspace sidebar may stay empty until reconnect')
} catch { /* ignore */ }
return null
}
let _wsAuthRetryTimer = null
let _wsAuthRetryCount = 0
let _wsAuthReconnectShared = null
let _softReconnectTimer = null
let _softReconnectQuietUntil = 0
/**
* Soft mux reconnect with debounce + quiet period.
*
* Desktop boot used to fire two ticketed reconnects (~200ms AuthBadge +
* ~250ms connection-inject). WorkspaceStateStream accepts the anonymous
* empty baseline, then dies terminally when a second CarrierError lands
* before the authenticated baseline is accepted (attempt>1 while
* connection.generation is live). Sidebar stays「未分组」forever even
* though a fresh follow with a ticket returns every workspace.
*/
/**
* Ask every client cache to re-read after the principal changes (login,
* identity attach after reconnect, logout). Debounced: one reset per burst.
*/
let _authResetTimer = null
function scheduleAuthReset(delay = 1500) {
try {
if (_authResetTimer) clearTimeout(_authResetTimer)
_authResetTimer = setTimeout(() => {
_authResetTimer = null
try { window.__udsAuthEmitReset?.() } catch { /* ignore */ }
}, delay)
} catch { /* ignore */ }
}
function softReconnectAuth(opts = {}) {
const force = !!(opts && opts.force)
const now = Date.now()
if (!force && now < _softReconnectQuietUntil) return
if (_softReconnectTimer) {
clearTimeout(_softReconnectTimer)
_softReconnectTimer = null
}
const delay = force ? 0 : 450
_softReconnectTimer = setTimeout(() => {
_softReconnectTimer = null
if (!force && Date.now() < _softReconnectQuietUntil) return
try {
if (typeof window.__udsAuthReconnect === 'function') {
window.__udsAuthReconnect()
} else {
window.location.reload()
return
}
// Keep a quiet window so the authenticated workspace baseline can land.
_softReconnectQuietUntil = Date.now() + 2800
scheduleAuthReset(1500)
} catch {
try { window.location.reload() } catch { /* ignore */ }
}
}, delay)
}
async function reconnectWithWsTicket() {
if (_wsAuthReconnectShared) return _wsAuthReconnectShared
_wsAuthReconnectShared = (async () => {
// One ticket is enough for the coalesced reconnect; spare is filled after quiet.
let minted = 0
try { minted = await prefetchWsTickets(1) } catch { minted = 0 }
// Even with 0 tickets, reconnect — withBridgeQuery falls back to loopback
// bridge query params so workspace follow still gets an identity.
softReconnectAuth()
setTimeout(() => {
try { void prefetchWsTickets(1) } catch { /* ignore */ }
}, 3000)
if (minted <= 0 && readBridge() && _wsAuthRetryCount < 5) {
_wsAuthRetryCount += 1
if (_wsAuthRetryTimer) clearTimeout(_wsAuthRetryTimer)
_wsAuthRetryTimer = setTimeout(() => {
try { void reconnectWithWsTicket() } catch { /* ignore */ }
}, 800 * _wsAuthRetryCount)
} else if (minted > 0) {
_wsAuthRetryCount = 0
}
})().finally(() => {
_wsAuthReconnectShared = null
})
return _wsAuthReconnectShared
}
/** Append one-time WS ticket (preferred) or loopback bridge query (Desktop fallback). */
function withBridgeQuery(url) {
const b = readBridge()
if (!b) return url
if (!isTrustedHostUrl(url)) return url
try {
const parsed = resolveTransportUrl(url)
if (!parsed) return url
if (!['ws:', 'wss:'].includes(parsed.protocol) || parsed.pathname !== '/api/remote.mux') return url
if (parsed.searchParams.has('udsWsTicket') || parsed.searchParams.has('udsBridgeToken')) {
return parsed.toString()
}
const ticket = mintWsTicketSync()
if (ticket) {
parsed.searchParams.set('udsWsTicket', ticket)
return parsed.toString()
}
// 0.3.9 Desktop path: ticket mint often fails on dsh-app:// (empty XHR /
// CORS). Loopback Host accepts udsBridge* on upgrade only.
parsed.searchParams.set('udsBridgeEmpNo', String(b.empNo))
parsed.searchParams.set('udsBridgeToken', String(b.token))
if (b.kind) parsed.searchParams.set('udsBridgeKind', String(b.kind))
return parsed.toString()
} catch {
return url
}
}
// Install once at module load so login→reconnect WS already carries bridge.
if (typeof window !== 'undefined' && !window.__udsAuthBridgeTransportPatched) {
window.__udsAuthBridgeTransportPatched = true
try {
const OrigWS = window.WebSocket
if (typeof OrigWS === 'function') {
function UdsAuthWebSocket(url, protocols) {
const next = withBridgeQuery(url)
if (protocols === undefined) return new OrigWS(next)
return new OrigWS(next, protocols)
}
UdsAuthWebSocket.prototype = OrigWS.prototype
Object.assign(UdsAuthWebSocket, OrigWS)
window.WebSocket = UdsAuthWebSocket
}
} catch { /* ignore */ }
try {
const origFetch = window.fetch.bind(window)
window.fetch = async function udsAuthBridgeFetch(input, init) {
const bHeaders = bridgeAuthHeaders()
if (!bHeaders['X-UDS-Bridge-Token'] || !isTrustedHostUrl(input)) {
return origFetch(input, init)
}
const opts = init ? { ...init } : {}
const prev = opts.headers || (typeof input === 'object' ? input.headers : null)
if (prev && typeof prev.forEach === 'function') {
const h = { ...bHeaders }
prev.forEach((v, k) => { h[k] = v })
opts.headers = h
} else {
opts.headers = { ...bHeaders, ...(prev || {}) }
}
if (opts.credentials == null) opts.credentials = 'include'
// Custom bridge headers are not stripped like Authorization on redirects.
// Refuse redirects while attaching a credential to the owned Host.
opts.redirect = 'error'
return origFetch(input, opts)
}
} catch { /* ignore */ }
}
function getEmpNo() {
return getCookie('PORTALSSOUser') || getCookie('ZTEDPGSSOUser') || getCookie('UDS_FALLBACK_USER') || getCookie('UDS_FALLBACK_UI')
|| readBridge()?.empNo || 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
try { root.setAttribute('data-uds-session-only', '1') } catch { /* ignore */ }
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
try { root.setAttribute('data-uds-session-only', '0') } catch { /* ignore */ }
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 / bridge must land before WS upgrade — hard reload is required.
// Admin-class (incl. sealed_box) must restore workspace grouping; only
// non-creators are forced into the flat session list.
try {
if (document.documentElement.getAttribute('data-uds-can-create-ws') === '1') {
ensureWorkspaceGroupedSidebar()
} else {
ensureFlatSessionSidebar()
}
} catch { /* ignore */ }
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 baseHeaders = bridgeAuthHeaders()
const optHeaders = options.headers || {}
const headers = (optHeaders && typeof optHeaders.forEach === 'function')
? (() => {
const h = { ...baseHeaders }
optHeaders.forEach((v, k) => { h[k] = v })
return h
})()
: { ...baseHeaders, ...optHeaders }
const res = await fetch(url, { credentials: 'include', ...options, headers })
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: '',
allowRawAgentToken: false,
})
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({ force: true }) } 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('label', { className: 'uds-auth-settings-toggle' },
h('input', {
type: 'checkbox',
checked: form.allowRawAgentToken === true,
disabled: !canSettings || busy,
onChange: (e) => setForm((prev) => ({ ...prev, allowRawAgentToken: e.target.checked })),
}),
h('span', null, t('ui.allowRawAgentToken')),
),
h('p', { className: 'uds-auth-settings-intro' }, t('ui.allowRawAgentTokenHint')),
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')
// Admin-class must not keep session-only CSS (would hide named workspaces).
if (canCreateWs) {
document.documentElement.setAttribute('data-uds-session-only', '0')
} else if (user) {
document.documentElement.setAttribute('data-uds-session-only', '1')
} else {
document.documentElement.setAttribute('data-uds-session-only', '0')
}
// Drop stale remote.mux identity after logout so workspace names disappear.
if (prev === '1' && !user) {
clearSessionIfAnonymous(window.__udsAuthSessions)
try { softReconnectAuth({ force: true }) } 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.binding = 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) {
try { window.__udsAuthWsAuthedOnce = false } catch { /* ignore */ }
try { window.__udsAuthResetEmp = null } catch { /* ignore */ }
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'))
try {
const resetEmp = String(d.empNo || d.userId || '')
if (resetEmp && window.__udsAuthResetEmp !== resetEmp) {
window.__udsAuthResetEmp = resetEmp
scheduleAuthReset(1500)
}
} catch { /* ignore */ }
// Desktop: first mux open often races ahead of bridge/ticket. Prefetch
// then one coalesced soft-reconnect — never on every /api/me refresh.
try {
if (readBridge() && !window.__udsAuthWsAuthedOnce) {
_wsAuthRetryCount = 0
window.__udsAuthWsAuthedOnce = true
setTimeout(() => {
try { void reconnectWithWsTicket() } catch { /* ignore */ }
}, 200)
}
} catch { /* ignore */ }
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, browserBinding, loginSystemCode, originSystemCode } = started
qrRef.current.key = qrCodeKey
qrRef.current.value = qrCodeValue
qrRef.current.binding = browserBinding
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 || qrRef.current.polling) return
if (qrRef.current.deadline && Date.now() >= qrRef.current.deadline) {
markQrExpired()
return
}
try {
qrRef.current.polling = true
const result = await fetchJson('/uds-auth/qr-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-UDS-QR-Binding': browserBinding },
body: JSON.stringify({ qrCodeKey, qrCodeValue }),
})
if (qrRef.current.key !== qrCodeKey) return
const codeCode = result.code?.code || ''
const boCode = result.bo?.code || ''
if (codeCode === '0000' && boCode === '0000') {
stopQr()
setQrExpired(false)
setQrFailed(false)
const auth = result.auth
if (!auth?.success || !auth?.bridge?.token) {
setQrStatus(t('ui.loginFailed'))
return
}
adoptAuthSession(auth)
setQrStatus(t('ui.loginSuccess'))
await refreshUser()
if (document.documentElement.getAttribute('data-uds-logged-in') !== '1') {
setQrStatus(t('ui.loginFailed'))
return
}
setOpen(false)
reconnectAfterLogin()
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 (err) {
// A reply from the local server carries a classified, localized message
// (DNS / timeout / TLS cert / lookup rejected); only a failed fetch is a real network error.
const detail = err && err.data && err.data.message
if (qrRef.current.key === qrCodeKey) setQrStatus(detail || t('ui.networkError'))
} finally {
qrRef.current.polling = false
}
}, 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 {
const data = await fetchJson('/uds-auth/api/fallback/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: fbUser.trim(), password: fbPass }),
})
adoptAuthSession(data)
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 {
const data = await fetchJson('/uds-auth/api/local-admin/unlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: localKey }),
})
adoptAuthSession(data)
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(() => {})
fetchJson('/uds-auth/api/local-admin/status')
.then((st) => { if (!cancelled) setLocalKeyEnabled(!!st.enabled) })
.catch(() => { if (!cancelled) setLocalKeyEnabled(false) })
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')),
),
// U01: emergency password always discoverable — disabled state is shown, not hidden.
// Local sealed-box unlock stays hidden unless Host actually has a box configured.
h('button', {
type: 'button',
className: 'uds-auth-btn-link',
onClick: () => {
stopQr()
setLoginMode('fallback')
setFbErr(fallbackEnabled ? '' : t('ui.fallbackDisabledHint'))
},
}, fallbackEnabled ? t('ui.fallbackLink') : t('ui.fallbackDisabledLink')),
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' },
fallbackEnabled ? t('ui.fallbackDetail') : t('ui.fallbackDisabledDetail')),
),
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',
disabled: !fallbackEnabled,
}),
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',
disabled: !fallbackEnabled,
onKeyDown: (e) => { if (e.key === 'Enter' && fallbackEnabled) 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 || !fallbackEnabled,
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()
clearBridge()
try { window.__udsAuthWsAuthedOnce = false } catch { /* ignore */ }
try { window.__udsAuthResetEmp = null } catch { /* ignore */ }
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({ force: true }) } 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() }
}
// connection.reconnect() only re-opens streams; it does not emit
// connection/reset, so boot-time caches (settings describe mirror →
// Settings › Models "key missing") keep the anonymous projection.
window.__udsAuthEmitReset = () => {
try { cctx.emit('connection/reset') } catch { /* ignore */ }
}
// Desktop ownsHost: mux WS often connects before tickets are cached.
// Claim the once-flag so AuthBadge refreshUser does not fire a second
// ticketed reconnect (that kills WorkspaceStateStream — see softReconnectAuth).
if (
readBridge()
&& window.__DSH_TRANSPORT__?.ownsHost
&& !window.__udsAuthBridgeMuxPrimed
) {
window.__udsAuthBridgeMuxPrimed = true
window.__udsAuthWsAuthedOnce = true
setTimeout(() => {
try { void reconnectWithWsTicket() } catch { /* ignore */ }
}, 250)
}
})
} 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')
// Fallback directoryFlow at priority 1 (NOT 0).
// Single-slot cells collide only on the exact priority; the shipped
// native/browse picker owns 0 ("lowest renders"). We occupy priority 1 so
// entries(hole).length > 0 → Add workspace button renders even when the
// official picker failed to mount, without crashing Loader.
ctx.effect(() => {
const Flow = function UdsAuthDirectoryFlow(props) {
const open = !!(props && props.open)
const armed = useRef(false)
const outcome = useRef(props)
outcome.current = props
const alive = useRef(true)
useEffect(() => {
alive.current = true
return () => { alive.current = false }
}, [])
useEffect(() => {
if (!open) {
armed.current = false
return
}
if (armed.current) return
armed.current = true
const can = document.documentElement.getAttribute('data-uds-can-create-ws') === '1'
if (!can) {
try { outcome.current.onCancel && outcome.current.onCancel() } catch { /* ignore */ }
return
}
let pickPromise
try {
const ui = ctx.uiWorkspace
if (ui && typeof ui.pickDirectory === 'function') {
pickPromise = ui.pickDirectory()
} else {
pickPromise = Promise.reject(new Error('directory picker unavailable'))
}
} catch (err) {
pickPromise = Promise.reject(err)
}
pickPromise.then(
(path) => {
if (!alive.current) return
if (path == null) {
try { outcome.current.onCancel && outcome.current.onCancel() } catch { /* ignore */ }
} else {
try { outcome.current.onPicked && outcome.current.onPicked(path) } catch { /* ignore */ }
}
},
(reason) => {
if (!alive.current) return
const msg = reason instanceof Error ? reason.message : String(reason)
try { outcome.current.onError && outcome.current.onError(msg) } catch { /* ignore */ }
},
)
}, [open])
return null
}
const disposers = []
const clear = () => {
for (const d of disposers.splice(0)) {
try { d() } catch { /* ignore */ }
}
}
const install = () => {
if (disposers.length) return
for (const slotName of [
'sidebar.workspaces.directoryFlow',
'conversation.hero.workspace.directoryFlow',
]) {
try {
disposers.push(ctx.slots.register({
name: slotName,
id: 'uds-auth-dir-flow',
// Must differ from shipped picker priority 0.
priority: 1,
}, Flow))
} catch { /* slot undeclared, or priority already taken — ignore */ }
}
}
const injectOffs = [
'sidebar.workspaces.directoryFlow',
'conversation.hero.workspace.directoryFlow',
].map((slotName) => {
try {
return ctx.slots.inject(slotName, () => { install() })
} catch {
return null
}
})
install()
return () => {
clear()
for (const off of injectOffs) {
try { if (typeof off === 'function') off() } catch { /* ignore */ }
}
}
}, 'uds-auth: directory-flow-fallback')
// Anonymous / non-creators: CSS (data-uds-can-create-ws) + click lock + Host ACL.
ctx.effect(() => {
// Open/choose workspace: admin-class (canCreateWorkspace).
// Everyone else uses the auto-provisioned per-user workspace and must not open the picker.
const CHOOSER = hostAriaSel('chooseWorkspace')
// Inert composer: onClick lives on the card (cardWorkspaceTrigger), not the labeled node.
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)
// Do NOT register into conversation.hero.workspace. A null lock at the
// default priority 0 steals the shipped picker (same single-slot clash as
// directoryFlow); clearing it never restores the occupant, so workspaces
// cannot be selected and sessions cannot be created in the DSH UI.
freezeChoosers()
const onAuth = () => { freezeChoosers() }
window.addEventListener('uds-auth-changed', onAuth)
const mo = new MutationObserver(() => { freezeChoosers() })
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)
}
}, '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
},
})