6 directories, 29 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';

/**
 * Users authentication, profile management, and administrative control.
 */
class Users {

	static instance = null;
	static isAdmin = false;
	static currentUser = null;

	#api;
	#storage;

	// Auth elements
	loginForm;
	usernameInput;
	passcodeInput;
	loginButton;
	errorMessageDiv;
	logoutButton;
	loginOverlay;
	loginBackdrop;
	appContainer;

	// Profile Form Elements
	profileForm;
	profileNameInput;
	profileEmailInput;
	profileLocationInput;
	profileLocationExplanation;
	profileTimezoneInput;
	profileStatusDiv;
	saveProfileButton;
	profileSessionExpirationSpan;
	#locationValidationTimeout = null;
	#lastValidatedLocation = "";

	// Account Elements
	accountBalanceSpan;
	accountCostMonthSpan;
	accountCostAllSpan;

	// Admin Elements - Main
	adminForm;
	btnAdminShowCreateUser;
	btnAdminShowAddFunds;
	btnAdminConfig;
	adminUsersTbody;
	adminStatusDiv;

	// Admin Elements - Create User
	adminCreateUserPanel;
	newUsernameInput;
	newPasswordInput;
	newNameInput;
	newEmailInput;
	newRoleSelect;
	newBalanceInput;
	btnAdminCancelCreate;
	btnAdminSubmitCreate;

	// Admin Elements - Edit User
	adminEditUserPanel;
	editUserIdInput;
	editUsernameDisplay;
	editUsernameInput;
	editPasswordInput;
	editNameInput;
	editEmailInput;
	editRoleSelect;
	btnAdminCancelEdit;
	btnAdminSubmitEdit;

	// Admin Elements - Add Funds
	adminAddFundsPanel;
	adminFundsUserSelect;
	adminFundsAmountInput;
	btnAdminCancelAddFunds;
	btnAdminSubmitAddFunds;

	// Admin Elements - Config Management Dialog
	configDialogOverlay;
	btnConfigDialogClose;
	btnConfigShowCreate;
	configCreatePanel;
	newConfigKeyInput;
	newConfigDescriptionInput;
	newConfigValueInput;
	btnConfigCancelCreate;
	btnConfigSubmitCreate;
	configEditPanel;
	configEditIdInput;
	configEditKeyDisplay;
	configEditKeyInput;
	configEditDescriptionInput;
	configEditValueInput;
	btnConfigCancelEdit;
	btnConfigSubmitEdit;
	configItemsTbody;
	configStatusDiv;
	configKnownKeysList;

	/**
	 * Initializes the Users class, UI element bindings.
	 */
	constructor() {
		Users.instance = this;
		this.#api = new Api();
		this.#storage = new Storage();

		// Apply theme on initialization
		this.applySavedTheme();

		// Auth elements
		this.loginForm = getEl('login-form') || getEl('form-login');
		this.usernameInput = getEl('username') || getEl('login-username');
		this.passcodeInput = getEl('passcode') || getEl('login-passcode');
		this.loginButton = getEl('login-button') || getEl('btn-login');
		this.errorMessageDiv = getEl('login-error-message');
		this.logoutButton = getEl('id-btn-logout') || getEl('btn-logout');
		this.loginOverlay = getEl('id-login-overlay');
		this.loginBackdrop = getEl('id-login-backdrop');
		this.appContainer = getEl('id-div-app-container');

		// Profile elements
		this.profileForm = getEl('id-form-profile-options');
		this.profileNameInput = getEl('profile-name');
		this.profileEmailInput = getEl('profile-email');
		this.profileLocationInput = getEl('profile-location');
		this.profileLocationExplanation = getEl('profile-location-explanation');
		this.profileTimezoneInput = getEl('profile-timezone');
		this.profileStatusDiv = getEl('profile-status-message');
		this.saveProfileButton = getEl('btn-save-profile');
		this.profileSessionExpirationSpan = getEl('profile-session-expiration');
		this.updateSessionExpirationDisplay();

		// Account elements
		this.accountBalanceSpan = getEl('account-balance-amount');
		this.accountCostMonthSpan = getEl('account-cost-month');
		this.accountCostAllSpan = getEl('account-cost-all');

		// Admin elements - Main
		this.adminForm = getEl('id-form-admin-options');
		this.btnAdminShowCreateUser = getEl('btn-admin-show-create-user');
		this.btnAdminShowAddFunds = getEl('btn-admin-show-add-funds');
		this.btnAdminConfig = getEl('btn-admin-config');
		this.adminUsersTbody = getEl('admin-users-tbody');
		this.adminStatusDiv = getEl('admin-management-status');

		// Admin elements - Create User
		this.adminCreateUserPanel = getEl('admin-create-user-panel');
		this.newUsernameInput = getEl('new-user-username');
		this.newPasswordInput = getEl('new-user-password');
		this.newNameInput = getEl('new-user-name');
		this.newEmailInput = getEl('new-user-email');
		this.newRoleSelect = getEl('new-user-role');
		this.newBalanceInput = getEl('new-user-balance');
		this.btnAdminCancelCreate = getEl('btn-admin-cancel-create');
		this.btnAdminSubmitCreate = getEl('btn-admin-submit-create');

		// Admin elements - Edit User
		this.adminEditUserPanel = getEl('admin-edit-user-panel');
		this.editUserIdInput = getEl('admin-edit-user-id');
		this.editUsernameDisplay = getEl('admin-edit-username-display');
		this.editUsernameInput = getEl('admin-edit-username');
		this.editPasswordInput = getEl('admin-edit-password');
		this.editNameInput = getEl('admin-edit-name');
		this.editEmailInput = getEl('admin-edit-email');
		this.editRoleSelect = getEl('admin-edit-role');
		this.btnAdminCancelEdit = getEl('btn-admin-cancel-edit');
		this.btnAdminSubmitEdit = getEl('btn-admin-submit-edit');

		// Admin elements - Add Funds
		this.adminAddFundsPanel = getEl('admin-add-funds-panel');
		this.adminFundsUserSelect = getEl('admin-funds-user-select');
		this.adminFundsAmountInput = getEl('admin-funds-amount');
		this.btnAdminCancelAddFunds = getEl('btn-admin-cancel-add-funds');
		this.btnAdminSubmitAddFunds = getEl('btn-admin-submit-add-funds');

		// Admin elements - Config Management Dialog
		this.configDialogOverlay = getEl('id-config-dialog-overlay');
		this.btnConfigDialogClose = getEl('btn-config-dialog-close');
		this.btnConfigShowCreate = getEl('btn-config-show-create');
		this.configCreatePanel = getEl('config-create-panel');
		this.newConfigKeyInput = getEl('new-config-key');
		this.newConfigDescriptionInput = getEl('new-config-description');
		this.newConfigValueInput = getEl('new-config-value');
		this.btnConfigCancelCreate = getEl('btn-config-cancel-create');
		this.btnConfigSubmitCreate = getEl('btn-config-submit-create');
		this.configEditPanel = getEl('config-edit-panel');
		this.configEditIdInput = getEl('config-edit-id');
		this.configEditKeyDisplay = getEl('config-edit-key-display');
		this.configEditKeyInput = getEl('config-edit-key');
		this.configEditDescriptionInput = getEl('config-edit-description');
		this.configEditValueInput = getEl('config-edit-value');
		this.btnConfigCancelEdit = getEl('btn-config-cancel-edit');
		this.btnConfigSubmitEdit = getEl('btn-config-submit-edit');
		this.configItemsTbody = getEl('config-items-tbody');
		this.configStatusDiv = getEl('config-management-status');
		this.configKnownKeysList = getEl('config-known-keys-list');

		this.initEventListeners();
	}

