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';
import { escapeHtml } from './util/format-utils.js';
/**
* Users authentication, profile management, and administrative control.
*/
class Users {
static instance = null;
static isAdmin = false;
static currentUser = null;
/**
* Shows or hides the admin-only settings (overlay tab, admin form, config
* button) based on Users.isAdmin.
*/
static update_admin_visibility() {
setElementDisplay(getEl('id-btn-settings-tab-admin'), Users.isAdmin ? '' : 'none');
setElementDisplay(getEl('id-form-admin-options'), Users.isAdmin ? 'block' : 'none');
setElementDisplay(getEl('btn-admin-config'), Users.isAdmin ? 'inline-block' : 'none');
}
#api;
#storage;
// Auth elements
loginForm;
usernameInput;
passcodeInput;
loginButton;
errorMessageDiv;
logoutButton;
userSwitcherSelect;
loginCloseButton;
loginOverlay;
loginBackdrop;
appContainer;
// Profile Form Elements
profileForm;
profileNameInput;
profileEmailInput;
profileLocationInput;
profileLocationExplanation;
profileTimezoneInput;
profilePasswordHintInput;
profileOldPasswordInput;
profileNewPasswordInput;
profileRepeatPasswordRow;
profileRepeatPasswordInput;
profileOldPasswordRow;
profileStatusDiv;
saveProfileButton;
profileSessionExpirationSpan;
#locationValidationTimeout = null;
#lastValidatedLocation = "";
#locationValid = true;
#passwordsValid = true;
// Account Elements
accountBalanceSpan;
accountCostMonthSpan;
accountCostAllSpan;
// Admin Elements - Main
btnAdminShowCreateUser;
btnAdminShowAddFunds;
btnAdminConfig;
adminUsersTbody;
adminStatusDiv;
// Admin Elements - Create User
adminCreateUserPanel;
newUsernameInput;
newPasswordInput;
newNameInput;
newEmailInput;
newRoleSelect;
newExperimentalCheckbox;
newBalanceInput;
btnAdminCancelCreate;
btnAdminSubmitCreate;
// Admin Elements - Edit User
adminEditUserPanel;
editUserIdInput;
editUsernameDisplay;
editUsernameInput;
editPasswordInput;
editNameInput;
editEmailInput;
editRoleSelect;
editExperimentalCheckbox;
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');
this.usernameInput = getEl('username');
this.passcodeInput = getEl('passcode');
this.loginButton = getEl('login-button');
this.errorMessageDiv = getEl('login-error-message');
this.logoutButton = getEl('id-btn-logout');
this.userSwitcherSelect = getEl('id-select-user-switcher');
this.loginCloseButton = getEl('btn-login-close');
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.profilePasswordHintInput = getEl('profile-password-hint');
this.profileOldPasswordInput = getEl('profile-old-password');
this.profileNewPasswordInput = getEl('profile-new-password');
this.profileRepeatPasswordRow = getEl('id-div-row-profile-repeat-password');
this.profileRepeatPasswordInput = getEl('profile-repeat-password');
this.profileOldPasswordRow = getEl('id-div-row-profile-old-password');
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.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.newExperimentalCheckbox = getEl('new-user-experimental');
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.editExperimentalCheckbox = getEl('admin-edit-experimental');
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();
});
addSafeEventListener(this.userSwitcherSelect, 'change', () => {
this.handleUserSwitcherChange();
});
addSafeEventListener(this.loginCloseButton, 'click', (e) => {
if (e) e.preventDefault();
this.handleLoginClose();
});
// 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.profileNewPasswordInput) {
addSafeEventListener(this.profileNewPasswordInput, 'input', () => this.updatePasswordFields());
}
if (this.profileRepeatPasswordInput) {
addSafeEventListener(this.profileRepeatPasswordInput, 'input', () => this.updatePasswordFields());
}
if (this.profileOldPasswordInput) {
addSafeEventListener(this.profileOldPasswordInput, 'input', () => this.updatePasswordFields());
}
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();
this.#show_panel(this.adminCreateUserPanel, [this.adminAddFundsPanel, this.adminEditUserPanel], true);
});
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();
this.#show_panel(this.adminAddFundsPanel, [this.adminCreateUserPanel, this.adminEditUserPanel], true);
});
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();
this.#show_panel(this.configCreatePanel, [this.configEditPanel], true);
});
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 one panel and hides the others; a null target only hides.
* @param {HTMLElement|null} target - Panel to show.
* @param {Array<HTMLElement|null>} others - Panels to hide.
* @param {boolean} [toggle=false] - Collapse the target when already visible.
*/
#show_panel(target, others, toggle = false) {
for (const el of others) {
if (el) setElementDisplay(el, 'none');
}
if (!target) return;
const visible = toggle && target.style.display && target.style.display !== 'none';
setElementDisplay(target, visible ? 'none' : 'block');
}
/**
* Shows a temporary status message in one of the settings panels.
* @param {HTMLElement|null} div - The status message container.
* @param {string} message - The message text.
* @param {boolean} isError - Whether the message represents an error.
*/
#show_status(div, message, isError) {
if (!div) return;
div.textContent = message;
div.className = `status-msg ${isError ? 'status-error' : 'status-success'}`;
setElementDisplay(div, 'block');
setTimeout(() => {
setElementDisplay(div, 'none');
}, 4000);
}
/**
* Sets an input's value when it exists.
* @param {HTMLInputElement|HTMLSelectElement|null} input - The input.
* @param {string} value - The value to set.
*/
#set_input(input, value) {
if (input) input.value = value;
}
/**
* Sets an element's text when it exists.
* @param {HTMLElement|null} el - The element.
* @param {string} text - The text to set.
*/
#set_text(el, text) {
if (el) el.textContent = text;
}
/**
* Clears the values of the given inputs.
* @param {Array<HTMLInputElement|HTMLSelectElement|null>} inputs - The inputs.
*/
#clear_fields(inputs) {
for (const input of inputs) {
this.#set_input(input, '');
}
}
/**
* Returns the client's IANA timezone, or null when it cannot be determined.
* @returns {string|null}
*/
#detect_timezone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (e) {
console.warn('Could not determine client timezone:', e);
return null;
}
}
/**
* Sends a management request and reports the outcome through a status
* message: the server's error for a rejected action, a generic message for
* a failed request, and the given work runs on success.
* @param {Object} payload - Request payload including the action.
* @param {string} fn - The API function ('users' or 'config').
* @param {HTMLElement|null} status - Status message container.
* @param {string} failed - Message when the API rejects the action.
* @param {string} action - Present-tense phrase for request-failure messages.
* @param {Function|null} [on_success=null] - Runs after a successful request.
*/
async #submit(payload, fn, status, failed, action, on_success = null) {
try {
const response = await this.#api.request(payload, fn);
if (response?.success) {
on_success?.();
return;
}
this.#show_status(status, response?.error || response?.message || failed, true);
} catch (error) {
console.error(`Failed to ${action}:`, error);
this.#show_status(status, `An error occurred while ${action}.`, true);
}
}
/**
* Shows login prompt and backdrop. The close button is only offered when a
* valid session is still active behind the dialog (e.g. "+ New Login").
*/
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');
}
if (this.loginCloseButton) {
const dismissible = !!(this.#storage.get_active_user_id() && this.#storage.get_auth_token());
setElementDisplay(this.loginCloseButton, dismissible ? 'block' : 'none');
}
fadeOutLoader();
}
/**
* Dismisses the login dialog and returns to the still-active user's session.
*/
handleLoginClose() {
if (this.loginBackdrop) {
this.loginBackdrop.classList.remove('active');
setElementDisplay(this.loginBackdrop, 'none');
}
if (this.loginOverlay) {
this.loginOverlay.classList.remove('active');
setElementDisplay(this.loginOverlay, 'none');
}
this.#clear_fields([this.usernameInput, this.passcodeInput]);
if (this.errorMessageDiv) setElementDisplay(this.errorMessageDiv, 'none');
this.showApp();
}
/**
* 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.
*/
async 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;
}
const locationId = this.#storage.get_login_location(username);
try {
const response = await this.#api.request(
{ action: 'login', username, passcode, location_id: locationId, timezone: this.#detect_timezone() },
'users'
);
if (response.success && response.token) {
this.#storage.set_active_user_auth(response.user_id, response.token, locationId);
window.location.reload();
} else {
let message = response.error || response.message || 'Login failed. Please check credentials.';
if (response.hint) {
message += ' Hint: ' + response.hint;
}
this.displayError(message);
}
} catch (error) {
console.error('Login error:', error);
this.displayError(error && error.message ? `Connection error: ${error.message}` : 'Connection error. Please try again.');
}
}
/**
* Handles session revocation and logging out.
*/
handleLogout() {
this.#api.request(
{ action: 'logout', location_id: this.#storage.get_location_id(), token: this.#storage.get_auth_token() },
'users'
).catch((error) => console.error('Logout error:', error)).finally(() => {
this.#storage.clear_active_session();
window.location.reload();
});
}
/**
* Handles a selection change in the footer user switcher.
*/
handleUserSwitcherChange() {
if (!this.userSwitcherSelect) return;
const value = this.userSwitcherSelect.value;
if (value === 'new') {
this.update_user_switcher();
this.showLoginForm();
return;
}
if (value && String(value) !== String(this.#storage.get_active_user_id())) {
this.handle_switch_user(value);
}
}
/**
* Validates and refreshes the selected user's JWT, then reloads the app as that user.
* On failure the user's cached session is dropped and the login form is shown.
* @param {string|number} userId
*/
async handle_switch_user(userId) {
const previousUserId = this.#storage.get_active_user_id();
this.#storage.set_active_user_id(userId);
const token = this.#storage.get_auth_token();
if (!token) {
this.#storage.clear_active_session();
this.update_user_switcher();
this.showLoginForm();
return;
}
try {
const response = await this.#api.request(
{ action: 'session', token, location_id: this.#storage.init_location_id() },
'users'
);
if (response && response.isLoggedIn) {
this.#storage.complete_authentication(response.user, response.token);
sessionStorage.setItem('pwa-skip-refresh', 'switching');
window.location.reload();
} else {
this.#storage.clear_active_session();
this.update_user_switcher();
this.showLoginForm();
}
} catch (error) {
console.error('User switch error:', error);
if (previousUserId !== null) {
this.#storage.set_active_user_id(previousUserId);
}
this.update_user_switcher();
}
}
/**
* Rebuilds the footer user switcher options from the logged-in user registry,
* with a "+ New Login" option at the end.
*/
update_user_switcher() {
if (!this.userSwitcherSelect) return;
const users = this.#storage.get_logged_in_users();
const activeUserId = this.#storage.get_active_user_id();
this.userSwitcherSelect.innerHTML = '';
users.forEach((user) => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = user.name || user.username || `User ${user.id}`;
this.userSwitcherSelect.appendChild(option);
});
const newLoginOption = document.createElement('option');
newLoginOption.value = 'new';
newLoginOption.textContent = '+ New Login';
this.userSwitcherSelect.appendChild(newLoginOption);
const hasActive = users.some(u => String(u.id) === String(activeUserId));
this.userSwitcherSelect.value = hasActive ? String(activeUserId) : 'new';
}
/**
* 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.
*/
async checkLoginStatus(onAuthenticated = null, onUnauthenticated = null) {
const token = this.#storage.get_auth_token();
if (!token) {
Users.isAdmin = false;
Users.currentUser = null;
this.showLoginForm();
onUnauthenticated?.();
return;
}
try {
const response = await this.#api.request(
{ action: 'session', token, location_id: this.#storage.init_location_id() },
'users'
);
if (response && response.isLoggedIn) {
if (response.token) {
this.#storage.complete_authentication(response.user, response.token);
}
Users.isAdmin = !!response.admin || !!response.isAdmin;
Users.currentUser = response.user || null;
this.showApp();
this.applyUserSession();
this.updateSessionExpirationDisplay();
onAuthenticated?.(response);
} else {
this.#storage.clear_active_session();
Users.isAdmin = false;
Users.currentUser = null;
this.showLoginForm();
onUnauthenticated?.();
}
} catch (error) {
console.error('Session check error:', error);
this.#storage.clear_auth_token();
Users.isAdmin = false;
Users.currentUser = null;
this.showLoginForm();
onUnauthenticated?.();
}
}
/**
* Applies session user state to application UI.
*/
applyUserSession() {
Users.update_admin_visibility();
if (this.logoutButton) {
setElementDisplay(this.logoutButton, 'inline-block');
}
this.update_user_switcher();
this.loadProfile();
this.updateSessionExpirationDisplay();
if (Users.isAdmin) {
this.loadAdminUsers();
}
}
/**
* Fetches user profile, spend, and balance information.
*/
async loadProfile() {
let response;
try {
response = await this.#api.request({ action: 'get_profile' }, 'users');
} catch (error) {
console.error('Profile fetch error:', error);
return;
}
if (!response?.success || !response.profile) return;
const profile = response.profile;
this.#set_input(this.profileNameInput, profile.name || '');
this.#set_input(this.profileEmailInput, profile.email || '');
this.#set_input(this.profileLocationInput, profile.location_string || '');
this.#lastValidatedLocation = profile.location_string ? profile.location_string.trim() : '';
this.set_location_validation_state(true, '');
this.#set_input(this.profileTimezoneInput, profile.latest_timezone_code || '');
this.#set_input(this.profilePasswordHintInput, profile.password_hint || '');
const balance = parseFloat(profile.balance || 0);
const lifetimeSpend = parseFloat(profile.lifetime_spend || 0);
this.#set_text(this.accountBalanceSpan, `$${balance.toFixed(6)}`);
this.#set_text(this.accountCostMonthSpan, `$${lifetimeSpend.toFixed(6)}`);
this.#set_text(this.accountCostAllSpan, `$${lifetimeSpend.toFixed(6)}`);
Users.currentUser = profile;
Users.isAdmin = Number(profile.admin) === 1;
Users.update_admin_visibility();
this.updateSessionExpirationDisplay();
}
/**
* 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
this.#locationValid = false;
this.updateProfileSaveState();
// 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.5-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 = '') {
this.#locationValid = isValid;
this.updateProfileSaveState();
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');
}
}
}
/**
* Enables the profile save button only when location and password validation both pass.
*/
updateProfileSaveState() {
if (this.saveProfileButton) {
this.saveProfileButton.disabled = !(this.#locationValid && this.#passwordsValid);
}
}
/**
* Shows the repeat password field once a new password is entered, shows the
* old password field once the repeat matches, and toggles the save button.
*/
updatePasswordFields() {
const newPassword = this.profileNewPasswordInput ? this.profileNewPasswordInput.value : '';
const repeatPassword = this.profileRepeatPasswordInput ? this.profileRepeatPasswordInput.value : '';
const oldPassword = this.profileOldPasswordInput ? this.profileOldPasswordInput.value : '';
const changing = newPassword !== '';
const matching = changing && repeatPassword === newPassword;
if (this.profileRepeatPasswordRow) {
setElementDisplay(this.profileRepeatPasswordRow, changing ? '' : 'none');
}
if (this.profileOldPasswordRow) {
setElementDisplay(this.profileOldPasswordRow, matching ? '' : 'none');
}
this.#passwordsValid = !changing || (matching && oldPassword !== '');
this.updateProfileSaveState();
}
/**
* Handles profile save submission.
*/
async handleSaveProfile() {
if (this.saveProfileButton && this.saveProfileButton.disabled) {
return;
}
const payload = {
action: 'update_profile',
name: this.profileNameInput ? this.profileNameInput.value.trim() : '',
email: this.profileEmailInput ? this.profileEmailInput.value.trim() : '',
location: this.profileLocationInput ? this.profileLocationInput.value.trim() : '',
timezone: this.#detect_timezone(),
password_hint: this.profilePasswordHintInput ? this.profilePasswordHintInput.value.trim() : ''
};
// Only send a password change when a new password was entered; the old password is then required
const newPassword = this.profileNewPasswordInput ? this.profileNewPasswordInput.value : '';
if (newPassword !== '') {
const oldPassword = this.profileOldPasswordInput ? this.profileOldPasswordInput.value : '';
if (oldPassword === '') {
this.#show_status(this.profileStatusDiv, 'Old password is required to change your password.', true);
return;
}
payload.old_password = oldPassword;
payload.new_password = newPassword;
}
try {
const response = await this.#api.request(payload, 'users');
if (response.success) {
this.#show_status(this.profileStatusDiv, 'Profile saved successfully!', false);
if (newPassword !== '') {
this.#clear_fields([this.profileOldPasswordInput, this.profileNewPasswordInput, this.profileRepeatPasswordInput]);
this.updatePasswordFields();
}
this.loadProfile();
} else {
this.#show_status(this.profileStatusDiv, response.error || response.message || 'Failed to save profile.', true);
}
} catch (error) {
console.error('Save profile error:', error);
this.#show_status(this.profileStatusDiv, 'An error occurred while saving profile.', true);
}
}
/**
* 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.
*/
async refreshSession() {
this.#storage.init_location_id();
const token = this.#storage.get_auth_token();
if (!token) return;
try {
const response = await this.#api.request(
{ action: 'refresh', token, location_id: this.#storage.get_location_id() },
'users'
);
if (response && response.token) {
this.#storage.set_auth_token(response.token);
this.updateSessionExpirationDisplay();
this.#show_status(this.profileStatusDiv, 'Session refreshed successfully!', false);
} else {
this.#show_status(this.profileStatusDiv, response?.error || response?.message || 'Failed to refresh session.', true);
}
} catch (error) {
console.error('Session refresh error:', error);
this.#show_status(this.profileStatusDiv, 'An error occurred while refreshing session.', true);
}
}
/**
* 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.
*/
async loadAdminUsers() {
if (!Users.isAdmin) return;
try {
const response = await this.#api.request({ action: 'get_all_users' }, 'users');
if (response?.success && Array.isArray(response.users)) {
this.renderAdminUsersTable(response.users);
this.populateAdminFundsUserSelect(response.users);
}
} catch (error) {
console.error('Error fetching admin users:', error);
}
}
/**
* 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>${escapeHtml(user.username)}</strong>
${user.name ? `<br/><small style="opacity: 0.7;">${escapeHtml(user.name)}</small>` : ''}
${user.email ? `<br/><small style="opacity: 0.7;">${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) {
this.#show_panel(this.adminEditUserPanel, [this.adminCreateUserPanel, this.adminAddFundsPanel]);
this.#set_input(this.editUserIdInput, user.id);
this.#set_text(this.editUsernameDisplay, user.username);
this.#set_input(this.editUsernameInput, user.username);
this.#set_input(this.editPasswordInput, '');
this.#set_input(this.editNameInput, user.name || '');
this.#set_input(this.editEmailInput, user.email || '');
this.#set_input(this.editRoleSelect, Number(user.admin) === 1 ? 'admin' : 'user');
if (this.editExperimentalCheckbox) this.editExperimentalCheckbox.checked = Number(user.experimental) === 1;
}
/**
* Handles saving edited user changes (including password update).
*/
async 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';
const experimental = this.editExperimentalCheckbox ? this.editExperimentalCheckbox.checked : false;
if (!id || !username) {
this.#show_status(this.adminStatusDiv, 'User ID and username are required.', true);
return;
}
const payload = {
action: 'add_or_update_user',
id,
username,
name,
email,
admin: role === 'admin',
experimental
};
if (password) {
payload.passcode = password;
}
await this.#submit(payload, 'users', this.adminStatusDiv, 'Failed to update user.', 'update user', () => {
this.#show_status(this.adminStatusDiv, `User "${username}" updated successfully!`, false);
this.resetEditUserPanel();
this.loadProfile();
this.loadAdminUsers();
});
}
/**
* Resets and closes the admin edit user panel.
*/
resetEditUserPanel() {
this.#show_panel(null, [this.adminEditUserPanel]);
this.#clear_fields([this.editUserIdInput, this.editUsernameInput, this.editPasswordInput, this.editNameInput, this.editEmailInput]);
this.#set_text(this.editUsernameDisplay, '');
this.#set_input(this.editRoleSelect, 'user');
if (this.editExperimentalCheckbox) this.editExperimentalCheckbox.checked = false;
}
/**
* Opens Add Funds panel pre-selecting a specific user.
* @param {string|number} userId
*/
openAddFundsPanelForUser(userId) {
this.#show_panel(this.adminAddFundsPanel, [this.adminCreateUserPanel, this.adminEditUserPanel]);
this.#set_input(this.adminFundsUserSelect, String(userId));
if (this.adminFundsAmountInput) this.adminFundsAmountInput.focus();
}
/**
* Handles adding funds to a user from the admin Add Funds form.
*/
async handleAddFundsFromAdmin() {
if (!this.adminFundsUserSelect || !this.adminFundsAmountInput) return;
const userId = this.adminFundsUserSelect.value;
const amount = parseFloat(this.adminFundsAmountInput.value);
if (!userId) {
this.#show_status(this.adminStatusDiv, 'Please select a user.', true);
return;
}
if (isNaN(amount) || amount <= 0) {
this.#show_status(this.adminStatusDiv, 'Please enter a valid amount greater than 0.', true);
return;
}
await this.#submit({ action: 'add_funds', id: userId, amount }, 'users', this.adminStatusDiv, 'Failed to add funds.', 'add funds', () => {
this.#show_status(this.adminStatusDiv, `Successfully added $${amount.toFixed(2)} in funds!`, false);
this.resetAddFundsPanel();
this.loadProfile();
this.loadAdminUsers();
});
}
/**
* Resets and closes the admin add funds panel.
*/
resetAddFundsPanel() {
this.#show_panel(null, [this.adminAddFundsPanel]);
this.#set_input(this.adminFundsAmountInput, '10.00');
}
/**
* Handles creating a new user from the admin create panel.
*/
async 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 experimental = this.newExperimentalCheckbox ? this.newExperimentalCheckbox.checked : false;
const balance = this.newBalanceInput ? parseFloat(this.newBalanceInput.value) || 0 : 0;
if (!username || !password) {
this.#show_status(this.adminStatusDiv, 'Username and password are required.', true);
return;
}
await this.#submit(
{
action: 'add_or_update_user',
username,
passcode: password,
name,
email,
admin: role === 'admin',
experimental,
initial_balance: balance
},
'users',
this.adminStatusDiv,
'Failed to create user.',
'create user',
() => {
this.#show_status(this.adminStatusDiv, 'User created successfully!', false);
this.resetCreateUserPanel();
this.loadAdminUsers();
}
);
}
/**
* Resets and closes the admin create user panel.
*/
resetCreateUserPanel() {
this.#show_panel(null, [this.adminCreateUserPanel]);
this.#clear_fields([this.newUsernameInput, this.newPasswordInput, this.newNameInput, this.newEmailInput]);
this.#set_input(this.newRoleSelect, 'user');
if (this.newExperimentalCheckbox) this.newExperimentalCheckbox.checked = false;
this.#set_input(this.newBalanceInput, '0.00');
}
/**
* Handles deleting a user by ID.
* @param {number|string} userId
*/
async handleDeleteUser(userId) {
await this.#submit({ action: 'delete_user', id: userId }, 'users', this.adminStatusDiv, 'Failed to delete user.', 'delete user', () => {
this.#show_status(this.adminStatusDiv, 'User deleted successfully!', false);
this.loadAdminUsers();
});
}
/**
* 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.
*/
async loadAdminConfigs() {
if (!Users.isAdmin) return;
try {
const response = await this.#api.request({ action: 'get_all' }, 'config');
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);
}
} catch (error) {
console.error('Error fetching configs:', error);
}
}
/**
* 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) {
this.#show_panel(this.configEditPanel, [this.configCreatePanel]);
this.#set_input(this.configEditIdInput, config.id);
this.#set_text(this.configEditKeyDisplay, config.key);
this.#set_input(this.configEditKeyInput, config.key || '');
this.#set_input(this.configEditDescriptionInput, config.description || '');
this.#set_input(this.configEditValueInput, config.value || '');
}
/**
* Handles creating a new config entry.
*/
async 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.#show_status(this.configStatusDiv, 'Key is required.', true);
return;
}
await this.#submit({ action: 'add_or_update', key, description, value }, 'config', this.configStatusDiv, 'Failed to create config.', 'create config', () => {
this.#show_status(this.configStatusDiv, `Config "${key}" created successfully!`, false);
this.resetCreateConfigPanel();
this.loadAdminConfigs();
});
}
/**
* Handles saving edits to a config entry.
*/
async 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.#show_status(this.configStatusDiv, 'Config ID and key are required.', true);
return;
}
await this.#submit({ action: 'add_or_update', id, key, description, value }, 'config', this.configStatusDiv, 'Failed to update config.', 'update config', () => {
this.#show_status(this.configStatusDiv, `Config "${key}" updated successfully!`, false);
this.resetEditConfigPanel();
this.loadAdminConfigs();
});
}
/**
* Handles deleting a config entry.
* @param {number|string} id
*/
async handleDeleteConfig(id) {
await this.#submit({ action: 'delete', id }, 'config', this.configStatusDiv, 'Failed to delete config.', 'delete config', () => {
this.#show_status(this.configStatusDiv, 'Config deleted successfully!', false);
this.loadAdminConfigs();
});
}
/**
* Resets and closes the create config panel.
*/
resetCreateConfigPanel() {
this.#show_panel(null, [this.configCreatePanel]);
this.#clear_fields([this.newConfigKeyInput, this.newConfigDescriptionInput, this.newConfigValueInput]);
}
/**
* Resets and closes the edit config panel.
*/
resetEditConfigPanel() {
this.#show_panel(null, [this.configEditPanel]);
this.#clear_fields([this.configEditIdInput, this.configEditKeyInput, this.configEditDescriptionInput, this.configEditValueInput]);
this.#set_text(this.configEditKeyDisplay, '');
}
}
export default Users;