diff --git a/PROMPT.md b/PROMPT.md index b6b2ee8..cd4bf8e 100644 --- a/PROMPT.md +++ b/PROMPT.md @@ -404,6 +404,78 @@ pub mod http_client; --- +## Security Architecture - MANDATORY + +### Centralized Auth Engine + +All authentication is handled by `security-bootstrap.js` which MUST be loaded immediately after HTMX in the `
` section. This provides: + +1. **Automatic HTMX auth headers** - All `hx-get`, `hx-post`, etc. requests get Authorization header +2. **Fetch API interception** - All `fetch()` calls automatically get auth headers +3. **XMLHttpRequest interception** - Legacy XHR calls also get auth headers +4. **Session management** - Handles token storage, refresh, and expiration + +### Script Loading Order (CRITICAL) + +```html + + + + + + + + + + + +``` + +### DO NOT Duplicate Auth Logic + +```javascript +// ❌ WRONG - Don't add auth headers manually +fetch("/api/data", { + headers: { "Authorization": "Bearer " + token } +}); + +// ✅ CORRECT - Let security-bootstrap.js handle it +fetch("/api/data"); +``` + +### DO NOT Register Multiple HTMX Auth Listeners + +```javascript +// ❌ WRONG - Don't register duplicate listeners +document.addEventListener("htmx:configRequest", (e) => { + e.detail.headers["Authorization"] = "Bearer " + token; +}); + +// ✅ CORRECT - This is handled by security-bootstrap.js automatically +``` + +### Auth Events + +The security engine dispatches these events: + +- `gb:security:ready` - Security bootstrap initialized +- `gb:auth:unauthorized` - 401 response received +- `gb:auth:expired` - Session expired, user should re-login +- `gb:auth:login` - Dispatch to store tokens after login +- `gb:auth:logout` - Dispatch to clear tokens + +### Token Storage Keys + +All auth data uses these keys (defined in security-bootstrap.js): + +- `gb-access-token` - JWT access token +- `gb-refresh-token` - Refresh token +- `gb-session-id` - Session identifier +- `gb-token-expires` - Token expiration timestamp +- `gb-user-data` - Cached user profile + +--- + ## HTMX Patterns ### Server-Side Rendering diff --git a/ui/suite/auth/login.html b/ui/suite/auth/login.html index 61cd3eb..c77640d 100644 --- a/ui/suite/auth/login.html +++ b/ui/suite/auth/login.html @@ -1235,19 +1235,14 @@ } // Save token before redirect + // ALWAYS use localStorage - sessionStorage doesn't persist across redirects properly if (response.access_token) { - const rememberCheckbox = - document.getElementById("remember"); - const storage = - rememberCheckbox && rememberCheckbox.checked - ? localStorage - : sessionStorage; - storage.setItem( + localStorage.setItem( "gb-access-token", response.access_token, ); if (response.refresh_token) { - storage.setItem( + localStorage.setItem( "gb-refresh-token", response.refresh_token, ); @@ -1255,14 +1250,15 @@ if (response.expires_in) { const expiresAt = Date.now() + response.expires_in * 1000; - storage.setItem( + localStorage.setItem( "gb-token-expires", expiresAt.toString(), ); } console.log( - "Token saved:", - response.access_token, + "[LOGIN] Token saved to localStorage:", + response.access_token.substring(0, 20) + + "...", ); } diff --git a/ui/suite/index.html b/ui/suite/index.html index cb6c37b..849831c 100644 --- a/ui/suite/index.html +++ b/ui/suite/index.html @@ -44,6 +44,10 @@ + + + + @@ -2358,7 +2362,7 @@ - + diff --git a/ui/suite/js/auth-service.js b/ui/suite/js/auth-service.js index 83d74b5..ce81597 100644 --- a/ui/suite/js/auth-service.js +++ b/ui/suite/js/auth-service.js @@ -4,698 +4,703 @@ */ (function (window) { - 'use strict'; + "use strict"; - const AUTH_STORAGE_KEYS = { - ACCESS_TOKEN: 'gb-access-token', - REFRESH_TOKEN: 'gb-refresh-token', - TOKEN_EXPIRES: 'gb-token-expires', - USER_DATA: 'gb-user-data', - REMEMBER_ME: 'gb-remember-me' - }; + const AUTH_STORAGE_KEYS = { + ACCESS_TOKEN: "gb-access-token", + REFRESH_TOKEN: "gb-refresh-token", + TOKEN_EXPIRES: "gb-token-expires", + USER_DATA: "gb-user-data", + REMEMBER_ME: "gb-remember-me", + }; - const AUTH_ENDPOINTS = { - LOGIN: '/api/auth/login', - LOGOUT: '/api/auth/logout', - REFRESH: '/api/auth/refresh', - CURRENT_USER: '/api/auth/me', - VERIFY_2FA: '/api/auth/2fa/verify', - RESEND_2FA: '/api/auth/2fa/resend' - }; + const AUTH_ENDPOINTS = { + LOGIN: "/api/auth/login", + LOGOUT: "/api/auth/logout", + REFRESH: "/api/auth/refresh", + CURRENT_USER: "/api/auth/me", + VERIFY_2FA: "/api/auth/2fa/verify", + RESEND_2FA: "/api/auth/2fa/resend", + }; - const USER_ENDPOINTS = { - LIST: '/api/directory/users/list', - CREATE: '/api/directory/users/create', - UPDATE: '/api/directory/users/:user_id/update', - DELETE: '/api/directory/users/:user_id/delete', - PROFILE: '/api/directory/users/:user_id/profile', - ROLES: '/api/directory/users/:user_id/roles' - }; + const USER_ENDPOINTS = { + LIST: "/api/directory/users/list", + CREATE: "/api/directory/users/create", + UPDATE: "/api/directory/users/:user_id/update", + DELETE: "/api/directory/users/:user_id/delete", + PROFILE: "/api/directory/users/:user_id/profile", + ROLES: "/api/directory/users/:user_id/roles", + }; - const GROUP_ENDPOINTS = { - LIST: '/api/directory/groups/list', - CREATE: '/api/directory/groups/create', - UPDATE: '/api/directory/groups/:group_id/update', - DELETE: '/api/directory/groups/:group_id/delete', - MEMBERS: '/api/directory/groups/:group_id/members', - ADD_MEMBER: '/api/directory/groups/:group_id/members/add', - REMOVE_MEMBER: '/api/directory/groups/:group_id/members/remove' - }; + const GROUP_ENDPOINTS = { + LIST: "/api/directory/groups/list", + CREATE: "/api/directory/groups/create", + UPDATE: "/api/directory/groups/:group_id/update", + DELETE: "/api/directory/groups/:group_id/delete", + MEMBERS: "/api/directory/groups/:group_id/members", + ADD_MEMBER: "/api/directory/groups/:group_id/members/add", + REMOVE_MEMBER: "/api/directory/groups/:group_id/members/remove", + }; - class AuthService { - constructor() { - this.currentUser = null; - this.tokenRefreshTimer = null; - this.eventListeners = {}; - this.init(); + class AuthService { + constructor() { + this.currentUser = null; + this.tokenRefreshTimer = null; + this.eventListeners = {}; + this.init(); + } + + init() { + this.loadStoredUser(); + this.setupTokenRefresh(); + // NOTE: Interceptors are now handled centrally by security-bootstrap.js + // No need to set up duplicate fetch interceptors here + } + + loadStoredUser() { + try { + const userData = localStorage.getItem(AUTH_STORAGE_KEYS.USER_DATA); + if (userData) { + this.currentUser = JSON.parse(userData); + } + } catch (e) { + console.warn("Failed to load stored user data:", e); + this.clearAuth(); + } + } + + setupTokenRefresh() { + const expiresAt = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN_EXPIRES); + if (expiresAt) { + const expiresMs = parseInt(expiresAt, 10) - Date.now(); + const refreshMs = expiresMs - 5 * 60 * 1000; + + if (refreshMs > 0) { + this.tokenRefreshTimer = setTimeout(() => { + this.refreshToken(); + }, refreshMs); + } else if (expiresMs > 0) { + this.refreshToken(); + } else { + console.log( + "[AuthService] Token already expired at startup, clearing auth (no redirect)", + ); + this.clearAuth(); + } + } + } + + // NOTE: setupInterceptors is deprecated - auth headers are now handled + // centrally by security-bootstrap.js which loads before any app code. + // This ensures ALL fetch, XHR, and HTMX requests get auth headers automatically. + setupInterceptors() { + // Interceptors handled by security-bootstrap.js + console.log("[AuthService] Fetch interceptors delegated to GBSecurity"); + } + + async login(email, password, remember) { + try { + const response = await fetch(AUTH_ENDPOINTS.LOGIN, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: email, + password: password, + remember: remember || false, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Login failed"); } - init() { - this.loadStoredUser(); - this.setupTokenRefresh(); - this.setupInterceptors(); + if (data.requires_2fa) { + return { + success: false, + requires_2fa: true, + session_token: data.session_token, + }; } - loadStoredUser() { - try { - const userData = localStorage.getItem(AUTH_STORAGE_KEYS.USER_DATA); - if (userData) { - this.currentUser = JSON.parse(userData); - } - } catch (e) { - console.warn('Failed to load stored user data:', e); - this.clearAuth(); - } + if (data.success && data.access_token) { + this.storeTokens(data, remember); + await this.fetchCurrentUser(); + this.emit("login", this.currentUser); + return { + success: true, + redirect: data.redirect || "/", + }; } - setupTokenRefresh() { - const expiresAt = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN_EXPIRES); - if (expiresAt) { - const expiresMs = parseInt(expiresAt, 10) - Date.now(); - const refreshMs = expiresMs - (5 * 60 * 1000); + throw new Error(data.message || "Login failed"); + } catch (error) { + console.error("Login error:", error); + throw error; + } + } - if (refreshMs > 0) { - this.tokenRefreshTimer = setTimeout(() => { - this.refreshToken(); - }, refreshMs); - } else if (expiresMs > 0) { - this.refreshToken(); - } else { - this.handleTokenExpired(); - } - } + async verify2FA(sessionToken, code, trustDevice) { + try { + const response = await fetch(AUTH_ENDPOINTS.VERIFY_2FA, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + session_token: sessionToken, + code: code, + trust_device: trustDevice || false, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "2FA verification failed"); } - setupInterceptors() { - const originalFetch = window.fetch; - const self = this; - - window.fetch = async function (url, options) { - options = options || {}; - options.headers = options.headers || {}; - - const token = self.getAccessToken(); - if (token && !options.headers['Authorization']) { - options.headers['Authorization'] = 'Bearer ' + token; - } - - try { - const response = await originalFetch(url, options); - - if (response.status === 401) { - const refreshed = await self.refreshToken(); - if (refreshed) { - options.headers['Authorization'] = 'Bearer ' + self.getAccessToken(); - return originalFetch(url, options); - } else { - self.handleTokenExpired(); - } - } - - return response; - } catch (error) { - throw error; - } - }; + if (data.success && data.access_token) { + this.storeTokens(data, false); + await this.fetchCurrentUser(); + this.emit("login", this.currentUser); + return { + success: true, + redirect: data.redirect || "/", + }; } - async login(email, password, remember) { - try { - const response = await fetch(AUTH_ENDPOINTS.LOGIN, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - email: email, - password: password, - remember: remember || false - }) - }); + throw new Error(data.message || "2FA verification failed"); + } catch (error) { + console.error("2FA verification error:", error); + throw error; + } + } - const data = await response.json(); + async resend2FA(sessionToken) { + try { + const response = await fetch(AUTH_ENDPOINTS.RESEND_2FA, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + session_token: sessionToken, + }), + }); - if (!response.ok) { - throw new Error(data.error || 'Login failed'); - } + const data = await response.json(); - if (data.requires_2fa) { - return { - success: false, - requires_2fa: true, - session_token: data.session_token - }; - } - - if (data.success && data.access_token) { - this.storeTokens(data, remember); - await this.fetchCurrentUser(); - this.emit('login', this.currentUser); - return { - success: true, - redirect: data.redirect || '/' - }; - } - - throw new Error(data.message || 'Login failed'); - } catch (error) { - console.error('Login error:', error); - throw error; - } + if (!response.ok) { + throw new Error(data.error || "Failed to resend 2FA code"); } - async verify2FA(sessionToken, code, trustDevice) { - try { - const response = await fetch(AUTH_ENDPOINTS.VERIFY_2FA, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - session_token: sessionToken, - code: code, - trust_device: trustDevice || false - }) - }); + return data; + } catch (error) { + console.error("Resend 2FA error:", error); + throw error; + } + } - const data = await response.json(); + async logout() { + try { + const token = this.getAccessToken(); + if (token) { + await fetch(AUTH_ENDPOINTS.LOGOUT, { + method: "POST", + headers: { + Authorization: "Bearer " + token, + "Content-Type": "application/json", + }, + }); + } + } catch (error) { + console.warn("Logout API call failed:", error); + } finally { + this.clearAuth(); + this.emit("logout"); + window.location.href = "/auth/login.html"; + } + } - if (!response.ok) { - throw new Error(data.error || '2FA verification failed'); - } - - if (data.success && data.access_token) { - this.storeTokens(data, false); - await this.fetchCurrentUser(); - this.emit('login', this.currentUser); - return { - success: true, - redirect: data.redirect || '/' - }; - } - - throw new Error(data.message || '2FA verification failed'); - } catch (error) { - console.error('2FA verification error:', error); - throw error; - } + async fetchCurrentUser() { + try { + const token = this.getAccessToken(); + if (!token) { + return null; } - async resend2FA(sessionToken) { - try { - const response = await fetch(AUTH_ENDPOINTS.RESEND_2FA, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - session_token: sessionToken - }) - }); + const response = await fetch(AUTH_ENDPOINTS.CURRENT_USER, { + headers: { + Authorization: "Bearer " + token, + }, + }); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.error || 'Failed to resend 2FA code'); - } - - return data; - } catch (error) { - console.error('Resend 2FA error:', error); - throw error; - } - } - - async logout() { - try { - const token = this.getAccessToken(); - if (token) { - await fetch(AUTH_ENDPOINTS.LOGOUT, { - method: 'POST', - headers: { - 'Authorization': 'Bearer ' + token, - 'Content-Type': 'application/json' - } - }); - } - } catch (error) { - console.warn('Logout API call failed:', error); - } finally { - this.clearAuth(); - this.emit('logout'); - window.location.href = '/auth/login'; - } - } - - async fetchCurrentUser() { - try { - const token = this.getAccessToken(); - if (!token) { - return null; - } - - const response = await fetch(AUTH_ENDPOINTS.CURRENT_USER, { - headers: { - 'Authorization': 'Bearer ' + token - } - }); - - if (!response.ok) { - if (response.status === 401) { - this.handleTokenExpired(); - } - return null; - } - - const userData = await response.json(); - this.currentUser = userData; - localStorage.setItem(AUTH_STORAGE_KEYS.USER_DATA, JSON.stringify(userData)); - this.emit('userUpdated', userData); - return userData; - } catch (error) { - console.error('Failed to fetch current user:', error); - return null; - } - } - - async refreshToken() { - const refreshToken = localStorage.getItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN); - if (!refreshToken) { - return false; - } - - try { - const response = await fetch(AUTH_ENDPOINTS.REFRESH, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - refresh_token: refreshToken - }) - }); - - if (!response.ok) { - this.handleTokenExpired(); - return false; - } - - const data = await response.json(); - if (data.access_token) { - this.storeTokens(data, !!localStorage.getItem(AUTH_STORAGE_KEYS.REMEMBER_ME)); - return true; - } - - return false; - } catch (error) { - console.error('Token refresh failed:', error); - this.handleTokenExpired(); - return false; - } - } - - storeTokens(data, remember) { - const storage = remember ? localStorage : sessionStorage; - - if (data.access_token) { - localStorage.setItem(AUTH_STORAGE_KEYS.ACCESS_TOKEN, data.access_token); - } - - if (data.refresh_token) { - localStorage.setItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN, data.refresh_token); - } - - if (data.expires_in) { - const expiresAt = Date.now() + (data.expires_in * 1000); - localStorage.setItem(AUTH_STORAGE_KEYS.TOKEN_EXPIRES, expiresAt.toString()); - } - - if (remember) { - localStorage.setItem(AUTH_STORAGE_KEYS.REMEMBER_ME, 'true'); - } - - this.setupTokenRefresh(); - } - - clearAuth() { - if (this.tokenRefreshTimer) { - clearTimeout(this.tokenRefreshTimer); - this.tokenRefreshTimer = null; - } - - Object.values(AUTH_STORAGE_KEYS).forEach(key => { - localStorage.removeItem(key); - sessionStorage.removeItem(key); - }); - - this.currentUser = null; - } - - handleTokenExpired() { + if (!response.ok) { + if (response.status === 401) { + console.log( + "[AuthService] fetchCurrentUser got 401, clearing auth (no redirect)", + ); this.clearAuth(); - this.emit('tokenExpired'); - - const currentPath = window.location.pathname; - if (!currentPath.startsWith('/auth/')) { - window.location.href = '/auth/login?expired=1&redirect=' + encodeURIComponent(currentPath); - } + } + return null; } - getAccessToken() { - return localStorage.getItem(AUTH_STORAGE_KEYS.ACCESS_TOKEN) || - sessionStorage.getItem(AUTH_STORAGE_KEYS.ACCESS_TOKEN); - } - - isAuthenticated() { - const token = this.getAccessToken(); - const expiresAt = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN_EXPIRES); - - if (!token) { - return false; - } - - if (expiresAt && Date.now() > parseInt(expiresAt, 10)) { - return false; - } - - return true; - } - - getCurrentUser() { - return this.currentUser; - } - - hasRole(role) { - if (!this.currentUser || !this.currentUser.roles) { - return false; - } - return this.currentUser.roles.includes(role); - } - - hasAnyRole(roles) { - if (!this.currentUser || !this.currentUser.roles) { - return false; - } - return roles.some(role => this.currentUser.roles.includes(role)); - } - - isAdmin() { - return this.hasAnyRole(['admin', 'super_admin', 'superadmin']); - } - - on(event, callback) { - if (!this.eventListeners[event]) { - this.eventListeners[event] = []; - } - this.eventListeners[event].push(callback); - } - - off(event, callback) { - if (!this.eventListeners[event]) { - return; - } - this.eventListeners[event] = this.eventListeners[event].filter(cb => cb !== callback); - } - - emit(event, data) { - if (!this.eventListeners[event]) { - return; - } - this.eventListeners[event].forEach(callback => { - try { - callback(data); - } catch (e) { - console.error('Event listener error:', e); - } - }); - } + const userData = await response.json(); + this.currentUser = userData; + localStorage.setItem( + AUTH_STORAGE_KEYS.USER_DATA, + JSON.stringify(userData), + ); + this.emit("userUpdated", userData); + return userData; + } catch (error) { + console.error("Failed to fetch current user:", error); + return null; + } } - class UserService { - constructor(authService) { - this.authService = authService; + async refreshToken() { + const refreshToken = localStorage.getItem( + AUTH_STORAGE_KEYS.REFRESH_TOKEN, + ); + if (!refreshToken) { + return false; + } + + try { + const response = await fetch(AUTH_ENDPOINTS.REFRESH, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + refresh_token: refreshToken, + }), + }); + + if (!response.ok) { + this.handleTokenExpired(); + return false; } - async listUsers(page, perPage, search) { - const params = new URLSearchParams(); - if (page) params.append('page', page); - if (perPage) params.append('per_page', perPage); - if (search) params.append('search', search); - - const url = USER_ENDPOINTS.LIST + (params.toString() ? '?' + params.toString() : ''); - - const response = await fetch(url); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to list users'); - } - return response.json(); + const data = await response.json(); + if (data.access_token) { + this.storeTokens( + data, + !!localStorage.getItem(AUTH_STORAGE_KEYS.REMEMBER_ME), + ); + return true; } - async createUser(userData) { - const response = await fetch(USER_ENDPOINTS.CREATE, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(userData) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to create user'); - } - return response.json(); - } - - async updateUser(userId, userData) { - const url = USER_ENDPOINTS.UPDATE.replace(':user_id', userId); - const response = await fetch(url, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(userData) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to update user'); - } - return response.json(); - } - - async deleteUser(userId) { - const url = USER_ENDPOINTS.DELETE.replace(':user_id', userId); - const response = await fetch(url, { - method: 'DELETE' - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to delete user'); - } - return response.json(); - } - - async getUserProfile(userId) { - const url = USER_ENDPOINTS.PROFILE.replace(':user_id', userId); - const response = await fetch(url); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to get user profile'); - } - return response.json(); - } - - async getUserRoles(userId) { - const url = USER_ENDPOINTS.ROLES.replace(':user_id', userId); - const response = await fetch(url); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to get user roles'); - } - return response.json(); - } + return false; + } catch (error) { + console.error("Token refresh failed:", error); + this.handleTokenExpired(); + return false; + } } - class GroupService { - constructor(authService) { - this.authService = authService; - } + storeTokens(data, remember) { + const storage = remember ? localStorage : sessionStorage; - async listGroups(page, perPage, search) { - const params = new URLSearchParams(); - if (page) params.append('page', page); - if (perPage) params.append('per_page', perPage); - if (search) params.append('search', search); + if (data.access_token) { + localStorage.setItem(AUTH_STORAGE_KEYS.ACCESS_TOKEN, data.access_token); + } - const url = GROUP_ENDPOINTS.LIST + (params.toString() ? '?' + params.toString() : ''); + if (data.refresh_token) { + localStorage.setItem( + AUTH_STORAGE_KEYS.REFRESH_TOKEN, + data.refresh_token, + ); + } - const response = await fetch(url); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to list groups'); - } - return response.json(); - } + if (data.expires_in) { + const expiresAt = Date.now() + data.expires_in * 1000; + localStorage.setItem( + AUTH_STORAGE_KEYS.TOKEN_EXPIRES, + expiresAt.toString(), + ); + } - async createGroup(groupData) { - const response = await fetch(GROUP_ENDPOINTS.CREATE, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(groupData) - }); + if (remember) { + localStorage.setItem(AUTH_STORAGE_KEYS.REMEMBER_ME, "true"); + } - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to create group'); - } - return response.json(); - } - - async updateGroup(groupId, groupData) { - const url = GROUP_ENDPOINTS.UPDATE.replace(':group_id', groupId); - const response = await fetch(url, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(groupData) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to update group'); - } - return response.json(); - } - - async deleteGroup(groupId) { - const url = GROUP_ENDPOINTS.DELETE.replace(':group_id', groupId); - const response = await fetch(url, { - method: 'DELETE' - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to delete group'); - } - return response.json(); - } - - async getGroupMembers(groupId) { - const url = GROUP_ENDPOINTS.MEMBERS.replace(':group_id', groupId); - const response = await fetch(url); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to get group members'); - } - return response.json(); - } - - async addGroupMember(groupId, userId, roles) { - const url = GROUP_ENDPOINTS.ADD_MEMBER.replace(':group_id', groupId); - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - user_id: userId, - roles: roles || [] - }) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to add group member'); - } - return response.json(); - } - - async removeGroupMember(groupId, userId) { - const url = GROUP_ENDPOINTS.REMOVE_MEMBER.replace(':group_id', groupId); - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - user_id: userId - }) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to remove group member'); - } - return response.json(); - } + this.setupTokenRefresh(); } - const authService = new AuthService(); - const userService = new UserService(authService); - const groupService = new GroupService(authService); + clearAuth() { + if (this.tokenRefreshTimer) { + clearTimeout(this.tokenRefreshTimer); + this.tokenRefreshTimer = null; + } - window.AuthService = authService; - window.UserService = userService; - window.GroupService = groupService; + Object.values(AUTH_STORAGE_KEYS).forEach((key) => { + localStorage.removeItem(key); + sessionStorage.removeItem(key); + }); - window.GBAuth = { - service: authService, - users: userService, - groups: groupService, + this.currentUser = null; + } - login: function (email, password, remember) { - return authService.login(email, password, remember); - }, + handleTokenExpired() { + this.clearAuth(); + this.emit("tokenExpired"); - logout: function () { - return authService.logout(); - }, + const currentPath = window.location.pathname; + if (!currentPath.startsWith("/auth/")) { + window.location.href = + "/auth/login.html?expired=1&redirect=" + + encodeURIComponent(currentPath); + } + } - isAuthenticated: function () { - return authService.isAuthenticated(); - }, + getAccessToken() { + return ( + localStorage.getItem(AUTH_STORAGE_KEYS.ACCESS_TOKEN) || + sessionStorage.getItem(AUTH_STORAGE_KEYS.ACCESS_TOKEN) + ); + } - getCurrentUser: function () { - return authService.getCurrentUser(); - }, + isAuthenticated() { + const token = this.getAccessToken(); + const expiresAt = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN_EXPIRES); - hasRole: function (role) { - return authService.hasRole(role); - }, + if (!token) { + return false; + } - isAdmin: function () { - return authService.isAdmin(); - }, + if (expiresAt && Date.now() > parseInt(expiresAt, 10)) { + return false; + } - on: function (event, callback) { - authService.on(event, callback); - }, + return true; + } - off: function (event, callback) { - authService.off(event, callback); + getCurrentUser() { + return this.currentUser; + } + + hasRole(role) { + if (!this.currentUser || !this.currentUser.roles) { + return false; + } + return this.currentUser.roles.includes(role); + } + + hasAnyRole(roles) { + if (!this.currentUser || !this.currentUser.roles) { + return false; + } + return roles.some((role) => this.currentUser.roles.includes(role)); + } + + isAdmin() { + return this.hasAnyRole(["admin", "super_admin", "superadmin"]); + } + + on(event, callback) { + if (!this.eventListeners[event]) { + this.eventListeners[event] = []; + } + this.eventListeners[event].push(callback); + } + + off(event, callback) { + if (!this.eventListeners[event]) { + return; + } + this.eventListeners[event] = this.eventListeners[event].filter( + (cb) => cb !== callback, + ); + } + + emit(event, data) { + if (!this.eventListeners[event]) { + return; + } + this.eventListeners[event].forEach((callback) => { + try { + callback(data); + } catch (e) { + console.error("Event listener error:", e); } - }; + }); + } + } - document.addEventListener('DOMContentLoaded', function () { - if (authService.isAuthenticated()) { - authService.fetchCurrentUser().then(function (user) { - if (user) { - document.body.classList.add('authenticated'); - if (authService.isAdmin()) { - document.body.classList.add('is-admin'); - } - } - }); + class UserService { + constructor(authService) { + this.authService = authService; + } + + async listUsers(page, perPage, search) { + const params = new URLSearchParams(); + if (page) params.append("page", page); + if (perPage) params.append("per_page", perPage); + if (search) params.append("search", search); + + const url = + USER_ENDPOINTS.LIST + + (params.toString() ? "?" + params.toString() : ""); + + const response = await fetch(url); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to list users"); + } + return response.json(); + } + + async createUser(userData) { + const response = await fetch(USER_ENDPOINTS.CREATE, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(userData), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to create user"); + } + return response.json(); + } + + async updateUser(userId, userData) { + const url = USER_ENDPOINTS.UPDATE.replace(":user_id", userId); + const response = await fetch(url, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(userData), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to update user"); + } + return response.json(); + } + + async deleteUser(userId) { + const url = USER_ENDPOINTS.DELETE.replace(":user_id", userId); + const response = await fetch(url, { + method: "DELETE", + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to delete user"); + } + return response.json(); + } + + async getUserProfile(userId) { + const url = USER_ENDPOINTS.PROFILE.replace(":user_id", userId); + const response = await fetch(url); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to get user profile"); + } + return response.json(); + } + + async getUserRoles(userId) { + const url = USER_ENDPOINTS.ROLES.replace(":user_id", userId); + const response = await fetch(url); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to get user roles"); + } + return response.json(); + } + } + + class GroupService { + constructor(authService) { + this.authService = authService; + } + + async listGroups(page, perPage, search) { + const params = new URLSearchParams(); + if (page) params.append("page", page); + if (perPage) params.append("per_page", perPage); + if (search) params.append("search", search); + + const url = + GROUP_ENDPOINTS.LIST + + (params.toString() ? "?" + params.toString() : ""); + + const response = await fetch(url); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to list groups"); + } + return response.json(); + } + + async createGroup(groupData) { + const response = await fetch(GROUP_ENDPOINTS.CREATE, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(groupData), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to create group"); + } + return response.json(); + } + + async updateGroup(groupId, groupData) { + const url = GROUP_ENDPOINTS.UPDATE.replace(":group_id", groupId); + const response = await fetch(url, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(groupData), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to update group"); + } + return response.json(); + } + + async deleteGroup(groupId) { + const url = GROUP_ENDPOINTS.DELETE.replace(":group_id", groupId); + const response = await fetch(url, { + method: "DELETE", + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to delete group"); + } + return response.json(); + } + + async getGroupMembers(groupId) { + const url = GROUP_ENDPOINTS.MEMBERS.replace(":group_id", groupId); + const response = await fetch(url); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to get group members"); + } + return response.json(); + } + + async addGroupMember(groupId, userId, roles) { + const url = GROUP_ENDPOINTS.ADD_MEMBER.replace(":group_id", groupId); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + user_id: userId, + roles: roles || [], + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to add group member"); + } + return response.json(); + } + + async removeGroupMember(groupId, userId) { + const url = GROUP_ENDPOINTS.REMOVE_MEMBER.replace(":group_id", groupId); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + user_id: userId, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to remove group member"); + } + return response.json(); + } + } + + const authService = new AuthService(); + const userService = new UserService(authService); + const groupService = new GroupService(authService); + + window.AuthService = authService; + window.UserService = userService; + window.GroupService = groupService; + + window.GBAuth = { + service: authService, + users: userService, + groups: groupService, + + login: function (email, password, remember) { + return authService.login(email, password, remember); + }, + + logout: function () { + return authService.logout(); + }, + + isAuthenticated: function () { + return authService.isAuthenticated(); + }, + + getCurrentUser: function () { + return authService.getCurrentUser(); + }, + + hasRole: function (role) { + return authService.hasRole(role); + }, + + isAdmin: function () { + return authService.isAdmin(); + }, + + on: function (event, callback) { + authService.on(event, callback); + }, + + off: function (event, callback) { + authService.off(event, callback); + }, + }; + + document.addEventListener("DOMContentLoaded", function () { + if (authService.isAuthenticated()) { + authService.fetchCurrentUser().then(function (user) { + if (user) { + document.body.classList.add("authenticated"); + if (authService.isAdmin()) { + document.body.classList.add("is-admin"); + } } - }); - + }); + } + }); })(window); diff --git a/ui/suite/js/htmx-app.js b/ui/suite/js/htmx-app.js index 97836f0..982328a 100644 --- a/ui/suite/js/htmx-app.js +++ b/ui/suite/js/htmx-app.js @@ -1,52 +1,8 @@ // HTMX-based application initialization +// NOTE: Auth headers are now handled centrally by security-bootstrap.js (function () { "use strict"; - // ========================================================================= - // CRITICAL: Register auth header listener IMMEDIATELY on document - // This MUST run before any HTMX requests are made - // ========================================================================= - console.log( - "[HTMX-AUTH] Registering htmx:configRequest listener on document", - ); - - document.addEventListener("htmx:configRequest", (event) => { - // Add Authorization header with access token - const accessToken = - localStorage.getItem("gb-access-token") || - sessionStorage.getItem("gb-access-token"); - - console.log( - "[HTMX-AUTH] configRequest for:", - event.detail.path, - "token:", - accessToken ? accessToken.substring(0, 20) + "..." : "NONE", - ); - - if (accessToken) { - event.detail.headers["Authorization"] = `Bearer ${accessToken}`; - console.log("[HTMX-AUTH] Authorization header SET"); - } else { - console.warn( - "[HTMX-AUTH] NO TOKEN FOUND - request will be unauthenticated", - ); - } - - // Add CSRF token if available - const csrfToken = localStorage.getItem("csrf_token"); - if (csrfToken) { - event.detail.headers["X-CSRF-Token"] = csrfToken; - } - - // Add session ID if available - const sessionId = - localStorage.getItem("gb-session-id") || - sessionStorage.getItem("gb-session-id"); - if (sessionId) { - event.detail.headers["X-Session-ID"] = sessionId; - } - }); - // Configuration const config = { wsUrl: "/ws", diff --git a/ui/suite/js/security-bootstrap.js b/ui/suite/js/security-bootstrap.js new file mode 100644 index 0000000..cd0552d --- /dev/null +++ b/ui/suite/js/security-bootstrap.js @@ -0,0 +1,332 @@ +/** + * SECURITY BOOTSTRAP - Centralized Authentication Engine + * + * This file MUST be loaded IMMEDIATELY after HTMX and BEFORE any other scripts. + * It provides a unified security mechanism for ALL apps in the suite. + * + * Features: + * - Automatic Authorization header injection for ALL HTMX requests + * - Fetch API interception for ALL fetch() calls + * - XMLHttpRequest interception for legacy code + * - Token refresh handling + * - Session management + * - Centralized auth state + */ + +(function (window, document) { + "use strict"; + + var AUTH_KEYS = { + ACCESS_TOKEN: "gb-access-token", + REFRESH_TOKEN: "gb-refresh-token", + SESSION_ID: "gb-session-id", + TOKEN_EXPIRES: "gb-token-expires", + USER_DATA: "gb-user-data", + }; + + var GBSecurity = { + initialized: false, + + getToken: function () { + return ( + localStorage.getItem(AUTH_KEYS.ACCESS_TOKEN) || + sessionStorage.getItem(AUTH_KEYS.ACCESS_TOKEN) || + null + ); + }, + + getSessionId: function () { + return ( + localStorage.getItem(AUTH_KEYS.SESSION_ID) || + sessionStorage.getItem(AUTH_KEYS.SESSION_ID) || + null + ); + }, + + getRefreshToken: function () { + return ( + localStorage.getItem(AUTH_KEYS.REFRESH_TOKEN) || + sessionStorage.getItem(AUTH_KEYS.REFRESH_TOKEN) || + null + ); + }, + + isAuthenticated: function () { + var token = this.getToken(); + if (!token) return false; + + var expires = + localStorage.getItem(AUTH_KEYS.TOKEN_EXPIRES) || + sessionStorage.getItem(AUTH_KEYS.TOKEN_EXPIRES); + if (expires && Date.now() > parseInt(expires, 10)) { + return false; + } + return true; + }, + + setTokens: function (accessToken, refreshToken, expiresIn, persistent) { + var storage = persistent ? localStorage : sessionStorage; + if (accessToken) { + storage.setItem(AUTH_KEYS.ACCESS_TOKEN, accessToken); + } + if (refreshToken) { + storage.setItem(AUTH_KEYS.REFRESH_TOKEN, refreshToken); + } + if (expiresIn) { + var expiresAt = Date.now() + expiresIn * 1000; + storage.setItem(AUTH_KEYS.TOKEN_EXPIRES, expiresAt.toString()); + } + }, + + clearTokens: function () { + Object.keys(AUTH_KEYS).forEach(function (key) { + localStorage.removeItem(AUTH_KEYS[key]); + sessionStorage.removeItem(AUTH_KEYS[key]); + }); + }, + + buildAuthHeaders: function (existingHeaders) { + var headers = existingHeaders || {}; + var token = this.getToken(); + var sessionId = this.getSessionId(); + + if (token && !headers["Authorization"]) { + headers["Authorization"] = "Bearer " + token; + } + if (sessionId && !headers["X-Session-ID"]) { + headers["X-Session-ID"] = sessionId; + } + + return headers; + }, + + handleUnauthorized: function (url) { + console.warn("[GBSecurity] Unauthorized response from:", url); + window.dispatchEvent( + new CustomEvent("gb:auth:unauthorized", { + detail: { url: url }, + }), + ); + }, + + init: function () { + if (this.initialized) { + console.warn("[GBSecurity] Already initialized"); + return; + } + + var self = this; + + this.initHTMXInterceptor(); + this.initFetchInterceptor(); + this.initXHRInterceptor(); + this.initAuthEventHandlers(); + + this.initialized = true; + console.log("[GBSecurity] Security bootstrap initialized"); + console.log( + "[GBSecurity] Current token:", + this.getToken() ? this.getToken().substring(0, 20) + "..." : "NONE", + ); + + window.dispatchEvent(new CustomEvent("gb:security:ready")); + }, + + initHTMXInterceptor: function () { + var self = this; + + if (typeof htmx === "undefined") { + console.warn("[GBSecurity] HTMX not found, skipping HTMX interceptor"); + return; + } + + document.addEventListener("htmx:configRequest", function (event) { + var token = self.getToken(); + var sessionId = self.getSessionId(); + + console.log( + "[GBSecurity] htmx:configRequest for:", + event.detail.path, + "token:", + token ? token.substring(0, 20) + "..." : "NONE", + ); + + if (token) { + event.detail.headers["Authorization"] = "Bearer " + token; + console.log("[GBSecurity] Authorization header added"); + } else { + console.warn( + "[GBSecurity] NO TOKEN - request will be unauthenticated", + ); + } + if (sessionId) { + event.detail.headers["X-Session-ID"] = sessionId; + } + }); + + document.addEventListener("htmx:responseError", function (event) { + if (event.detail.xhr && event.detail.xhr.status === 401) { + self.handleUnauthorized(event.detail.pathInfo.requestPath); + } + }); + + console.log("[GBSecurity] HTMX interceptor registered"); + }, + + initFetchInterceptor: function () { + var self = this; + var originalFetch = window.fetch; + + window.fetch = function (input, init) { + var url = typeof input === "string" ? input : input.url; + init = init || {}; + init.headers = init.headers || {}; + + console.log( + "[GBSecurity] fetch intercepted:", + url, + "token:", + self.getToken() ? "EXISTS" : "NONE", + ); + + if (typeof init.headers.entries === "function") { + var headerObj = {}; + init.headers.forEach(function (value, key) { + headerObj[key] = value; + }); + init.headers = headerObj; + } + + if (init.headers instanceof Headers) { + var headerObj = {}; + init.headers.forEach(function (value, key) { + headerObj[key] = value; + }); + init.headers = headerObj; + } + + init.headers = self.buildAuthHeaders(init.headers); + + return originalFetch + .call(window, input, init) + .then(function (response) { + if (response.status === 401) { + var url = typeof input === "string" ? input : input.url; + self.handleUnauthorized(url); + } + return response; + }); + }; + + console.log("[GBSecurity] Fetch interceptor registered"); + }, + + initXHRInterceptor: function () { + var self = this; + var originalOpen = XMLHttpRequest.prototype.open; + var originalSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function ( + method, + url, + async, + user, + password, + ) { + this._gbUrl = url; + this._gbMethod = method; + return originalOpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function (body) { + var xhr = this; + var token = self.getToken(); + var sessionId = self.getSessionId(); + + if (token && !this._gbSkipAuth) { + try { + this.setRequestHeader("Authorization", "Bearer " + token); + } catch (e) {} + } + if (sessionId && !this._gbSkipAuth) { + try { + this.setRequestHeader("X-Session-ID", sessionId); + } catch (e) {} + } + + this.addEventListener("load", function () { + if (xhr.status === 401) { + self.handleUnauthorized(xhr._gbUrl); + } + }); + + return originalSend.apply(this, arguments); + }; + + console.log("[GBSecurity] XHR interceptor registered"); + }, + + initAuthEventHandlers: function () { + var self = this; + + window.addEventListener("gb:auth:unauthorized", function (event) { + var isLoginPage = + window.location.pathname.includes("/auth/") || + window.location.hash.includes("login"); + + var isAuthEndpoint = + event.detail && + event.detail.url && + (event.detail.url.includes("/api/auth/login") || + event.detail.url.includes("/api/auth/refresh")); + + if (isLoginPage || isAuthEndpoint) { + return; + } + + console.log( + "[GBSecurity] Unauthorized response, dispatching expired event", + ); + window.dispatchEvent( + new CustomEvent("gb:auth:expired", { + detail: { url: event.detail.url }, + }), + ); + }); + + window.addEventListener("gb:auth:expired", function (event) { + console.log( + "[GBSecurity] Auth expired, clearing tokens and redirecting", + ); + self.clearTokens(); + + var currentPath = window.location.pathname + window.location.hash; + window.location.href = + "/auth/login.html?expired=1&redirect=" + + encodeURIComponent(currentPath); + }); + + window.addEventListener("gb:auth:login", function (event) { + var data = event.detail; + if (data.accessToken) { + self.setTokens( + data.accessToken, + data.refreshToken, + data.expiresIn, + data.persistent !== false, + ); + console.log("[GBSecurity] Tokens stored after login"); + } + }); + + window.addEventListener("gb:auth:logout", function () { + self.clearTokens(); + console.log("[GBSecurity] Tokens cleared after logout"); + }); + }, + }; + + GBSecurity.init(); + + window.GBSecurity = GBSecurity; +})(window, document); diff --git a/ui/suite/settings/settings.js b/ui/suite/settings/settings.js index e90790d..1dfa007 100644 --- a/ui/suite/settings/settings.js +++ b/ui/suite/settings/settings.js @@ -66,7 +66,9 @@ fetch(API_ENDPOINTS.CURRENT_USER, { headers: { Authorization: "Bearer " + token }, }) - .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (r) { + return r.ok ? r.json() : null; + }) .then(function (user) { if (user) { currentUser = user; @@ -75,7 +77,9 @@ checkAdminAccess(); } }) - .catch(function (e) { console.warn("Failed to load user:", e); }); + .catch(function (e) { + console.warn("Failed to load user:", e); + }); } function updateUserDisplay() { @@ -83,7 +87,8 @@ var displayNameEl = document.getElementById("user-display-name"); var emailEl = document.getElementById("user-email"); if (displayNameEl) { - displayNameEl.textContent = currentUser.display_name || currentUser.username || ""; + displayNameEl.textContent = + currentUser.display_name || currentUser.username || ""; } if (emailEl) { emailEl.textContent = currentUser.email || ""; @@ -103,10 +108,18 @@ var rl = r.toLowerCase(); return rl.indexOf("admin") !== -1 || rl.indexOf("super") !== -1; }); - var adminSections = document.querySelectorAll('[data-admin-only="true"], .admin-only'); - var adminNavItems = document.querySelectorAll('.nav-item[href="#users"], .nav-item[href="#groups"]'); - adminSections.forEach(function (s) { s.style.display = isAdmin ? "" : "none"; }); - adminNavItems.forEach(function (i) { i.style.display = isAdmin ? "" : "none"; }); + var adminSections = document.querySelectorAll( + '[data-admin-only="true"], .admin-only', + ); + var adminNavItems = document.querySelectorAll( + '.nav-item[href="#users"], .nav-item[href="#groups"]', + ); + adminSections.forEach(function (s) { + s.style.display = isAdmin ? "" : "none"; + }); + adminNavItems.forEach(function (i) { + i.style.display = isAdmin ? "" : "none"; + }); if (isAdmin) { loadUsers(); loadGroups(); @@ -117,18 +130,22 @@ document.querySelectorAll(".settings-nav-item").forEach(function (item) { item.addEventListener("click", function (e) { e.preventDefault(); - document.querySelectorAll(".settings-nav-item").forEach(function (i) { i.classList.remove("active"); }); + document.querySelectorAll(".settings-nav-item").forEach(function (i) { + i.classList.remove("active"); + }); this.classList.add("active"); }); }); } function bindToggles() { - document.querySelectorAll(".toggle-switch input").forEach(function (toggle) { - toggle.addEventListener("change", function () { - saveSetting(this.dataset.setting, this.checked); + document + .querySelectorAll(".toggle-switch input") + .forEach(function (toggle) { + toggle.addEventListener("change", function () { + saveSetting(this.dataset.setting, this.checked); + }); }); - }); } function bindThemeSelector() { @@ -188,10 +205,13 @@ var theme = localStorage.getItem(STORAGE_KEYS.THEME); if (theme) { document.body.setAttribute("data-theme", theme); - var themeOption = document.querySelector('.theme-option[data-theme="' + theme + '"]'); + var themeOption = document.querySelector( + '.theme-option[data-theme="' + theme + '"]', + ); if (themeOption) themeOption.classList.add("active"); } - var compactMode = localStorage.getItem(STORAGE_KEYS.COMPACT_MODE) === "true"; + var compactMode = + localStorage.getItem(STORAGE_KEYS.COMPACT_MODE) === "true"; var compactToggle = document.querySelector('[name="compact_mode"]'); if (compactToggle) { compactToggle.checked = compactMode; @@ -206,8 +226,11 @@ } function saveSetting(key, value) { - try { localStorage.setItem(key, JSON.stringify(value)); } - catch (e) { console.warn("Failed to save setting:", key, e); } + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.warn("Failed to save setting:", key, e); + } } function initUserManagement() { @@ -215,7 +238,12 @@ if (addUserBtn) addUserBtn.addEventListener("click", openAddUserDialog); var userSearchInput = document.getElementById("user-search"); if (userSearchInput) { - userSearchInput.addEventListener("input", debounce(function () { loadUsers(this.value); }, 300)); + userSearchInput.addEventListener( + "input", + debounce(function () { + loadUsers(this.value); + }, 300), + ); } var addUserForm = document.getElementById("add-user-form"); if (addUserForm) addUserForm.addEventListener("submit", handleAddUser); @@ -226,7 +254,12 @@ if (addGroupBtn) addGroupBtn.addEventListener("click", openAddGroupDialog); var groupSearchInput = document.getElementById("group-search"); if (groupSearchInput) { - groupSearchInput.addEventListener("input", debounce(function () { loadGroups(this.value); }, 300)); + groupSearchInput.addEventListener( + "input", + debounce(function () { + loadGroups(this.value); + }, 300), + ); } var addGroupForm = document.getElementById("add-group-form"); if (addGroupForm) addGroupForm.addEventListener("submit", handleAddGroup); @@ -235,7 +268,8 @@ function loadUsers(search) { var container = document.getElementById("users-list"); if (!container) return; - container.innerHTML = 'Loading...
Loading...
Failed to load users.
Failed to load users.
No users found
No users found
| User | Organization | Roles | Status | Actions |
|---|
| User | Organization | Roles | Status | Actions | |||
|---|---|---|---|---|---|---|---|
' + initials + ' | ';
+ html +=
+ '' +
+ initials +
+ ' | ";
html += "" + escapeHtml(user.email || "") + " | "; html += "" + escapeHtml(orgId) + " | "; html += "" + escapeHtml(roles) + " | "; - html += '' + (isActive ? 'Active' : 'Inactive') + ' | '; + html += + '' + + (isActive ? "Active" : "Inactive") + + " | "; html += ''; - html += ''; - html += ''; - html += ' |
Loading...
Loading...
Failed to load groups.
Failed to load groups.
No groups found
No groups found
' + escapeHtml(group.description) + '
'; + html += + '' + + escapeHtml(group.description) + + "
"; html += '