	/**
	 * Applies the saved theme from configuration.
	 */
	applySavedTheme() {
		const config = this.#storage.get_app_config();
		const theme = (config && config[this.#storage.KEY_CONFIG_THEME]) ? config[this.#storage.KEY_CONFIG_THEME] : 'theme_dark';
		applyTheme(theme, true);
	}

	/**
	 * Binds event listeners for user login, logout, profile, and admin forms.
	 */
	initEventListeners() {
		if (this.loginForm) {
			addSafeEventListener(this.loginForm, 'submit', (e) => {
				e.preventDefault();
				this.handleLogin();
			});
		}
		addSafeEventListener(this.loginButton, 'click', (e) => {
			if (e) e.preventDefault();
			this.handleLogin();
		});
		addSafeEventListener(this.logoutButton, 'click', (e) => {
			if (e) e.preventDefault();
			this.handleLogout();
		});

		// Profile form
		if (this.profileForm) {
			addSafeEventListener(this.profileForm, 'submit', (e) => {
				e.preventDefault();
				this.handleSaveProfile();
			});
		}
		addSafeEventListener(this.saveProfileButton, 'click', (e) => {
			if (e) e.preventDefault();
			this.handleSaveProfile();
		});
		if (this.profileLocationInput) {
			addSafeEventListener(this.profileLocationInput, 'input', () => this.handleLocationInput());
		}
		if (this.profileSessionExpirationSpan) {
			addSafeEventListener(this.profileSessionExpirationSpan, 'click', async (e) => {
				if (e) e.preventDefault();
				await this.handleRefreshSessionPrompt();
			});
		}

		// Admin management - Create user
		addSafeEventListener(this.btnAdminShowCreateUser, 'click', (e) => {
			if (e) e.preventDefault();
			if (this.adminAddFundsPanel) setElementDisplay(this.adminAddFundsPanel, 'none');
			if (this.adminEditUserPanel) setElementDisplay(this.adminEditUserPanel, 'none');
			if (this.adminCreateUserPanel) {
				const isHidden = this.adminCreateUserPanel.style.display === 'none' || !this.adminCreateUserPanel.style.display;
				setElementDisplay(this.adminCreateUserPanel, isHidden ? 'block' : 'none');
			}
		});
		addSafeEventListener(this.btnAdminCancelCreate, 'click', (e) => {
			if (e) e.preventDefault();
			this.resetCreateUserPanel();
		});
		addSafeEventListener(this.btnAdminSubmitCreate, 'click', (e) => {
			if (e) e.preventDefault();
			this.handleCreateUser();
		});

		// Admin management - Edit user
		addSafeEventListener(this.btnAdminCancelEdit, 'click', (e) => {
			if (e) e.preventDefault();
			this.resetEditUserPanel();
		});
		addSafeEventListener(this.btnAdminSubmitEdit, 'click', (e) => {
			if (e) e.preventDefault();
			this.handleSaveEditUser();
		});

		// Admin management - Add funds
		addSafeEventListener(this.btnAdminShowAddFunds, 'click', (e) => {
			if (e) e.preventDefault();
			if (this.adminCreateUserPanel) setElementDisplay(this.adminCreateUserPanel, 'none');
			if (this.adminEditUserPanel) setElementDisplay(this.adminEditUserPanel, 'none');
			if (this.adminAddFundsPanel) {
				const isHidden = this.adminAddFundsPanel.style.display === 'none' || !this.adminAddFundsPanel.style.display;
				setElementDisplay(this.adminAddFundsPanel, isHidden ? 'block' : 'none');
			}
		});
		addSafeEventListener(this.btnAdminCancelAddFunds, 'click', (e) => {
			if (e) e.preventDefault();
			this.resetAddFundsPanel();
		});
		addSafeEventListener(this.btnAdminSubmitAddFunds, 'click', (e) => {
			if (e) e.preventDefault();
			this.handleAddFundsFromAdmin();
		});

		// Admin management - Config
		addSafeEventListener(this.btnAdminConfig, 'click', (e) => {
			if (e) e.preventDefault();
			this.openConfigDialog();
		});
		addSafeEventListener(this.btnConfigDialogClose, 'click', (e) => {
			if (e) e.preventDefault();
			this.closeConfigDialog();
		});

		// Config create panel
		addSafeEventListener(this.btnConfigShowCreate, 'click', (e) => {
			if (e) e.preventDefault();
			if (this.configEditPanel) setElementDisplay(this.configEditPanel, 'none');
			if (this.configCreatePanel) {
				const isHidden = this.configCreatePanel.style.display === 'none' || !this.configCreatePanel.style.display;
				setElementDisplay(this.configCreatePanel, isHidden ? 'block' : 'none');
			}
		});
		addSafeEventListener(this.btnConfigCancelCreate, 'click', (e) => {
			if (e) e.preventDefault();
			this.resetCreateConfigPanel();
		});
		addSafeEventListener(this.btnConfigSubmitCreate, 'click', (e) => {
			if (e) e.preventDefault();
			this.handleCreateConfig();
		});

		// Config edit panel
		addSafeEventListener(this.btnConfigCancelEdit, 'click', (e) => {
			if (e) e.preventDefault();
			this.resetEditConfigPanel();
		});
		addSafeEventListener(this.btnConfigSubmitEdit, 'click', (e) => {
			if (e) e.preventDefault();
			this.handleSaveEditConfig();
		});
	}

	/**
	 * Shows login prompt and backdrop.
	 */
	showLoginForm() {
		if (this.appContainer) setElementDisplay(this.appContainer, 'none');
		if (this.loginBackdrop) {
			this.loginBackdrop.classList.add('active');
			setElementDisplay(this.loginBackdrop, 'block');
		}
		if (this.loginOverlay) {
			this.loginOverlay.classList.add('active');
			setElementDisplay(this.loginOverlay, 'block');
		}
		fadeOutLoader();
	}

	/**
	 * Hides login prompt and shows main application layout.
	 */
	showApp() {
		if (this.loginBackdrop) {
			this.loginBackdrop.classList.remove('active');
			setElementDisplay(this.loginBackdrop, 'none');
		}
		if (this.loginOverlay) {
			this.loginOverlay.classList.remove('active');
			setElementDisplay(this.loginOverlay, 'none');
		}
		if (this.appContainer) {
			setElementDisplay(this.appContainer, 'grid');
		}
	}

	/**
	 * Handles authentication request on login form submission.
	 */
	handleLogin() {
		if (!this.usernameInput || !this.passcodeInput) return;
		const username = this.usernameInput.value.trim();
		const passcode = this.passcodeInput.value.trim();

		if (!username || !passcode) {
			this.displayError('Please provide both username and passcode.');
			return;
		}

		let detectedTimezone = null;
		try {
			detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
		} catch (e) {
			console.warn('Could not determine client timezone:', e);
		}

		this.#storage.init_location_id();
		const locationId = this.#storage.get_location_id();

		this.#api.post(
			{ action: 'login', username, passcode, location_id: locationId, timezone: detectedTimezone },
			(response) => {
				if (response.success && response.token) {
					this.#storage.set_auth_token(response.token);
					window.location.reload();
				} else {
					this.displayError(response.error || response.message || 'Login failed. Please check credentials.');
				}
			},
			(error) => {
				this.displayError('Connection error. Please try again.');
				console.error('Login error:', error);
			},
			'users'
		);
	}

	/**
	 * Handles session revocation and logging out.
	 */
	handleLogout() {
		const locationId = this.#storage.get_location_id();
		const token = this.#storage.get_auth_token();
		this.#api.post(
			{ action: 'logout', location_id: locationId, token },
			() => {
				this.#storage.clear_auth_token();
				window.location.reload();
			},
			(error) => {
				console.error('Logout error:', error);
				this.#storage.clear_auth_token();
				window.location.reload();
			},
			'users'
		);
	}

