import Api from './api.js';
import Storage from './storage.js';
import { getEl, addSafeEventListener, setElementDisplay } from './util/dom-utils.js';
import { applyTheme, fadeOutLoader } from './util/theme-utils.js';
import { customConfirm } from './util/confirm-dialog.js';
import { copyToClipboard } from './util/async-utils.js';
import { showOverlay, hideOverlay } from './util/overlay-utils.js';
/**
* Users authentication, profile management, and administrative control.
*/
class Users {
static instance = null;
static isAdmin = false;
static currentUser = null;
#api;
#storage;
// Auth elements
loginForm;
usernameInput;
passcodeInput;
loginButton;
errorMessageDiv;
logoutButton;
loginOverlay;
loginBackdrop;
appContainer;
// Profile Form Elements
profileForm;
profileNameInput;
profileEmailInput;
profileLocationInput;
profileLocationExplanation;
profileTimezoneInput;
profileStatusDiv;
saveProfileButton;
profileSessionExpirationSpan;
#locationValidationTimeout = null;
#lastValidatedLocation = "";
// Account Elements
accountBalanceSpan;
accountCostMonthSpan;
accountCostAllSpan;
// Admin Elements - Main
adminForm;
btnAdminShowCreateUser;
btnAdminShowAddFunds;
btnAdminConfig;
adminUsersTbody;
adminStatusDiv;
// Admin Elements - Create User
adminCreateUserPanel;
newUsernameInput;
newPasswordInput;
newNameInput;
newEmailInput;
newRoleSelect;
newBalanceInput;
btnAdminCancelCreate;
btnAdminSubmitCreate;
// Admin Elements - Edit User
adminEditUserPanel;
editUserIdInput;
editUsernameDisplay;
editUsernameInput;
editPasswordInput;
editNameInput;
editEmailInput;
editRoleSelect;
btnAdminCancelEdit;
btnAdminSubmitEdit;
// Admin Elements - Add Funds
adminAddFundsPanel;
adminFundsUserSelect;
adminFundsAmountInput;
btnAdminCancelAddFunds;
btnAdminSubmitAddFunds;
// Admin Elements - Config Management Dialog
configDialogOverlay;
btnConfigDialogClose;
btnConfigShowCreate;
configCreatePanel;
newConfigKeyInput;
newConfigDescriptionInput;
newConfigValueInput;
btnConfigCancelCreate;
btnConfigSubmitCreate;
configEditPanel;
configEditIdInput;
configEditKeyDisplay;
configEditKeyInput;
configEditDescriptionInput;
configEditValueInput;
btnConfigCancelEdit;
btnConfigSubmitEdit;
configItemsTbody;
configStatusDiv;
configKnownKeysList;
/**
* Initializes the Users class, UI element bindings.
*/
constructor() {
Users.instance = this;
this.#api = new Api();
this.#storage = new Storage();
// Apply theme on initialization
this.applySavedTheme();
// Auth elements
this.loginForm = getEl('login-form') || getEl('form-login');
this.usernameInput = getEl('username') || getEl('login-username');
this.passcodeInput = getEl('passcode') || getEl('login-passcode');
this.loginButton = getEl('login-button') || getEl('btn-login');
this.errorMessageDiv = getEl('login-error-message');
this.logoutButton = getEl('id-btn-logout') || getEl('btn-logout');
this.loginOverlay = getEl('id-login-overlay');
this.loginBackdrop = getEl('id-login-backdrop');
this.appContainer = getEl('id-div-app-container');
// Profile elements
this.profileForm = getEl('id-form-profile-options');
this.profileNameInput = getEl('profile-name');
this.profileEmailInput = getEl('profile-email');
this.profileLocationInput = getEl('profile-location');
this.profileLocationExplanation = getEl('profile-location-explanation');
this.profileTimezoneInput = getEl('profile-timezone');
this.profileStatusDiv = getEl('profile-status-message');
this.saveProfileButton = getEl('btn-save-profile');
this.profileSessionExpirationSpan = getEl('profile-session-expiration');
this.updateSessionExpirationDisplay();
// Account elements
this.accountBalanceSpan = getEl('account-balance-amount');
this.accountCostMonthSpan = getEl('account-cost-month');
this.accountCostAllSpan = getEl('account-cost-all');
// Admin elements - Main
this.adminForm = getEl('id-form-admin-options');
this.btnAdminShowCreateUser = getEl('btn-admin-show-create-user');
this.btnAdminShowAddFunds = getEl('btn-admin-show-add-funds');
this.btnAdminConfig = getEl('btn-admin-config');
this.adminUsersTbody = getEl('admin-users-tbody');
this.adminStatusDiv = getEl('admin-management-status');
// Admin elements - Create User
this.adminCreateUserPanel = getEl('admin-create-user-panel');
this.newUsernameInput = getEl('new-user-username');
this.newPasswordInput = getEl('new-user-password');
this.newNameInput = getEl('new-user-name');
this.newEmailInput = getEl('new-user-email');
this.newRoleSelect = getEl('new-user-role');
this.newBalanceInput = getEl('new-user-balance');
this.btnAdminCancelCreate = getEl('btn-admin-cancel-create');
this.btnAdminSubmitCreate = getEl('btn-admin-submit-create');
// Admin elements - Edit User
this.adminEditUserPanel = getEl('admin-edit-user-panel');
this.editUserIdInput = getEl('admin-edit-user-id');
this.editUsernameDisplay = getEl('admin-edit-username-display');
this.editUsernameInput = getEl('admin-edit-username');
this.editPasswordInput = getEl('admin-edit-password');
this.editNameInput = getEl('admin-edit-name');
this.editEmailInput = getEl('admin-edit-email');
this.editRoleSelect = getEl('admin-edit-role');
this.btnAdminCancelEdit = getEl('btn-admin-cancel-edit');
this.btnAdminSubmitEdit = getEl('btn-admin-submit-edit');
// Admin elements - Add Funds
this.adminAddFundsPanel = getEl('admin-add-funds-panel');
this.adminFundsUserSelect = getEl('admin-funds-user-select');
this.adminFundsAmountInput = getEl('admin-funds-amount');
this.btnAdminCancelAddFunds = getEl('btn-admin-cancel-add-funds');
this.btnAdminSubmitAddFunds = getEl('btn-admin-submit-add-funds');
// Admin elements - Config Management Dialog
this.configDialogOverlay = getEl('id-config-dialog-overlay');
this.btnConfigDialogClose = getEl('btn-config-dialog-close');
this.btnConfigShowCreate = getEl('btn-config-show-create');
this.configCreatePanel = getEl('config-create-panel');
this.newConfigKeyInput = getEl('new-config-key');
this.newConfigDescriptionInput = getEl('new-config-description');
this.newConfigValueInput = getEl('new-config-value');
this.btnConfigCancelCreate = getEl('btn-config-cancel-create');
this.btnConfigSubmitCreate = getEl('btn-config-submit-create');
this.configEditPanel = getEl('config-edit-panel');
this.configEditIdInput = getEl('config-edit-id');
this.configEditKeyDisplay = getEl('config-edit-key-display');
this.configEditKeyInput = getEl('config-edit-key');
this.configEditDescriptionInput = getEl('config-edit-description');
this.configEditValueInput = getEl('config-edit-value');
this.btnConfigCancelEdit = getEl('btn-config-cancel-edit');
this.btnConfigSubmitEdit = getEl('btn-config-submit-edit');
this.configItemsTbody = getEl('config-items-tbody');
this.configStatusDiv = getEl('config-management-status');
this.configKnownKeysList = getEl('config-known-keys-list');
this.initEventListeners();
}
/**
* Applies the saved theme from configuration.
*/
applySavedTheme() {
const config = this.#storage.get_app_config();
const theme = (config && config[this.#storage.KEY_CONFIG_THEME]) ? config[this.#storage.KEY_CONFIG_THEME] : 'theme_dark';
applyTheme(theme, true);
}
/**
* Binds event listeners for user login, logout, profile, and admin forms.
*/
initEventListeners() {
if (this.loginForm) {
addSafeEventListener(this.loginForm, 'submit', (e) => {
e.preventDefault();
this.handleLogin();
});
}
addSafeEventListener(this.loginButton, 'click', (e) => {
if (e) e.preventDefault();
this.handleLogin();
});
addSafeEventListener(this.logoutButton, 'click', (e) => {
if (e) e.preventDefault();
this.handleLogout();
});
// Profile form
if (this.profileForm) {
addSafeEventListener(this.profileForm, 'submit', (e) => {
e.preventDefault();
this.handleSaveProfile();
});
}
addSafeEventListener(this.saveProfileButton, 'click', (e) => {
if (e) e.preventDefault();
this.handleSaveProfile();
});
if (this.profileLocationInput) {
addSafeEventListener(this.profileLocationInput, 'input', () => this.handleLocationInput());
}
if (this.profileSessionExpirationSpan) {
addSafeEventListener(this.profileSessionExpirationSpan, 'click', async (e) => {
if (e) e.preventDefault();
await this.handleRefreshSessionPrompt();
});
}
// Admin management - Create user
addSafeEventListener(this.btnAdminShowCreateUser, 'click', (e) => {
if (e) e.preventDefault();
if (this.adminAddFundsPanel) setElementDisplay(this.adminAddFundsPanel, 'none');
if (this.adminEditUserPanel) setElementDisplay(this.adminEditUserPanel, 'none');
if (this.adminCreateUserPanel) {
const isHidden = this.adminCreateUserPanel.style.display === 'none' || !this.adminCreateUserPanel.style.display;
setElementDisplay(this.adminCreateUserPanel, isHidden ? 'block' : 'none');
}
});
addSafeEventListener(this.btnAdminCancelCreate, 'click', (e) => {
if (e) e.preventDefault();
this.resetCreateUserPanel();
});
addSafeEventListener(this.btnAdminSubmitCreate, 'click', (e) => {
if (e) e.preventDefault();
this.handleCreateUser();
});
// Admin management - Edit user
addSafeEventListener(this.btnAdminCancelEdit, 'click', (e) => {
if (e) e.preventDefault();
this.resetEditUserPanel();
});
addSafeEventListener(this.btnAdminSubmitEdit, 'click', (e) => {
if (e) e.preventDefault();
this.handleSaveEditUser();
});
// Admin management - Add funds
addSafeEventListener(this.btnAdminShowAddFunds, 'click', (e) => {
if (e) e.preventDefault();
if (this.adminCreateUserPanel) setElementDisplay(this.adminCreateUserPanel, 'none');
if (this.adminEditUserPanel) setElementDisplay(this.adminEditUserPanel, 'none');
if (this.adminAddFundsPanel) {
const isHidden = this.adminAddFundsPanel.style.display === 'none' || !this.adminAddFundsPanel.style.display;
setElementDisplay(this.adminAddFundsPanel, isHidden ? 'block' : 'none');
}
});
addSafeEventListener(this.btnAdminCancelAddFunds, 'click', (e) => {
if (e) e.preventDefault();
this.resetAddFundsPanel();
});
addSafeEventListener(this.btnAdminSubmitAddFunds, 'click', (e) => {
if (e) e.preventDefault();
this.handleAddFundsFromAdmin();
});
// Admin management - Config
addSafeEventListener(this.btnAdminConfig, 'click', (e) => {
if (e) e.preventDefault();
this.openConfigDialog();
});
addSafeEventListener(this.btnConfigDialogClose, 'click', (e) => {
if (e) e.preventDefault();
this.closeConfigDialog();
});
// Config create panel
addSafeEventListener(this.btnConfigShowCreate, 'click', (e) => {
if (e) e.preventDefault();
if (this.configEditPanel) setElementDisplay(this.configEditPanel, 'none');
if (this.configCreatePanel) {
const isHidden = this.configCreatePanel.style.display === 'none' || !this.configCreatePanel.style.display;
setElementDisplay(this.configCreatePanel, isHidden ? 'block' : 'none');
}
});
addSafeEventListener(this.btnConfigCancelCreate, 'click', (e) => {
if (e) e.preventDefault();
this.resetCreateConfigPanel();
});
addSafeEventListener(this.btnConfigSubmitCreate, 'click', (e) => {
if (e) e.preventDefault();
this.handleCreateConfig();
});
// Config edit panel
addSafeEventListener(this.btnConfigCancelEdit, 'click', (e) => {
if (e) e.preventDefault();
this.resetEditConfigPanel();
});
addSafeEventListener(this.btnConfigSubmitEdit, 'click', (e) => {
if (e) e.preventDefault();
this.handleSaveEditConfig();
});
}
/**
* Shows login prompt and backdrop.
*/
showLoginForm() {
if (this.appContainer) setElementDisplay(this.appContainer, 'none');
if (this.loginBackdrop) {
this.loginBackdrop.classList.add('active');
setElementDisplay(this.loginBackdrop, 'block');
}
if (this.loginOverlay) {
this.loginOverlay.classList.add('active');
setElementDisplay(this.loginOverlay, 'block');
}
fadeOutLoader();
}
/**
* Hides login prompt and shows main application layout.
*/
showApp() {
if (this.loginBackdrop) {
this.loginBackdrop.classList.remove('active');
setElementDisplay(this.loginBackdrop, 'none');
}
if (this.loginOverlay) {
this.loginOverlay.classList.remove('active');
setElementDisplay(this.loginOverlay, 'none');
}
if (this.appContainer) {
setElementDisplay(this.appContainer, 'grid');
}
}
/**
* Handles authentication request on login form submission.
*/
handleLogin() {
if (!this.usernameInput || !this.passcodeInput) return;
const username = this.usernameInput.value.trim();
const passcode = this.passcodeInput.value.trim();
if (!username || !passcode) {
this.displayError('Please provide both username and passcode.');
return;
}
let detectedTimezone = null;
try {
detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (e) {
console.warn('Could not determine client timezone:', e);
}
this.#storage.init_location_id();
const locationId = this.#storage.get_location_id();
this.#api.post(
{ action: 'login', username, passcode, location_id: locationId, timezone: detectedTimezone },
(response) => {
if (response.success && response.token) {
this.#storage.set_auth_token(response.token);
window.location.reload();
} else {
this.displayError(response.error || response.message || 'Login failed. Please check credentials.');
}
},
(error) => {
this.displayError('Connection error. Please try again.');
console.error('Login error:', error);
},
'users'
);
}
/**
* Handles session revocation and logging out.
*/
handleLogout() {
const locationId = this.#storage.get_location_id();
const token = this.#storage.get_auth_token();
this.#api.post(
{ action: 'logout', location_id: locationId, token },
() => {
this.#storage.clear_auth_token();
window.location.reload();
},
(error) => {
console.error('Logout error:', error);
this.#storage.clear_auth_token();
window.location.reload();
},
'users'
);
}
/**
* Checks user session status with the API backend.
* @param {Function|null} onAuthenticated - Callback when user is authenticated.
* @param {Function|null} onUnauthenticated - Callback when user is not authenticated.
*/
checkLoginStatus(onAuthenticated = null, onUnauthenticated = null) {
this.#storage.init_location_id();
const token = this.#storage.get_auth_token();
const locationId = this.#storage.get_location_id();
if (!token) {
Users.isAdmin = false;
Users.currentUser = null;
this.showLoginForm();
if (typeof onUnauthenticated === 'function') {
onUnauthenticated();
}
return;
}
this.#api.post(
{ action: 'session', token, location_id: locationId },
(response) => {
if (response && response.isLoggedIn) {
if (response.token) {
this.#storage.set_auth_token(response.token);
}
Users.isAdmin = !!response.admin || !!response.isAdmin;
Users.currentUser = response.user || null;
this.showApp();
this.applyUserSession(response);
this.updateSessionExpirationDisplay();
if (typeof onAuthenticated === 'function') {
onAuthenticated(response);
}
} else {
this.#storage.clear_auth_token();
Users.isAdmin = false;
Users.currentUser = null;
this.showLoginForm();
if (typeof onUnauthenticated === 'function') {
onUnauthenticated();
}
}
},
(error) => {
console.error('Session check error:', error);
this.#storage.clear_auth_token();
Users.isAdmin = false;
Users.currentUser = null;
this.showLoginForm();
if (typeof onUnauthenticated === 'function') {
onUnauthenticated();
}
},
'users'
);
}
/**
* Applies session user state to application UI.
* @param {object} sessionData
*/
applyUserSession(sessionData) {
const isAdmin = !!sessionData.admin || !!sessionData.isAdmin;
setElementDisplay(this.adminForm, isAdmin ? 'block' : 'none');
if (this.btnAdminConfig) {
setElementDisplay(this.btnAdminConfig, isAdmin ? 'inline-block' : 'none');
}
if (this.logoutButton) {
setElementDisplay(this.logoutButton, 'inline-block');
}
this.loadProfile();
this.updateSessionExpirationDisplay();
if (isAdmin) {
this.loadAdminUsers();
}
fadeOutLoader();
}
/**
* Fetches user profile, spend, and balance information.
*/
loadProfile() {
this.#api.post(
{ action: 'get_profile' },
(response) => {
if (response.success && response.profile) {
const profile = response.profile;
if (this.profileNameInput) this.profileNameInput.value = profile.name || '';
if (this.profileEmailInput) this.profileEmailInput.value = profile.email || '';
if (this.profileLocationInput) this.profileLocationInput.value = profile.location_string || '';
this.#lastValidatedLocation = profile.location_string ? profile.location_string.trim() : '';
this.set_location_validation_state(true, '');
if (this.profileTimezoneInput) this.profileTimezoneInput.value = profile.latest_timezone_code || '';
const balance = parseFloat(profile.balance || 0);
const lifetimeSpend = parseFloat(profile.lifetime_spend || 0);
if (this.accountBalanceSpan) {
this.accountBalanceSpan.textContent = `$${balance.toFixed(6)}`;
}
if (this.accountCostMonthSpan) {
this.accountCostMonthSpan.textContent = `$${lifetimeSpend.toFixed(6)}`;
}
if (this.accountCostAllSpan) {
this.accountCostAllSpan.textContent = `$${lifetimeSpend.toFixed(6)}`;
}
Users.currentUser = profile;
Users.isAdmin = Number(profile.admin) === 1;
setElementDisplay(this.adminForm, Users.isAdmin ? 'block' : 'none');
if (this.btnAdminConfig) {
setElementDisplay(this.btnAdminConfig, Users.isAdmin ? 'inline-block' : 'none');
}
this.updateSessionExpirationDisplay();
}
},
(error) => {
console.error('Profile fetch error:', error);
},
'users'
);
}
/**
* Handles input changes in the profile location field, debouncing validation requests.
*/
handleLocationInput() {
if (this.#locationValidationTimeout) {
clearTimeout(this.#locationValidationTimeout);
this.#locationValidationTimeout = null;
}
const val = this.profileLocationInput ? this.profileLocationInput.value.trim() : '';
// When the user is typing, disable the save button immediately
if (this.saveProfileButton) {
this.saveProfileButton.disabled = true;
}
// Ensure that you CAN save an EMPTY location, just not an INVALID one
if (val === '') {
this.#lastValidatedLocation = '';
this.set_location_validation_state(true, '');
return;
}
// Clear previous error explanation while typing
if (this.profileLocationExplanation) {
setElementDisplay(this.profileLocationExplanation, 'none');
}
// Debounce to 500ms
this.#locationValidationTimeout = setTimeout(() => {
this.validateLocation(val);
}, 500);
}
/**
* Validates the specified location string using the utility endpoint.
* @param {string} locationToValidate
*/
async validateLocation(locationToValidate) {
const currentVal = this.profileLocationInput ? this.profileLocationInput.value.trim() : '';
if (currentVal !== locationToValidate) {
return;
}
if (currentVal === '') {
this.set_location_validation_state(true, '');
return;
}
const config = this.#storage.get_app_config();
const utilityModel = (config && config[this.#storage.KEY_CONFIG_UTILITY_MODEL]) ? config[this.#storage.KEY_CONFIG_UTILITY_MODEL] : 'gemini-3.1-fl';
try {
const response = await this.#api.evaluate_utility(currentVal, 'location', utilityModel);
// Check if user continued typing while request was in-flight
const latestVal = this.profileLocationInput ? this.profileLocationInput.value.trim() : '';
if (latestVal !== locationToValidate) {
return;
}
if (response && response.evaluation === true) {
this.#lastValidatedLocation = currentVal;
const explanation = (response && response.explanation) ? response.explanation : 'Valid location.';
this.set_location_validation_state(true, explanation);
} else {
const explanation = (response && response.explanation) ? response.explanation : 'Invalid location.';
this.set_location_validation_state(false, explanation);
}
} catch (error) {
const latestVal = this.profileLocationInput ? this.profileLocationInput.value.trim() : '';
if (latestVal !== locationToValidate) {
return;
}
console.error('Location validation error:', error);
this.set_location_validation_state(false, 'Unable to validate location.');
}
}
/**
* Sets the UI validation state for the profile location field.
* @param {boolean} isValid - Whether the location is valid or empty.
* @param {string} [explanation=''] - Explanation to display if invalid.
*/
set_location_validation_state(isValid, explanation = '') {
if (this.saveProfileButton) {
this.saveProfileButton.disabled = !isValid;
}
if (this.profileLocationExplanation) {
if (explanation) {
this.profileLocationExplanation.textContent = explanation;
this.profileLocationExplanation.className = `div-field-explanation ${isValid ? 'status-success' : 'status-error'}`;
setElementDisplay(this.profileLocationExplanation, 'block');
} else {
this.profileLocationExplanation.textContent = '';
setElementDisplay(this.profileLocationExplanation, 'none');
}
}
}
/**
* Handles profile save submission.
*/
handleSaveProfile() {
if (this.saveProfileButton && this.saveProfileButton.disabled) {
return;
}
const name = this.profileNameInput ? this.profileNameInput.value.trim() : '';
const email = this.profileEmailInput ? this.profileEmailInput.value.trim() : '';
const location = this.profileLocationInput ? this.profileLocationInput.value.trim() : '';
let detectedTimezone = null;
try {
detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (e) {
console.warn('Could not determine client timezone:', e);
}
this.#api.post(
{ action: 'update_profile', name, email, location, timezone: detectedTimezone },
(response) => {
if (response.success) {
this.displayProfileStatus('Profile saved successfully!', false);
this.loadProfile();
} else {
this.displayProfileStatus(response.error || response.message || 'Failed to save profile.', true);
}
},
(error) => {
this.displayProfileStatus('An error occurred while saving profile.', true);
console.error('Save profile error:', error);
},
'users'
);
}
/**
* Displays temporary status message on the user profile section.
* @param {string} message
* @param {boolean} isError
*/
displayProfileStatus(message, isError) {
if (!this.profileStatusDiv) return;
this.profileStatusDiv.textContent = message;
this.profileStatusDiv.className = `status-msg ${isError ? 'status-error' : 'status-success'}`;
setElementDisplay(this.profileStatusDiv, 'block');
setTimeout(() => {
if (this.profileStatusDiv) {
setElementDisplay(this.profileStatusDiv, 'none');
}
}, 4000);
}
/**
* Prompts the user with a confirmation dialog to refresh the session.
*/
async handleRefreshSessionPrompt() {
const confirmed = await customConfirm('Refresh Session', 'Do you want to refresh the session?');
if (!confirmed) {
return;
}
this.refreshSession();
}
/**
* Sends a session refresh request to the API backend to extend JWT expiration.
*/
refreshSession() {
this.#storage.init_location_id();
const token = this.#storage.get_auth_token();
const locationId = this.#storage.get_location_id();
if (!token) return;
this.#api.post(
{ action: 'refresh', token, location_id: locationId },
(response) => {
if (response && response.token) {
this.#storage.set_auth_token(response.token);
this.updateSessionExpirationDisplay();
this.displayProfileStatus('Session refreshed successfully!', false);
} else {
this.displayProfileStatus(response?.error || response?.message || 'Failed to refresh session.', true);
}
},
(error) => {
console.error('Session refresh error:', error);
this.displayProfileStatus('An error occurred while refreshing session.', true);
},
'users'
);
}
/**
* Decodes the payload portion of a JWT token string.
* @param {string} token
* @returns {object|null}
*/
decodeJwt(token) {
if (!token || typeof token !== 'string') return null;
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
let base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4 !== 0) {
base64 += '=';
}
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(jsonPayload);
} catch (e) {
console.warn('Failed to decode JWT:', e);
return null;
}
}
/**
* Updates the session expiration display in the user profile settings.
*/
updateSessionExpirationDisplay() {
if (!this.profileSessionExpirationSpan) return;
const token = this.#storage.get_auth_token();
if (!token) {
this.profileSessionExpirationSpan.textContent = '';
return;
}
const payload = this.decodeJwt(token);
if (!payload || !payload.exp) {
this.profileSessionExpirationSpan.textContent = 'Session Expiration: Unknown';
return;
}
const expDate = new Date(payload.exp * 1000);
if (isNaN(expDate.getTime())) {
this.profileSessionExpirationSpan.textContent = 'Session Expiration: Unknown';
return;
}
const formattedDate = expDate.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
this.profileSessionExpirationSpan.textContent = `Expires: ${formattedDate}`;
this.profileSessionExpirationSpan.title = 'Click to refresh session';
}
/**
* Displays authentication error on login page.
* @param {string} message
*/
displayError(message) {
if (this.errorMessageDiv) {
this.errorMessageDiv.textContent = message;
this.errorMessageDiv.style.color = '#ff6b6b';
setElementDisplay(this.errorMessageDiv, 'block');
}
}
/**
* Loads all registered users from the backend for the admin management view.
*/
loadAdminUsers() {
if (!Users.isAdmin) return;
this.#api.post(
{ action: 'get_all_users' },
(response) => {
if (response.success && Array.isArray(response.users)) {
this.renderAdminUsersTable(response.users);
this.populateAdminFundsUserSelect(response.users);
}
},
(error) => {
console.error('Error fetching admin users:', error);
},
'users'
);
}
/**
* Populates the user selection dropdown in the admin add funds form.
* @param {Array<object>} users
*/
populateAdminFundsUserSelect(users) {
if (!this.adminFundsUserSelect) return;
const currentValue = this.adminFundsUserSelect.value;
this.adminFundsUserSelect.innerHTML = '<option value="">-- Select User --</option>';
users.forEach((user) => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = `${user.username} (${user.name || 'No Name'}) - Balance: $${parseFloat(user.balance || 0).toFixed(4)}`;
this.adminFundsUserSelect.appendChild(option);
});
if (currentValue) {
this.adminFundsUserSelect.value = currentValue;
}
}
/**
* Renders the users list table within the admin management panel.
* @param {Array<object>} users
*/
renderAdminUsersTable(users) {
if (!this.adminUsersTbody) return;
this.adminUsersTbody.innerHTML = '';
users.forEach((user) => {
const tr = document.createElement('tr');
const isAdmin = Number(user.admin) === 1;
const balance = parseFloat(user.balance || 0).toFixed(4);
const cost = parseFloat(user.lifetime_spend || 0).toFixed(4);
tr.innerHTML = `
<td>
<strong>${this.escapeHtml(user.username)}</strong>
${user.name ? `<br/><small style="opacity: 0.7;">${this.escapeHtml(user.name)}</small>` : ''}
${user.email ? `<br/><small style="opacity: 0.7;">${this.escapeHtml(user.email)}</small>` : ''}
</td>
<td>
${isAdmin ? '<span class="badge-admin">Admin</span>' : '<span>User</span>'}
</td>
<td style="font-family: monospace;">$${balance}</td>
<td style="font-family: monospace;">$${cost}</td>
<td style="text-align: right; white-space: nowrap;">
<button type="button" class="as-icon btn-table-edit-user" data-id="${user.id}" style="margin-right: 4px;">Edit</button>
<button type="button" class="as-icon btn-table-add-funds" data-id="${user.id}" style="margin-right: 4px;">+ Funds</button>
<button type="button" class="as-icon btn-delete-user" data-id="${user.id}">Delete</button>
</td>
`;
const editBtn = tr.querySelector('.btn-table-edit-user');
if (editBtn) {
addSafeEventListener(editBtn, 'click', () => {
this.openEditUserPanel(user);
});
}
const addFundsBtn = tr.querySelector('.btn-table-add-funds');
if (addFundsBtn) {
addSafeEventListener(addFundsBtn, 'click', () => {
this.openAddFundsPanelForUser(user.id);
});
}
const deleteBtn = tr.querySelector('.btn-delete-user');
if (deleteBtn) {
addSafeEventListener(deleteBtn, 'click', async () => {
if (await customConfirm('Delete User', `Are you sure you want to delete user "${user.username}"?`)) {
this.handleDeleteUser(user.id);
}
});
}
this.adminUsersTbody.appendChild(tr);
});
}
/**
* Opens Edit User panel with the selected user's details.
* @param {object} user
*/
openEditUserPanel(user) {
if (this.adminCreateUserPanel) setElementDisplay(this.adminCreateUserPanel, 'none');
if (this.adminAddFundsPanel) setElementDisplay(this.adminAddFundsPanel, 'none');
if (this.adminEditUserPanel) setElementDisplay(this.adminEditUserPanel, 'block');
if (this.editUserIdInput) this.editUserIdInput.value = user.id;
if (this.editUsernameDisplay) this.editUsernameDisplay.textContent = user.username;
if (this.editUsernameInput) this.editUsernameInput.value = user.username;
if (this.editPasswordInput) this.editPasswordInput.value = '';
if (this.editNameInput) this.editNameInput.value = user.name || '';
if (this.editEmailInput) this.editEmailInput.value = user.email || '';
if (this.editRoleSelect) this.editRoleSelect.value = Number(user.admin) === 1 ? 'admin' : 'user';
}
/**
* Handles saving edited user changes (including password update).
*/
handleSaveEditUser() {
if (!this.editUserIdInput || !this.editUsernameInput) return;
const id = parseInt(this.editUserIdInput.value, 10);
const username = this.editUsernameInput.value.trim();
const password = this.editPasswordInput ? this.editPasswordInput.value.trim() : '';
const name = this.editNameInput ? this.editNameInput.value.trim() : '';
const email = this.editEmailInput ? this.editEmailInput.value.trim() : '';
const role = this.editRoleSelect ? this.editRoleSelect.value : 'user';
if (!id || !username) {
this.displayAdminStatus('User ID and username are required.', true);
return;
}
const payload = {
action: 'add_or_update_user',
id,
username,
name,
email,
admin: role === 'admin'
};
if (password) {
payload.passcode = password;
}
this.#api.post(
payload,
(response) => {
if (response.success) {
this.displayAdminStatus(`User "${username}" updated successfully!`, false);
this.resetEditUserPanel();
this.loadProfile();
this.loadAdminUsers();
} else {
this.displayAdminStatus(response.error || response.message || 'Failed to update user.', true);
}
},
(error) => {
this.displayAdminStatus('An error occurred while updating user.', true);
console.error('Update user error:', error);
},
'users'
);
}
/**
* Resets and closes the admin edit user panel.
*/
resetEditUserPanel() {
if (this.adminEditUserPanel) {
setElementDisplay(this.adminEditUserPanel, 'none');
}
if (this.editUserIdInput) this.editUserIdInput.value = '';
if (this.editUsernameDisplay) this.editUsernameDisplay.textContent = '';
if (this.editUsernameInput) this.editUsernameInput.value = '';
if (this.editPasswordInput) this.editPasswordInput.value = '';
if (this.editNameInput) this.editNameInput.value = '';
if (this.editEmailInput) this.editEmailInput.value = '';
if (this.editRoleSelect) this.editRoleSelect.value = 'user';
}
/**
* Opens Add Funds panel pre-selecting a specific user.
* @param {string|number} userId
*/
openAddFundsPanelForUser(userId) {
if (this.adminCreateUserPanel) setElementDisplay(this.adminCreateUserPanel, 'none');
if (this.adminEditUserPanel) setElementDisplay(this.adminEditUserPanel, 'none');
if (this.adminAddFundsPanel) setElementDisplay(this.adminAddFundsPanel, 'block');
if (this.adminFundsUserSelect) this.adminFundsUserSelect.value = String(userId);
if (this.adminFundsAmountInput) this.adminFundsAmountInput.focus();
}
/**
* Handles adding funds to a user from the admin Add Funds form.
*/
handleAddFundsFromAdmin() {
if (!this.adminFundsUserSelect || !this.adminFundsAmountInput) return;
const userId = this.adminFundsUserSelect.value;
const amount = parseFloat(this.adminFundsAmountInput.value);
if (!userId) {
this.displayAdminStatus('Please select a user.', true);
return;
}
if (isNaN(amount) || amount <= 0) {
this.displayAdminStatus('Please enter a valid amount greater than 0.', true);
return;
}
this.#api.post(
{ action: 'add_funds', id: userId, amount },
(response) => {
if (response.success) {
this.displayAdminStatus(`Successfully added $${amount.toFixed(2)} in funds!`, false);
this.resetAddFundsPanel();
this.loadProfile();
this.loadAdminUsers();
} else {
this.displayAdminStatus(response.error || response.message || 'Failed to add funds.', true);
}
},
(error) => {
this.displayAdminStatus('An error occurred while adding funds.', true);
console.error('Add funds error:', error);
},
'users'
);
}
/**
* Resets and closes the admin add funds panel.
*/
resetAddFundsPanel() {
if (this.adminAddFundsPanel) {
setElementDisplay(this.adminAddFundsPanel, 'none');
}
if (this.adminFundsAmountInput) this.adminFundsAmountInput.value = '10.00';
}
/**
* Handles creating a new user from the admin create panel.
*/
handleCreateUser() {
const username = this.newUsernameInput ? this.newUsernameInput.value.trim() : '';
const password = this.newPasswordInput ? this.newPasswordInput.value.trim() : '';
const name = this.newNameInput ? this.newNameInput.value.trim() : '';
const email = this.newEmailInput ? this.newEmailInput.value.trim() : '';
const role = this.newRoleSelect ? this.newRoleSelect.value : 'user';
const balance = this.newBalanceInput ? parseFloat(this.newBalanceInput.value) || 0 : 0;
if (!username || !password) {
this.displayAdminStatus('Username and password are required.', true);
return;
}
this.#api.post(
{
action: 'add_or_update_user',
username,
passcode: password,
name,
email,
admin: role === 'admin',
initial_balance: balance
},
(response) => {
if (response.success) {
this.displayAdminStatus('User created successfully!', false);
this.resetCreateUserPanel();
this.loadAdminUsers();
} else {
this.displayAdminStatus(response.error || response.message || 'Failed to create user.', true);
}
},
(error) => {
this.displayAdminStatus('An error occurred while creating user.', true);
console.error('Create user error:', error);
},
'users'
);
}
/**
* Resets and closes the admin create user panel.
*/
resetCreateUserPanel() {
if (this.adminCreateUserPanel) {
setElementDisplay(this.adminCreateUserPanel, 'none');
}
if (this.newUsernameInput) this.newUsernameInput.value = '';
if (this.newPasswordInput) this.newPasswordInput.value = '';
if (this.newNameInput) this.newNameInput.value = '';
if (this.newEmailInput) this.newEmailInput.value = '';
if (this.newRoleSelect) this.newRoleSelect.value = 'user';
if (this.newBalanceInput) this.newBalanceInput.value = '0.00';
}
/**
* Handles deleting a user by ID.
* @param {number|string} userId
*/
handleDeleteUser(userId) {
this.#api.post(
{ action: 'delete_user', id: userId },
(response) => {
if (response.success) {
this.displayAdminStatus('User deleted successfully!', false);
this.loadAdminUsers();
} else {
this.displayAdminStatus(response.error || response.message || 'Failed to delete user.', true);
}
},
(error) => {
this.displayAdminStatus('An error occurred while deleting user.', true);
console.error('Delete user error:', error);
},
'users'
);
}
/**
* Opens the Config Management dialog overlay and loads configs.
*/
openConfigDialog() {
if (!Users.isAdmin) return;
this.resetCreateConfigPanel();
this.resetEditConfigPanel();
this.loadAdminConfigs();
if (this.configDialogOverlay) {
showOverlay(this.configDialogOverlay);
}
}
/**
* Closes the Config Management dialog overlay.
*/
closeConfigDialog() {
if (this.configDialogOverlay) {
hideOverlay(this.configDialogOverlay);
}
}
/**
* Loads all configuration records from the server.
*/
loadAdminConfigs() {
if (!Users.isAdmin) return;
this.#api.post(
{ action: 'get_all' },
(response) => {
if (response.success && Array.isArray(response.configs)) {
this.renderAdminConfigsTable(response.configs);
if (response.known_keys && Array.isArray(response.known_keys) && this.configKnownKeysList) {
this.configKnownKeysList.textContent = response.known_keys.join(", ");
}
} else {
console.error('Failed to load configs:', response.error || response.message);
}
},
(error) => {
console.error('Error fetching configs:', error);
},
'config'
);
}
/**
* Renders configuration entries into the table.
* @param {Array<object>} configs
*/
renderAdminConfigsTable(configs) {
if (!this.configItemsTbody) return;
this.configItemsTbody.innerHTML = '';
if (configs.length === 0) {
const tr = document.createElement('tr');
tr.innerHTML = '<td colspan="4" style="text-align: center; opacity: 0.6; padding: 12px;">No configuration entries found.</td>';
this.configItemsTbody.appendChild(tr);
return;
}
configs.forEach((item) => {
const tr = document.createElement('tr');
const keyTd = document.createElement('td');
keyTd.style.fontFamily = 'monospace';
keyTd.style.fontWeight = 'bold';
keyTd.textContent = item.key || '';
const descTd = document.createElement('td');
descTd.style.opacity = '0.85';
descTd.textContent = item.description || '';
const valTd = document.createElement('td');
valTd.style.fontFamily = 'monospace';
valTd.style.maxWidth = '240px';
valTd.style.whiteSpace = 'nowrap';
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'as-icon btn-copy-config-value';
copyBtn.title = 'Copy Value';
copyBtn.textContent = 'Copy';
addSafeEventListener(copyBtn, 'click', async (e) => {
if (e) e.stopPropagation();
await copyToClipboard(item.value || '', copyBtn);
});
const valSpan = document.createElement('span');
valSpan.style.overflow = 'hidden';
valSpan.style.textOverflow = 'ellipsis';
valSpan.style.display = 'inline-block';
valSpan.style.maxWidth = '200px';
valSpan.style.verticalAlign = 'middle';
valSpan.title = item.value || '';
valSpan.textContent = item.value || '';
valTd.appendChild(copyBtn);
valTd.appendChild(valSpan);
const actionTd = document.createElement('td');
actionTd.style.textAlign = 'right';
actionTd.style.whiteSpace = 'nowrap';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'as-icon btn-table-edit-config';
editBtn.style.marginRight = '4px';
editBtn.textContent = 'Edit';
addSafeEventListener(editBtn, 'click', () => {
this.openEditConfigPanel(item);
});
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'as-icon btn-table-delete-config';
deleteBtn.textContent = 'Delete';
addSafeEventListener(deleteBtn, 'click', async () => {
if (await customConfirm('Delete Config', `Are you sure you want to delete config key "${item.key}"?`)) {
this.handleDeleteConfig(item.id);
}
});
actionTd.appendChild(editBtn);
actionTd.appendChild(deleteBtn);
tr.appendChild(keyTd);
tr.appendChild(descTd);
tr.appendChild(valTd);
tr.appendChild(actionTd);
this.configItemsTbody.appendChild(tr);
});
}
/**
* Opens the edit config panel and populates its fields.
* @param {object} config
*/
openEditConfigPanel(config) {
if (this.configCreatePanel) setElementDisplay(this.configCreatePanel, 'none');
if (this.configEditPanel) setElementDisplay(this.configEditPanel, 'block');
if (this.configEditIdInput) this.configEditIdInput.value = config.id;
if (this.configEditKeyDisplay) this.configEditKeyDisplay.textContent = config.key;
if (this.configEditKeyInput) this.configEditKeyInput.value = config.key || '';
if (this.configEditDescriptionInput) this.configEditDescriptionInput.value = config.description || '';
if (this.configEditValueInput) this.configEditValueInput.value = config.value || '';
}
/**
* Handles creating a new config entry.
*/
handleCreateConfig() {
if (!this.newConfigKeyInput) return;
const key = this.newConfigKeyInput.value.trim();
const description = this.newConfigDescriptionInput ? this.newConfigDescriptionInput.value.trim() : '';
const value = this.newConfigValueInput ? this.newConfigValueInput.value : '';
if (!key) {
this.displayConfigStatus('Key is required.', true);
return;
}
this.#api.post(
{
action: 'add_or_update',
key,
description,
value
},
(response) => {
if (response.success) {
this.displayConfigStatus(`Config "${key}" created successfully!`, false);
this.resetCreateConfigPanel();
this.loadAdminConfigs();
} else {
this.displayConfigStatus(response.error || response.message || 'Failed to create config.', true);
}
},
(error) => {
this.displayConfigStatus('An error occurred while creating config.', true);
console.error('Create config error:', error);
},
'config'
);
}
/**
* Handles saving edits to a config entry.
*/
handleSaveEditConfig() {
if (!this.configEditIdInput || !this.configEditKeyInput) return;
const id = parseInt(this.configEditIdInput.value, 10);
const key = this.configEditKeyInput.value.trim();
const description = this.configEditDescriptionInput ? this.configEditDescriptionInput.value.trim() : '';
const value = this.configEditValueInput ? this.configEditValueInput.value : '';
if (!id || !key) {
this.displayConfigStatus('Config ID and key are required.', true);
return;
}
this.#api.post(
{
action: 'add_or_update',
id,
key,
description,
value
},
(response) => {
if (response.success) {
this.displayConfigStatus(`Config "${key}" updated successfully!`, false);
this.resetEditConfigPanel();
this.loadAdminConfigs();
} else {
this.displayConfigStatus(response.error || response.message || 'Failed to update config.', true);
}
},
(error) => {
this.displayConfigStatus('An error occurred while updating config.', true);
console.error('Update config error:', error);
},
'config'
);
}
/**
* Handles deleting a config entry.
* @param {number|string} id
*/
handleDeleteConfig(id) {
this.#api.post(
{ action: 'delete', id },
(response) => {
if (response.success) {
this.displayConfigStatus('Config deleted successfully!', false);
this.loadAdminConfigs();
} else {
this.displayConfigStatus(response.error || response.message || 'Failed to delete config.', true);
}
},
(error) => {
this.displayConfigStatus('An error occurred while deleting config.', true);
console.error('Delete config error:', error);
},
'config'
);
}
/**
* Resets and closes the create config panel.
*/
resetCreateConfigPanel() {
if (this.configCreatePanel) setElementDisplay(this.configCreatePanel, 'none');
if (this.newConfigKeyInput) this.newConfigKeyInput.value = '';
if (this.newConfigDescriptionInput) this.newConfigDescriptionInput.value = '';
if (this.newConfigValueInput) this.newConfigValueInput.value = '';
}
/**
* Resets and closes the edit config panel.
*/
resetEditConfigPanel() {
if (this.configEditPanel) setElementDisplay(this.configEditPanel, 'none');
if (this.configEditIdInput) this.configEditIdInput.value = '';
if (this.configEditKeyDisplay) this.configEditKeyDisplay.textContent = '';
if (this.configEditKeyInput) this.configEditKeyInput.value = '';
if (this.configEditDescriptionInput) this.configEditDescriptionInput.value = '';
if (this.configEditValueInput) this.configEditValueInput.value = '';
}
/**
* Displays a status message in the config management panel.
* @param {string} message
* @param {boolean} isError
*/
displayConfigStatus(message, isError) {
if (!this.configStatusDiv) return;
this.configStatusDiv.textContent = message;
this.configStatusDiv.className = `status-msg ${isError ? 'status-error' : 'status-success'}`;
setElementDisplay(this.configStatusDiv, 'block');
setTimeout(() => {
if (this.configStatusDiv) {
setElementDisplay(this.configStatusDiv, 'none');
}
}, 4000);
}
/**
* Displays a temporary status message in the admin panel.
* @param {string} message - Status message text.
* @param {boolean} isError - Whether the status represents an error.
*/
displayAdminStatus(message, isError) {
if (this.adminStatusDiv) {
this.adminStatusDiv.textContent = message;
this.adminStatusDiv.className = isError ? 'status-msg status-error' : 'status-msg status-success';
setElementDisplay(this.adminStatusDiv, 'block');
setTimeout(() => {
setElementDisplay(this.adminStatusDiv, 'none');
}, 4000);
}
}
/**
* Utility method to escape HTML text.
* @param {string} str
* @returns {string}
*/
escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
}
export default Users;