mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
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:
parent
b60c626d41
commit
fd0df9b890
9 changed files with 1183 additions and 202 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
393
uds-auth/lib/i18n.js
Normal 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(',')
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
53
uds-auth/test/i18n.test.js
Normal file
53
uds-auth/test/i18n.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue