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:
parent
c6fc5306c6
commit
d4dc504d69
8 changed files with 1448 additions and 795 deletions
72
PROMPT.md
72
PROMPT.md
|
|
@ -404,6 +404,78 @@ pub mod http_client;
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Security Architecture - MANDATORY
|
||||||
|
|
||||||
|
### Centralized Auth Engine
|
||||||
|
|
||||||
|
All authentication is handled by `security-bootstrap.js` which MUST be loaded immediately after HTMX in the `<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
|
||||||
|
|
|
||||||
|
|
@ -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) +
|
||||||
|
"...",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
332
ui/suite/js/security-bootstrap.js
Normal file
332
ui/suite/js/security-bootstrap.js
Normal 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);
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue