/** * Authentication plugin using the new class-based architecture */ /** * @import { ApplicationState } from '../state.js' * @import { SlDialog } from '../ui.js' * @import { PluginContext } from '../modules/plugin-context.js' * @import { loginDialogPart } from '../templates/login-dialog.types.js' */ import { registerTemplate, createSingleFromTemplate } from '../modules/ui-system.js'; import Plugin from '../modules/plugin-base.js'; import { SessionActivityTracker } from '../modules/session-activity-tracker.js'; /** * @typedef {Object} UserData * @property {string} username * @property {string} fullname * @property {string[]} roles */ /** * @typedef { UserData & {sessionId: string} } AuthenticationData */ // Register templates await registerTemplate('login-dialog', 'login-dialog.html'); // // Authentication Plugin Class // class AuthenticationPlugin extends Plugin { /** * @param {PluginContext} context */ constructor(context) { super(context, { name: 'authentication', deps: ['client', 'config', 'filedata', 'sse'] }); /** @type {SessionActivityTracker|null} */ this.activityTracker = null; } get #logger() { return this.getDependency('logger') } get #client() { return this.getDependency('client') } get #config() { return this.getDependency('config') } /** @type {SlDialog & loginDialogPart} */ #ui = null /** * Plugin installation - creates UI and sets up event handlers * @param {ApplicationState} initialState */ async install(initialState) { await super.install(initialState); this.#logger.debug(`Installing plugin "${this.name}"`); // Create UI elements this.#ui = this.createUi(createSingleFromTemplate('login-dialog', document.body)) // Prevent dialog from closing this.#ui.addEventListener('sl-request-close', (event) => event.preventDefault()); // Add Enter key handling for login this.#ui.username.addEventListener('keydown', (event) => { if (event.key === 'Enter') { this.#ui.password.focus(); } }); this.#ui.password.addEventListener('keydown', (event) => { if (event.key === 'Enter') { this.#ui.submit.click(); } }); } async start() { // Initialize session activity tracker await this._initializeActivityTracker(); this.getDependency('sse').addEventListener('forceLogout', async (event) => { let message = 'Your session has been ended.'; try { const data = JSON.parse(event.data); if (data.message) message = data.message; } catch (_e) { /* ignore */ } alert(message); await this.logout(); }); } /** * React to state changes */ async onStateUpdate() { // No UI updates needed - user menu is handled by user-account plugin } /** * Save session to URL hash on shutdown */ async shutdown() { // Stop activity tracker if (this.activityTracker) { this.activityTracker.stop(); this.activityTracker = null; } // release lock - this really should be in xmleditor plugin but the shutdown endpoint will be called only after this if (this.state?.xml) { try { await this.#client.releaseLock(this.state?.xml) } catch (_e) { // Ignore network errors during page unload } } } // // Public API methods // /** * Checks if the user is authenticated. If not, shows a login dialog * and returns a promise that resolves only after a successful login. * @returns {Promise} the authentication status data */ async ensureAuthenticated() { /** @type {AuthenticationData} */ let authData; try { authData = await this.#client.status(); } catch (error) { // Not authenticated, proceed to show login dialog authData = await this.showLoginDialog(); } // Only update sessionId if userData contains one (from login), not from status check const stateUpdate = { user: authData }; if (authData.sessionId) { // @ts-ignore stateUpdate.sessionId = authData.sessionId; } await this.dispatchStateChange(stateUpdate); // Reload file data after authentication to get user-specific files try { await this.getDependency('filedata').reload({ refresh: true }); this.#logger.debug('File data reloaded after authentication'); } catch (error) { this.#logger.error('Failed to reload file data after authentication: ' + String(error)); } return authData; } /** * Returns the current user or null if none has been authenticated * @returns {UserData|null} */ getUser() { return this.state?.user || null; } /** * Shows the login dialog and updates state if login was successful, * otherwise shows it again in a loop until correct authentication data is supplied * @returns {Promise} */ async showLoginDialog() { let authData while (true) { try { authData = await this._showLoginDialog(); await this.dispatchStateChange({ sessionId: authData.sessionId, user: authData }); break; } catch (error) { this.#logger.error("Error logging in: " + String(error)); } } return authData } /** * Hides the login dialog without logging out */ hideLoginDialog() { this.#ui.hide() } /** * Appends an element to the login dialog * @param {HTMLElement} element */ appendToLoginDialog(element) { this.#ui.insertAdjacentElement('beforeend', element) } /** * Logs the user out */ async logout() { try { await this.#client.logout(); this.#logger.info('User logged out successfully'); await this.dispatchStateChange({ user: null, sessionId: null, xml: null, pdf: null, diff: null }); // re-login await this.showLoginDialog(); // Reload file data after re-login to get new user's files try { await this.getDependency('filedata').reload({ refresh: true }); this.#logger.debug('File data reloaded after logout/re-login'); } catch (error) { this.#logger.error('Failed to reload file data after re-login: ' + String(error)); } } catch (error) { this.#logger.critical('Logout failed:' + error); } } // // Private methods // /** * Creates and displays the login dialog for one login attenpt * @returns {Promise} A promise that resolves on successful login with * the user data or rejects in case the credentials are wrong */ async _showLoginDialog() { // Load and display login message if configured const loginMessage = await this.#config.get("application.login-message") if (loginMessage) { this.#ui.loginMessage.innerHTML = loginMessage; this.#ui.loginMessage.style.display = 'block'; } else { this.#ui.loginMessage.innerHTML = ''; this.#ui.loginMessage.style.display = 'none'; } return new Promise((resolve, reject) => { this.#ui.submit.addEventListener('click', async () => { const username = this.#ui.username.value; const password = this.#ui.password.value; this.#ui.message.textContent = ''; const passwd_hash = await this._hashPassword(password); try { const authData = await this.#client.login(username, passwd_hash); this.#logger.info(`Login successful for user: ${authData.username}`); this.#ui.hide(); this.#ui.username.value = ""; resolve(authData); } catch (error) { this.#ui.message.textContent = 'Wrong username or password'; this.#logger.info('Login failed: ' + String(error)) reject(error); } finally { this.#ui.password.value = ""; } }, {once: true}); this.#ui.show(); }); } /** * Hashes a password using SHA-256. * @param {string} password * @returns {Promise} * @private */ async _hashPassword(password) { const encoder = new TextEncoder(); const data = encoder.encode(password); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); } /** * Initialize or reinitialize the session activity tracker * @private */ async _initializeActivityTracker() { // Get session timeout from config const sessionTimeout = await this.#config.get('session.timeout'); // Stop existing tracker if any if (this.activityTracker) { this.activityTracker.stop(); } // Create new tracker this.activityTracker = new SessionActivityTracker({ sessionTimeout, onLogout: async () => { this.#logger.info('Session expired due to inactivity. Logging out automatically.'); await this.logout(); }, logger: this.#logger }); this.#logger.debug(`Session activity tracker initialized with ${sessionTimeout}s timeout`); } } // // Exports // // Export the Plugin class - app.js will create singleton and export API export default AuthenticationPlugin;