feat(auth): add security bootstrap and improve auth handling

- Add security-bootstrap.js for centralized auth token management
- Improve login flow with token persistence
- Update htmx-app.js auth integration
- Fix settings and tasks auth handling
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-10 17:32:01 -03:00
parent c6fc5306c6
commit d4dc504d69
8 changed files with 1448 additions and 795 deletions

View file

@ -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 `<head>` 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
<head>
<!-- 1. HTMX must load first -->
<script src="js/vendor/htmx.min.js"></script>
<script src="js/vendor/htmx-ws.js"></script>
<!-- 2. Security bootstrap IMMEDIATELY after HTMX -->
<script src="js/security-bootstrap.js"></script>
<!-- 3. Other scripts can follow -->
<script src="js/api-client.js"></script>
</head>
```
### 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 ## HTMX Patterns
### Server-Side Rendering ### Server-Side Rendering

View file

@ -1235,19 +1235,14 @@
} }
// Save token before redirect // Save token before redirect
// ALWAYS use localStorage - sessionStorage doesn't persist across redirects properly
if (response.access_token) { if (response.access_token) {
const rememberCheckbox = localStorage.setItem(
document.getElementById("remember");
const storage =
rememberCheckbox && rememberCheckbox.checked
? localStorage
: sessionStorage;
storage.setItem(
"gb-access-token", "gb-access-token",
response.access_token, response.access_token,
); );
if (response.refresh_token) { if (response.refresh_token) {
storage.setItem( localStorage.setItem(
"gb-refresh-token", "gb-refresh-token",
response.refresh_token, response.refresh_token,
); );
@ -1255,14 +1250,15 @@
if (response.expires_in) { if (response.expires_in) {
const expiresAt = const expiresAt =
Date.now() + response.expires_in * 1000; Date.now() + response.expires_in * 1000;
storage.setItem( localStorage.setItem(
"gb-token-expires", "gb-token-expires",
expiresAt.toString(), expiresAt.toString(),
); );
} }
console.log( console.log(
"Token saved:", "[LOGIN] Token saved to localStorage:",
response.access_token, response.access_token.substring(0, 20) +
"...",
); );
} }

View file

@ -44,6 +44,10 @@
<script src="js/vendor/htmx-json-enc.js"></script> <script src="js/vendor/htmx-json-enc.js"></script>
<script src="js/vendor/marked.min.js"></script> <script src="js/vendor/marked.min.js"></script>
<!-- SECURITY BOOTSTRAP - MUST load immediately after HTMX -->
<!-- This provides centralized auth for ALL apps: HTMX, fetch, XHR -->
<script src="js/security-bootstrap.js?v=20260110"></script>
<!-- i18n --> <!-- i18n -->
<script src="js/i18n.js"></script> <script src="js/i18n.js"></script>
@ -2358,7 +2362,7 @@
<!-- Sections will be loaded dynamically --> <!-- Sections will be loaded dynamically -->
</main> </main>
<!-- Core scripts --> <!-- Core scripts (auth handled by security-bootstrap.js in head) -->
<script src="js/api-client.js"></script> <script src="js/api-client.js"></script>
<script src="js/theme-manager.js"></script> <script src="js/theme-manager.js"></script>
<script src="js/htmx-app.js"></script> <script src="js/htmx-app.js"></script>

View file

@ -4,42 +4,42 @@
*/ */
(function (window) { (function (window) {
'use strict'; "use strict";
const AUTH_STORAGE_KEYS = { const AUTH_STORAGE_KEYS = {
ACCESS_TOKEN: 'gb-access-token', ACCESS_TOKEN: "gb-access-token",
REFRESH_TOKEN: 'gb-refresh-token', REFRESH_TOKEN: "gb-refresh-token",
TOKEN_EXPIRES: 'gb-token-expires', TOKEN_EXPIRES: "gb-token-expires",
USER_DATA: 'gb-user-data', USER_DATA: "gb-user-data",
REMEMBER_ME: 'gb-remember-me' REMEMBER_ME: "gb-remember-me",
}; };
const AUTH_ENDPOINTS = { const AUTH_ENDPOINTS = {
LOGIN: '/api/auth/login', LOGIN: "/api/auth/login",
LOGOUT: '/api/auth/logout', LOGOUT: "/api/auth/logout",
REFRESH: '/api/auth/refresh', REFRESH: "/api/auth/refresh",
CURRENT_USER: '/api/auth/me', CURRENT_USER: "/api/auth/me",
VERIFY_2FA: '/api/auth/2fa/verify', VERIFY_2FA: "/api/auth/2fa/verify",
RESEND_2FA: '/api/auth/2fa/resend' RESEND_2FA: "/api/auth/2fa/resend",
}; };
const USER_ENDPOINTS = { const USER_ENDPOINTS = {
LIST: '/api/directory/users/list', LIST: "/api/directory/users/list",
CREATE: '/api/directory/users/create', CREATE: "/api/directory/users/create",
UPDATE: '/api/directory/users/:user_id/update', UPDATE: "/api/directory/users/:user_id/update",
DELETE: '/api/directory/users/:user_id/delete', DELETE: "/api/directory/users/:user_id/delete",
PROFILE: '/api/directory/users/:user_id/profile', PROFILE: "/api/directory/users/:user_id/profile",
ROLES: '/api/directory/users/:user_id/roles' ROLES: "/api/directory/users/:user_id/roles",
}; };
const GROUP_ENDPOINTS = { const GROUP_ENDPOINTS = {
LIST: '/api/directory/groups/list', LIST: "/api/directory/groups/list",
CREATE: '/api/directory/groups/create', CREATE: "/api/directory/groups/create",
UPDATE: '/api/directory/groups/:group_id/update', UPDATE: "/api/directory/groups/:group_id/update",
DELETE: '/api/directory/groups/:group_id/delete', DELETE: "/api/directory/groups/:group_id/delete",
MEMBERS: '/api/directory/groups/:group_id/members', MEMBERS: "/api/directory/groups/:group_id/members",
ADD_MEMBER: '/api/directory/groups/:group_id/members/add', ADD_MEMBER: "/api/directory/groups/:group_id/members/add",
REMOVE_MEMBER: '/api/directory/groups/:group_id/members/remove' REMOVE_MEMBER: "/api/directory/groups/:group_id/members/remove",
}; };
class AuthService { class AuthService {
@ -53,7 +53,8 @@
init() { init() {
this.loadStoredUser(); this.loadStoredUser();
this.setupTokenRefresh(); this.setupTokenRefresh();
this.setupInterceptors(); // NOTE: Interceptors are now handled centrally by security-bootstrap.js
// No need to set up duplicate fetch interceptors here
} }
loadStoredUser() { loadStoredUser() {
@ -63,7 +64,7 @@
this.currentUser = JSON.parse(userData); this.currentUser = JSON.parse(userData);
} }
} catch (e) { } catch (e) {
console.warn('Failed to load stored user data:', e); console.warn("Failed to load stored user data:", e);
this.clearAuth(); this.clearAuth();
} }
} }
@ -72,7 +73,7 @@
const expiresAt = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN_EXPIRES); const expiresAt = localStorage.getItem(AUTH_STORAGE_KEYS.TOKEN_EXPIRES);
if (expiresAt) { if (expiresAt) {
const expiresMs = parseInt(expiresAt, 10) - Date.now(); const expiresMs = parseInt(expiresAt, 10) - Date.now();
const refreshMs = expiresMs - (5 * 60 * 1000); const refreshMs = expiresMs - 5 * 60 * 1000;
if (refreshMs > 0) { if (refreshMs > 0) {
this.tokenRefreshTimer = setTimeout(() => { this.tokenRefreshTimer = setTimeout(() => {
@ -81,85 +82,63 @@
} else if (expiresMs > 0) { } else if (expiresMs > 0) {
this.refreshToken(); this.refreshToken();
} else { } else {
this.handleTokenExpired(); 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() { setupInterceptors() {
const originalFetch = window.fetch; // Interceptors handled by security-bootstrap.js
const self = this; console.log("[AuthService] Fetch interceptors delegated to GBSecurity");
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;
}
};
} }
async login(email, password, remember) { async login(email, password, remember) {
try { try {
const response = await fetch(AUTH_ENDPOINTS.LOGIN, { const response = await fetch(AUTH_ENDPOINTS.LOGIN, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
email: email, email: email,
password: password, password: password,
remember: remember || false remember: remember || false,
}) }),
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || 'Login failed'); throw new Error(data.error || "Login failed");
} }
if (data.requires_2fa) { if (data.requires_2fa) {
return { return {
success: false, success: false,
requires_2fa: true, requires_2fa: true,
session_token: data.session_token session_token: data.session_token,
}; };
} }
if (data.success && data.access_token) { if (data.success && data.access_token) {
this.storeTokens(data, remember); this.storeTokens(data, remember);
await this.fetchCurrentUser(); await this.fetchCurrentUser();
this.emit('login', this.currentUser); this.emit("login", this.currentUser);
return { return {
success: true, success: true,
redirect: data.redirect || '/' redirect: data.redirect || "/",
}; };
} }
throw new Error(data.message || 'Login failed'); throw new Error(data.message || "Login failed");
} catch (error) { } catch (error) {
console.error('Login error:', error); console.error("Login error:", error);
throw error; throw error;
} }
} }
@ -167,36 +146,36 @@
async verify2FA(sessionToken, code, trustDevice) { async verify2FA(sessionToken, code, trustDevice) {
try { try {
const response = await fetch(AUTH_ENDPOINTS.VERIFY_2FA, { const response = await fetch(AUTH_ENDPOINTS.VERIFY_2FA, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
session_token: sessionToken, session_token: sessionToken,
code: code, code: code,
trust_device: trustDevice || false trust_device: trustDevice || false,
}) }),
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || '2FA verification failed'); throw new Error(data.error || "2FA verification failed");
} }
if (data.success && data.access_token) { if (data.success && data.access_token) {
this.storeTokens(data, false); this.storeTokens(data, false);
await this.fetchCurrentUser(); await this.fetchCurrentUser();
this.emit('login', this.currentUser); this.emit("login", this.currentUser);
return { return {
success: true, success: true,
redirect: data.redirect || '/' redirect: data.redirect || "/",
}; };
} }
throw new Error(data.message || '2FA verification failed'); throw new Error(data.message || "2FA verification failed");
} catch (error) { } catch (error) {
console.error('2FA verification error:', error); console.error("2FA verification error:", error);
throw error; throw error;
} }
} }
@ -204,24 +183,24 @@
async resend2FA(sessionToken) { async resend2FA(sessionToken) {
try { try {
const response = await fetch(AUTH_ENDPOINTS.RESEND_2FA, { const response = await fetch(AUTH_ENDPOINTS.RESEND_2FA, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
session_token: sessionToken session_token: sessionToken,
}) }),
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || 'Failed to resend 2FA code'); throw new Error(data.error || "Failed to resend 2FA code");
} }
return data; return data;
} catch (error) { } catch (error) {
console.error('Resend 2FA error:', error); console.error("Resend 2FA error:", error);
throw error; throw error;
} }
} }
@ -231,19 +210,19 @@
const token = this.getAccessToken(); const token = this.getAccessToken();
if (token) { if (token) {
await fetch(AUTH_ENDPOINTS.LOGOUT, { await fetch(AUTH_ENDPOINTS.LOGOUT, {
method: 'POST', method: "POST",
headers: { headers: {
'Authorization': 'Bearer ' + token, Authorization: "Bearer " + token,
'Content-Type': 'application/json' "Content-Type": "application/json",
} },
}); });
} }
} catch (error) { } catch (error) {
console.warn('Logout API call failed:', error); console.warn("Logout API call failed:", error);
} finally { } finally {
this.clearAuth(); this.clearAuth();
this.emit('logout'); this.emit("logout");
window.location.href = '/auth/login'; window.location.href = "/auth/login.html";
} }
} }
@ -256,43 +235,51 @@
const response = await fetch(AUTH_ENDPOINTS.CURRENT_USER, { const response = await fetch(AUTH_ENDPOINTS.CURRENT_USER, {
headers: { headers: {
'Authorization': 'Bearer ' + token Authorization: "Bearer " + token,
} },
}); });
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
this.handleTokenExpired(); console.log(
"[AuthService] fetchCurrentUser got 401, clearing auth (no redirect)",
);
this.clearAuth();
} }
return null; return null;
} }
const userData = await response.json(); const userData = await response.json();
this.currentUser = userData; this.currentUser = userData;
localStorage.setItem(AUTH_STORAGE_KEYS.USER_DATA, JSON.stringify(userData)); localStorage.setItem(
this.emit('userUpdated', userData); AUTH_STORAGE_KEYS.USER_DATA,
JSON.stringify(userData),
);
this.emit("userUpdated", userData);
return userData; return userData;
} catch (error) { } catch (error) {
console.error('Failed to fetch current user:', error); console.error("Failed to fetch current user:", error);
return null; return null;
} }
} }
async refreshToken() { async refreshToken() {
const refreshToken = localStorage.getItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN); const refreshToken = localStorage.getItem(
AUTH_STORAGE_KEYS.REFRESH_TOKEN,
);
if (!refreshToken) { if (!refreshToken) {
return false; return false;
} }
try { try {
const response = await fetch(AUTH_ENDPOINTS.REFRESH, { const response = await fetch(AUTH_ENDPOINTS.REFRESH, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
refresh_token: refreshToken refresh_token: refreshToken,
}) }),
}); });
if (!response.ok) { if (!response.ok) {
@ -302,13 +289,16 @@
const data = await response.json(); const data = await response.json();
if (data.access_token) { if (data.access_token) {
this.storeTokens(data, !!localStorage.getItem(AUTH_STORAGE_KEYS.REMEMBER_ME)); this.storeTokens(
data,
!!localStorage.getItem(AUTH_STORAGE_KEYS.REMEMBER_ME),
);
return true; return true;
} }
return false; return false;
} catch (error) { } catch (error) {
console.error('Token refresh failed:', error); console.error("Token refresh failed:", error);
this.handleTokenExpired(); this.handleTokenExpired();
return false; return false;
} }
@ -322,16 +312,22 @@
} }
if (data.refresh_token) { if (data.refresh_token) {
localStorage.setItem(AUTH_STORAGE_KEYS.REFRESH_TOKEN, data.refresh_token); localStorage.setItem(
AUTH_STORAGE_KEYS.REFRESH_TOKEN,
data.refresh_token,
);
} }
if (data.expires_in) { if (data.expires_in) {
const expiresAt = Date.now() + (data.expires_in * 1000); const expiresAt = Date.now() + data.expires_in * 1000;
localStorage.setItem(AUTH_STORAGE_KEYS.TOKEN_EXPIRES, expiresAt.toString()); localStorage.setItem(
AUTH_STORAGE_KEYS.TOKEN_EXPIRES,
expiresAt.toString(),
);
} }
if (remember) { if (remember) {
localStorage.setItem(AUTH_STORAGE_KEYS.REMEMBER_ME, 'true'); localStorage.setItem(AUTH_STORAGE_KEYS.REMEMBER_ME, "true");
} }
this.setupTokenRefresh(); this.setupTokenRefresh();
@ -343,7 +339,7 @@
this.tokenRefreshTimer = null; this.tokenRefreshTimer = null;
} }
Object.values(AUTH_STORAGE_KEYS).forEach(key => { Object.values(AUTH_STORAGE_KEYS).forEach((key) => {
localStorage.removeItem(key); localStorage.removeItem(key);
sessionStorage.removeItem(key); sessionStorage.removeItem(key);
}); });
@ -353,17 +349,21 @@
handleTokenExpired() { handleTokenExpired() {
this.clearAuth(); this.clearAuth();
this.emit('tokenExpired'); this.emit("tokenExpired");
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
if (!currentPath.startsWith('/auth/')) { if (!currentPath.startsWith("/auth/")) {
window.location.href = '/auth/login?expired=1&redirect=' + encodeURIComponent(currentPath); window.location.href =
"/auth/login.html?expired=1&redirect=" +
encodeURIComponent(currentPath);
} }
} }
getAccessToken() { getAccessToken() {
return localStorage.getItem(AUTH_STORAGE_KEYS.ACCESS_TOKEN) || return (
sessionStorage.getItem(AUTH_STORAGE_KEYS.ACCESS_TOKEN); localStorage.getItem(AUTH_STORAGE_KEYS.ACCESS_TOKEN) ||
sessionStorage.getItem(AUTH_STORAGE_KEYS.ACCESS_TOKEN)
);
} }
isAuthenticated() { isAuthenticated() {
@ -396,11 +396,11 @@
if (!this.currentUser || !this.currentUser.roles) { if (!this.currentUser || !this.currentUser.roles) {
return false; return false;
} }
return roles.some(role => this.currentUser.roles.includes(role)); return roles.some((role) => this.currentUser.roles.includes(role));
} }
isAdmin() { isAdmin() {
return this.hasAnyRole(['admin', 'super_admin', 'superadmin']); return this.hasAnyRole(["admin", "super_admin", "superadmin"]);
} }
on(event, callback) { on(event, callback) {
@ -414,18 +414,20 @@
if (!this.eventListeners[event]) { if (!this.eventListeners[event]) {
return; return;
} }
this.eventListeners[event] = this.eventListeners[event].filter(cb => cb !== callback); this.eventListeners[event] = this.eventListeners[event].filter(
(cb) => cb !== callback,
);
} }
emit(event, data) { emit(event, data) {
if (!this.eventListeners[event]) { if (!this.eventListeners[event]) {
return; return;
} }
this.eventListeners[event].forEach(callback => { this.eventListeners[event].forEach((callback) => {
try { try {
callback(data); callback(data);
} catch (e) { } catch (e) {
console.error('Event listener error:', e); console.error("Event listener error:", e);
} }
}); });
} }
@ -438,84 +440,86 @@
async listUsers(page, perPage, search) { async listUsers(page, perPage, search) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (page) params.append('page', page); if (page) params.append("page", page);
if (perPage) params.append('per_page', perPage); if (perPage) params.append("per_page", perPage);
if (search) params.append('search', search); if (search) params.append("search", search);
const url = USER_ENDPOINTS.LIST + (params.toString() ? '?' + params.toString() : ''); const url =
USER_ENDPOINTS.LIST +
(params.toString() ? "?" + params.toString() : "");
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to list users'); throw new Error(error.error || "Failed to list users");
} }
return response.json(); return response.json();
} }
async createUser(userData) { async createUser(userData) {
const response = await fetch(USER_ENDPOINTS.CREATE, { const response = await fetch(USER_ENDPOINTS.CREATE, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify(userData) body: JSON.stringify(userData),
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to create user'); throw new Error(error.error || "Failed to create user");
} }
return response.json(); return response.json();
} }
async updateUser(userId, userData) { async updateUser(userId, userData) {
const url = USER_ENDPOINTS.UPDATE.replace(':user_id', userId); const url = USER_ENDPOINTS.UPDATE.replace(":user_id", userId);
const response = await fetch(url, { const response = await fetch(url, {
method: 'PUT', method: "PUT",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify(userData) body: JSON.stringify(userData),
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to update user'); throw new Error(error.error || "Failed to update user");
} }
return response.json(); return response.json();
} }
async deleteUser(userId) { async deleteUser(userId) {
const url = USER_ENDPOINTS.DELETE.replace(':user_id', userId); const url = USER_ENDPOINTS.DELETE.replace(":user_id", userId);
const response = await fetch(url, { const response = await fetch(url, {
method: 'DELETE' method: "DELETE",
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to delete user'); throw new Error(error.error || "Failed to delete user");
} }
return response.json(); return response.json();
} }
async getUserProfile(userId) { async getUserProfile(userId) {
const url = USER_ENDPOINTS.PROFILE.replace(':user_id', userId); const url = USER_ENDPOINTS.PROFILE.replace(":user_id", userId);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to get user profile'); throw new Error(error.error || "Failed to get user profile");
} }
return response.json(); return response.json();
} }
async getUserRoles(userId) { async getUserRoles(userId) {
const url = USER_ENDPOINTS.ROLES.replace(':user_id', userId); const url = USER_ENDPOINTS.ROLES.replace(":user_id", userId);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to get user roles'); throw new Error(error.error || "Failed to get user roles");
} }
return response.json(); return response.json();
} }
@ -528,112 +532,114 @@
async listGroups(page, perPage, search) { async listGroups(page, perPage, search) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (page) params.append('page', page); if (page) params.append("page", page);
if (perPage) params.append('per_page', perPage); if (perPage) params.append("per_page", perPage);
if (search) params.append('search', search); if (search) params.append("search", search);
const url = GROUP_ENDPOINTS.LIST + (params.toString() ? '?' + params.toString() : ''); const url =
GROUP_ENDPOINTS.LIST +
(params.toString() ? "?" + params.toString() : "");
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to list groups'); throw new Error(error.error || "Failed to list groups");
} }
return response.json(); return response.json();
} }
async createGroup(groupData) { async createGroup(groupData) {
const response = await fetch(GROUP_ENDPOINTS.CREATE, { const response = await fetch(GROUP_ENDPOINTS.CREATE, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify(groupData) body: JSON.stringify(groupData),
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to create group'); throw new Error(error.error || "Failed to create group");
} }
return response.json(); return response.json();
} }
async updateGroup(groupId, groupData) { async updateGroup(groupId, groupData) {
const url = GROUP_ENDPOINTS.UPDATE.replace(':group_id', groupId); const url = GROUP_ENDPOINTS.UPDATE.replace(":group_id", groupId);
const response = await fetch(url, { const response = await fetch(url, {
method: 'PUT', method: "PUT",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify(groupData) body: JSON.stringify(groupData),
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to update group'); throw new Error(error.error || "Failed to update group");
} }
return response.json(); return response.json();
} }
async deleteGroup(groupId) { async deleteGroup(groupId) {
const url = GROUP_ENDPOINTS.DELETE.replace(':group_id', groupId); const url = GROUP_ENDPOINTS.DELETE.replace(":group_id", groupId);
const response = await fetch(url, { const response = await fetch(url, {
method: 'DELETE' method: "DELETE",
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to delete group'); throw new Error(error.error || "Failed to delete group");
} }
return response.json(); return response.json();
} }
async getGroupMembers(groupId) { async getGroupMembers(groupId) {
const url = GROUP_ENDPOINTS.MEMBERS.replace(':group_id', groupId); const url = GROUP_ENDPOINTS.MEMBERS.replace(":group_id", groupId);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to get group members'); throw new Error(error.error || "Failed to get group members");
} }
return response.json(); return response.json();
} }
async addGroupMember(groupId, userId, roles) { async addGroupMember(groupId, userId, roles) {
const url = GROUP_ENDPOINTS.ADD_MEMBER.replace(':group_id', groupId); const url = GROUP_ENDPOINTS.ADD_MEMBER.replace(":group_id", groupId);
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
user_id: userId, user_id: userId,
roles: roles || [] roles: roles || [],
}) }),
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to add group member'); throw new Error(error.error || "Failed to add group member");
} }
return response.json(); return response.json();
} }
async removeGroupMember(groupId, userId) { async removeGroupMember(groupId, userId) {
const url = GROUP_ENDPOINTS.REMOVE_MEMBER.replace(':group_id', groupId); const url = GROUP_ENDPOINTS.REMOVE_MEMBER.replace(":group_id", groupId);
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
user_id: userId user_id: userId,
}) }),
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to remove group member'); throw new Error(error.error || "Failed to remove group member");
} }
return response.json(); return response.json();
} }
@ -682,20 +688,19 @@
off: function (event, callback) { off: function (event, callback) {
authService.off(event, callback); authService.off(event, callback);
} },
}; };
document.addEventListener('DOMContentLoaded', function () { document.addEventListener("DOMContentLoaded", function () {
if (authService.isAuthenticated()) { if (authService.isAuthenticated()) {
authService.fetchCurrentUser().then(function (user) { authService.fetchCurrentUser().then(function (user) {
if (user) { if (user) {
document.body.classList.add('authenticated'); document.body.classList.add("authenticated");
if (authService.isAdmin()) { if (authService.isAdmin()) {
document.body.classList.add('is-admin'); document.body.classList.add("is-admin");
} }
} }
}); });
} }
}); });
})(window); })(window);

View file

@ -1,52 +1,8 @@
// HTMX-based application initialization // HTMX-based application initialization
// NOTE: Auth headers are now handled centrally by security-bootstrap.js
(function () { (function () {
"use strict"; "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 // Configuration
const config = { const config = {
wsUrl: "/ws", wsUrl: "/ws",

View file

@ -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);

View file

@ -66,7 +66,9 @@
fetch(API_ENDPOINTS.CURRENT_USER, { fetch(API_ENDPOINTS.CURRENT_USER, {
headers: { Authorization: "Bearer " + token }, 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) { .then(function (user) {
if (user) { if (user) {
currentUser = user; currentUser = user;
@ -75,7 +77,9 @@
checkAdminAccess(); checkAdminAccess();
} }
}) })
.catch(function (e) { console.warn("Failed to load user:", e); }); .catch(function (e) {
console.warn("Failed to load user:", e);
});
} }
function updateUserDisplay() { function updateUserDisplay() {
@ -83,7 +87,8 @@
var displayNameEl = document.getElementById("user-display-name"); var displayNameEl = document.getElementById("user-display-name");
var emailEl = document.getElementById("user-email"); var emailEl = document.getElementById("user-email");
if (displayNameEl) { if (displayNameEl) {
displayNameEl.textContent = currentUser.display_name || currentUser.username || ""; displayNameEl.textContent =
currentUser.display_name || currentUser.username || "";
} }
if (emailEl) { if (emailEl) {
emailEl.textContent = currentUser.email || ""; emailEl.textContent = currentUser.email || "";
@ -103,10 +108,18 @@
var rl = r.toLowerCase(); var rl = r.toLowerCase();
return rl.indexOf("admin") !== -1 || rl.indexOf("super") !== -1; return rl.indexOf("admin") !== -1 || rl.indexOf("super") !== -1;
}); });
var adminSections = document.querySelectorAll('[data-admin-only="true"], .admin-only'); var adminSections = document.querySelectorAll(
var adminNavItems = document.querySelectorAll('.nav-item[href="#users"], .nav-item[href="#groups"]'); '[data-admin-only="true"], .admin-only',
adminSections.forEach(function (s) { s.style.display = isAdmin ? "" : "none"; }); );
adminNavItems.forEach(function (i) { i.style.display = isAdmin ? "" : "none"; }); 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) { if (isAdmin) {
loadUsers(); loadUsers();
loadGroups(); loadGroups();
@ -117,14 +130,18 @@
document.querySelectorAll(".settings-nav-item").forEach(function (item) { document.querySelectorAll(".settings-nav-item").forEach(function (item) {
item.addEventListener("click", function (e) { item.addEventListener("click", function (e) {
e.preventDefault(); 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"); this.classList.add("active");
}); });
}); });
} }
function bindToggles() { function bindToggles() {
document.querySelectorAll(".toggle-switch input").forEach(function (toggle) { document
.querySelectorAll(".toggle-switch input")
.forEach(function (toggle) {
toggle.addEventListener("change", function () { toggle.addEventListener("change", function () {
saveSetting(this.dataset.setting, this.checked); saveSetting(this.dataset.setting, this.checked);
}); });
@ -188,10 +205,13 @@
var theme = localStorage.getItem(STORAGE_KEYS.THEME); var theme = localStorage.getItem(STORAGE_KEYS.THEME);
if (theme) { if (theme) {
document.body.setAttribute("data-theme", 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"); 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"]'); var compactToggle = document.querySelector('[name="compact_mode"]');
if (compactToggle) { if (compactToggle) {
compactToggle.checked = compactMode; compactToggle.checked = compactMode;
@ -206,8 +226,11 @@
} }
function saveSetting(key, value) { function saveSetting(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } try {
catch (e) { console.warn("Failed to save setting:", key, e); } localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.warn("Failed to save setting:", key, e);
}
} }
function initUserManagement() { function initUserManagement() {
@ -215,7 +238,12 @@
if (addUserBtn) addUserBtn.addEventListener("click", openAddUserDialog); if (addUserBtn) addUserBtn.addEventListener("click", openAddUserDialog);
var userSearchInput = document.getElementById("user-search"); var userSearchInput = document.getElementById("user-search");
if (userSearchInput) { 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"); var addUserForm = document.getElementById("add-user-form");
if (addUserForm) addUserForm.addEventListener("submit", handleAddUser); if (addUserForm) addUserForm.addEventListener("submit", handleAddUser);
@ -226,7 +254,12 @@
if (addGroupBtn) addGroupBtn.addEventListener("click", openAddGroupDialog); if (addGroupBtn) addGroupBtn.addEventListener("click", openAddGroupDialog);
var groupSearchInput = document.getElementById("group-search"); var groupSearchInput = document.getElementById("group-search");
if (groupSearchInput) { 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"); var addGroupForm = document.getElementById("add-group-form");
if (addGroupForm) addGroupForm.addEventListener("submit", handleAddGroup); if (addGroupForm) addGroupForm.addEventListener("submit", handleAddGroup);
@ -235,7 +268,8 @@
function loadUsers(search) { function loadUsers(search) {
var container = document.getElementById("users-list"); var container = document.getElementById("users-list");
if (!container) return; if (!container) return;
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Loading...</p></div>'; container.innerHTML =
'<div class="loading-state"><div class="spinner"></div><p>Loading...</p></div>';
var params = new URLSearchParams(); var params = new URLSearchParams();
params.append("per_page", "50"); params.append("per_page", "50");
if (search) params.append("search", search); if (search) params.append("search", search);
@ -244,33 +278,63 @@
fetch(apiUrl(API_ENDPOINTS.USERS_LIST) + "?" + params.toString(), { fetch(apiUrl(API_ENDPOINTS.USERS_LIST) + "?" + params.toString(), {
headers: { Authorization: "Bearer " + token }, headers: { Authorization: "Bearer " + token },
}) })
.then(function (r) { if (!r.ok) throw new Error("Failed"); return r.json(); }) .then(function (r) {
.then(function (data) { usersData = data.users || []; renderUsers(container); }) if (!r.ok) throw new Error("Failed");
.catch(function () { container.innerHTML = '<div class="error-state"><p>Failed to load users.</p></div>'; }); return r.json();
})
.then(function (data) {
usersData = data.users || [];
renderUsers(container);
})
.catch(function () {
container.innerHTML =
'<div class="error-state"><p>Failed to load users.</p></div>';
});
} }
function renderUsers(container) { function renderUsers(container) {
if (usersData.length === 0) { if (usersData.length === 0) {
container.innerHTML = '<div class="empty-state"><p>No users found</p></div>'; container.innerHTML =
'<div class="empty-state"><p>No users found</p></div>';
return; return;
} }
var html = '<table class="data-table users-table"><thead><tr><th>User</th><th>Email</th><th>Organization</th><th>Roles</th><th>Status</th><th>Actions</th></tr></thead><tbody>'; var html =
'<table class="data-table users-table"><thead><tr><th>User</th><th>Email</th><th>Organization</th><th>Roles</th><th>Status</th><th>Actions</th></tr></thead><tbody>';
usersData.forEach(function (user) { usersData.forEach(function (user) {
var initials = getInitials(user.username || user.email); var initials = getInitials(user.username || user.email);
var displayName = user.display_name || user.username || user.email; var displayName = user.display_name || user.username || user.email;
var isActive = user.state === "active" || user.state === "USER_STATE_ACTIVE"; var isActive =
user.state === "active" || user.state === "USER_STATE_ACTIVE";
var roles = (user.roles || []).join(", ") || "user"; var roles = (user.roles || []).join(", ") || "user";
var orgId = user.organization_id || "-"; var orgId = user.organization_id || "-";
html += "<tr>"; html += "<tr>";
html += '<td class="user-cell"><div class="user-avatar">' + initials + '</div><div class="user-info"><span class="user-name">' + escapeHtml(displayName) + '</span><span class="user-username">@' + escapeHtml(user.username || "") + '</span></div></td>'; html +=
'<td class="user-cell"><div class="user-avatar">' +
initials +
'</div><div class="user-info"><span class="user-name">' +
escapeHtml(displayName) +
'</span><span class="user-username">@' +
escapeHtml(user.username || "") +
"</span></div></td>";
html += "<td>" + escapeHtml(user.email || "") + "</td>"; html += "<td>" + escapeHtml(user.email || "") + "</td>";
html += "<td>" + escapeHtml(orgId) + "</td>"; html += "<td>" + escapeHtml(orgId) + "</td>";
html += "<td>" + escapeHtml(roles) + "</td>"; html += "<td>" + escapeHtml(roles) + "</td>";
html += '<td><span class="status-badge ' + (isActive ? 'status-active' : 'status-inactive') + '">' + (isActive ? 'Active' : 'Inactive') + '</span></td>'; html +=
'<td><span class="status-badge ' +
(isActive ? "status-active" : "status-inactive") +
'">' +
(isActive ? "Active" : "Inactive") +
"</span></td>";
html += '<td class="actions-cell">'; html += '<td class="actions-cell">';
html += '<button class="btn-icon" onclick="SettingsModule.editUser(\'' + user.id + '\')" title="Edit"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button>'; html +=
html += '<button class="btn-icon btn-danger" onclick="SettingsModule.deleteUser(\'' + user.id + '\')" title="Delete"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>'; '<button class="btn-icon" onclick="SettingsModule.editUser(\'' +
html += '</td></tr>'; user.id +
'\')" title="Edit"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button>';
html +=
'<button class="btn-icon btn-danger" onclick="SettingsModule.deleteUser(\'' +
user.id +
'\')" title="Delete"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>';
html += "</td></tr>";
}); });
html += "</tbody></table>"; html += "</tbody></table>";
container.innerHTML = html; container.innerHTML = html;
@ -279,7 +343,8 @@
function loadGroups(search) { function loadGroups(search) {
var container = document.getElementById("groups-list"); var container = document.getElementById("groups-list");
if (!container) return; if (!container) return;
container.innerHTML = '<div class="loading-state"><div class="spinner"></div><p>Loading...</p></div>'; container.innerHTML =
'<div class="loading-state"><div class="spinner"></div><p>Loading...</p></div>';
var params = new URLSearchParams(); var params = new URLSearchParams();
params.append("per_page", "50"); params.append("per_page", "50");
if (search) params.append("search", search); if (search) params.append("search", search);
@ -287,28 +352,56 @@
fetch(apiUrl(API_ENDPOINTS.GROUPS_LIST) + "?" + params.toString(), { fetch(apiUrl(API_ENDPOINTS.GROUPS_LIST) + "?" + params.toString(), {
headers: { Authorization: "Bearer " + token }, headers: { Authorization: "Bearer " + token },
}) })
.then(function (r) { if (!r.ok) throw new Error("Failed"); return r.json(); }) .then(function (r) {
.then(function (data) { groupsData = data.groups || []; renderGroups(container); }) if (!r.ok) throw new Error("Failed");
.catch(function () { container.innerHTML = '<div class="error-state"><p>Failed to load groups.</p></div>'; }); return r.json();
})
.then(function (data) {
groupsData = data.groups || [];
renderGroups(container);
})
.catch(function () {
container.innerHTML =
'<div class="error-state"><p>Failed to load groups.</p></div>';
});
} }
function renderGroups(container) { function renderGroups(container) {
if (groupsData.length === 0) { if (groupsData.length === 0) {
container.innerHTML = '<div class="empty-state"><p>No groups found</p></div>'; container.innerHTML =
'<div class="empty-state"><p>No groups found</p></div>';
return; return;
} }
var html = '<div class="groups-grid">'; var html = '<div class="groups-grid">';
groupsData.forEach(function (group) { groupsData.forEach(function (group) {
html += '<div class="group-card" data-group-id="' + group.id + '">'; html += '<div class="group-card" data-group-id="' + group.id + '">';
html += '<div class="group-header"><h3 class="group-name">' + escapeHtml(group.name) + '</h3><span class="member-count">' + (group.member_count || 0) + ' members</span></div>'; html +=
if (group.description) html += '<p class="group-description">' + escapeHtml(group.description) + '</p>'; '<div class="group-header"><h3 class="group-name">' +
escapeHtml(group.name) +
'</h3><span class="member-count">' +
(group.member_count || 0) +
" members</span></div>";
if (group.description)
html +=
'<p class="group-description">' +
escapeHtml(group.description) +
"</p>";
html += '<div class="group-actions">'; html += '<div class="group-actions">';
html += '<button class="btn-secondary btn-sm" onclick="SettingsModule.viewGroupMembers(\'' + group.id + '\')">View Members</button>'; html +=
html += '<button class="btn-icon" onclick="SettingsModule.editGroup(\'' + group.id + '\')" title="Edit"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button>'; '<button class="btn-secondary btn-sm" onclick="SettingsModule.viewGroupMembers(\'' +
html += '<button class="btn-icon btn-danger" onclick="SettingsModule.deleteGroup(\'' + group.id + '\')" title="Delete"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>'; group.id +
html += '</div></div>'; "')\">View Members</button>";
html +=
'<button class="btn-icon" onclick="SettingsModule.editGroup(\'' +
group.id +
'\')" title="Edit"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button>';
html +=
'<button class="btn-icon btn-danger" onclick="SettingsModule.deleteGroup(\'' +
group.id +
'\')" title="Delete"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>';
html += "</div></div>";
}); });
html += '</div>'; html += "</div>";
container.innerHTML = html; container.innerHTML = html;
} }
@ -319,7 +412,11 @@
function closeAddUserDialog() { function closeAddUserDialog() {
var dialog = document.getElementById("add-user-dialog"); var dialog = document.getElementById("add-user-dialog");
if (dialog) { dialog.close(); var f = document.getElementById("add-user-form"); if (f) f.reset(); } if (dialog) {
dialog.close();
var f = document.getElementById("add-user-form");
if (f) f.reset();
}
} }
function openAddGroupDialog() { function openAddGroupDialog() {
@ -329,13 +426,20 @@
function closeAddGroupDialog() { function closeAddGroupDialog() {
var dialog = document.getElementById("add-group-dialog"); var dialog = document.getElementById("add-group-dialog");
if (dialog) { dialog.close(); var f = document.getElementById("add-group-form"); if (f) f.reset(); } if (dialog) {
dialog.close();
var f = document.getElementById("add-group-form");
if (f) f.reset();
}
} }
function handleAddUser(e) { function handleAddUser(e) {
e.preventDefault(); e.preventDefault();
var form = e.target, btn = form.querySelector('button[type="submit"]'), orig = btn.textContent; var form = e.target,
btn.disabled = true; btn.textContent = "Creating..."; btn = form.querySelector('button[type="submit"]'),
orig = btn.textContent;
btn.disabled = true;
btn.textContent = "Creating...";
var userData = { var userData = {
username: form.username.value, username: form.username.value,
@ -344,39 +448,85 @@
first_name: form.first_name.value, first_name: form.first_name.value,
last_name: form.last_name.value, last_name: form.last_name.value,
role: form.role ? form.role.value : "user", role: form.role ? form.role.value : "user",
organization_id: currentOrgId || (form.organization_id ? form.organization_id.value : null), organization_id:
roles: form.roles ? [form.roles.value] : ["user"] currentOrgId ||
(form.organization_id ? form.organization_id.value : null),
roles: form.roles ? [form.roles.value] : ["user"],
}; };
fetch(apiUrl(API_ENDPOINTS.USERS_CREATE), { fetch(apiUrl(API_ENDPOINTS.USERS_CREATE), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + getAuthToken() }, headers: {
body: JSON.stringify(userData) "Content-Type": "application/json",
Authorization: "Bearer " + getAuthToken(),
},
body: JSON.stringify(userData),
}) })
.then(function (r) { if (!r.ok) return r.json().then(function (err) { throw new Error(err.error || "Failed"); }); return r.json(); }) .then(function (r) {
.then(function () { showToast("User created and added to organization", "success"); closeAddUserDialog(); loadUsers(); }) if (!r.ok)
.catch(function (e) { showToast(e.message, "error"); }) return r.json().then(function (err) {
.finally(function () { btn.disabled = false; btn.textContent = orig; }); throw new Error(err.error || "Failed");
});
return r.json();
})
.then(function () {
showToast("User created and added to organization", "success");
closeAddUserDialog();
loadUsers();
})
.catch(function (e) {
showToast(e.message, "error");
})
.finally(function () {
btn.disabled = false;
btn.textContent = orig;
});
} }
function handleAddGroup(e) { function handleAddGroup(e) {
e.preventDefault(); e.preventDefault();
var form = e.target, btn = form.querySelector('button[type="submit"]'), orig = btn.textContent; var form = e.target,
btn.disabled = true; btn.textContent = "Creating..."; btn = form.querySelector('button[type="submit"]'),
var groupData = { name: form.name.value, description: form.description ? form.description.value : "" }; orig = btn.textContent;
btn.disabled = true;
btn.textContent = "Creating...";
var groupData = {
name: form.name.value,
description: form.description ? form.description.value : "",
};
fetch(apiUrl(API_ENDPOINTS.GROUPS_CREATE), { fetch(apiUrl(API_ENDPOINTS.GROUPS_CREATE), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + getAuthToken() }, headers: {
body: JSON.stringify(groupData) "Content-Type": "application/json",
Authorization: "Bearer " + getAuthToken(),
},
body: JSON.stringify(groupData),
}) })
.then(function (r) { if (!r.ok) return r.json().then(function (err) { throw new Error(err.error || "Failed"); }); return r.json(); }) .then(function (r) {
.then(function () { showToast("Group created successfully", "success"); closeAddGroupDialog(); loadGroups(); }) if (!r.ok)
.catch(function (e) { showToast(e.message, "error"); }) return r.json().then(function (err) {
.finally(function () { btn.disabled = false; btn.textContent = orig; }); throw new Error(err.error || "Failed");
});
return r.json();
})
.then(function () {
showToast("Group created successfully", "success");
closeAddGroupDialog();
loadGroups();
})
.catch(function (e) {
showToast(e.message, "error");
})
.finally(function () {
btn.disabled = false;
btn.textContent = orig;
});
} }
function editUser(userId) { function editUser(userId) {
var user = usersData.find(function (u) { return u.id === userId; }); var user = usersData.find(function (u) {
return u.id === userId;
});
if (!user) return; if (!user) return;
var d = document.getElementById("edit-user-dialog"); var d = document.getElementById("edit-user-dialog");
if (d) d.showModal(); if (d) d.showModal();
@ -386,14 +536,22 @@
if (!confirm("Delete this user?")) return; if (!confirm("Delete this user?")) return;
fetch(apiUrl(API_ENDPOINTS.USERS_DELETE.replace(":user_id", userId)), { fetch(apiUrl(API_ENDPOINTS.USERS_DELETE.replace(":user_id", userId)), {
method: "DELETE", method: "DELETE",
headers: { Authorization: "Bearer " + getAuthToken() } headers: { Authorization: "Bearer " + getAuthToken() },
}) })
.then(function (r) { if (!r.ok) throw new Error("Failed"); showToast("User deleted", "success"); loadUsers(); }) .then(function (r) {
.catch(function (e) { showToast(e.message, "error"); }); if (!r.ok) throw new Error("Failed");
showToast("User deleted", "success");
loadUsers();
})
.catch(function (e) {
showToast(e.message, "error");
});
} }
function editGroup(groupId) { function editGroup(groupId) {
var group = groupsData.find(function (g) { return g.id === groupId; }); var group = groupsData.find(function (g) {
return g.id === groupId;
});
if (!group) return; if (!group) return;
var d = document.getElementById("edit-group-dialog"); var d = document.getElementById("edit-group-dialog");
if (d) d.showModal(); if (d) d.showModal();
@ -403,10 +561,16 @@
if (!confirm("Delete this group?")) return; if (!confirm("Delete this group?")) return;
fetch(apiUrl(API_ENDPOINTS.GROUPS_DELETE.replace(":group_id", groupId)), { fetch(apiUrl(API_ENDPOINTS.GROUPS_DELETE.replace(":group_id", groupId)), {
method: "DELETE", method: "DELETE",
headers: { Authorization: "Bearer " + getAuthToken() } headers: { Authorization: "Bearer " + getAuthToken() },
}) })
.then(function (r) { if (!r.ok) throw new Error("Failed"); showToast("Group deleted", "success"); loadGroups(); }) .then(function (r) {
.catch(function (e) { showToast(e.message, "error"); }); if (!r.ok) throw new Error("Failed");
showToast("Group deleted", "success");
loadGroups();
})
.catch(function (e) {
showToast(e.message, "error");
});
} }
function viewGroupMembers(groupId) { function viewGroupMembers(groupId) {
@ -416,27 +580,58 @@
function removeGroupMember(groupId, userId) { function removeGroupMember(groupId, userId) {
if (!confirm("Remove member?")) return; if (!confirm("Remove member?")) return;
fetch(apiUrl(API_ENDPOINTS.GROUPS_REMOVE_MEMBER.replace(":group_id", groupId)), { fetch(
apiUrl(API_ENDPOINTS.GROUPS_REMOVE_MEMBER.replace(":group_id", groupId)),
{
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + getAuthToken() }, headers: {
body: JSON.stringify({ user_id: userId }) "Content-Type": "application/json",
Authorization: "Bearer " + getAuthToken(),
},
body: JSON.stringify({ user_id: userId }),
},
)
.then(function (r) {
if (!r.ok) throw new Error("Failed");
showToast("Member removed", "success");
loadGroups();
}) })
.then(function (r) { if (!r.ok) throw new Error("Failed"); showToast("Member removed", "success"); loadGroups(); }) .catch(function (e) {
.catch(function (e) { showToast(e.message, "error"); }); showToast(e.message, "error");
});
} }
function handleLogout() { function handleLogout() {
var token = getAuthToken(); var token = getAuthToken();
if (token) fetch(API_ENDPOINTS.LOGOUT, { method: "POST", headers: { Authorization: "Bearer " + token } }).catch(function () {}); if (token)
fetch(API_ENDPOINTS.LOGOUT, {
method: "POST",
headers: { Authorization: "Bearer " + token },
}).catch(function () {});
localStorage.removeItem("gb-access-token"); localStorage.removeItem("gb-access-token");
localStorage.removeItem("gb-refresh-token"); localStorage.removeItem("gb-refresh-token");
localStorage.removeItem("gb-token-expires"); localStorage.removeItem("gb-token-expires");
localStorage.removeItem("gb-user-data"); localStorage.removeItem("gb-user-data");
window.location.href = "/auth/login"; window.location.href = "/auth/login.html";
} }
function escapeHtml(text) { if (!text) return ""; var d = document.createElement("div"); d.textContent = text; return d.innerHTML; } function escapeHtml(text) {
function debounce(func, wait) { var timeout; return function () { var ctx = this, args = arguments; clearTimeout(timeout); timeout = setTimeout(function () { func.apply(ctx, args); }, wait); }; } if (!text) return "";
var d = document.createElement("div");
d.textContent = text;
return d.innerHTML;
}
function debounce(func, wait) {
var timeout;
return function () {
var ctx = this,
args = arguments;
clearTimeout(timeout);
timeout = setTimeout(function () {
func.apply(ctx, args);
}, wait);
};
}
function showToast(message, type) { function showToast(message, type) {
type = type || "success"; type = type || "success";
@ -444,33 +639,125 @@
if (existing) existing.remove(); if (existing) existing.remove();
var toast = document.createElement("div"); var toast = document.createElement("div");
toast.className = "toast toast-" + type; toast.className = "toast toast-" + type;
toast.innerHTML = '<span class="toast-message">' + message + '</span><button class="toast-close" onclick="this.parentElement.remove()"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>'; toast.innerHTML =
'<span class="toast-message">' +
message +
'</span><button class="toast-close" onclick="this.parentElement.remove()"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>';
document.body.appendChild(toast); document.body.appendChild(toast);
requestAnimationFrame(function () { toast.classList.add("show"); }); requestAnimationFrame(function () {
setTimeout(function () { toast.classList.remove("show"); setTimeout(function () { toast.remove(); }, 300); }, 3000); toast.classList.add("show");
});
setTimeout(function () {
toast.classList.remove("show");
setTimeout(function () {
toast.remove();
}, 300);
}, 3000);
} }
window.changeLanguage = function (locale) { localStorage.setItem(STORAGE_KEYS.LOCALE, locale); document.documentElement.lang = locale; showToast("Language changed"); setTimeout(function () { location.reload(); }, 500); }; window.changeLanguage = function (locale) {
window.selectLanguage = function (locale, element) { document.querySelectorAll(".language-option").forEach(function (o) { o.classList.remove("active"); }); if (element) element.classList.add("active"); changeLanguage(locale); }; localStorage.setItem(STORAGE_KEYS.LOCALE, locale);
window.changeDateFormat = function (format) { localStorage.setItem(STORAGE_KEYS.DATE_FORMAT, format); showToast("Date format updated"); }; document.documentElement.lang = locale;
window.changeTimeFormat = function (format) { localStorage.setItem(STORAGE_KEYS.TIME_FORMAT, format); showToast("Time format updated"); }; showToast("Language changed");
window.setTheme = function (theme, element) { document.body.setAttribute("data-theme", theme); localStorage.setItem(STORAGE_KEYS.THEME, theme); document.querySelectorAll(".theme-option").forEach(function (o) { o.classList.remove("active"); }); if (element) element.classList.add("active"); showToast("Theme updated"); }; setTimeout(function () {
window.toggleCompactMode = function (checkbox) { var enabled = checkbox.checked; localStorage.setItem(STORAGE_KEYS.COMPACT_MODE, enabled); document.body.classList.toggle("compact-mode", enabled); showToast(enabled ? "Compact mode enabled" : "Compact mode disabled"); }; location.reload();
window.toggleAnimations = function (checkbox) { var enabled = checkbox.checked; localStorage.setItem(STORAGE_KEYS.ANIMATIONS, enabled); document.body.classList.toggle("no-animations", !enabled); showToast(enabled ? "Animations enabled" : "Animations disabled"); }; }, 500);
window.showSection = function (sectionId, navElement) { document.querySelectorAll(".settings-section").forEach(function (s) { s.classList.remove("active"); }); document.querySelectorAll(".nav-item").forEach(function (i) { i.classList.remove("active"); }); var section = document.getElementById(sectionId + "-section"); if (section) section.classList.add("active"); if (navElement) navElement.classList.add("active"); history.replaceState(null, "", "#" + sectionId); }; };
window.selectLanguage = function (locale, element) {
document.querySelectorAll(".language-option").forEach(function (o) {
o.classList.remove("active");
});
if (element) element.classList.add("active");
changeLanguage(locale);
};
window.changeDateFormat = function (format) {
localStorage.setItem(STORAGE_KEYS.DATE_FORMAT, format);
showToast("Date format updated");
};
window.changeTimeFormat = function (format) {
localStorage.setItem(STORAGE_KEYS.TIME_FORMAT, format);
showToast("Time format updated");
};
window.setTheme = function (theme, element) {
document.body.setAttribute("data-theme", theme);
localStorage.setItem(STORAGE_KEYS.THEME, theme);
document.querySelectorAll(".theme-option").forEach(function (o) {
o.classList.remove("active");
});
if (element) element.classList.add("active");
showToast("Theme updated");
};
window.toggleCompactMode = function (checkbox) {
var enabled = checkbox.checked;
localStorage.setItem(STORAGE_KEYS.COMPACT_MODE, enabled);
document.body.classList.toggle("compact-mode", enabled);
showToast(enabled ? "Compact mode enabled" : "Compact mode disabled");
};
window.toggleAnimations = function (checkbox) {
var enabled = checkbox.checked;
localStorage.setItem(STORAGE_KEYS.ANIMATIONS, enabled);
document.body.classList.toggle("no-animations", !enabled);
showToast(enabled ? "Animations enabled" : "Animations disabled");
};
window.showSection = function (sectionId, navElement) {
document.querySelectorAll(".settings-section").forEach(function (s) {
s.classList.remove("active");
});
document.querySelectorAll(".nav-item").forEach(function (i) {
i.classList.remove("active");
});
var section = document.getElementById(sectionId + "-section");
if (section) section.classList.add("active");
if (navElement) navElement.classList.add("active");
history.replaceState(null, "", "#" + sectionId);
};
window.showToast = showToast; window.showToast = showToast;
window.previewAvatar = function (input) { if (input.files && input.files[0]) { var reader = new FileReader(); reader.onload = function (e) { var avatar = document.getElementById("current-avatar"); if (avatar) avatar.innerHTML = '<img src="' + e.target.result + '" alt="Avatar">'; }; reader.readAsDataURL(input.files[0]); } }; window.previewAvatar = function (input) {
window.removeAvatar = function () { var avatar = document.getElementById("current-avatar"); if (avatar) avatar.innerHTML = "<span>JD</span>"; showToast("Avatar removed"); }; if (input.files && input.files[0]) {
var reader = new FileReader();
reader.onload = function (e) {
var avatar = document.getElementById("current-avatar");
if (avatar)
avatar.innerHTML = '<img src="' + e.target.result + '" alt="Avatar">';
};
reader.readAsDataURL(input.files[0]);
}
};
window.removeAvatar = function () {
var avatar = document.getElementById("current-avatar");
if (avatar) avatar.innerHTML = "<span>JD</span>";
showToast("Avatar removed");
};
function initFromHash() { var hash = window.location.hash.slice(1); if (hash) { var navItem = document.querySelector('.nav-item[href="#' + hash + '"]'); if (navItem) showSection(hash, navItem); } } function initFromHash() {
var hash = window.location.hash.slice(1);
if (hash) {
var navItem = document.querySelector('.nav-item[href="#' + hash + '"]');
if (navItem) showSection(hash, navItem);
}
}
window.SettingsModule = { window.SettingsModule = {
init: init, changeLanguage: window.changeLanguage, changeDateFormat: window.changeDateFormat, changeTimeFormat: window.changeTimeFormat, setTheme: window.setTheme, showToast: showToast, init: init,
editUser: editUser, deleteUser: deleteUser, editGroup: editGroup, deleteGroup: deleteGroup, viewGroupMembers: viewGroupMembers, removeGroupMember: removeGroupMember, logout: handleLogout changeLanguage: window.changeLanguage,
changeDateFormat: window.changeDateFormat,
changeTimeFormat: window.changeTimeFormat,
setTheme: window.setTheme,
showToast: showToast,
editUser: editUser,
deleteUser: deleteUser,
editGroup: editGroup,
deleteGroup: deleteGroup,
viewGroupMembers: viewGroupMembers,
removeGroupMember: removeGroupMember,
logout: handleLogout,
}; };
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () { init(); initFromHash(); }); document.addEventListener("DOMContentLoaded", function () {
init();
initFromHash();
});
} else { } else {
init(); init();
initFromHash(); initFromHash();

View file

@ -376,6 +376,7 @@
}); });
// Load task statistics // Load task statistics
// Auth headers automatically added by security-bootstrap.js
function loadTaskStats() { function loadTaskStats() {
fetch("/api/tasks/stats/json") fetch("/api/tasks/stats/json")
.then((r) => r.json()) .then((r) => r.json())