mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-13 15:37:26 +02:00
748 lines
25 KiB
JavaScript
748 lines
25 KiB
JavaScript
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, '<br>');
|
|
}
|
|
|
|
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);
|