Files
CyberStrikeAI/web/static/js/auth.js
T
2026-07-10 21:19:55 +08:00

933 lines
34 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;
let robotBindingCountdownTimer = null;
let robotBindingExpiresAt = 0;
let robotBindingLifetimeMs = 5 * 60 * 1000;
let activeRobotBindingCode = '';
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 openRobotAccountBinding() {
setUserMenuOpen(false);
openAppModal('robot-account-binding-modal');
if (robotBindingExpiresAt) updateRobotBindingCountdown();
await loadRobotAccountBindings();
}
function closeRobotAccountBinding() {
closeAppModal('robot-account-binding-modal');
}
async function generateRobotBindingCode() {
const generateBtn = document.getElementById('robot-binding-generate-btn');
if (generateBtn) {
generateBtn.disabled = true;
generateBtn.textContent = '正在生成…';
}
let response;
let data;
try {
response = await apiFetch('/api/auth/robot-binding-code', { method: 'POST' });
data = await response.json().catch(() => ({}));
} catch (error) {
if (typeof showNotification === 'function') showNotification('生成绑定码失败,请检查网络连接', 'error');
resetRobotBindingGenerateButton();
return;
}
if (!response.ok || !data.code) {
if (typeof showNotification === 'function') showNotification(data.error || '生成绑定码失败', 'error');
resetRobotBindingGenerateButton();
return;
}
const codeEl = document.getElementById('robot-binding-code');
const copyBtn = document.getElementById('robot-binding-copy-btn');
const card = document.getElementById('robot-binding-code-card');
const timer = document.getElementById('robot-binding-timer');
const state = document.getElementById('robot-binding-code-state');
activeRobotBindingCode = data.code;
robotBindingLifetimeMs = Math.max(1000, Number(data.expires_in_seconds || 300) * 1000);
// Use the server-provided duration instead of comparing wall clocks, so a
// client machine with clock skew still gets an accurate countdown.
robotBindingExpiresAt = Date.now() + robotBindingLifetimeMs;
if (codeEl) codeEl.textContent = data.code;
if (copyBtn) copyBtn.disabled = false;
if (card) card.className = 'robot-binding-code-card is-active';
if (timer) timer.hidden = false;
if (state) state.textContent = '等待绑定';
if (generateBtn) {
generateBtn.disabled = false;
generateBtn.textContent = '重新生成';
}
startRobotBindingCountdown();
}
function resetRobotBindingGenerateButton() {
const generateBtn = document.getElementById('robot-binding-generate-btn');
if (generateBtn) {
generateBtn.disabled = false;
generateBtn.textContent = activeRobotBindingCode ? '重新生成' : '生成绑定码';
}
}
function startRobotBindingCountdown() {
if (robotBindingCountdownTimer) clearInterval(robotBindingCountdownTimer);
updateRobotBindingCountdown();
robotBindingCountdownTimer = setInterval(updateRobotBindingCountdown, 250);
}
function updateRobotBindingCountdown() {
if (!robotBindingExpiresAt) return;
const remaining = Math.max(0, robotBindingExpiresAt - Date.now());
const seconds = Math.ceil(remaining / 1000);
const minutesPart = String(Math.floor(seconds / 60)).padStart(2, '0');
const secondsPart = String(seconds % 60).padStart(2, '0');
const countdown = document.getElementById('robot-binding-countdown');
const progress = document.getElementById('robot-binding-progress-bar');
const expiry = document.getElementById('robot-binding-expiry');
if (countdown) countdown.textContent = `${minutesPart}:${secondsPart}`;
if (progress) progress.style.width = `${Math.max(0, Math.min(100, remaining / robotBindingLifetimeMs * 100))}%`;
if (expiry && activeRobotBindingCode) expiry.textContent = `请发送:绑定 ${activeRobotBindingCode}`;
if (remaining <= 0) expireRobotBindingCode();
}
function expireRobotBindingCode() {
if (robotBindingCountdownTimer) clearInterval(robotBindingCountdownTimer);
robotBindingCountdownTimer = null;
robotBindingExpiresAt = 0;
activeRobotBindingCode = '';
const card = document.getElementById('robot-binding-code-card');
const codeEl = document.getElementById('robot-binding-code');
const state = document.getElementById('robot-binding-code-state');
const expiry = document.getElementById('robot-binding-expiry');
const countdown = document.getElementById('robot-binding-countdown');
const progress = document.getElementById('robot-binding-progress-bar');
const copyBtn = document.getElementById('robot-binding-copy-btn');
if (card) card.className = 'robot-binding-code-card is-expired';
if (codeEl) codeEl.textContent = '已失效';
if (state) state.textContent = '已过期';
if (expiry) expiry.textContent = '绑定码已过期,请重新生成';
if (countdown) countdown.textContent = '00:00';
if (progress) progress.style.width = '0%';
if (copyBtn) copyBtn.disabled = true;
resetRobotBindingGenerateButton();
const generateBtn = document.getElementById('robot-binding-generate-btn');
if (generateBtn) generateBtn.textContent = '重新生成';
loadRobotAccountBindings();
}
async function copyRobotBindingCode() {
if (!activeRobotBindingCode || !robotBindingExpiresAt || robotBindingExpiresAt <= Date.now()) return;
const command = `绑定 ${activeRobotBindingCode}`;
try {
await navigator.clipboard.writeText(command);
} catch (_) {
const textarea = document.createElement('textarea');
textarea.value = command;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
const copyBtn = document.getElementById('robot-binding-copy-btn');
const label = copyBtn?.querySelector('span');
if (label) label.textContent = '已复制';
setTimeout(() => { if (label) label.textContent = '复制命令'; }, 1400);
if (typeof showNotification === 'function') showNotification('绑定命令已复制', 'success');
}
async function loadRobotAccountBindings() {
const list = document.getElementById('robot-account-binding-list');
if (!list) return;
const response = await apiFetch('/api/auth/robot-bindings');
const data = await response.json().catch(() => ({}));
if (!response.ok) {
list.textContent = data.error || '获取绑定失败';
return;
}
const bindings = Array.isArray(data.bindings) ? data.bindings : [];
if (!bindings.length) {
list.innerHTML = `<div class="robot-binding-empty-state">
<span class="robot-binding-empty-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" stroke-width="2"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" stroke-width="2"/></svg></span>
<div><strong>暂无绑定账号</strong><p>生成绑定码并在机器人中发送,即可完成首次绑定。</p></div>
</div>`;
return;
}
const platformLabels = { wechat: '微信', wecom: '企业微信', dingtalk: '钉钉', lark: '飞书', telegram: 'Telegram', slack: 'Slack', discord: 'Discord', qq: 'QQ' };
list.innerHTML = bindings.map(binding => `
<div class="robot-binding-account-card">
<span class="robot-binding-platform-icon">${escapeHtml((platformLabels[binding.platform] || binding.platform || '?').slice(0, 1).toUpperCase())}</span>
<div class="robot-binding-account-main">
<div class="robot-binding-account-name"><strong>${escapeHtml(platformLabels[binding.platform] || binding.platform || '-')}</strong><span>已连接</span></div>
<small>账号标识 ${escapeHtml(binding.external_user_hint || '-')} · 更新于 ${escapeHtml(formatRobotBindingTime(binding.updated_at))}</small>
</div>
<button type="button" class="btn-secondary btn-small robot-binding-unbind-btn" onclick="deleteRobotAccountBinding('${escapeHtml(binding.id || '')}')">解除绑定</button>
</div>`).join('');
}
function formatRobotBindingTime(value) {
const date = new Date(value || '');
if (Number.isNaN(date.getTime())) return '未知时间';
return date.toLocaleString([], { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
async function deleteRobotAccountBinding(id) {
if (!id || !window.confirm('确定解除该机器人账号绑定吗?')) return;
const response = await apiFetch(`/api/auth/robot-bindings/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!response.ok) {
const data = await response.json().catch(() => ({}));
if (typeof showNotification === 'function') showNotification(data.error || '解绑失败', 'error');
return;
}
await loadRobotAccountBindings();
}
// 退出登录
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.openRobotAccountBinding = openRobotAccountBinding;
window.closeRobotAccountBinding = closeRobotAccountBinding;
window.generateRobotBindingCode = generateRobotBindingCode;
window.copyRobotBindingCode = copyRobotBindingCode;
window.deleteRobotAccountBinding = deleteRobotAccountBinding;
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);