UNPKG

@fleetbase/ember-core

Version:

Provides all the core services, decorators and utilities for building a Fleetbase extension for the Console.

457 lines (373 loc) 13.9 kB
import Service from '@ember/service'; import Evented from '@ember/object/evented'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { dasherize } from '@ember/string'; import { get } from '@ember/object'; import { isBlank } from '@ember/utils'; import { alias } from '@ember/object/computed'; import { storageFor } from 'ember-local-storage'; import { debug } from '@ember/debug'; import lookupUserIp, { getBrowserTimezone } 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; @service fetch; @service theme; @service notifications; @service intl; @service events; @service universe; @tracked user = { id: 'anon' }; @tracked userSnapshot = { id: 'anon' }; @tracked company = {}; @tracked permissions = []; @tracked organizations = []; @tracked whoisData = {}; @tracked locale = 'en-us'; @storageFor('user-options') options; @storageFor('local-cache') cache; @alias('userSnapshot.id') id; @alias('userSnapshot.name') name; @alias('userSnapshot.phone') phone; @alias('userSnapshot.email') email; @alias('userSnapshot.avatar_url') avatarUrl; @alias('userSnapshot.is_admin') isAdmin; @alias('userSnapshot.company_uuid') companyId; @alias('userSnapshot.company_name') companyName; @alias('userSnapshot.role_name') roleName; @alias('userSnapshot.role') role; get authenticatedOptionOwnerId() { const authenticatedUserId = this.session?.data?.authenticated?.user; if (this.session?.isAuthenticated && authenticatedUserId) { return authenticatedUserId; } try { const localStorageSession = JSON.parse(window.localStorage.getItem('ember_simple_auth-session')); const authenticatedSession = localStorageSession?.authenticated; if (authenticatedSession?.token && authenticatedSession?.user) { return authenticatedSession.user; } } catch (error) { // Ignore malformed session storage and fall back to the current snapshot. } return null; } get optionsPrefix() { return `${this.authenticatedOptionOwnerId || this.id || 'anon'}:`; } get latitude() { return this.whois('latitude'); } get longitude() { return this.whois('longitude'); } get currency() { return this.whois('currency.code'); } get city() { return this.whois('city'); } get country() { return this.whois('country_code'); } get timezone() { return this.whois('timezone') || getBrowserTimezone(); } async load() { if (this.session.isAuthenticated) { const user = await this.store.findRecord('user', 'me'); // set user await this.setUser(user); // Load preferences await this.loadPreferences(); return user; } return null; } async promiseUser(options = {}) { const NoUserAuthenticatedError = new Error('Failed to authenticate user.'); if (!this.session.isAuthenticated) { throw NoUserAuthenticatedError; } try { const user = await this.store.queryRecord('user', { me: true }); // set user await this.setUser(user); // Load user whois data await this.loadWhois(); // Load user organizations await this.loadOrganizations(); // Optional callback if (typeof options?.onUserResolved === 'function') { options.onUserResolved(user); } return user; } catch (error) { debug(`Error loading current user : ${error.message}`); throw error; } } async loadPreferences() { await this.loadLocale(); await this.loadWhois(); await this.loadOrganizations(); } async loadLocale() { try { const { locale } = await this.fetch.get('users/locale'); this.setLocale(locale); return locale; } catch (error) { this.notifications.serverError(error); } } async loadOrganizations() { try { const organizations = await this.fetch.get('auth/organizations', {}, { normalizeToEmberData: true, normalizeModelType: 'company' }); this.setOption('organizations', organizations); this.organizations = organizations; return organizations; } catch (error) { this.notifications.serverError(error); } } async loadWhois() { try { // Use frontend IP lookup to get accurate user location // This avoids the issue of server-side lookup returning server IP instead of user IP const whois = await lookupUserIp({ timeout: 5000, cache: true, }); this.setOption('whois', whois); this.whoisData = whois; return whois; } catch (error) { console.error('[currentUser] Failed to load whois:', error); this.notifications.warning('Unable to detect your location. Some features may use default settings.'); // Return fallback data with browser timezone const fallback = { city: null, country_code: null, timezone: getBrowserTimezone(), _source: 'fallback', }; this.setOption('whois', fallback); this.whoisData = fallback; return fallback; } } getCompany() { this.company = this.store.peekRecord('company', this.user.company_uuid); return this.company; } async loadCompany() { const company = this.store.peekRecord('company', this.user.company_uuid); if (company) { return company; } return this.store.findRecord('company', this.user.company_uuid); } getUserPermissions(user) { const permissions = []; // get direct applied permissions if (user.get('permissions')) { permissions.pushObjects(user.get('permissions').toArray()); } // get role permissions and role policies permissions if (user.get('role')) { if (user.get('role.permissions')) { permissions.pushObjects(user.get('role.permissions').toArray()); } if (user.get('role.policies')) { for (let i = 0; i < user.get('role.policies').length; i++) { const policy = user.get('role.policies').objectAt(i); if (policy.get('permissions')) { permissions.pushObjects(policy.get('permissions').toArray()); } } } } // get direct applied policy permissions if (user.get('policies')) { for (let i = 0; i < user.get('policies').length; i++) { const policy = user.get('policies').objectAt(i); if (policy.get('permissions')) { permissions.pushObjects(policy.get('permissions').toArray()); } } } return permissions; } whois(key) { return this.getWhoisProperty(key); } setLocale(locale) { this.setOption('locale', locale); this.intl.setLocale(locale); this.locale = locale; return this; } setOption(key, value) { key = `${this.optionsPrefix}${dasherize(key)}`; this.options.set(key, value); return this; } getOption(key, defaultValue = null) { key = `${this.optionsPrefix}${dasherize(key)}`; const value = this.options.get(key); return value !== undefined ? value : defaultValue; } getWhoisProperty(prop) { const whois = this.getOption('whois'); if (!whois || typeof whois !== 'object') { // Fallback to lookup/whois in local-cache const cachedWhois = this.cache.get('lookup/whois'); if (cachedWhois) { return get(cachedWhois, prop); } return null; } return get(whois, prop); } hasOption(key) { return this.getOption(key) !== undefined; } filledOption(key) { return !isBlank(this.getOption(key)); } async getUserSnapshot(user) { const role = await user.get('role'); const snapshot = user.serialize({ includeId: true }); return { ...snapshot, id: snapshot.uuid, company_name: user.get('company_name'), role_name: user.get('role_name'), role: { ...role.serialize({ includeId: true }), id: role.get('id'), }, }; } getCompanyOption(key, defaultValue = null) { const company = this.store.peekRecord('company', this.companyId); if (company) { const value = get(company, `options.${key}`); if (value === undefined) { return defaultValue; } return value; } 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); this.theme.syncThemeFromCurrentUser(); // 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); // Set environment from user option this.theme.setEnvironment(); // Set locale if (user.locale) { this.setLocale(user.locale); } else { 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); } } }