const AUTH_STORAGE_KEY = 'cyberstrike-auth'; let authToken = null; let authTokenExpiry = null; let authUser = null; let authRoles = []; let authPermissions = new Set(); let authScope = ''; let authPromise = null; let authPromiseResolvers = []; let isAppInitialized = false; function isTokenValid() { return !!authToken && authTokenExpiry instanceof Date && authTokenExpiry.getTime() > Date.now(); } function saveAuth(token, expiresAt, meta = {}) { const expiry = expiresAt instanceof Date ? expiresAt : new Date(expiresAt); authToken = token; authTokenExpiry = expiry; authUser = meta.user || null; authRoles = Array.isArray(meta.roles) ? meta.roles : []; authPermissions = new Set(Array.isArray(meta.permissions) ? meta.permissions : []); authScope = meta.scope || ''; try { localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ token, expiresAt: expiry.toISOString(), user: authUser, roles: authRoles, permissions: Array.from(authPermissions), scope: authScope, })); } catch (error) { console.warn('无法持久化认证信息:', error); } renderUserMenuProfile(); } function clearAuthStorage() { authToken = null; authTokenExpiry = null; authUser = null; authRoles = []; authPermissions = new Set(); authScope = ''; try { localStorage.removeItem(AUTH_STORAGE_KEY); } catch (error) { console.warn('无法清除认证信息:', error); } renderUserMenuProfile(); } function loadAuthFromStorage() { try { const raw = localStorage.getItem(AUTH_STORAGE_KEY); if (!raw) { return false; } const stored = JSON.parse(raw); if (!stored.token || !stored.expiresAt) { clearAuthStorage(); return false; } const expiry = new Date(stored.expiresAt); if (Number.isNaN(expiry.getTime())) { clearAuthStorage(); return false; } authToken = stored.token; authTokenExpiry = expiry; authUser = stored.user || null; authRoles = Array.isArray(stored.roles) ? stored.roles : []; authPermissions = new Set(Array.isArray(stored.permissions) ? stored.permissions : []); authScope = stored.scope || ''; return isTokenValid(); } catch (error) { console.error('读取认证信息失败:', error); clearAuthStorage(); return false; } } function resolveAuthPromises(success) { authPromiseResolvers.forEach(resolve => resolve(success)); authPromiseResolvers = []; authPromise = null; } function showLoginOverlay(message = '') { const overlay = document.getElementById('login-overlay'); const errorBox = document.getElementById('login-error'); const usernameInput = document.getElementById('login-username'); const passwordInput = document.getElementById('login-password'); if (!overlay) { return; } openAppModal('login-overlay', { focus: false }); if (errorBox) { if (message) { errorBox.textContent = message; errorBox.style.display = 'block'; } else { errorBox.textContent = ''; errorBox.style.display = 'none'; } } setTimeout(function () { if (usernameInput && !usernameInput.value) { usernameInput.focus(); } else if (passwordInput) { passwordInput.focus(); } }, 100); } function hideLoginOverlay() { const overlay = document.getElementById('login-overlay'); const errorBox = document.getElementById('login-error'); const usernameInput = document.getElementById('login-username'); const passwordInput = document.getElementById('login-password'); closeAppModal('login-overlay'); if (errorBox) { errorBox.textContent = ''; errorBox.style.display = 'none'; } if (passwordInput) { passwordInput.value = ''; } if (usernameInput && !authUser) { usernameInput.value = ''; } } function ensureAuthPromise() { if (!authPromise) { authPromise = new Promise(resolve => { authPromiseResolvers.push(resolve); }); } return authPromise; } async function ensureAuthenticated() { if (isTokenValid()) { return true; } showLoginOverlay(); await ensureAuthPromise(); return true; } function handleUnauthorized({ message = null, silent = false } = {}) { clearAuthStorage(); authPromise = null; authPromiseResolvers = []; let finalMessage = message; if (!finalMessage) { if (typeof window !== 'undefined' && typeof window.t === 'function') { finalMessage = window.t('auth.sessionExpired'); } else { finalMessage = '认证已过期,请重新登录'; } } if (!silent) { showLoginOverlay(finalMessage); } else { showLoginOverlay(); } return false; } async function apiFetch(url, options = {}) { await ensureAuthenticated(); const opts = { ...options }; const headers = new Headers(options && options.headers ? options.headers : undefined); if (authToken && !headers.has('Authorization')) { headers.set('Authorization', `Bearer ${authToken}`); } opts.headers = headers; const response = await fetch(url, opts); if (response.status === 401) { handleUnauthorized(); const msg = (typeof window !== 'undefined' && typeof window.t === 'function') ? window.t('auth.unauthorized') : '未授权访问'; throw new Error(msg); } // 403 属于可预期的 RBAC 拒绝,返回 Response 供调用方通过 res.ok / ensureApiOk 处理。 return response; } /** * multipart POST with XMLHttpRequest so upload progress is available (fetch 无法可靠上报进度). * 返回与 fetch 类似的对象:ok、status、json()、text() */ async function apiUploadWithProgress(url, formData, options = {}) { await ensureAuthenticated(); const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null; return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', url); if (authToken) { xhr.setRequestHeader('Authorization', `Bearer ${authToken}`); } xhr.upload.onprogress = (e) => { if (!onProgress || !e.lengthComputable) return; const percent = e.total > 0 ? Math.round((e.loaded / e.total) * 100) : 0; onProgress({ loaded: e.loaded, total: e.total, percent }); }; xhr.onerror = () => { reject(new Error('Network error')); }; xhr.onload = () => { if (xhr.status === 401) { handleUnauthorized(); const msg = (typeof window !== 'undefined' && typeof window.t === 'function') ? window.t('auth.unauthorized') : '未授权访问'; reject(new Error(msg)); return; } const responseText = xhr.responseText || ''; resolve({ ok: xhr.status >= 200 && xhr.status < 300, status: xhr.status, text: async () => responseText, json: async () => { try { return responseText ? JSON.parse(responseText) : {}; } catch (err) { throw err; } }, }); }; xhr.send(formData); }); } async function submitLogin(event) { event.preventDefault(); const usernameInput = document.getElementById('login-username'); const passwordInput = document.getElementById('login-password'); const errorBox = document.getElementById('login-error'); const submitBtn = document.querySelector('.login-submit'); if (!passwordInput) { return; } const username = usernameInput ? usernameInput.value.trim() : ''; const password = passwordInput.value.trim(); if (!password) { if (errorBox) { const msgEmpty = (typeof window !== 'undefined' && typeof window.t === 'function') ? window.t('auth.enterPassword') : '请输入密码'; errorBox.textContent = msgEmpty; errorBox.style.display = 'block'; } return; } if (submitBtn) { submitBtn.disabled = true; } try { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ username, password }), }); const result = await response.json().catch(() => ({})); if (!response.ok || !result.token) { if (errorBox) { const fallback = (typeof window !== 'undefined' && typeof window.t === 'function') ? window.t('auth.loginFailedCheck') : '登录失败,请检查密码'; errorBox.textContent = result.error || fallback; errorBox.style.display = 'block'; } return; } saveAuth(result.token, result.expires_at, { user: result.user, roles: result.roles, permissions: result.permissions, scope: result.scope, }); hideLoginOverlay(); applyRBACToUI(); resolveAuthPromises(true); if (!isAppInitialized) { await bootstrapApp(); } else { await refreshAppData(); } } catch (error) { console.error('登录失败:', error); if (errorBox) { const fallback = (typeof window !== 'undefined' && typeof window.t === 'function') ? window.t('auth.loginFailedRetry') : '登录失败,请稍后重试'; errorBox.textContent = fallback; errorBox.style.display = 'block'; } } finally { if (submitBtn) { submitBtn.disabled = false; } } } async function refreshAppData(showTaskErrors = false) { if (typeof initChatAgentModeFromConfig === 'function') { try { await initChatAgentModeFromConfig(); } catch (error) { console.warn('刷新对话模式配置失败:', error); } } await Promise.allSettled([ loadConversations(), loadActiveTasks(showTaskErrors), ]); } async function bootstrapApp() { if (!isAppInitialized) { // 等待 i18n 首包加载完成后再插系统就绪消息,避免清除缓存后语言显示 English 气泡仍是中文 try { if (window.i18nReady && typeof window.i18nReady.then === 'function') { await window.i18nReady; } } catch (e) { console.warn('等待 i18n 就绪失败,继续初始化聊天', e); } initializeChatUI(); isAppInitialized = true; } applyRBACToUI(); if (typeof installWriteHandlerGuards === 'function') { installWriteHandlerGuards(); } await refreshAppData(); } const PAGE_PERMISSION_MAP = { dashboard: 'dashboard:read', chat: 'chat:read', hitl: 'hitl:read', 'info-collect': 'fofa:execute', tasks: 'tasks:read', workflows: 'workflow:read', projects: 'project:read', vulnerabilities: 'vulnerability:read', 'chat-files': 'files:read', webshell: 'webshell:read', c2: 'c2:read', 'c2-listeners': 'c2:read', 'c2-sessions': 'c2:read', 'c2-tasks': 'c2:read', 'c2-payloads': 'c2:read', 'c2-events': 'c2:read', 'c2-profiles': 'c2:read', mcp: 'mcp:read', 'mcp-monitor': 'monitor:read', 'mcp-management': 'mcp:read', knowledge: 'knowledge:read', 'knowledge-retrieval-logs': 'knowledge:read', 'knowledge-management': 'knowledge:read', skills: 'skills:read', 'skills-monitor': 'skills:read', 'skills-management': 'skills:read', agents: 'agents:read', 'agents-management': 'agents:read', roles: 'roles:read', 'roles-management': 'roles:read', 'platform-rbac': 'rbac:read', settings: 'config:read', }; function hasPermission(permission) { return !permission || authPermissions.has(permission); } function hasAnyPermission(permissions) { if (!Array.isArray(permissions) || !permissions.length) return true; return permissions.some((permission) => hasPermission(permission)); } async function readApiError(response, fallback) { if (!response) { return fallback || authT('auth.requestFailed', '请求失败'); } try { const body = await response.clone().json(); return body.error || body.message || fallback || authT('auth.requestFailed', '请求失败'); } catch (error) { return fallback || authT('auth.requestFailed', '请求失败'); } } function notifyApiError(message, type = 'error') { const text = (message || '').trim() || authT('auth.requestFailed', '请求失败'); if (typeof showNotification === 'function') { showNotification(text, type); return; } if (typeof showToast === 'function') { showToast(text, type); return; } alert(text); } async function notifyApiResponseError(response, fallback) { notifyApiError(await readApiError(response, fallback)); } async function ensureApiOk(response, fallback) { if (response && response.ok) return true; await notifyApiResponseError(response, fallback); return false; } function requirePermission(permission, customMessage) { const allowed = Array.isArray(permission) ? hasAnyPermission(permission) : hasPermission(permission); if (allowed) return true; notifyApiError(customMessage || authT('auth.forbidden', '权限不足')); return false; } function permissionAllowedForElement(el) { if (!el) return true; const anyOf = el.getAttribute('data-require-permission-any'); const permission = el.getAttribute('data-require-permission'); if (anyOf) { return hasAnyPermission(anyOf.split(/[\s,|]+/).map((item) => item.trim()).filter(Boolean)); } if (permission) { return hasPermission(permission); } return true; } function applyPermissionElement(el) { const anyOf = el.getAttribute('data-require-permission-any'); const permission = el.getAttribute('data-require-permission'); if (!anyOf && !permission) return; const allowed = permissionAllowedForElement(el); el.hidden = !allowed; el.classList.toggle('rbac-permission-denied', !allowed); if ('disabled' in el) { el.disabled = !allowed; } el.setAttribute('aria-hidden', allowed ? 'false' : 'true'); el.setAttribute('aria-disabled', allowed ? 'false' : 'true'); } let permissionClickGuardInstalled = false; function installPermissionClickGuard() { if (permissionClickGuardInstalled) return; permissionClickGuardInstalled = true; document.addEventListener('click', (event) => { const target = event.target instanceof Element ? event.target.closest('[data-require-permission], [data-require-permission-any]') : null; if (!target || permissionAllowedForElement(target)) return; event.preventDefault(); event.stopPropagation(); if (typeof event.stopImmediatePropagation === 'function') { event.stopImmediatePropagation(); } notifyApiError(authT('auth.forbidden', '权限不足')); }, true); } function applyRBACToUI(root) { installPermissionClickGuard(); document.querySelectorAll('[data-page]').forEach((el) => { const page = el.getAttribute('data-page'); const permission = PAGE_PERMISSION_MAP[page]; if (!permission) return; const allowed = hasPermission(permission); el.hidden = !allowed; el.setAttribute('aria-hidden', allowed ? 'false' : 'true'); }); const permissionRoot = root instanceof Element ? root : document; permissionRoot.querySelectorAll('[data-require-permission], [data-require-permission-any]').forEach(applyPermissionElement); if (permissionRoot instanceof Element && permissionRoot.matches('[data-require-permission], [data-require-permission-any]')) { applyPermissionElement(permissionRoot); } const userAvatar = document.querySelector('.user-avatar-btn'); if (userAvatar && authUser && authUser.username) { const displayName = getAuthDisplayName(); userAvatar.setAttribute('title', displayName); userAvatar.setAttribute('aria-label', authT('header.userMenuFor', '用户菜单:{{name}}', { name: displayName })); } renderUserMenuProfile(); } function authT(key, fallback, opts = {}) { if (typeof window !== 'undefined' && typeof window.t === 'function') { const translated = window.t(key, opts); if (translated && translated !== key) { return translated; } } return fallback.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_, name) { return Object.prototype.hasOwnProperty.call(opts, name) ? String(opts[name]) : ''; }); } function getAuthDisplayName() { if (!authUser) { return authT('header.unknownUser', '未知用户'); } return String(authUser.display_name || authUser.displayName || authUser.username || '').trim() || authT('header.unknownUser', '未知用户'); } function getAuthUsername() { if (!authUser) { return '-'; } const username = String(authUser.username || '').trim(); return username ? `@${username}` : '-'; } function getScopeLabel(scope) { const normalized = String(scope || '').trim().toLowerCase(); const keyMap = { all: 'header.scopeAll', assigned: 'header.scopeAssigned', own: 'header.scopeOwn', }; const fallbackMap = { all: '全部资源', assigned: '指定资源', own: '自己的资源', }; const key = keyMap[normalized] || 'header.scopeUnknown'; const fallback = fallbackMap[normalized] || '资源范围未知'; return authT(key, fallback); } function renderUserMenuProfile() { const displayNameEl = document.getElementById('user-menu-display-name'); const usernameEl = document.getElementById('user-menu-username'); const scopeEl = document.getElementById('user-menu-scope'); const rolesEl = document.getElementById('user-menu-roles'); const permissionsEl = document.getElementById('user-menu-permissions'); const avatarBtn = document.getElementById('user-avatar-btn') || document.querySelector('.user-avatar-btn'); const displayName = getAuthDisplayName(); const roleCount = Array.isArray(authRoles) ? authRoles.length : 0; const permissionCount = authPermissions instanceof Set ? authPermissions.size : 0; if (displayNameEl) displayNameEl.textContent = displayName; if (usernameEl) usernameEl.textContent = getAuthUsername(); if (scopeEl) scopeEl.textContent = getScopeLabel(authScope); if (rolesEl) rolesEl.textContent = authT('header.rolesCount', '{{count}} 个角色', { count: roleCount }); if (permissionsEl) permissionsEl.textContent = authT('header.permissionsCount', '{{count}} 项权限', { count: permissionCount }); if (avatarBtn && authUser) { avatarBtn.setAttribute('title', displayName); avatarBtn.setAttribute('aria-label', authT('header.userMenuFor', '用户菜单:{{name}}', { name: displayName })); } else if (avatarBtn) { avatarBtn.setAttribute('aria-label', authT('header.userMenu', '用户菜单')); } } function setUserMenuOpen(open) { const dropdown = document.getElementById('user-menu-dropdown'); const avatarBtn = document.getElementById('user-avatar-btn') || document.querySelector('.user-avatar-btn'); if (!dropdown) return; dropdown.style.display = open ? 'block' : 'none'; if (avatarBtn) { avatarBtn.classList.toggle('active', open); avatarBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); } if (open) { renderUserMenuProfile(); } } // 通用工具函数 function getStatusText(status) { const s = (status && String(status).toLowerCase()) || ''; if (typeof window.t !== 'function') { const fallback = { pending: '等待中', running: '执行中', completed: '已完成', failed: '失败', cancelled: '已终止' }; return fallback[s] || status; } const keyMap = { pending: 'mcpDetailModal.statusPending', running: 'mcpDetailModal.statusRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed', cancelled: 'mcpDetailModal.statusCancelled' }; const key = keyMap[s]; return key ? window.t(key) : status; } function formatDuration(ms) { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); if (hours > 0) { return `${hours}小时${minutes % 60}分钟`; } else if (minutes > 0) { return `${minutes}分钟${seconds % 60}秒`; } else { return `${seconds}秒`; } } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** @param {string} text @param {{ profile?: 'chat'|'timeline' }} [options] */ function formatMarkdown(text, options) { if (typeof window.csMarkdownSanitize !== 'undefined') { return window.csMarkdownSanitize.formatMarkdownToHtml(text, options); } const raw = text == null ? '' : String(text); return escapeHtml(raw).replace(/\n/g, '
'); } function setupLoginUI() { installPermissionClickGuard(); const loginForm = document.getElementById('login-form'); if (loginForm) { loginForm.addEventListener('submit', submitLogin); } } async function initializeApp() { setupLoginUI(); const hasStoredAuth = loadAuthFromStorage(); if (hasStoredAuth && isTokenValid()) { try { const response = await apiFetch('/api/auth/validate', { method: 'GET', }); if (response.ok) { const result = await response.json().catch(() => ({})); saveAuth(result.token || authToken, result.expires_at || authTokenExpiry, { user: result.user || authUser, roles: result.roles || authRoles, permissions: result.permissions || Array.from(authPermissions), scope: result.scope || authScope, }); hideLoginOverlay(); applyRBACToUI(); resolveAuthPromises(true); await bootstrapApp(); return; } } catch (error) { console.warn('本地会话已失效,需重新登录'); } } clearAuthStorage(); showLoginOverlay(); } // 用户菜单控制 function toggleUserMenu() { const dropdown = document.getElementById('user-menu-dropdown'); if (!dropdown) return; const isVisible = dropdown.style.display !== 'none'; setUserMenuOpen(!isVisible); } // 点击页面其他地方时关闭下拉菜单 document.addEventListener('click', function(event) { const dropdown = document.getElementById('user-menu-dropdown'); const avatarBtn = document.querySelector('.user-avatar-btn'); if (dropdown && avatarBtn && !dropdown.contains(event.target) && !avatarBtn.contains(event.target)) { setUserMenuOpen(false); } }); document.addEventListener('languagechange', function () { renderUserMenuProfile(); }); // 退出登录 async function logout() { // 关闭下拉菜单 setUserMenuOpen(false); try { // 先尝试调用退出API(如果token有效) if (authToken) { const headers = new Headers(); headers.set('Authorization', `Bearer ${authToken}`); await fetch('/api/auth/logout', { method: 'POST', headers: headers, }).catch(() => { // 忽略错误,继续清除本地认证信息 }); } } catch (error) { console.error('退出登录API调用失败:', error); } finally { // 无论如何都清除本地认证信息 clearAuthStorage(); hideLoginOverlay(); showLoginOverlay(typeof window.t === 'function' ? window.t('auth.loggedOut') : '已退出登录'); } } // 导出函数供HTML使用 window.toggleUserMenu = toggleUserMenu; window.logout = logout; window.hasPermission = hasPermission; window.hasAnyPermission = hasAnyPermission; window.readApiError = readApiError; window.notifyApiError = notifyApiError; window.notifyApiResponseError = notifyApiResponseError; window.ensureApiOk = ensureApiOk; window.requirePermission = requirePermission; window.permissionAllowedForElement = permissionAllowedForElement; window.applyRBACToUI = applyRBACToUI; function rbacAfterDynamicRender(root) { applyRBACToUI(root); } window.rbacAfterDynamicRender = rbacAfterDynamicRender; document.addEventListener('DOMContentLoaded', initializeApp);