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
### Server-Side Rendering

View file

@ -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) +
"...",
);
}

View file

@ -44,6 +44,10 @@
<script src="js/vendor/htmx-json-enc.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 -->
<script src="js/i18n.js"></script>
@ -2358,7 +2362,7 @@
<!-- Sections will be loaded dynamically -->
</main>
<!-- Core scripts -->
<!-- Core scripts (auth handled by security-bootstrap.js in head) -->
<script src="js/api-client.js"></script>
<script src="js/theme-manager.js"></script>
<script src="js/htmx-app.js"></script>

File diff suppressed because it is too large Load diff

View file

@ -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",

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, {
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 = '<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();
params.append("per_page", "50");
if (search) params.append("search", search);
@ -244,33 +278,63 @@
fetch(apiUrl(API_ENDPOINTS.USERS_LIST) + "?" + params.toString(), {
headers: { Authorization: "Bearer " + token },
})
.then(function (r) { if (!r.ok) throw new Error("Failed"); 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>'; });
.then(function (r) {
if (!r.ok) throw new Error("Failed");
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) {
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;
}
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) {
var initials = getInitials(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 orgId = user.organization_id || "-";
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(orgId) + "</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 += '<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 += '<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 +=
'<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 +=
'<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>";
container.innerHTML = html;
@ -279,7 +343,8 @@
function loadGroups(search) {
var container = document.getElementById("groups-list");
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();
params.append("per_page", "50");
if (search) params.append("search", search);
@ -287,28 +352,56 @@
fetch(apiUrl(API_ENDPOINTS.GROUPS_LIST) + "?" + params.toString(), {
headers: { Authorization: "Bearer " + token },
})
.then(function (r) { if (!r.ok) throw new Error("Failed"); 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>'; });
.then(function (r) {
if (!r.ok) throw new Error("Failed");
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) {
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;
}
var html = '<div class="groups-grid">';
groupsData.forEach(function (group) {
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>';
if (group.description) html += '<p class="group-description">' + escapeHtml(group.description) + '</p>';
html +=
'<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 += '<button class="btn-secondary btn-sm" onclick="SettingsModule.viewGroupMembers(\'' + group.id + '\')">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 +=
'<button class="btn-secondary btn-sm" onclick="SettingsModule.viewGroupMembers(\'' +
group.id +
"')\">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;
}
@ -319,7 +412,11 @@
function closeAddUserDialog() {
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() {
@ -329,14 +426,21 @@
function closeAddGroupDialog() {
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) {
e.preventDefault();
var form = e.target, btn = form.querySelector('button[type="submit"]'), orig = btn.textContent;
btn.disabled = true; btn.textContent = "Creating...";
var form = e.target,
btn = form.querySelector('button[type="submit"]'),
orig = btn.textContent;
btn.disabled = true;
btn.textContent = "Creating...";
var userData = {
username: form.username.value,
email: form.email.value,
@ -344,39 +448,85 @@
first_name: form.first_name.value,
last_name: form.last_name.value,
role: form.role ? form.role.value : "user",
organization_id: currentOrgId || (form.organization_id ? form.organization_id.value : null),
roles: form.roles ? [form.roles.value] : ["user"]
organization_id:
currentOrgId ||
(form.organization_id ? form.organization_id.value : null),
roles: form.roles ? [form.roles.value] : ["user"],
};
fetch(apiUrl(API_ENDPOINTS.USERS_CREATE), {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + getAuthToken() },
body: JSON.stringify(userData)
headers: {
"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 () { 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; });
.then(function (r) {
if (!r.ok)
return r.json().then(function (err) {
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) {
e.preventDefault();
var form = e.target, btn = form.querySelector('button[type="submit"]'), orig = btn.textContent;
btn.disabled = true; btn.textContent = "Creating...";
var groupData = { name: form.name.value, description: form.description ? form.description.value : "" };
var form = e.target,
btn = form.querySelector('button[type="submit"]'),
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), {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + getAuthToken() },
body: JSON.stringify(groupData)
headers: {
"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 () { showToast("Group created successfully", "success"); closeAddGroupDialog(); loadGroups(); })
.catch(function (e) { showToast(e.message, "error"); })
.finally(function () { btn.disabled = false; btn.textContent = orig; });
.then(function (r) {
if (!r.ok)
return r.json().then(function (err) {
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) {
var user = usersData.find(function (u) { return u.id === userId; });
var user = usersData.find(function (u) {
return u.id === userId;
});
if (!user) return;
var d = document.getElementById("edit-user-dialog");
if (d) d.showModal();
@ -386,14 +536,22 @@
if (!confirm("Delete this user?")) return;
fetch(apiUrl(API_ENDPOINTS.USERS_DELETE.replace(":user_id", userId)), {
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(); })
.catch(function (e) { showToast(e.message, "error"); });
.then(function (r) {
if (!r.ok) throw new Error("Failed");
showToast("User deleted", "success");
loadUsers();
})
.catch(function (e) {
showToast(e.message, "error");
});
}
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;
var d = document.getElementById("edit-group-dialog");
if (d) d.showModal();
@ -403,10 +561,16 @@
if (!confirm("Delete this group?")) return;
fetch(apiUrl(API_ENDPOINTS.GROUPS_DELETE.replace(":group_id", groupId)), {
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(); })
.catch(function (e) { showToast(e.message, "error"); });
.then(function (r) {
if (!r.ok) throw new Error("Failed");
showToast("Group deleted", "success");
loadGroups();
})
.catch(function (e) {
showToast(e.message, "error");
});
}
function viewGroupMembers(groupId) {
@ -416,61 +580,184 @@
function removeGroupMember(groupId, userId) {
if (!confirm("Remove member?")) return;
fetch(apiUrl(API_ENDPOINTS.GROUPS_REMOVE_MEMBER.replace(":group_id", groupId)), {
method: "POST",
headers: { "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(); })
.catch(function (e) { showToast(e.message, "error"); });
fetch(
apiUrl(API_ENDPOINTS.GROUPS_REMOVE_MEMBER.replace(":group_id", groupId)),
{
method: "POST",
headers: {
"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();
})
.catch(function (e) {
showToast(e.message, "error");
});
}
function handleLogout() {
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-refresh-token");
localStorage.removeItem("gb-token-expires");
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 debounce(func, wait) {
var timeout;
return function () {
var ctx = this,
args = arguments;
clearTimeout(timeout);
timeout = setTimeout(function () {
func.apply(ctx, args);
}, wait);
};
}
function escapeHtml(text) { 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) {
type = type || "success";
var existing = document.querySelector(".toast");
if (existing) existing.remove();
var toast = document.createElement("div");
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);
requestAnimationFrame(function () { toast.classList.add("show"); });
setTimeout(function () { toast.classList.remove("show"); setTimeout(function () { toast.remove(); }, 300); }, 3000);
requestAnimationFrame(function () {
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.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.changeLanguage = function (locale) {
localStorage.setItem(STORAGE_KEYS.LOCALE, locale);
document.documentElement.lang = locale;
showToast("Language changed");
setTimeout(function () {
location.reload();
}, 500);
};
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.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.removeAvatar = function () { var avatar = document.getElementById("current-avatar"); if (avatar) avatar.innerHTML = "<span>JD</span>"; showToast("Avatar removed"); };
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.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 = {
init: init, 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
init: init,
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") {
document.addEventListener("DOMContentLoaded", function () { init(); initFromHash(); });
document.addEventListener("DOMContentLoaded", function () {
init();
initFromHash();
});
} else {
init();
initFromHash();

View file

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