Add uds-auth zh/en i18n, UAC-branded settings, and QR expire/fail overlay.

Wire client locale to the DSH host, return stable API error codes with localized messages, and replace bottom refresh with an in-frame retry for expired or offline QR.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-09 22:10:37 +08:00
parent b60c626d41
commit fd0df9b890
9 changed files with 1183 additions and 202 deletions

View file

@ -3,6 +3,7 @@
*/
import { directRequest } from './uds/user-search.js'
import { requestIsLoopback } from './skill-credentials.js'
import { apiError, resolveLocale } from './i18n.js'
const DEFAULT_OUTBOUND_HOSTS = [
'icenterapi.zte.com.cn',
@ -16,6 +17,10 @@ function sendJSON(res, code, data) {
res.end(JSON.stringify(data))
}
function sendErr(req, res, status, code, vars) {
return sendJSON(res, status, apiError(code, resolveLocale(req), vars))
}
function readSessionId(req, body) {
const h = req.headers || {}
return (
@ -76,18 +81,17 @@ export function createAgentAuthHandlers(deps) {
async function handleAgentCredentials(req, res) {
if (!requestIsLoopback(req)) {
return sendJSON(res, 403, { error: 'agent-credentials is loopback-only' })
return sendErr(req, res, 403, 'loopback_only_credentials')
}
const method = (req.method || 'GET').toUpperCase()
if (method !== 'GET' && method !== 'POST') {
return sendJSON(res, 405, { error: 'Method not allowed' })
return sendErr(req, res, 405, 'method_not_allowed')
}
const body = method === 'POST' ? await readJsonBody(req) : {}
const { creds, sessionId } = await resolveFromRequest(req, body)
if (!creds) {
return sendJSON(res, 401, {
error: 'no_skill_credentials',
message: '请先完成 UDS 扫码登录',
...apiError('no_skill_credentials', resolveLocale(req)),
sessionId: sessionId || null,
})
}
@ -100,36 +104,33 @@ export function createAgentAuthHandlers(deps) {
async function handleOutbound(req, res) {
if (!requestIsLoopback(req)) {
return sendJSON(res, 403, { error: 'outbound is loopback-only' })
return sendErr(req, res, 403, 'loopback_only_outbound')
}
const method = (req.method || 'POST').toUpperCase()
if (method !== 'POST') {
return sendJSON(res, 405, { error: 'Method not allowed' })
return sendErr(req, res, 405, 'method_not_allowed')
}
const body = await readJsonBody(req)
const { creds } = await resolveFromRequest(req, body)
if (!creds) {
return sendJSON(res, 401, {
error: 'no_skill_credentials',
message: '请先完成 UDS 扫码登录',
})
return sendErr(req, res, 401, 'no_skill_credentials')
}
const targetUrl = body.url
if (!targetUrl || typeof targetUrl !== 'string') {
return sendJSON(res, 400, { error: 'url required' })
return sendErr(req, res, 400, 'url_required')
}
let parsed
try {
parsed = new URL(targetUrl)
} catch {
return sendJSON(res, 400, { error: 'invalid url' })
return sendErr(req, res, 400, 'invalid_url')
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return sendJSON(res, 400, { error: 'unsupported protocol' })
return sendErr(req, res, 400, 'unsupported_protocol')
}
if (!hostAllowed(parsed.hostname)) {
return sendJSON(res, 403, { error: 'host_not_allowed', host: parsed.hostname })
return sendJSON(res, 403, apiError('host_not_allowed', resolveLocale(req), { host: parsed.hostname }))
}
const upstreamMethod = String(body.method || 'POST').toUpperCase()
@ -170,7 +171,9 @@ export function createAgentAuthHandlers(deps) {
json: out.json,
}))
} catch (err) {
return sendJSON(res, 502, { error: 'upstream_failed', message: err.message || String(err) })
const payload = apiError('upstream_failed', resolveLocale(req))
payload.message = payload.message + ': ' + (err.message || String(err))
return sendJSON(res, 502, payload)
}
}

View file

@ -1,18 +1,9 @@
import { ROLES, ROLE_LABELS } from './roles.js'
import { requirePermission } from './middleware/auth-middleware.js'
import { apiError, apiOk, resolveLocale, roleLabel } from './i18n.js'
/**
* API handlers — 带权限守卫
*
* 权限:
* GET /me → 所有人(需登录)
* POST /logout → 所有人(需登录)
* GET /users → super_admin only(列出所有用户)
* POST /users/role → super_admin only(修改角色)
* POST /users → super_admin only(添加用户)
* DELETE /users/:empNo → super_admin only(删除用户)
* POST /fallback/password → super_admin only(设置兜底密码)
* POST /fallback/clear → super_admin only(清除兜底密码)
* API handlers — permission guards + localized messages via stable error codes.
*/
/**
* @param {object} config
@ -27,17 +18,38 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
res.end(JSON.stringify(data))
}
function locale(ctx) {
return resolveLocale(ctx.req, ctx.userContext)
}
function fail(ctx, code, status, vars) {
return sendRes(ctx.res, status, apiError(code, locale(ctx), vars))
}
function ok(ctx, code, vars, extraFields = {}) {
return sendRes(ctx.res, 200, { ...apiOk(code, locale(ctx), vars), ...extraFields })
}
function mapThrown(ctx, err, status = 400) {
const code = err?.code || err?.message
if (code && typeof code === 'string' && !code.includes(' ') && !/[\u4e00-\u9fff]/.test(code)) {
return fail(ctx, code, status)
}
return sendRes(ctx.res, status, {
error: 'request_failed',
message: err?.message || String(err),
})
}
async function logout(ctx) {
const empNo = ctx.empNo
if (empNo) await sessionStore.delete(empNo)
// Skill 凭证轨:默认保留;retainSkillCredentialsOnLogout=false 时清除
const retain = extra.retainSkillCredentialsOnLogout
? extra.retainSkillCredentialsOnLogout() !== false
: true
if (empNo && !retain) {
try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ }
}
// Cookie clear attrs must match login (Secure + HttpOnly), or browsers keep the old cookie.
const clear = []
for (const name of [
'UDS_FALLBACK_USER',
@ -55,7 +67,7 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
clear.push(base + '; Secure')
}
ctx.res.setHeader('Set-Cookie', clear)
await sendRes(ctx.res, 200, { message: 'Logged out' })
await ok(ctx, 'logged_out')
}
async function getCurrentUser(ctx) {
@ -73,97 +85,100 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {})
})
}
// === 用户管理 (super_admin only) ===
async function listUsers(ctx) {
if (!requirePermission(ctx, 'super_admin')) {
return sendRes(ctx.res, 403, { error: '只有超级管理员可以查看用户列表' })
return fail(ctx, 'forbidden_list_users', 403)
}
const url = new URL(ctx.req.url, 'http://localhost')
const page = url.searchParams.get('page')
const pageSize = url.searchParams.get('pageSize')
const q = url.searchParams.get('q') || ''
const result = rolesStore.listPage({ page, pageSize, q })
const loc = locale(ctx)
if (Array.isArray(result.users)) {
result.users = result.users.map((u) => ({
...u,
roleLabel: roleLabel(u.role, loc) || ROLE_LABELS[u.role] || u.role,
}))
}
await sendRes(ctx.res, 200, result)
}
async function setUserRole(ctx) {
if (!requirePermission(ctx, 'super_admin')) {
return sendRes(ctx.res, 403, { error: '只有超级管理员可以修改角色' })
return fail(ctx, 'forbidden_set_role', 403)
}
const body = await readBody(ctx.req)
const { empNo, role } = body || {}
if (!empNo || !role || !Object.values(ROLES).includes(role)) {
return sendRes(ctx.res, 400, { error: '参数错误: empNo 和 role 必填' })
return fail(ctx, 'invalid_role_params', 400)
}
try {
await rolesStore.setRole(empNo, role, ctx.role)
await sendRes(ctx.res, 200, { message: `${empNo} 角色已更新为 ${ROLE_LABELS[role]}` })
await ok(ctx, 'role_updated', { empNo, role: roleLabel(role, locale(ctx)) })
} catch (err) {
await sendRes(ctx.res, 400, { error: err.message })
await mapThrown(ctx, err)
}
}
async function addUser(ctx) {
if (!requirePermission(ctx, 'super_admin')) {
return sendRes(ctx.res, 403, { error: '只有超级管理员可以添加用户' })
return fail(ctx, 'forbidden_add_user', 403)
}
const body = await readBody(ctx.req)
const { empNo, role } = body || {}
if (!empNo) {
return sendRes(ctx.res, 400, { error: 'empNo 必填' })
return fail(ctx, 'emp_no_required', 400)
}
const targetRole = role && Object.values(ROLES).includes(role) ? role : ROLES.USER
try {
await rolesStore.ensureUser(empNo, ctx.role)
if (role) await rolesStore.setRole(empNo, role, ctx.role)
await sendRes(ctx.res, 200, { message: `${empNo} 已添加为 ${ROLE_LABELS[targetRole]}` })
await ok(ctx, 'user_added', { empNo, role: roleLabel(targetRole, locale(ctx)) })
} catch (err) {
await sendRes(ctx.res, 400, { error: err.message })
await mapThrown(ctx, err)
}
}
async function removeUser(ctx) {
if (!requirePermission(ctx, 'super_admin')) {
return sendRes(ctx.res, 403, { error: '只有超级管理员可以删除用户' })
return fail(ctx, 'forbidden_remove_user', 403)
}
const body = await readBody(ctx.req)
const { empNo } = body || {}
if (!empNo) {
return sendRes(ctx.res, 400, { error: 'empNo 必填' })
return fail(ctx, 'emp_no_required', 400)
}
try {
await rolesStore.removeUser(empNo, ctx.role)
await sessionStore.delete(empNo)
try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ }
await sendRes(ctx.res, 200, { message: `${empNo} 已删除` })
await ok(ctx, 'user_removed', { empNo })
} catch (err) {
await sendRes(ctx.res, 400, { error: err.message })
await mapThrown(ctx, err)
}
}
// === Fallback Admin (super_admin only) ===
async function setFallbackPassword(ctx) {
if (!requirePermission(ctx, 'super_admin')) {
return sendRes(ctx.res, 403, { error: '只有超级管理员可以设置兜底密码' })
return fail(ctx, 'forbidden_set_fallback', 403)
}
const body = await readBody(ctx.req)
const { password } = body || {}
try {
await rolesStore.setFallbackPassword(password, ctx.role)
await sendRes(ctx.res, 200, { message: '兜底管理员密码已设置' })
await ok(ctx, 'fallback_password_set')
} catch (err) {
await sendRes(ctx.res, 400, { error: err.message })
await mapThrown(ctx, err)
}
}
async function clearFallbackPassword(ctx) {
if (!requirePermission(ctx, 'super_admin')) {
return sendRes(ctx.res, 403, { error: '只有超级管理员可以清除兜底密码' })
return fail(ctx, 'forbidden_clear_fallback', 403)
}
await rolesStore.clearFallbackPassword(ctx.role)
await sendRes(ctx.res, 200, { message: '兜底管理员密码已清除' })
await ok(ctx, 'fallback_password_cleared')
}
async function fallbackStatus(ctx) {

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@
import { createRequire } from 'node:module'
import { withUserContext, getUserContext, runWithUserContext } from './context.js'
import { computePermissions, ROLES } from './roles.js'
import { resolveLocale, t } from './i18n.js'
const require = createRequire(import.meta.url)
@ -271,7 +272,7 @@ export function patchWebServerWithIdentity(server, resolveIdentity, resolveIdent
res.end(JSON.stringify({
ok: false,
error: 'login_required',
message: '登录后才能使用定时任务',
message: t('err.login_required_cron', resolveLocale(req, identity)),
}))
return
}
@ -354,14 +355,18 @@ export function patchWebServerWithIdentity(server, resolveIdentity, resolveIdent
function throwForbidden(message) {
function throwForbidden(code) {
// Structural RemoteError so Gateway rpcFailure keeps the message instead of
// remapping a plain Error to gateway/internal. Use gateway/bad-request (declared).
const locale = resolveLocale(null, getUserContext())
const raw = String(code || 'request_failed')
const key = raw.startsWith('err.') ? raw : 'err.' + raw
const message = t(key, locale)
const err = new Error(message || 'forbidden')
err.name = 'RemoteError'
err.isDSHRemoteError = true
err.code = 'gateway/bad-request'
err.details = {}
err.details = { udsError: raw.replace(/^err\./, '') }
throw err
}
@ -492,11 +497,11 @@ export function installDshAcl(ctx, {
const assertCanAccess = (request, rowHint) => {
const identity = getUserContext()
if (identity === undefined) return
if (!empOf(identity)) throwForbidden('登录后才能访问会话')
if (!empOf(identity)) throwForbidden('login_required_session')
if (canSeeAll(identity)) return
const sessionId = extractSessionId(request)
if (!canAccessSession(sessionId, identity, rowHint)) {
throwForbidden('无权访问该会话')
throwForbidden('session_forbidden')
}
}
@ -508,13 +513,13 @@ export function installDshAcl(ctx, {
const registry = resolveRegistry()
const ws = registry?.get?.(req.workspaceId)
if (!ws || !isVisibleWorkspace(identity, ws)) {
throwForbidden('只能在自己的工作区创建会话')
throwForbidden('session_workspace_only')
}
return
}
if (req.cwd !== undefined) {
if (!userWorkspaces.isUserPath(empNo, req.cwd, root)) {
throwForbidden('只能在自己的工作区创建会话')
throwForbidden('session_workspace_only')
}
}
}
@ -572,7 +577,7 @@ export function installDshAcl(ctx, {
if (identity === undefined) {
return origCreate(request || {})
}
if (!empOf(identity)) throwForbidden('登录后才能创建会话')
if (!empOf(identity)) throwForbidden('login_required_create_session')
let req = { ...(request || {}) }
await assertCreateTargetAllowed(req, identity)
@ -638,11 +643,11 @@ export function installDshAcl(ctx, {
sc.openWorkspacePath = async (request, signal) => {
const identity = getUserContext()
if (identity === undefined) return origOpenPath(request, signal)
if (!empOf(identity)) throwForbidden('登录后才能访问会话')
if (!empOf(identity)) throwForbidden('login_required_session')
if (!canSeeAll(identity) && !identity.permissions?.canCreateWorkspace) {
const path = request?.path
if (!path || !userWorkspaces.isUserPath(empOf(identity), path, getWorkspaceRoot())) {
throwForbidden('只能打开自己的工作区路径')
throwForbidden('workspace_path_only')
}
}
return origOpenPath(request, signal)
@ -673,7 +678,7 @@ ctx.inject(['workspaceController'], (wctx) => {
const identity = getUserContext()
if (identity?._internalProvision) return origCreate(request)
if (!identity?.permissions?.canCreateWorkspace) {
throwForbidden('只有超级管理员可以创建工作区')
throwForbidden('workspace_create_forbidden')
}
return origCreate(request)
}
@ -780,7 +785,7 @@ ctx.inject(['workspaceController'], (wctx) => {
// Allow uds-auth namespace writes from our own settings section for admins;
// users cannot touch any settings.
if (!identity?.permissions?.canAccessSettings) {
throwForbidden('当前账号无设置权限')
throwForbidden('forbidden_settings')
}
return orig(...args)
}
@ -795,9 +800,9 @@ ctx.inject(['workspaceController'], (wctx) => {
const assertCanCreateWorkspace = () => {
const identity = getUserContext()
if (!identity?.empNo) throwForbidden('登录后才能使用工作区')
if (!identity?.empNo) throwForbidden('login_required_workspace')
if (!identity?.permissions?.canCreateWorkspace) {
throwForbidden('只有超级管理员可以创建工作区')
throwForbidden('workspace_create_forbidden')
}
}

393
uds-auth/lib/i18n.js Normal file
View file

@ -0,0 +1,393 @@
/**
* uds-auth i18n — single source for UI + API user-facing strings.
* Locales: zh (default), en. Keys are stable error/UI ids.
*
* Browser client.js embeds a copy of MESSAGES (ModuleLoader cannot import this file).
* When changing strings here, also refresh the `UDS_I18N_MESSAGES` block in lib/client.js
* (search for `uds-auth-i18n-begin`).
*/
export const DEFAULT_LOCALE = 'zh'
/** @type {Record<string, Record<string, string>>} */
export const MESSAGES = {
zh: {
// roles
'role.super_admin': '超级管理员',
'role.admin': '管理员',
'role.user': '普通用户',
'role.fallback_admin': '应急管理员',
'role.badge.super_admin': '超管',
'role.badge.admin': '管理员',
'role.badge.fallback_admin': '应急',
'role.badge.user': '',
// settings / users
'ui.settingsTitle': 'UAC 认证',
'ui.settingsIntro': '工号+token 双校验;UAC 挂死时用应急账号 administrator 密码登录。',
'ui.loginRequiredPage': '请先登录后查看此页',
'ui.roleHint': '当前角色:{role}。首位扫码登录且 roles.json 为空时会自动成为超管;普通 admin 需超管在「用户管理」提权后再扫码登录。应急账号 administrator 需超管先设密码,再在登录面板用账密登录。',
'ui.deployConfig': '部署配置',
'ui.userSearchUrl': '用户搜索 URL(token 校验)',
'ui.workspaceRoot': '工作区根目录(空=$DSH_HOME/user-workspaces)',
'ui.saveConfig': '保存配置',
'ui.saving': '保存中...',
'ui.configSaved': '配置已保存',
'ui.saveFailed': '保存失败',
'ui.fallbackTitle': '应急登录(UAC 不可用)',
'ui.fallbackStatus': '状态:{status}。可在此改密或关闭。仅在扫码不可用时从登录面板切换。',
'ui.enabled': '已启用',
'ui.disabled': '未启用',
'ui.fallbackPassword': '应急密码(至少 6 位)',
'ui.saveFallbackPassword': '保存应急密码',
'ui.fallbackPasswordSet': '应急密码已设置',
'ui.confirmClearFallback': '确认清除应急密码?',
'ui.clear': '清除',
'ui.userManagement': '用户管理',
'ui.searchEmpNo': '搜索工号',
'ui.search': '搜索',
'ui.loading': '加载中...',
'ui.loadFailed': '加载失败',
'ui.empNo': '工号',
'ui.role': '角色',
'ui.actions': '操作',
'ui.noUsers': '暂无用户',
'ui.delete': '删除',
'ui.confirmDelete': '确认删除 {empNo}?',
'ui.pager': '共 {total} 人,第 {page} / {totalPages} 页',
'ui.prevPage': '上一页',
'ui.nextPage': '下一页',
'ui.add': '添加',
'ui.department': '部门',
// login panel
'ui.notLoggedIn': '未登录',
'ui.pleaseScan': '请使用 iCenter 扫码登录',
'ui.refreshQr': '刷新二维码',
'ui.fallbackLink': 'UAC 不可用?应急账号登录',
'ui.fallbackLogin': '应急登录',
'ui.fallbackDetail': 'UAC / 扫码不可用时使用',
'ui.username': '用户名',
'ui.password': '密码',
'ui.login': '登录',
'ui.loggingIn': '登录中...',
'ui.backToQr': '返回扫码登录',
'ui.logout': '退出登录',
'ui.userPrefix': '用户',
// QR status
'ui.qrGenerating': '正在生成二维码...',
'ui.qrScanPrompt': '请使用 iCenter 扫码登录...',
'ui.userInfoFailed': '用户信息查询失败',
'ui.loginSuccess': '登录成功!',
'ui.missingToken': '缺少 token,无法完成校验',
'ui.waitingScan': '等待扫码...',
'ui.qrExpired': '二维码已失效',
'ui.qrExpiredHint': '请刷新后重新扫描',
'ui.loginFailed': '登录失败',
'ui.networkError': '网络错误...',
'ui.qrGenerateFailed': '生成二维码失败',
// client gates
'ui.cronLoginRequired': '登录后才能使用定时任务',
'ui.workspaceCreateForbidden': '只有超级管理员可以创建工作区',
'ui.workspaceLoginRequired': '登录后才能使用工作区',
// API / ACL errors (stable codes)
'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.invalid_role_params': '参数错误: empNo 和 role 必填',
'err.emp_no_required': 'empNo 必填',
'err.username_password_required': '用户名和密码必填',
'err.invalid_credentials': '用户名或密码错误',
'err.last_super_admin_demote': '系统至少需要 1 个超级管理员,不能降级最后一个',
'err.last_super_admin_delete': '系统至少需要 1 个超级管理员,不能删除最后一个',
'err.password_too_short': '密码至少 6 位',
'err.config_not_ready': '配置未初始化',
'err.request_failed': '请求失败',
'err.method_not_allowed': '方法不允许',
'err.missing_qr_params': '缺少 qrCodeKey 或 qrCodeValue',
'err.missing_emp_token': '缺少 empNo 或 token',
'err.user_search_failed': '用户搜索失败',
'err.not_found': '未找到',
'err.internal': '内部错误',
'err.login_required_cron': '登录后才能使用定时任务',
'err.login_required_session': '登录后才能访问会话',
'err.login_required_create_session': '登录后才能创建会话',
'err.login_required_workspace': '登录后才能使用工作区',
'err.session_forbidden': '无权访问该会话',
'err.session_workspace_only': '只能在自己的工作区创建会话',
'err.workspace_path_only': '只能打开自己的工作区路径',
'err.workspace_create_forbidden': '只有超级管理员可以创建工作区',
'err.no_skill_credentials': '请先完成 UAC 扫码登录',
'err.loopback_only_credentials': 'agent-credentials 仅允许本机访问',
'err.loopback_only_outbound': 'outbound 仅允许本机访问',
'err.url_required': '缺少 url',
'err.invalid_url': '无效 url',
'err.unsupported_protocol': '不支持的协议',
'err.host_not_allowed': '主机不在白名单',
'err.upstream_failed': '上游请求失败',
'err.skill_credentials_not_ready': 'skill 凭证未就绪',
'err.outbound_not_ready': 'outbound 未就绪',
// API success
'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': '应急管理员登录成功',
},
en: {
'role.super_admin': 'Super admin',
'role.admin': 'Admin',
'role.user': 'User',
'role.fallback_admin': 'Emergency admin',
'role.badge.super_admin': 'Super',
'role.badge.admin': 'Admin',
'role.badge.fallback_admin': 'Emergency',
'role.badge.user': '',
'ui.settingsTitle': 'UAC Auth',
'ui.settingsIntro': 'EmpNo + token verification; when UAC is down, sign in with emergency account administrator.',
'ui.loginRequiredPage': 'Sign in to view this page',
'ui.roleHint': 'Current role: {role}. The first QR login with an empty roles.json becomes super admin; grant admin in User management then re-scan. Set the emergency password before using administrator on the login panel.',
'ui.deployConfig': 'Deploy config',
'ui.userSearchUrl': 'User search URL (token verify)',
'ui.workspaceRoot': 'Workspace root (empty=$DSH_HOME/user-workspaces)',
'ui.saveConfig': 'Save config',
'ui.saving': 'Saving...',
'ui.configSaved': 'Config saved',
'ui.saveFailed': 'Save failed',
'ui.fallbackTitle': 'Emergency login (UAC unavailable)',
'ui.fallbackStatus': 'Status: {status}. Change or disable here. Switch from the login panel only when QR is unavailable.',
'ui.enabled': 'Enabled',
'ui.disabled': 'Disabled',
'ui.fallbackPassword': 'Emergency password (min 6 chars)',
'ui.saveFallbackPassword': 'Save emergency password',
'ui.fallbackPasswordSet': 'Emergency password set',
'ui.confirmClearFallback': 'Clear emergency password?',
'ui.clear': 'Clear',
'ui.userManagement': 'User management',
'ui.searchEmpNo': 'Search empNo',
'ui.search': 'Search',
'ui.loading': 'Loading...',
'ui.loadFailed': 'Load failed',
'ui.empNo': 'EmpNo',
'ui.role': 'Role',
'ui.actions': 'Actions',
'ui.noUsers': 'No users',
'ui.delete': 'Delete',
'ui.confirmDelete': 'Delete {empNo}?',
'ui.pager': '{total} users, page {page} / {totalPages}',
'ui.prevPage': 'Previous',
'ui.nextPage': 'Next',
'ui.add': 'Add',
'ui.department': 'Department',
'ui.notLoggedIn': 'Not signed in',
'ui.pleaseScan': 'Scan with iCenter to sign in',
'ui.refreshQr': 'Refresh QR',
'ui.fallbackLink': 'UAC down? Emergency account',
'ui.fallbackLogin': 'Emergency login',
'ui.fallbackDetail': 'Use when UAC / QR is unavailable',
'ui.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 super 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.invalid_role_params': 'Invalid params: empNo and role required',
'err.emp_no_required': 'empNo required',
'err.username_password_required': 'Username and password required',
'err.invalid_credentials': 'Invalid username or password',
'err.last_super_admin_demote': 'At least one super admin is required; cannot demote the last one',
'err.last_super_admin_delete': 'At least one super admin is required; cannot delete the last one',
'err.password_too_short': 'Password must be at least 6 characters',
'err.config_not_ready': 'Config not initialized',
'err.request_failed': 'Request failed',
'err.method_not_allowed': 'Method not allowed',
'err.missing_qr_params': 'Missing qrCodeKey or qrCodeValue',
'err.missing_emp_token': 'Missing empNo or token',
'err.user_search_failed': 'User search failed',
'err.not_found': 'Not found',
'err.internal': 'Internal error',
'err.login_required_cron': 'Sign in to use scheduled tasks',
'err.login_required_session': 'Sign in to access sessions',
'err.login_required_create_session': 'Sign in to create a session',
'err.login_required_workspace': 'Sign in to use workspaces',
'err.session_forbidden': 'No access to this session',
'err.session_workspace_only': 'Sessions can only be created in your own workspace',
'err.workspace_path_only': 'You can only open your own workspace path',
'err.workspace_create_forbidden': 'Only super 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',
},
}
/**
* @param {unknown} lang
* @returns {'zh' | 'en'}
*/
export function normalizeLocale(lang) {
const raw = String(lang || '').trim().toLowerCase()
if (!raw) return DEFAULT_LOCALE
if (raw.startsWith('en')) return 'en'
if (raw.startsWith('zh')) return DEFAULT_LOCALE
return DEFAULT_LOCALE
}
/**
* @param {string} key
* @param {'zh' | 'en' | string} [locale]
* @param {Record<string, string | number>} [vars]
*/
export function t(key, locale = DEFAULT_LOCALE, vars = {}) {
const loc = normalizeLocale(locale)
const table = MESSAGES[loc] || MESSAGES[DEFAULT_LOCALE]
let text = table[key] || MESSAGES[DEFAULT_LOCALE][key] || key
for (const [k, v] of Object.entries(vars || {})) {
text = text.split('{' + k + '}').join(String(v))
}
return text
}
/** Role display label (full). */
export function roleLabel(role, locale = DEFAULT_LOCALE) {
return t('role.' + String(role || 'user'), locale)
}
/** Compact badge label. */
export function roleBadge(role, locale = DEFAULT_LOCALE) {
return t('role.badge.' + String(role || 'user'), locale)
}
/** Default zh labels for roles.json search / list (server-side). */
export const ROLE_LABELS_ZH = {
super_admin: MESSAGES.zh['role.super_admin'],
admin: MESSAGES.zh['role.admin'],
user: MESSAGES.zh['role.user'],
fallback_admin: MESSAGES.zh['role.fallback_admin'],
}
function parseCookie(header, name) {
if (!header) return null
const prefix = name + '='
for (const part of String(header).split(';')) {
const v = part.trim()
if (v.startsWith(prefix)) {
try { return decodeURIComponent(v.slice(prefix.length)) } catch { return v.slice(prefix.length) }
}
}
return null
}
/**
* Resolve locale from HTTP request + optional userContext.lang.
* @param {import('node:http').IncomingMessage | null | undefined} req
* @param {{ lang?: string } | null | undefined} [userContext]
*/
export function resolveLocale(req, userContext) {
const headers = req?.headers || {}
const cookie = headers.cookie || ''
const fromCtx = userContext?.lang
const fromHeader = headers['x-lang-id'] || headers['X-Lang-Id']
const fromCookie = parseCookie(cookie, 'PORTALSSOLanguage')
|| parseCookie(cookie, 'ZTEDPGSSOLanguage')
const accept = String(headers['accept-language'] || '').split(',')[0]
return normalizeLocale(fromCtx || fromHeader || fromCookie || accept || DEFAULT_LOCALE)
}
/**
* API error payload: stable `error` code + localized `message`.
* @param {string} code
* @param {'zh' | 'en' | string} locale
* @param {Record<string, string | number>} [vars]
*/
export function apiError(code, locale, vars) {
const key = code.startsWith('err.') || code.startsWith('ok.') ? code : 'err.' + code
return {
error: code.startsWith('err.') ? code.slice(4) : code,
message: t(key, locale, vars),
}
}
/**
* @param {string} code
* @param {'zh' | 'en' | string} locale
* @param {Record<string, string | number>} [vars]
*/
export function apiOk(code, locale, vars) {
const key = code.startsWith('ok.') ? code : 'ok.' + code
return {
message: t(key, locale, vars),
}
}
/** Host UI aria-labels (zh + en) for CSS / DOM gates — not translated UI of this plugin. */
export const HOST_ARIA = {
addWorkspace: ['添加工作区', 'Add workspace'],
chooseWorkspace: ['选择工作区', 'Choose workspace'],
sessions: ['会话', 'Sessions'],
}
export function ariaSelector(labels) {
return labels.map((l) => '[aria-label="' + l + '"]').join(',')
}
export function buttonAriaSelector(labels) {
return labels.map((l) => 'button[aria-label="' + l + '"]').join(',')
}

View file

@ -4,6 +4,7 @@
import { requirePermission } from './middleware/auth-middleware.js'
import { computePermissions } from './roles.js'
import { apiError, apiOk, resolveLocale } from './i18n.js'
import { readFile, writeFile } from 'node:fs/promises'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -170,7 +171,7 @@ function createQrChallenge(config = _currentConfig || {}) {
async function handleQrStart(req, res) {
if (req.method !== 'POST' && req.method !== 'GET') {
res.writeHead(405, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Method not allowed' }))
res.end(JSON.stringify(apiError('method_not_allowed', localeOf(req))))
return
}
const challenge = createQrChallenge(_currentConfig)
@ -185,7 +186,7 @@ async function handleQrStart(req, res) {
async function handleQRProxy(req, res) {
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Method not allowed' }))
res.end(JSON.stringify(apiError('method_not_allowed', localeOf(req))))
return
}
@ -341,7 +342,7 @@ function calculateVerifyCode(qrCodeKey, qrCodeValue, loginClientIp, loginSystemC
async function handleVerifyCode(req, res) {
if (req.method !== 'GET') {
res.writeHead(405, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Method not allowed' }))
res.end(JSON.stringify(apiError('method_not_allowed', localeOf(req))))
return
}
@ -354,7 +355,7 @@ async function handleVerifyCode(req, res) {
if (!qrCodeKey || !qrCodeValue) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Missing qrCodeKey or qrCodeValue' }))
res.end(JSON.stringify(apiError('missing_qr_params', localeOf(req))))
return
}
@ -379,7 +380,7 @@ async function handleVerifyCode(req, res) {
async function handleUserInfo(req, res) {
if (req.method !== 'GET') {
res.writeHead(405, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Method not allowed' }))
res.end(JSON.stringify(apiError('method_not_allowed', localeOf(req))))
return
}
@ -389,7 +390,7 @@ async function handleUserInfo(req, res) {
if (!empNo || !token) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Missing empNo or token' }))
res.end(JSON.stringify(apiError('missing_emp_token', localeOf(req))))
return
}
@ -407,7 +408,7 @@ async function handleUserInfo(req, res) {
const status = out.statusCode === 401 || out.statusCode === 403 ? out.statusCode : 502
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({
error: 'user search failed',
...apiError('user_search_failed', localeOf(req)),
reason: out.reason,
hint: out.hint || 'Ensure userSearchUrl is intranet-reachable and Host does not force HTTP(S)_PROXY for *.zte.com.cn',
detail: { code: out.code, msg: out.msg, statusCode: out.statusCode },
@ -428,6 +429,18 @@ function sendJSON(res, code, data) {
res.end(JSON.stringify(data))
}
function localeOf(req, userContext) {
return resolveLocale(req, userContext)
}
function sendErr(res, req, status, code, vars, userContext) {
return sendJSON(res, status, apiError(code, localeOf(req, userContext), vars))
}
function sendOkMsg(res, req, code, vars, userContext, extra = {}) {
return sendJSON(res, 200, { ...apiOk(code, localeOf(req, userContext), vars), ...extra })
}
/** Prefer Secure cookies only on HTTPS — http://127.0.0.1 drops Secure cookies from WS. */
function isHttpsRequest(req) {
@ -446,15 +459,15 @@ async function handleFallbackLogin(req, res) {
const ip = req.socket?.remoteAddress || 'unknown'
if (!username || !password) {
return sendJSON(res, 400, { error: '用户名和密码必填' })
return sendErr(res, req, 400, 'username_password_required')
}
if (username !== 'administrator') {
// 不泄露"administrator"是唯一用户名
return sendJSON(res, 401, { error: '用户名或密码错误' })
return sendErr(res, req, 401, 'invalid_credentials')
}
if (!_rolesStore.verifyFallback(password, ip)) {
return sendJSON(res, 401, { error: '用户名或密码错误' })
return sendErr(res, req, 401, 'invalid_credentials')
}
// 登录成功:创建 session,角色 = fallback_admin (等同 super_admin)
@ -494,11 +507,10 @@ async function handleFallbackLogin(req, res) {
return [partsUser.join('; '), partsUi.join('; ')]
})())
sendJSON(res, 200, {
sendOkMsg(res, req, 'fallback_login', null, userContext, {
success: true,
empNo,
role: 'fallback_admin',
message: '兜底管理员登录成功',
})
}
@ -506,7 +518,11 @@ async function handleFallbackLogin(req, res) {
const RUNTIME_CONFIG_FILE = resolve(__dirname, '..', 'config.runtime.json')
async function saveRuntimeConfig(partial) {
if (!_currentConfig) throw new Error('配置未初始化')
if (!_currentConfig) {
const err = new Error('config_not_ready')
err.code = 'config_not_ready'
throw err
}
// 只允许修改 4 个可配置字段
const allowed = [
'uacBaseUrl',
@ -584,7 +600,7 @@ async function handleAllRoutes(req, res) {
if (!_agentAuthHandlers) {
res.statusCode = 503
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: 'skill credentials not ready' }))
res.end(JSON.stringify(apiError('skill_credentials_not_ready', localeOf(req))))
return
}
return await _agentAuthHandlers.handleAgentCredentials(req, res)
@ -593,7 +609,7 @@ async function handleAllRoutes(req, res) {
if (!_agentAuthHandlers) {
res.statusCode = 503
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: 'outbound not ready' }))
res.end(JSON.stringify(apiError('outbound_not_ready', localeOf(req))))
return
}
return await _agentAuthHandlers.handleOutbound(req, res)
@ -612,7 +628,7 @@ async function handleAllRoutes(req, res) {
// 未知路径
res.statusCode = 404
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: 'Not found', path: pathname }))
res.end(JSON.stringify({ ...apiError('not_found', localeOf(req)), path: pathname }))
} catch (err) {
console.error('[uds-auth] handleAllRoutes error:', err)
res.statusCode = 500
@ -643,7 +659,7 @@ function handleRequest(req, res) {
// 以下都需要登录态
if (!ctx2.empNo) {
return sendJSON(res, 401, { error: '未登录' })
return sendErr(res, req, 401, 'not_logged_in', null, ctx2.userContext)
}
// 用户管理 (super_admin only)
@ -671,14 +687,14 @@ function handleRequest(req, res) {
// 配置端点 (admin / super_admin:canAccessSettings)
if (url === '/api/config' && method === 'GET') {
if (!requirePermission(ctx2, 'canAccessSettings')) {
return sendJSON(res, 403, { error: '当前账号无设置权限' })
return sendErr(res, req, 403, 'forbidden_settings', null, ctx2.userContext)
}
sendJSON(res, 200, { config: _currentConfig })
return
}
if (url === '/api/config' && method === 'POST') {
if (!requirePermission(ctx2, 'canAccessSettings')) {
return sendJSON(res, 403, { error: '当前账号无设置权限' })
return sendErr(res, req, 403, 'forbidden_settings', null, ctx2.userContext)
}
let body = ''
for await (const chunk of req) body += chunk
@ -686,9 +702,14 @@ function handleRequest(req, res) {
try { parsed = JSON.parse(body) } catch { parsed = {} }
try {
await saveRuntimeConfig(parsed)
sendJSON(res, 200, { message: '配置已保存' })
} catch(err) {
sendJSON(res, 400, { error: err.message })
sendOkMsg(res, req, 'config_saved', null, ctx2.userContext)
} catch (err) {
const code = err?.code || err?.message
if (code === 'config_not_ready') {
sendErr(res, req, 400, 'config_not_ready', null, ctx2.userContext)
} else {
sendJSON(res, 400, { error: 'request_failed', message: err.message })
}
}
return
}

View file

@ -11,6 +11,13 @@
import { createHash, randomBytes } from 'node:crypto'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { ROLE_LABELS_ZH } from './i18n.js'
function codedError(code) {
const err = new Error(code)
err.code = code
return err
}
export const ROLES = {
SUPER_ADMIN: 'super_admin',
@ -19,12 +26,8 @@ export const ROLES = {
FALLBACK_ADMIN: 'fallback_admin', // 特殊,UAC 挂了时用,等同 super_admin 权限
}
export const ROLE_LABELS = {
super_admin: '超级管理员',
admin: '管理员',
user: '普通用户',
fallback_admin: '兜底管理员',
}
/** zh labels for list/search; UI should translate via i18n role.* keys. */
export const ROLE_LABELS = ROLE_LABELS_ZH
/** 计算角色权限 (纯函数) */
export function computePermissions(role) {
@ -237,7 +240,7 @@ export class RolesStore {
*/
async setRole(empNo, newRole, currentAdminRole) {
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
throw new Error('只有超级管理员可以修改角色')
throw codedError('forbidden_set_role')
}
// invariant: 不能让系统变成 0 个 super_admin
@ -245,7 +248,7 @@ export class RolesStore {
if (currentRole === ROLES.SUPER_ADMIN && newRole !== ROLES.SUPER_ADMIN) {
const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw new Error('系统至少需要 1 个超级管理员,不能降级最后一个')
throw codedError('last_super_admin_demote')
}
}
@ -257,13 +260,13 @@ export class RolesStore {
/** 删除用户 */
async removeUser(empNo, currentAdminRole) {
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
throw new Error('只有超级管理员可以删除用户')
throw codedError('forbidden_remove_user')
}
const currentRole = this._roles.get(empNo)
if (currentRole === ROLES.SUPER_ADMIN) {
const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw new Error('系统至少需要 1 个超级管理员,不能删除最后一个')
throw codedError('last_super_admin_delete')
}
}
this._roles.delete(empNo)
@ -274,7 +277,7 @@ export class RolesStore {
/** 确保用户存在 (如果不存在设为 user) */
ensureUser(empNo, currentAdminRole) {
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
throw new Error('只有超级管理员可以添加用户')
throw codedError('forbidden_add_user')
}
if (!this._roles.has(empNo)) {
this._roles.set(empNo, ROLES.USER)
@ -287,10 +290,10 @@ export class RolesStore {
setFallbackPassword(password, currentAdminRole) {
if (currentAdminRole !== ROLES.SUPER_ADMIN && currentAdminRole !== ROLES.FALLBACK_ADMIN) {
throw new Error('只有超级管理员可以设置兜底管理员密码')
throw codedError('forbidden_set_fallback')
}
if (!password || password.length < 6) {
throw new Error('密码至少 6 位')
throw codedError('password_too_short')
}
this._fallbackPasswordHash = hashPassword(password)
this._markDirty()
@ -299,7 +302,7 @@ export class RolesStore {
clearFallbackPassword(currentAdminRole) {
if (currentAdminRole !== ROLES.SUPER_ADMIN && currentAdminRole !== ROLES.FALLBACK_ADMIN) {
throw new Error('只有超级管理员可以清除兜底管理员密码')
throw codedError('forbidden_clear_fallback')
}
this._fallbackPasswordHash = null
this._markDirty()

View file

@ -8,6 +8,7 @@
".": "./lib/index.js",
"./client": "./lib/client.js",
"./config": "./lib/config.js",
"./i18n": "./lib/i18n.js",
"./session": "./lib/session/factory.js",
"./middleware": "./lib/middleware/auth-middleware.js",
"./api": "./lib/api.js",
@ -64,7 +65,8 @@
"immediately": true,
"inject": [
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-layout"
"@deepseek-ai/dsh-client-ui-layout",
"@deepseek-ai/dsh-client-locale"
]
}
}

View file

@ -0,0 +1,53 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
t,
normalizeLocale,
apiError,
apiOk,
roleLabel,
roleBadge,
MESSAGES,
} from '../lib/i18n.js'
describe('uds-auth i18n', () => {
it('normalizes locales', () => {
assert.equal(normalizeLocale('en-US'), 'en')
assert.equal(normalizeLocale('zh-CN'), 'zh')
assert.equal(normalizeLocale(''), 'zh')
})
it('translates UI keys', () => {
assert.equal(t('ui.logout', 'zh'), '退出登录')
assert.equal(t('ui.logout', 'en'), 'Sign out')
})
it('interpolates vars', () => {
assert.equal(t('ui.pager', 'en', { total: 3, page: 1, totalPages: 2 }), '3 users, page 1 / 2')
})
it('returns stable API error codes with localized message', () => {
const zh = apiError('not_logged_in', 'zh')
const en = apiError('not_logged_in', 'en')
assert.equal(zh.error, 'not_logged_in')
assert.equal(en.error, 'not_logged_in')
assert.equal(zh.message, '未登录')
assert.equal(en.message, 'Not signed in')
})
it('returns localized success messages', () => {
assert.equal(apiOk('config_saved', 'en').message, 'Config saved')
})
it('role labels are consistent', () => {
assert.equal(roleLabel('super_admin', 'zh'), '超级管理员')
assert.equal(roleBadge('super_admin', 'zh'), '超管')
assert.equal(roleLabel('super_admin', 'en'), 'Super admin')
})
it('zh and en tables share the same keys', () => {
const zhKeys = Object.keys(MESSAGES.zh).sort()
const enKeys = Object.keys(MESSAGES.en).sort()
assert.deepEqual(zhKeys, enKeys)
})
})