9 directories, 58 files

tinai

Home / tinai
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;
🌐
users.js ×
Type: Web, text/plain
49.86 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File