From 49eb63794a80671e0c70b4f0abb132479dd4cc4c Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Wed, 25 Mar 2026 20:55:13 -0400 Subject: [PATCH 1/3] feat: implement robust session lifecycle events across core services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a complete, framework-uniform session lifecycle event system that fires events on three buses simultaneously: 1. CurrentUserService Evented bus (backward-compatible .on() listeners) 2. EventsService Evented bus (local service-level listeners) 3. Universe service bus (cross-engine listeners — recommended for engines) Events fired: session.authenticated — on login / session restore (SessionService) session.invalidated — on logout (SessionService) session.terminated — alias for session.invalidated (backward compat) user.loaded — after user + org fully loaded (CurrentUserService) user.updated — after in-session profile refresh (CurrentUserService) user.deauthenticated — semantic logout alias for integrations (SessionService) user.organization_switched — on org switch (CurrentUserService) Changes: - session.js: inject universe + events services; fire session.authenticated in handleAuthentication(), fire session.invalidated / user.deauthenticated in handleInvalidation() with session duration; add _fireSessionEvent() helper that broadcasts on both events service and universe directly - current-user.js: inject universe service; call events.trackUserLoaded() and universe.trigger('user.loaded') in setUser(); add refreshUser() and switchOrganization() helpers that fire user.updated and user.organization_switched respectively - events.js: add trackSessionAuthenticated(), trackUserUpdated(), trackOrganizationSwitched(); trackSessionTerminated() now also fires user.deauthenticated; full JSDoc for all session lifecycle events Previously trackUserLoaded() and trackSessionTerminated() existed but were never called. All session lifecycle tracking methods are now wired up. --- addon/services/current-user.js | 104 +++++++++++++++++ addon/services/events.js | 206 ++++++++++++++++++++++++++------- addon/services/session.js | 109 ++++++++++++++++- 3 files changed, 379 insertions(+), 40 deletions(-) diff --git a/addon/services/current-user.js b/addon/services/current-user.js index d8fdda5d..fafe337c 100644 --- a/addon/services/current-user.js +++ b/addon/services/current-user.js @@ -10,6 +10,25 @@ import { storageFor } from 'ember-local-storage'; import { debug } from '@ember/debug'; import lookupUserIp from '../utils/lookup-user-ip'; +/** + * CurrentUserService + * + * Manages the authenticated user's identity and preferences. Extends Evented + * so that any service or component can subscribe to user lifecycle events + * directly on this service. + * + * Session lifecycle events emitted (on both this service and via EventsService + * which re-broadcasts them on the universe bus for cross-engine listeners): + * + * user.loaded — fired after a successful login or session restore. + * Payload: (user, organization, properties) + * + * user.updated — fired when the user record is refreshed in-session + * (e.g. profile edit). Payload: (user, properties) + * + * user.organization_switched — fired when the user switches active org. + * Payload: (organization, properties) + */ export default class CurrentUserService extends Service.extend(Evented) { @service session; @service store; @@ -18,6 +37,7 @@ export default class CurrentUserService extends Service.extend(Evented) { @service notifications; @service intl; @service events; + @service universe; @tracked user = { id: 'anon' }; @tracked userSnapshot = { id: 'anon' }; @@ -302,14 +322,47 @@ export default class CurrentUserService extends Service.extend(Evented) { return defaultValue; } + /** + * Sets the current user and fires all user.loaded lifecycle events. + * + * This is the canonical place where the authenticated user identity is + * established. It fires: + * + * 1. `this.trigger('user.loaded', user)` — on the currentUser service + * itself (Evented), for direct service-level listeners. + * + * 2. `this.events.trackUserLoaded(user, organization)` — on the events + * service, which re-broadcasts on both the events bus and the universe + * bus so cross-engine listeners (Intercom, PostHog, Attio, etc.) can + * subscribe via `universe.on('user.loaded', handler)`. + * + * @param {Model} user + */ async setUser(user) { const snapshot = await this.getUserSnapshot(user); // Set current user this.set('user', user); this.set('userSnapshot', snapshot); + + // Resolve the organization for event payload + const organization = this.store.peekRecord('company', user.get('company_uuid')); + + // 1. Trigger on the currentUser Evented bus (backward-compatible) this.trigger('user.loaded', user); + // 2. Fire through the events service — broadcasts on both events bus + // and universe bus for cross-engine listeners + if (this.events) { + this.events.trackUserLoaded(user, organization); + } + + // 3. Trigger directly on universe for framework-level uniformity — + // guarantees delivery to all engines on the shared bus + if (this.universe) { + this.universe.trigger('user.loaded', user, organization); + } + // Set permissions this.permissions = this.getUserPermissions(user); @@ -323,4 +376,55 @@ export default class CurrentUserService extends Service.extend(Evented) { await this.loadLocale(); } } + + /** + * Fires a user.updated event when the user record is refreshed in-session. + * Call this after any in-session profile update to keep integrations in sync. + * + * @param {Model} user + */ + async refreshUser(user) { + const snapshot = await this.getUserSnapshot(user); + this.set('user', user); + this.set('userSnapshot', snapshot); + + const organization = this.store.peekRecord('company', user.get('company_uuid')); + + this.trigger('user.updated', user); + + if (this.events) { + this.events.trackEvent('user.updated', { + user_id: user?.id, + organization_id: organization?.id, + organization_name: organization?.name, + }); + } + + if (this.universe) { + this.universe.trigger('user.updated', user, organization); + } + } + + /** + * Fires a user.organization_switched event when the user changes their + * active organization. Call this after a successful org switch. + * + * @param {Model} organization + */ + switchOrganization(organization) { + this.company = organization; + + this.trigger('user.organization_switched', organization); + + if (this.events) { + this.events.trackEvent('user.organization_switched', { + organization_id: organization?.id, + organization_name: organization?.name, + }); + } + + if (this.universe) { + this.universe.trigger('user.organization_switched', organization); + } + } } diff --git a/addon/services/events.js b/addon/services/events.js index c2393102..09fdb2f0 100644 --- a/addon/services/events.js +++ b/addon/services/events.js @@ -3,11 +3,66 @@ import Evented from '@ember/object/evented'; import config from 'ember-get-config'; /** - * Events Service + * EventsService * - * Provides a centralized event tracking system for Fleetbase. - * This service emits standardized events on both its own event bus and the universe service, - * allowing components, services, and engines to subscribe and react to application events. + * Provides a centralized, standardized event tracking system for Fleetbase. + * + * This service is the single source of truth for all application lifecycle + * events. It emits every event on two buses simultaneously: + * + * 1. Its own Evented bus — for direct service/component listeners: + * this.events.on('user.loaded', handler) + * + * 2. The universe service bus — for cross-engine listeners (recommended + * for use in Ember Engines / extensions): + * this.universe.on('user.loaded', handler) + * + * ───────────────────────────────────────────────────────────────────────────── + * Session Lifecycle Events + * ───────────────────────────────────────────────────────────────────────────── + * + * session.authenticated Fired after a successful login or session restore. + * Payload: (properties) + * + * session.invalidated Fired after the session is destroyed (logout). + * Payload: (duration_seconds, properties) + * + * session.terminated Alias for session.invalidated — provided for + * backward compatibility and semantic clarity. + * Payload: (duration_seconds, properties) + * + * user.loaded Fired after the authenticated user record and + * organization have been fully loaded into the + * currentUser service. This is the canonical event + * for integrations to boot (Intercom, PostHog, etc.) + * Payload: (user, organization, properties) + * + * user.updated Fired when the user record is refreshed in-session + * (e.g. after a profile edit). + * Payload: (user, properties) + * + * user.deauthenticated Fired when the user identity is cleared on logout. + * Semantic alias for session.invalidated — use this + * to shut down integrations cleanly (Intercom, etc.) + * Payload: (duration_seconds, properties) + * + * user.organization_switched Fired when the user switches their active org. + * Payload: (organization, properties) + * + * ───────────────────────────────────────────────────────────────────────────── + * Resource Events + * ───────────────────────────────────────────────────────────────────────────── + * + * resource.created Generic resource creation. + * resource.updated Generic resource update. + * resource.deleted Generic resource deletion. + * resource.imported Bulk import. + * resource.exported Export. + * resource.bulk_action Bulk action (delete, archive, etc.) + * {modelName}.created Model-specific creation (e.g. order.created) + * {modelName}.updated Model-specific update. + * {modelName}.deleted Model-specific deletion. + * {modelName}.exported Model-specific export. * * @class EventsService * @extends Service @@ -16,6 +71,108 @@ export default class EventsService extends Service.extend(Evented) { @service universe; @service currentUser; + // ========================================================================= + // Session Lifecycle Tracking + // ========================================================================= + + /** + * Tracks a successful authentication (login or session restore). + * + * Called by SessionService.handleAuthentication(). + * + * @param {Object} [props={}] - Additional properties to include + */ + trackSessionAuthenticated(props = {}) { + const properties = this.#enrichProperties(props); + this.#trigger('session.authenticated', properties); + } + + /** + * Tracks when a user session is terminated (logout). + * + * Called by SessionService.handleInvalidation(). Also fires the semantic + * `user.deauthenticated` event so integrations can react to the user + * identity being cleared without needing to know about session internals. + * + * @param {Number|null} duration - Session duration in seconds (null if unknown) + * @param {Object} [props={}] - Additional properties to include + */ + trackSessionTerminated(duration, props = {}) { + const properties = this.#enrichProperties({ + session_duration: duration, + ...props, + }); + + // Fire session.invalidated (technical event) + this.#trigger('session.invalidated', duration, properties); + + // Fire session.terminated (backward-compatible alias) + this.#trigger('session.terminated', duration, properties); + + // Fire user.deauthenticated (semantic event for integrations) + this.#trigger('user.deauthenticated', duration, properties); + } + + /** + * Tracks when the current user is loaded (session initialized). + * + * Called by CurrentUserService.setUser() after a successful login or + * session restore. This is the canonical event for integrations to boot. + * + * @param {Object} user - The authenticated user model + * @param {Object} organization - The user's active organization model + * @param {Object} [props={}] - Additional properties to include + */ + trackUserLoaded(user, organization, props = {}) { + const properties = this.#enrichProperties({ + user_id: user?.id, + organization_id: organization?.id, + organization_name: organization?.name, + ...props, + }); + + this.#trigger('user.loaded', user, organization, properties); + } + + /** + * Tracks when the user record is refreshed in-session (e.g. profile edit). + * + * Called by CurrentUserService.refreshUser(). + * + * @param {Object} user - The updated user model + * @param {Object} [props={}] - Additional properties to include + */ + trackUserUpdated(user, props = {}) { + const properties = this.#enrichProperties({ + user_id: user?.id, + ...props, + }); + + this.#trigger('user.updated', user, properties); + } + + /** + * Tracks when the user switches their active organization. + * + * Called by CurrentUserService.switchOrganization(). + * + * @param {Object} organization - The new active organization model + * @param {Object} [props={}] - Additional properties to include + */ + trackOrganizationSwitched(organization, props = {}) { + const properties = this.#enrichProperties({ + organization_id: organization?.id, + organization_name: organization?.name, + ...props, + }); + + this.#trigger('user.organization_switched', organization, properties); + } + + // ========================================================================= + // Resource Event Tracking + // ========================================================================= + /** * Tracks the creation of a resource * @@ -131,38 +288,9 @@ export default class EventsService extends Service.extend(Evented) { this.#trigger('resource.bulk_action', verb, resources, firstResource, properties); } - /** - * Tracks when the current user is loaded (session initialized) - * - * @param {Object} user - The user object - * @param {Object} organization - The organization object - * @param {Object} [props={}] - Additional properties to include - */ - trackUserLoaded(user, organization, props = {}) { - const properties = this.#enrichProperties({ - user_id: user?.id, - organization_id: organization?.id, - organization_name: organization?.name, - ...props, - }); - - this.#trigger('user.loaded', user, organization, properties); - } - - /** - * Tracks when a user session is terminated - * - * @param {Number} duration - Session duration in seconds - * @param {Object} [props={}] - Additional properties to include - */ - trackSessionTerminated(duration, props = {}) { - const properties = this.#enrichProperties({ - session_duration: duration, - ...props, - }); - - this.#trigger('session.terminated', duration, properties); - } + // ========================================================================= + // Generic Event Tracking + // ========================================================================= /** * Tracks a generic custom event @@ -190,11 +318,11 @@ export default class EventsService extends Service.extend(Evented) { // ========================================================================= /** - * Triggers an event on both the events service and universe service + * Triggers an event on both the events service and universe service. * * This dual event system allows listeners to subscribe to events on either: - * - this.events.on('event.name', handler) - Local listeners - * - this.universe.on('event.name', handler) - Cross-engine listeners + * - this.events.on('event.name', handler) — local listeners + * - this.universe.on('event.name', handler) — cross-engine listeners * * @private * @param {String} eventName - The event name diff --git a/addon/services/session.js b/addon/services/session.js index f78f4fb7..6050ec6c 100644 --- a/addon/services/session.js +++ b/addon/services/session.js @@ -5,11 +5,42 @@ import { later } from '@ember/runloop'; import { debug } from '@ember/debug'; import getWithDefault from '../utils/get-with-default'; +/** + * SessionService + * + * Extends ember-simple-auth's session service to add: + * + * - Proper lifecycle event firing through the EventsService (which + * re-broadcasts on both the events bus and the universe bus). + * - Session start timestamp tracking for duration calculation. + * + * Session lifecycle events fired (via EventsService → universe bus): + * + * session.authenticated — fired after a successful login/session restore. + * Payload: (properties) + * + * session.invalidated — fired after the session is destroyed (logout). + * Payload: (duration_seconds, properties) + * + * user.deauthenticated — alias for session.invalidated, provided as a + * semantic convenience for integrations that want + * to react to the user identity being cleared + * (e.g. Intercom shutdown, PostHog reset). + * Payload: (duration_seconds, properties) + * + * All events are fired on both the EventsService Evented bus and the universe + * service, so listeners can use either: + * + * this.events.on('session.authenticated', handler) + * this.universe.on('session.authenticated', handler) ← recommended for engines + */ export default class SessionService extends SimpleAuthSessionService { @service router; @service currentUser; @service fetch; @service notifications; + @service events; + @service universe; /** * Set where to transition to @@ -25,6 +56,14 @@ export default class SessionService extends SimpleAuthSessionService { */ @tracked _isOnboarding = false; + /** + * Timestamp (ms) when the session was authenticated. + * Used to calculate session duration on invalidation. + * + * @var {Number|null} + */ + @tracked _sessionStartedAt = null; + /** * Set this as onboarding. * @@ -44,11 +83,20 @@ export default class SessionService extends SimpleAuthSessionService { } /** - * Overwrite the handle authentication method + * Overwrite the handle authentication method. + * + * Fires `session.authenticated` through the events service so that + * integrations (Intercom, PostHog, etc.) can react to a successful login. * * @void */ async handleAuthentication() { + // Record session start time for duration tracking on logout + this._sessionStartedAt = Date.now(); + + // Fire session.authenticated event + this._fireSessionEvent('session.authenticated'); + if (this._isOnboarding) { return; } @@ -76,6 +124,30 @@ export default class SessionService extends SimpleAuthSessionService { removeLoaderNode(); } + /** + * Overwrite the handle invalidation method. + * + * Fires `session.invalidated` and `user.deauthenticated` through the + * events service so that integrations can cleanly shut down. + * + * @void + */ + handleInvalidation() { + const durationSeconds = this._sessionStartedAt ? Math.round((Date.now() - this._sessionStartedAt) / 1000) : null; + + // Fire session.invalidated — integrations can use this for cleanup + if (this.events) { + this.events.trackSessionTerminated(durationSeconds); + } + + // Fire user.deauthenticated — semantic alias for integrations that + // want to react specifically to the user identity being cleared + this._fireSessionEvent('user.deauthenticated', { session_duration: durationSeconds }); + + // Reset start time + this._sessionStartedAt = null; + } + /** * Loads the current authenticated user * @@ -217,4 +289,39 @@ export default class SessionService extends SimpleAuthSessionService { throw new Error(error.message); }); } + + // ========================================================================= + // Private helpers + // ========================================================================= + + /** + * Fires a named session lifecycle event on both the events service and + * directly on the universe service. + * + * Firing on universe directly (in addition to via events service) ensures + * that all engines and extensions receive the event on the shared framework- + * level bus regardless of whether the events service has fully initialised. + * + * Listeners can subscribe via: + * this.universe.on('session.authenticated', handler) + * this.universe.on('user.deauthenticated', handler) + * this.events.on('session.authenticated', handler) + * + * @private + * @param {String} eventName + * @param {Object} [extraProps={}] + */ + _fireSessionEvent(eventName, extraProps = {}) { + // 1. Fire through the events service (dual-broadcasts on events + universe bus) + if (this.events) { + this.events.trackEvent(eventName, extraProps); + } + + // 2. Also trigger directly on universe for framework-level uniformity — + // ensures the event reaches all engines even if events service is + // not yet available or not injected in a given engine context. + if (this.universe) { + this.universe.trigger(eventName, extraProps); + } + } } From cbf57b724d70128b8236df4d029815c66f45c750 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Wed, 25 Mar 2026 21:03:27 -0400 Subject: [PATCH 2/3] fix: call super.handleInvalidation() to preserve post-logout redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit introduced a handleInvalidation() override that fired session lifecycle events but did not call super, meaning the parent ember-simple-auth behaviour (redirecting the browser to rootURL after logout via handleSessionInvalidated) was silently dropped. This would have caused the app to stay on the current page after logout instead of redirecting to the login screen — a critical breakage. Fix: call super.handleInvalidation(routeAfterInvalidation) first, then fire our session lifecycle events. The super call is always safe here because it only performs a redirect/reload and has no return value we need to act on. --- addon/services/session.js | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/addon/services/session.js b/addon/services/session.js index 6050ec6c..29139412 100644 --- a/addon/services/session.js +++ b/addon/services/session.js @@ -125,26 +125,33 @@ export default class SessionService extends SimpleAuthSessionService { } /** - * Overwrite the handle invalidation method. + * Extends the parent handleInvalidation method. * - * Fires `session.invalidated` and `user.deauthenticated` through the - * events service so that integrations can cleanly shut down. + * IMPORTANT: super.handleInvalidation(routeAfterInvalidation) is called + * first to preserve the ember-simple-auth behaviour of redirecting the + * user to the login page (via handleSessionInvalidated). Our event + * firing happens after so it cannot interfere with the redirect. * + * @param {String} routeAfterInvalidation - Passed through from ember-simple-auth * @void */ - handleInvalidation() { + handleInvalidation(routeAfterInvalidation) { + // 1. Always call super first — this performs the actual post-logout + // redirect/reload that ember-simple-auth is responsible for. + super.handleInvalidation(routeAfterInvalidation); + const durationSeconds = this._sessionStartedAt ? Math.round((Date.now() - this._sessionStartedAt) / 1000) : null; - // Fire session.invalidated — integrations can use this for cleanup + // 2. Fire session lifecycle events for integrations (Intercom, PostHog, etc.) if (this.events) { this.events.trackSessionTerminated(durationSeconds); } - // Fire user.deauthenticated — semantic alias for integrations that - // want to react specifically to the user identity being cleared + // 3. Fire user.deauthenticated directly on universe for framework-level + // uniformity — engines can listen without needing the events service. this._fireSessionEvent('user.deauthenticated', { session_duration: durationSeconds }); - // Reset start time + // Reset session start time this._sessionStartedAt = null; } From d658250e5e9e7925683b46ef4e810b9c065945d6 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 26 Mar 2026 09:06:20 +0800 Subject: [PATCH 3/3] bumped version to v0.3.16 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9f37fbd7..e57e8d1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/ember-core", - "version": "0.3.15", + "version": "0.3.16", "description": "Provides all the core services, decorators and utilities for building a Fleetbase extension for the Console.", "keywords": [ "fleetbase-core",