	/**
	 * Checks user session status with the API backend.
	 * @param {Function|null} onAuthenticated - Callback when user is authenticated.
	 * @param {Function|null} onUnauthenticated - Callback when user is not authenticated.
	 */
	checkLoginStatus(onAuthenticated = null, onUnauthenticated = null) {
		this.#storage.init_location_id();
		const token = this.#storage.get_auth_token();
		const locationId = this.#storage.get_location_id();

		if (!token) {
			Users.isAdmin = false;
			Users.currentUser = null;
			this.showLoginForm();
			if (typeof onUnauthenticated === 'function') {
				onUnauthenticated();
			}
			return;
		}

		this.#api.post(
			{ action: 'session', token, location_id: locationId },
			(response) => {
				if (response && response.isLoggedIn) {
					if (response.token) {
						this.#storage.set_auth_token(response.token);
					}
					Users.isAdmin = !!response.admin || !!response.isAdmin;
					Users.currentUser = response.user || null;
					this.showApp();
					this.applyUserSession(response);
					this.updateSessionExpirationDisplay();
					if (typeof onAuthenticated === 'function') {
						onAuthenticated(response);
					}
				} else {
					this.#storage.clear_auth_token();
					Users.isAdmin = false;
					Users.currentUser = null;
					this.showLoginForm();
					if (typeof onUnauthenticated === 'function') {
						onUnauthenticated();
					}
				}
			},
			(error) => {
				console.error('Session check error:', error);
				this.#storage.clear_auth_token();
				Users.isAdmin = false;
				Users.currentUser = null;
				this.showLoginForm();
				if (typeof onUnauthenticated === 'function') {
					onUnauthenticated();
				}
			},
			'users'
		);
	}

	/**
	 * Applies session user state to application UI.
	 * @param {object} sessionData
	 */
	applyUserSession(sessionData) {
		const isAdmin = !!sessionData.admin || !!sessionData.isAdmin;
		setElementDisplay(this.adminForm, isAdmin ? 'block' : 'none');
		if (this.btnAdminConfig) {
			setElementDisplay(this.btnAdminConfig, isAdmin ? 'inline-block' : 'none');
		}
		if (this.logoutButton) {
			setElementDisplay(this.logoutButton, 'inline-block');
		}

		this.loadProfile();
		this.updateSessionExpirationDisplay();

		if (isAdmin) {
			this.loadAdminUsers();
		}

		fadeOutLoader();
	}

	/**
	 * Fetches user profile, spend, and balance information.
	 */
	loadProfile() {
		this.#api.post(
			{ action: 'get_profile' },
			(response) => {
				if (response.success && response.profile) {
					const profile = response.profile;
					if (this.profileNameInput) this.profileNameInput.value = profile.name || '';
					if (this.profileEmailInput) this.profileEmailInput.value = profile.email || '';
					if (this.profileLocationInput) this.profileLocationInput.value = profile.location_string || '';
					this.#lastValidatedLocation = profile.location_string ? profile.location_string.trim() : '';
					this.set_location_validation_state(true, '');
					if (this.profileTimezoneInput) this.profileTimezoneInput.value = profile.latest_timezone_code || '';

					const balance = parseFloat(profile.balance || 0);
					const lifetimeSpend = parseFloat(profile.lifetime_spend || 0);

					if (this.accountBalanceSpan) {
						this.accountBalanceSpan.textContent = `$${balance.toFixed(6)}`;
					}
					if (this.accountCostMonthSpan) {
						this.accountCostMonthSpan.textContent = `$${lifetimeSpend.toFixed(6)}`;
					}
					if (this.accountCostAllSpan) {
						this.accountCostAllSpan.textContent = `$${lifetimeSpend.toFixed(6)}`;
					}

					Users.currentUser = profile;
					Users.isAdmin = Number(profile.admin) === 1;
					setElementDisplay(this.adminForm, Users.isAdmin ? 'block' : 'none');
					if (this.btnAdminConfig) {
						setElementDisplay(this.btnAdminConfig, Users.isAdmin ? 'inline-block' : 'none');
					}
					this.updateSessionExpirationDisplay();
				}
			},
			(error) => {
				console.error('Profile fetch error:', error);
			},
			'users'
		);
	}

	/**
	 * Handles input changes in the profile location field, debouncing validation requests.
	 */
	handleLocationInput() {
		if (this.#locationValidationTimeout) {
			clearTimeout(this.#locationValidationTimeout);
			this.#locationValidationTimeout = null;
		}

		const val = this.profileLocationInput ? this.profileLocationInput.value.trim() : '';

		// When the user is typing, disable the save button immediately
		if (this.saveProfileButton) {
			this.saveProfileButton.disabled = true;
		}

		// Ensure that you CAN save an EMPTY location, just not an INVALID one
		if (val === '') {
			this.#lastValidatedLocation = '';
			this.set_location_validation_state(true, '');
			return;
		}

		// Clear previous error explanation while typing
		if (this.profileLocationExplanation) {
			setElementDisplay(this.profileLocationExplanation, 'none');
		}

		// Debounce to 500ms
		this.#locationValidationTimeout = setTimeout(() => {
			this.validateLocation(val);
		}, 500);
	}

	/**
	 * Validates the specified location string using the utility endpoint.
	 * @param {string} locationToValidate
	 */
	async validateLocation(locationToValidate) {
		const currentVal = this.profileLocationInput ? this.profileLocationInput.value.trim() : '';
		if (currentVal !== locationToValidate) {
			return;
		}

		if (currentVal === '') {
			this.set_location_validation_state(true, '');
			return;
		}

		const config = this.#storage.get_app_config();
		const utilityModel = (config && config[this.#storage.KEY_CONFIG_UTILITY_MODEL]) ? config[this.#storage.KEY_CONFIG_UTILITY_MODEL] : 'gemini-3.1-fl';

		try {
			const response = await this.#api.evaluate_utility(currentVal, 'location', utilityModel);

			// Check if user continued typing while request was in-flight
			const latestVal = this.profileLocationInput ? this.profileLocationInput.value.trim() : '';
			if (latestVal !== locationToValidate) {
				return;
			}

			if (response && response.evaluation === true) {
				this.#lastValidatedLocation = currentVal;
				const explanation = (response && response.explanation) ? response.explanation : 'Valid location.';
				this.set_location_validation_state(true, explanation);
			} else {
				const explanation = (response && response.explanation) ? response.explanation : 'Invalid location.';
				this.set_location_validation_state(false, explanation);
			}
		} catch (error) {
			const latestVal = this.profileLocationInput ? this.profileLocationInput.value.trim() : '';
			if (latestVal !== locationToValidate) {
				return;
			}
			console.error('Location validation error:', error);
			this.set_location_validation_state(false, 'Unable to validate location.');
		}
	}

	/**
	 * Sets the UI validation state for the profile location field.
	 * @param {boolean} isValid - Whether the location is valid or empty.
	 * @param {string} [explanation=''] - Explanation to display if invalid.
	 */
	set_location_validation_state(isValid, explanation = '') {
		if (this.saveProfileButton) {
			this.saveProfileButton.disabled = !isValid;
		}
		if (this.profileLocationExplanation) {
			if (explanation) {
				this.profileLocationExplanation.textContent = explanation;
				this.profileLocationExplanation.className = `div-field-explanation ${isValid ? 'status-success' : 'status-error'}`;
				setElementDisplay(this.profileLocationExplanation, 'block');
			} else {
				this.profileLocationExplanation.textContent = '';
				setElementDisplay(this.profileLocationExplanation, 'none');
			}
		}
	}

	/**
	 * Handles profile save submission.
	 */
	handleSaveProfile() {
		if (this.saveProfileButton && this.saveProfileButton.disabled) {
			return;
		}
		const name = this.profileNameInput ? this.profileNameInput.value.trim() : '';
		const email = this.profileEmailInput ? this.profileEmailInput.value.trim() : '';
		const location = this.profileLocationInput ? this.profileLocationInput.value.trim() : '';

		let detectedTimezone = null;
		try {
			detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
		} catch (e) {
			console.warn('Could not determine client timezone:', e);
		}

		this.#api.post(
			{ action: 'update_profile', name, email, location, timezone: detectedTimezone },
			(response) => {
				if (response.success) {
					this.displayProfileStatus('Profile saved successfully!', false);
					this.loadProfile();
				} else {
					this.displayProfileStatus(response.error || response.message || 'Failed to save profile.', true);
				}
			},
			(error) => {
				this.displayProfileStatus('An error occurred while saving profile.', true);
				console.error('Save profile error:', error);
			},
			'users'
		);
	}

	/**
	 * Displays temporary status message on the user profile section.
	 * @param {string} message
	 * @param {boolean} isError
	 */
	displayProfileStatus(message, isError) {
		if (!this.profileStatusDiv) return;
		this.profileStatusDiv.textContent = message;
		this.profileStatusDiv.className = `status-msg ${isError ? 'status-error' : 'status-success'}`;
		setElementDisplay(this.profileStatusDiv, 'block');

		setTimeout(() => {
			if (this.profileStatusDiv) {
				setElementDisplay(this.profileStatusDiv, 'none');
			}
		}, 4000);
	}

	/**
	 * Prompts the user with a confirmation dialog to refresh the session.
	 */
	async handleRefreshSessionPrompt() {
		const confirmed = await customConfirm('Refresh Session', 'Do you want to refresh the session?');
		if (!confirmed) {
			return;
		}
		this.refreshSession();
	}

	/**
	 * Sends a session refresh request to the API backend to extend JWT expiration.
	 */
	refreshSession() {
		this.#storage.init_location_id();
		const token = this.#storage.get_auth_token();
		const locationId = this.#storage.get_location_id();

		if (!token) return;

		this.#api.post(
			{ action: 'refresh', token, location_id: locationId },
			(response) => {
				if (response && response.token) {
					this.#storage.set_auth_token(response.token);
					this.updateSessionExpirationDisplay();
					this.displayProfileStatus('Session refreshed successfully!', false);
				} else {
					this.displayProfileStatus(response?.error || response?.message || 'Failed to refresh session.', true);
				}
			},
			(error) => {
				console.error('Session refresh error:', error);
				this.displayProfileStatus('An error occurred while refreshing session.', true);
			},
			'users'
		);
	}

	/**
	 * Decodes the payload portion of a JWT token string.
	 * @param {string} token
	 * @returns {object|null}
	 */
	decodeJwt(token) {
		if (!token || typeof token !== 'string') return null;
		try {
			const parts = token.split('.');
			if (parts.length !== 3) return null;
			let base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
			while (base64.length % 4 !== 0) {
				base64 += '=';
			}
			const jsonPayload = decodeURIComponent(
				atob(base64)
					.split('')
					.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
					.join('')
			);
			return JSON.parse(jsonPayload);
		} catch (e) {
			console.warn('Failed to decode JWT:', e);
			return null;
		}
	}

	/**
	 * Updates the session expiration display in the user profile settings.
	 */
	updateSessionExpirationDisplay() {
		if (!this.profileSessionExpirationSpan) return;
		const token = this.#storage.get_auth_token();
		if (!token) {
			this.profileSessionExpirationSpan.textContent = '';
			return;
		}

		const payload = this.decodeJwt(token);
		if (!payload || !payload.exp) {
			this.profileSessionExpirationSpan.textContent = 'Session Expiration: Unknown';
			return;
		}

		const expDate = new Date(payload.exp * 1000);
		if (isNaN(expDate.getTime())) {
			this.profileSessionExpirationSpan.textContent = 'Session Expiration: Unknown';
			return;
		}

		const formattedDate = expDate.toLocaleString(undefined, {
			year: 'numeric',
			month: 'short',
			day: 'numeric',
			hour: 'numeric',
			minute: '2-digit'
		});

		this.profileSessionExpirationSpan.textContent = `Expires: ${formattedDate}`;
		this.profileSessionExpirationSpan.title = 'Click to refresh session';
	}

	/**
	 * Displays authentication error on login page.
	 * @param {string} message
	 */
	displayError(message) {
		if (this.errorMessageDiv) {
			this.errorMessageDiv.textContent = message;
			this.errorMessageDiv.style.color = '#ff6b6b';
			setElementDisplay(this.errorMessageDiv, 'block');
		}
	}

	/**
	 * Loads all registered users from the backend for the admin management view.
	 */
	loadAdminUsers() {
		if (!Users.isAdmin) return;

		this.#api.post(
			{ action: 'get_all_users' },
			(response) => {
				if (response.success && Array.isArray(response.users)) {
					this.renderAdminUsersTable(response.users);
					this.populateAdminFundsUserSelect(response.users);
				}
			},
			(error) => {
				console.error('Error fetching admin users:', error);
			},
			'users'
		);
	}

	/**
	 * Populates the user selection dropdown in the admin add funds form.
	 * @param {Array<object>} users
	 */
	populateAdminFundsUserSelect(users) {
		if (!this.adminFundsUserSelect) return;
		const currentValue = this.adminFundsUserSelect.value;
		this.adminFundsUserSelect.innerHTML = '<option value="">-- Select User --</option>';

		users.forEach((user) => {
			const option = document.createElement('option');
			option.value = user.id;
			option.textContent = `${user.username} (${user.name || 'No Name'}) - Balance: $${parseFloat(user.balance || 0).toFixed(4)}`;
			this.adminFundsUserSelect.appendChild(option);
		});

		if (currentValue) {
			this.adminFundsUserSelect.value = currentValue;
		}
	}

	/**
	 * Renders the users list table within the admin management panel.
	 * @param {Array<object>} users
	 */
	renderAdminUsersTable(users) {
		if (!this.adminUsersTbody) return;
		this.adminUsersTbody.innerHTML = '';

		users.forEach((user) => {
			const tr = document.createElement('tr');

			const isAdmin = Number(user.admin) === 1;
			const balance = parseFloat(user.balance || 0).toFixed(4);
			const cost = parseFloat(user.lifetime_spend || 0).toFixed(4);

			tr.innerHTML = `
				<td>
					<strong>${this.escapeHtml(user.username)}</strong>
					${user.name ? `<br/><small style="opacity: 0.7;">${this.escapeHtml(user.name)}</small>` : ''}
					${user.email ? `<br/><small style="opacity: 0.7;">${this.escapeHtml(user.email)}</small>` : ''}
				</td>
				<td>
					${isAdmin ? '<span class="badge-admin">Admin</span>' : '<span>User</span>'}
				</td>
				<td style="font-family: monospace;">$${balance}</td>
				<td style="font-family: monospace;">$${cost}</td>
				<td style="text-align: right; white-space: nowrap;">
					<button type="button" class="as-icon btn-table-edit-user" data-id="${user.id}" style="margin-right: 4px;">Edit</button>
					<button type="button" class="as-icon btn-table-add-funds" data-id="${user.id}" style="margin-right: 4px;">+ Funds</button>
					<button type="button" class="as-icon btn-delete-user" data-id="${user.id}">Delete</button>
				</td>
			`;

			const editBtn = tr.querySelector('.btn-table-edit-user');
			if (editBtn) {
				addSafeEventListener(editBtn, 'click', () => {
					this.openEditUserPanel(user);
				});
			}

			const addFundsBtn = tr.querySelector('.btn-table-add-funds');
			if (addFundsBtn) {
				addSafeEventListener(addFundsBtn, 'click', () => {
					this.openAddFundsPanelForUser(user.id);
				});
			}

			const deleteBtn = tr.querySelector('.btn-delete-user');
			if (deleteBtn) {
				addSafeEventListener(deleteBtn, 'click', async () => {
					if (await customConfirm('Delete User', `Are you sure you want to delete user "${user.username}"?`)) {
						this.handleDeleteUser(user.id);
					}
				});
			}

			this.adminUsersTbody.appendChild(tr);
		});
	}

	/**
	 * Opens Edit User panel with the selected user's details.
	 * @param {object} user
	 */
	openEditUserPanel(user) {
		if (this.adminCreateUserPanel) setElementDisplay(this.adminCreateUserPanel, 'none');
		if (this.adminAddFundsPanel) setElementDisplay(this.adminAddFundsPanel, 'none');
		if (this.adminEditUserPanel) setElementDisplay(this.adminEditUserPanel, 'block');

		if (this.editUserIdInput) this.editUserIdInput.value = user.id;
		if (this.editUsernameDisplay) this.editUsernameDisplay.textContent = user.username;
		if (this.editUsernameInput) this.editUsernameInput.value = user.username;
		if (this.editPasswordInput) this.editPasswordInput.value = '';
		if (this.editNameInput) this.editNameInput.value = user.name || '';
		if (this.editEmailInput) this.editEmailInput.value = user.email || '';
		if (this.editRoleSelect) this.editRoleSelect.value = Number(user.admin) === 1 ? 'admin' : 'user';
	}

	/**
	 * Handles saving edited user changes (including password update).
	 */
	handleSaveEditUser() {
		if (!this.editUserIdInput || !this.editUsernameInput) return;
		const id = parseInt(this.editUserIdInput.value, 10);
		const username = this.editUsernameInput.value.trim();
		const password = this.editPasswordInput ? this.editPasswordInput.value.trim() : '';
		const name = this.editNameInput ? this.editNameInput.value.trim() : '';
		const email = this.editEmailInput ? this.editEmailInput.value.trim() : '';
		const role = this.editRoleSelect ? this.editRoleSelect.value : 'user';

		if (!id || !username) {
			this.displayAdminStatus('User ID and username are required.', true);
			return;
		}

		const payload = {
			action: 'add_or_update_user',
			id,
			username,
			name,
			email,
			admin: role === 'admin'
		};

		if (password) {
			payload.passcode = password;
		}

		this.#api.post(
			payload,
			(response) => {
				if (response.success) {
					this.displayAdminStatus(`User "${username}" updated successfully!`, false);
					this.resetEditUserPanel();
					this.loadProfile();
					this.loadAdminUsers();
				} else {
					this.displayAdminStatus(response.error || response.message || 'Failed to update user.', true);
				}
			},
			(error) => {
				this.displayAdminStatus('An error occurred while updating user.', true);
				console.error('Update user error:', error);
			},
			'users'
		);
	}

	/**
	 * Resets and closes the admin edit user panel.
	 */
	resetEditUserPanel() {
		if (this.adminEditUserPanel) {
			setElementDisplay(this.adminEditUserPanel, 'none');
		}
		if (this.editUserIdInput) this.editUserIdInput.value = '';
		if (this.editUsernameDisplay) this.editUsernameDisplay.textContent = '';
		if (this.editUsernameInput) this.editUsernameInput.value = '';
		if (this.editPasswordInput) this.editPasswordInput.value = '';
		if (this.editNameInput) this.editNameInput.value = '';
		if (this.editEmailInput) this.editEmailInput.value = '';
		if (this.editRoleSelect) this.editRoleSelect.value = 'user';
	}

	/**
	 * Opens Add Funds panel pre-selecting a specific user.
	 * @param {string|number} userId
	 */
	openAddFundsPanelForUser(userId) {
		if (this.adminCreateUserPanel) setElementDisplay(this.adminCreateUserPanel, 'none');
		if (this.adminEditUserPanel) setElementDisplay(this.adminEditUserPanel, 'none');
		if (this.adminAddFundsPanel) setElementDisplay(this.adminAddFundsPanel, 'block');
		if (this.adminFundsUserSelect) this.adminFundsUserSelect.value = String(userId);
		if (this.adminFundsAmountInput) this.adminFundsAmountInput.focus();
	}

	/**
	 * Handles adding funds to a user from the admin Add Funds form.
	 */
	handleAddFundsFromAdmin() {
		if (!this.adminFundsUserSelect || !this.adminFundsAmountInput) return;
		const userId = this.adminFundsUserSelect.value;
		const amount = parseFloat(this.adminFundsAmountInput.value);

		if (!userId) {
			this.displayAdminStatus('Please select a user.', true);
			return;
		}
		if (isNaN(amount) || amount <= 0) {
			this.displayAdminStatus('Please enter a valid amount greater than 0.', true);
			return;
		}

		this.#api.post(
			{ action: 'add_funds', id: userId, amount },
			(response) => {
				if (response.success) {
					this.displayAdminStatus(`Successfully added $${amount.toFixed(2)} in funds!`, false);
					this.resetAddFundsPanel();
					this.loadProfile();
					this.loadAdminUsers();
				} else {
					this.displayAdminStatus(response.error || response.message || 'Failed to add funds.', true);
				}
			},
			(error) => {
				this.displayAdminStatus('An error occurred while adding funds.', true);
				console.error('Add funds error:', error);
			},
			'users'
		);
	}

	/**
	 * Resets and closes the admin add funds panel.
	 */
	resetAddFundsPanel() {
		if (this.adminAddFundsPanel) {
			setElementDisplay(this.adminAddFundsPanel, 'none');
		}
		if (this.adminFundsAmountInput) this.adminFundsAmountInput.value = '10.00';
	}

	/**
	 * Handles creating a new user from the admin create panel.
	 */
	handleCreateUser() {
		const username = this.newUsernameInput ? this.newUsernameInput.value.trim() : '';
		const password = this.newPasswordInput ? this.newPasswordInput.value.trim() : '';
		const name = this.newNameInput ? this.newNameInput.value.trim() : '';
		const email = this.newEmailInput ? this.newEmailInput.value.trim() : '';
		const role = this.newRoleSelect ? this.newRoleSelect.value : 'user';
		const balance = this.newBalanceInput ? parseFloat(this.newBalanceInput.value) || 0 : 0;

		if (!username || !password) {
			this.displayAdminStatus('Username and password are required.', true);
			return;
		}

		this.#api.post(
			{
				action: 'add_or_update_user',
				username,
				passcode: password,
				name,
				email,
				admin: role === 'admin',
				initial_balance: balance
			},
			(response) => {
				if (response.success) {
					this.displayAdminStatus('User created successfully!', false);
					this.resetCreateUserPanel();
					this.loadAdminUsers();
				} else {
					this.displayAdminStatus(response.error || response.message || 'Failed to create user.', true);
				}
			},
			(error) => {
				this.displayAdminStatus('An error occurred while creating user.', true);
				console.error('Create user error:', error);
			},
			'users'
		);
	}

	/**
	 * Resets and closes the admin create user panel.
	 */
	resetCreateUserPanel() {
		if (this.adminCreateUserPanel) {
			setElementDisplay(this.adminCreateUserPanel, 'none');
		}
		if (this.newUsernameInput) this.newUsernameInput.value = '';
		if (this.newPasswordInput) this.newPasswordInput.value = '';
		if (this.newNameInput) this.newNameInput.value = '';
		if (this.newEmailInput) this.newEmailInput.value = '';
		if (this.newRoleSelect) this.newRoleSelect.value = 'user';
		if (this.newBalanceInput) this.newBalanceInput.value = '0.00';
	}

	/**
	 * Handles deleting a user by ID.
	 * @param {number|string} userId
	 */
	handleDeleteUser(userId) {
		this.#api.post(
			{ action: 'delete_user', id: userId },
			(response) => {
				if (response.success) {
					this.displayAdminStatus('User deleted successfully!', false);
					this.loadAdminUsers();
				} else {
					this.displayAdminStatus(response.error || response.message || 'Failed to delete user.', true);
				}
			},
			(error) => {
				this.displayAdminStatus('An error occurred while deleting user.', true);
				console.error('Delete user error:', error);
			},
			'users'
		);
	}

	/**
	 * Opens the Config Management dialog overlay and loads configs.
	 */
	openConfigDialog() {
		if (!Users.isAdmin) return;
		this.resetCreateConfigPanel();
		this.resetEditConfigPanel();
		this.loadAdminConfigs();
		if (this.configDialogOverlay) {
			showOverlay(this.configDialogOverlay);
		}
	}

	/**
	 * Closes the Config Management dialog overlay.
	 */
	closeConfigDialog() {
		if (this.configDialogOverlay) {
			hideOverlay(this.configDialogOverlay);
		}
	}

	/**
	 * Loads all configuration records from the server.
	 */
	loadAdminConfigs() {
		if (!Users.isAdmin) return;

		this.#api.post(
			{ action: 'get_all' },
			(response) => {
				if (response.success && Array.isArray(response.configs)) {
					this.renderAdminConfigsTable(response.configs);
					if (response.known_keys && Array.isArray(response.known_keys) && this.configKnownKeysList) {
						this.configKnownKeysList.textContent = response.known_keys.join(", ");
					}
				} else {
					console.error('Failed to load configs:', response.error || response.message);
				}
			},
			(error) => {
				console.error('Error fetching configs:', error);
			},
			'config'
		);
	}

	/**
	 * Renders configuration entries into the table.
	 * @param {Array<object>} configs
	 */
	renderAdminConfigsTable(configs) {
		if (!this.configItemsTbody) return;
		this.configItemsTbody.innerHTML = '';

		if (configs.length === 0) {
			const tr = document.createElement('tr');
			tr.innerHTML = '<td colspan="4" style="text-align: center; opacity: 0.6; padding: 12px;">No configuration entries found.</td>';
			this.configItemsTbody.appendChild(tr);
			return;
		}

		configs.forEach((item) => {
			const tr = document.createElement('tr');

			const keyTd = document.createElement('td');
			keyTd.style.fontFamily = 'monospace';
			keyTd.style.fontWeight = 'bold';
			keyTd.textContent = item.key || '';

			const descTd = document.createElement('td');
			descTd.style.opacity = '0.85';
			descTd.textContent = item.description || '';

			const valTd = document.createElement('td');
			valTd.style.fontFamily = 'monospace';
			valTd.style.maxWidth = '240px';
			valTd.style.whiteSpace = 'nowrap';

			const copyBtn = document.createElement('button');
			copyBtn.type = 'button';
			copyBtn.className = 'as-icon btn-copy-config-value';
			copyBtn.title = 'Copy Value';
			copyBtn.textContent = 'Copy';
			addSafeEventListener(copyBtn, 'click', async (e) => {
				if (e) e.stopPropagation();
				await copyToClipboard(item.value || '', copyBtn);
			});

			const valSpan = document.createElement('span');
			valSpan.style.overflow = 'hidden';
			valSpan.style.textOverflow = 'ellipsis';
			valSpan.style.display = 'inline-block';
			valSpan.style.maxWidth = '200px';
			valSpan.style.verticalAlign = 'middle';
			valSpan.title = item.value || '';
			valSpan.textContent = item.value || '';

			valTd.appendChild(copyBtn);
			valTd.appendChild(valSpan);

			const actionTd = document.createElement('td');
			actionTd.style.textAlign = 'right';
			actionTd.style.whiteSpace = 'nowrap';

			const editBtn = document.createElement('button');
			editBtn.type = 'button';
			editBtn.className = 'as-icon btn-table-edit-config';
			editBtn.style.marginRight = '4px';
			editBtn.textContent = 'Edit';
			addSafeEventListener(editBtn, 'click', () => {
				this.openEditConfigPanel(item);
			});

			const deleteBtn = document.createElement('button');
			deleteBtn.type = 'button';
			deleteBtn.className = 'as-icon btn-table-delete-config';
			deleteBtn.textContent = 'Delete';
			addSafeEventListener(deleteBtn, 'click', async () => {
				if (await customConfirm('Delete Config', `Are you sure you want to delete config key "${item.key}"?`)) {
					this.handleDeleteConfig(item.id);
				}
			});

			actionTd.appendChild(editBtn);
			actionTd.appendChild(deleteBtn);

			tr.appendChild(keyTd);
			tr.appendChild(descTd);
			tr.appendChild(valTd);
			tr.appendChild(actionTd);

			this.configItemsTbody.appendChild(tr);
		});
	}

	/**
	 * Opens the edit config panel and populates its fields.
	 * @param {object} config
	 */
	openEditConfigPanel(config) {
		if (this.configCreatePanel) setElementDisplay(this.configCreatePanel, 'none');
		if (this.configEditPanel) setElementDisplay(this.configEditPanel, 'block');

		if (this.configEditIdInput) this.configEditIdInput.value = config.id;
		if (this.configEditKeyDisplay) this.configEditKeyDisplay.textContent = config.key;
		if (this.configEditKeyInput) this.configEditKeyInput.value = config.key || '';
		if (this.configEditDescriptionInput) this.configEditDescriptionInput.value = config.description || '';
		if (this.configEditValueInput) this.configEditValueInput.value = config.value || '';
	}

	/**
	 * Handles creating a new config entry.
	 */
	handleCreateConfig() {
		if (!this.newConfigKeyInput) return;
		const key = this.newConfigKeyInput.value.trim();
		const description = this.newConfigDescriptionInput ? this.newConfigDescriptionInput.value.trim() : '';
		const value = this.newConfigValueInput ? this.newConfigValueInput.value : '';

		if (!key) {
			this.displayConfigStatus('Key is required.', true);
			return;
		}

		this.#api.post(
			{
				action: 'add_or_update',
				key,
				description,
				value
			},
			(response) => {
				if (response.success) {
					this.displayConfigStatus(`Config "${key}" created successfully!`, false);
					this.resetCreateConfigPanel();
					this.loadAdminConfigs();
				} else {
					this.displayConfigStatus(response.error || response.message || 'Failed to create config.', true);
				}
			},
			(error) => {
				this.displayConfigStatus('An error occurred while creating config.', true);
				console.error('Create config error:', error);
			},
			'config'
		);
	}

	/**
	 * Handles saving edits to a config entry.
	 */
	handleSaveEditConfig() {
		if (!this.configEditIdInput || !this.configEditKeyInput) return;
		const id = parseInt(this.configEditIdInput.value, 10);
		const key = this.configEditKeyInput.value.trim();
		const description = this.configEditDescriptionInput ? this.configEditDescriptionInput.value.trim() : '';
		const value = this.configEditValueInput ? this.configEditValueInput.value : '';

		if (!id || !key) {
			this.displayConfigStatus('Config ID and key are required.', true);
			return;
		}

		this.#api.post(
			{
				action: 'add_or_update',
				id,
				key,
				description,
				value
			},
			(response) => {
				if (response.success) {
					this.displayConfigStatus(`Config "${key}" updated successfully!`, false);
					this.resetEditConfigPanel();
					this.loadAdminConfigs();
				} else {
					this.displayConfigStatus(response.error || response.message || 'Failed to update config.', true);
				}
			},
			(error) => {
				this.displayConfigStatus('An error occurred while updating config.', true);
				console.error('Update config error:', error);
			},
			'config'
		);
	}

	/**
	 * Handles deleting a config entry.
	 * @param {number|string} id
	 */
	handleDeleteConfig(id) {
		this.#api.post(
			{ action: 'delete', id },
			(response) => {
				if (response.success) {
					this.displayConfigStatus('Config deleted successfully!', false);
					this.loadAdminConfigs();
				} else {
					this.displayConfigStatus(response.error || response.message || 'Failed to delete config.', true);
				}
			},
			(error) => {
				this.displayConfigStatus('An error occurred while deleting config.', true);
				console.error('Delete config error:', error);
			},
			'config'
		);
	}

	/**
	 * Resets and closes the create config panel.
	 */
	resetCreateConfigPanel() {
		if (this.configCreatePanel) setElementDisplay(this.configCreatePanel, 'none');
		if (this.newConfigKeyInput) this.newConfigKeyInput.value = '';
		if (this.newConfigDescriptionInput) this.newConfigDescriptionInput.value = '';
		if (this.newConfigValueInput) this.newConfigValueInput.value = '';
	}

	/**
	 * Resets and closes the edit config panel.
	 */
	resetEditConfigPanel() {
		if (this.configEditPanel) setElementDisplay(this.configEditPanel, 'none');
		if (this.configEditIdInput) this.configEditIdInput.value = '';
		if (this.configEditKeyDisplay) this.configEditKeyDisplay.textContent = '';
		if (this.configEditKeyInput) this.configEditKeyInput.value = '';
		if (this.configEditDescriptionInput) this.configEditDescriptionInput.value = '';
		if (this.configEditValueInput) this.configEditValueInput.value = '';
	}

	/**
	 * Displays a status message in the config management panel.
	 * @param {string} message
	 * @param {boolean} isError
	 */
	displayConfigStatus(message, isError) {
		if (!this.configStatusDiv) return;
		this.configStatusDiv.textContent = message;
		this.configStatusDiv.className = `status-msg ${isError ? 'status-error' : 'status-success'}`;
		setElementDisplay(this.configStatusDiv, 'block');

		setTimeout(() => {
			if (this.configStatusDiv) {
				setElementDisplay(this.configStatusDiv, 'none');
			}
		}, 4000);
	}

	/**
	 * Displays a temporary status message in the admin panel.
	 * @param {string} message - Status message text.
	 * @param {boolean} isError - Whether the status represents an error.
	 */
	displayAdminStatus(message, isError) {
		if (this.adminStatusDiv) {
			this.adminStatusDiv.textContent = message;
			this.adminStatusDiv.className = isError ? 'status-msg status-error' : 'status-msg status-success';
			setElementDisplay(this.adminStatusDiv, 'block');
			setTimeout(() => {
				setElementDisplay(this.adminStatusDiv, 'none');
			}, 4000);
		}
	}

	/**
	 * Utility method to escape HTML text.
	 * @param {string} str
	 * @returns {string}
	 */
	escapeHtml(str) {
		if (!str) return '';
		const div = document.createElement('div');
		div.textContent = str;
		return div.innerHTML;
	}
}

export default Users;
🌐
users.js ×
Type: Web, text/plain
45.79 Kilobytes
Last Modified 2026-09-23 02:25:11
⬇ Download File