import dayjs from 'dayjs' import { cloneDeep, findIndex } from 'lodash' import log from 'loglevel' import { action, CancellablePromise, computed, flow, makeObservable, observable, reaction, } from 'mobx' import { DEFAULT_CONFIG, OPEN_USER_DATA } from '../config/default' import { ElementDataWrapper, ElementSchemaVersion, NotificationEvent, SecurityGroup, SecurityGroupsResponse, User, } from '../interfaces' import { OrganizationConfiguration } from '../interfaces/config.interface' import { Organization } from '../interfaces/organization.interface' import { ChangePasswordFormData, EditableUserAttributes, ProfileDataExtended, ProfileFormData, ProfileOptions, UserElementError, } from '../interfaces/user.interface' import { UserElement } from '../models/_default/userElement' import { getPlatform, Platform } from '../platform' import { ENDPOINTS } from '../services' import { ApiResponse, RequestTransform } from '../services/api/apisauce' import { Toast } from '../services/toast' import { MainStore } from './mainStore' import { LANGUAGE_MAP } from './translationStore' const OPEN_PREFIX = '/open' let isPublicUser = false export class UserStore { main: MainStore isPublicUser?: boolean = undefined user?: ProfileDataExtended = undefined userElement?: UserElement | null = undefined organizations?: Organization[] = undefined userSecurityGroups: Map = new Map() // To be set by app if it required any additional setup isSetupComplete = false isSetupError?: Error = undefined users: Map = new Map() get schemaVersion(): ElementSchemaVersion { const organizationForce = this.main.config?.organizations?.[this.organization.id]?.forceVersion if (organizationForce) { return organizationForce } const globalDefault = this.main.config?.forceVersion return ( this.user?.preferredOrganization?.useVersion || globalDefault || ElementSchemaVersion.V2 ) } constructor(main: MainStore) { makeObservable(this, { isPublicUser: observable, user: observable, userElement: observable, organizations: observable, userSecurityGroups: observable, isSetupComplete: observable, isSetupError: observable, users: observable, schemaVersion: computed, organization: computed, organizationConfig: computed, getSecurityGroups: flow, loadUser: action, setupUserMetadata: action, getUsers: action, saveProfile: flow, loadUserElement: flow, loadOrganizations: flow, setOrganization: flow, logout: action, }) this.main = main this.main.api.apisauce.addRequestTransform(publicRequestTransform) // This is a little dirty but we need to access this from requestTransform reaction( () => this.isPublicUser, () => { isPublicUser = this.isPublicUser ?? false }, ) } getSecurityGroups: ( organization: string, ) => CancellablePromise = flow(function* ( this: UserStore, organization: string, ) { if (this.userSecurityGroups.has(organization)) { return this.userSecurityGroups.get(organization) ?? [] } const sgsRequest: ApiResponse< SecurityGroupsResponse, SecurityGroupsResponse > = yield this.main.privileges.getUserSecurityGroups(organization) if ( !sgsRequest.ok || !sgsRequest.data || sgsRequest.headers?.['content-type'] === 'text/html;charset=UTF-8' ) { throw new Error("Could not load user's security groups") } this.userSecurityGroups.set( organization, sgsRequest.data['security-groups'], ) return sgsRequest.data['security-groups'] }) async loadUser(options?: ProfileOptions): Promise { try { const user = await this.main.registration.getMyProfile(options) this.user = user this.isPublicUser = false return user } catch (e) { // Falling back to OPEN_USER_DATA is correct for a genuine "not logged // in" (no/expired session cookie), but this also swallows unrelated // failures (network error, wrong baseUrl, non-2xx that isn't an auth // rejection) the same way, so a real bug here looks identical to a // normal logged-out state. Surface it so the two are distinguishable. log.error('loadUser failed, falling back to OPEN_USER_DATA', e) this.user = OPEN_USER_DATA this.isPublicUser = true return OPEN_USER_DATA } } async setupUserMetadata( user?: ProfileDataExtended, forceLocalOrganization?: Organization, ) { log.debug('setupUserMetadata', user) try { if (!user) { user = await this.loadUser() } if (!user.id) { throw new Error('Invalid user data.') } const organization = forceLocalOrganization || user.selectedOrganization || user.preferredOrganization this.user = { ...user, selectedOrganization: organization, } this.isPublicUser = user.id === OPEN_USER_DATA.id const locale = LANGUAGE_MAP[user.preferredLanguage] const translationPromise = this.main.translationStore.setupBackendTranslations( user.preferredLanguage, ) // RN has an issue with moment locale (dynamic import of files) if (locale && getPlatform() !== Platform.REACT_NATIVE) { dayjs.locale(user.preferredLanguage) } if (organization) { this.main.setModels(organization.id) } await this.main.offlineService?.initializeStorage() const userElementP = this.loadUserElement() const userOrganizationP = this.loadOrganizations() const preloadP = this.main.offlineStore.preloadElementMetadata() const sgP = organization ? this.getSecurityGroups(organization.id) : Promise.resolve([]) await Promise.all([ userElementP, userOrganizationP, translationPromise, preloadP, sgP, ]) // Handle case of no organization by setting one for user. if (user && !organization) { log.debug('No organization selected, setting preferred organization') let toSelect: Organization if (user.preferredOrganization) { toSelect = user.preferredOrganization } else { if (this.organizations?.length) { toSelect = this.organizations[0] } else { throw new Error('Cannot determine user organization') } } // select org for user. await this.changeOrganization(toSelect) } } catch (e) { if (e instanceof Error) { console.error(e) this.isSetupError = e } } } subscribeToNotifications( handler: (notification: NotificationEvent) => void, ): { topic: string; reference: string } | undefined { if (!this.user) { return } const topic = `notifications/${this.organization.id}/${this.user.id}` const reference = this.main.notificationStore.subscribe(topic, handler) if (!reference) { return } // Returns subscriptionId return { topic, reference, } } async resetPassword(oldPassword: string, newPassword: string): Promise { if (!this.user?.id) { return } await this.main.auth.updateUser({ confirmPassword: oldPassword, password: newPassword, }) } async getUsers( this: UserStore, uids: string[], attributes: EditableUserAttributes[] = ['cn', 'sn'], ): Promise> { const toGet = uids.filter((u) => !this.users.has(u)) if (toGet.length > 0) { const data = await this.main.auth.getUsers({ uids: toGet, required_info: attributes, }) Object.entries(data || {}).forEach(([uid, info]) => { this.users.set(uid, { uid, cn: info.cn, sn: info.sn, }) }) } return uids.reduce((acc: Record, uid) => { const user = this.users.get(uid) if (user) { acc[uid] = user } return acc }, {}) } saveProfile = flow< ApiResponse>, [data: Partial | ChangePasswordFormData] >(function* ( this: UserStore, data: ProfileFormData | ChangePasswordFormData, ) { if (!this.user) { return } const res = yield this.main.auth.updateUser(data) if (!('password' in data)) { this.user = { ...this.user, ...data, } } return res }) async getCurrentOrganizationMembers() { const organizationId = this.user?.selectedOrganization?.id if (!organizationId) { return } const members = await this.main.organizations.getOrganizationMembers(organizationId) return members } async changeOrganization(organization: Organization) { if (!this.user) { return } const newUserData: ProfileDataExtended = { ...cloneDeep(this.user), selectedOrganization: organization, } this.main.initializeUserDependentStores() await this.setupUserMetadata(newUserData) } loadUserElement: () => CancellablePromise = flow(function* (this: UserStore) { const userModel = this.main.models.elementModels?.userElement if ( !this.user || !this.organizationConfig.usesUserElement || !userModel ) { return } // try membrane user request const userElementReq: ApiResponse = yield this.main.elements.getUserElement( this.organization.id, userModel.TYPE, ) let userElement: UserElement | undefined if (userElementReq.ok) { log.info('Loaded user element using new membrane API') if (userElementReq.data) { userElement = this.main.elementStore.instantiateElement( userElementReq.data.element.hash, userElementReq.data.element, ) as UserElement | undefined } } else { // try legacy user request log.warn('Loading user element using legacy API') userElement = yield this.main.listingStore.getFirstElement( userModel.TYPE, { user: this.user.id, }, Object.values(userModel.ATTRIBUTES), ) } if (!userElement) { if (!this.organizationConfig.userElementRequired) { // If user element is not required, just // return null and continue this.userElement = null return null } // set Error flag, disable application throw new UserElementError( "Couldn't load definite user element, application may not work correctly.", ) } if (userElement.loadingPromise) { yield userElement.loadingPromise } this.userElement = userElement return userElement }) loadOrganizations: () => CancellablePromise = flow(function* ( this: UserStore, ) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const organizationsRequest: any = yield this.main.organizations.get() if (!organizationsRequest.ok || !organizationsRequest.data.data) { throw new Error("Couldn't load user organizations") } const organizations = organizationsRequest.data.data this.organizations = organizations return organizationsRequest }) setOrganization: (id: string) => CancellablePromise = flow(function* ( this: UserStore, id: string, ) { if (!this.organizations) { return } const organizationIndex = findIndex(this.organizations, { id }) if (organizationIndex === -1) { Toast.show("Organization doesn't exist", 'danger') return } const organizationRequest = yield this.main.organizations.change(id) if (!organizationRequest.ok) { Toast.show('Cannot change organization', 'danger') return } if (this.user?.superAdmin) { try { yield this.main.registration.updateRecentOrganizations(id, { autoLogin: true, }) } catch (error) { log.warn( 'Could not update user metadata after organization change', error, ) } } const newOrganization: Organization = this.organizations[organizationIndex] yield this.changeOrganization(newOrganization) }) get organization(): Organization { if (!this.user || !this.user.selectedOrganization) { return { id: '', name: 'No organization', useVersion: ElementSchemaVersion.V1, } } return this.user.selectedOrganization } get organizationConfig(): OrganizationConfiguration { const defaultConfig = DEFAULT_CONFIG const customConfig = this.user?.selectedOrganization?.id && this.main.config?.organizations?.[this.user?.selectedOrganization.id] if (!customConfig) { return defaultConfig } return customConfig } async logout() { await this.main.offlineService?.clearElementsAndListing() this.organizations = undefined this.userElement = undefined this.userSecurityGroups = new Map() this.isPublicUser = undefined this.user = undefined this.isSetupError = undefined } } const publicRequestTransform: RequestTransform = (request) => { if (request.url?.includes(ENDPOINTS.MEMBRANE) && isPublicUser) { log.debug('sending request to open API', request.url) request.url = `${OPEN_PREFIX}${request.url}` } }