@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
JavaScript
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) {
session;
store;
fetch;
theme;
notifications;
intl;
events;
universe;
user = { id: 'anon' };
userSnapshot = { id: 'anon' };
company = {};
permissions = [];
organizations = [];
whoisData = {};
locale = 'en-us';
options;
cache;
id;
name;
phone;
email;
avatarUrl;
isAdmin;
companyId;
companyName;
roleName;
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);
}
}
}