src/app/core/session/session-service/session-manager.service.ts

Description

This service handles the user session. This includes an online and offline login and logout. After a successful login, the database for the current user is initialised.

Relationships

Used by

Depends on

No results matching.

Index

Properties
Methods

Methods

Async checkRemoteSession
checkRemoteSession()

Silently check for an existing SSO session without redirecting. If a session exists, complete the login. Otherwise, set state to LOGGED_OUT (this is not a failure — the user simply hasn't logged in yet).

Returns : unknown
clearRemoteSessionIfNecessary
clearRemoteSessionIfNecessary()
Returns : any
getOfflineUsers
getOfflineUsers()

Get a list of all users that can log in offline (have a local database).

Async logout
logout()

If online, clear the remote session. If offline, reset the state and forward to login page.

Returns : unknown
offlineLogin
offlineLogin(user: SessionInfo)

Login an offline session without sync.

Parameters :
Name Type Optional
user SessionInfo No
Returns : any
Async remoteLogin
remoteLogin()

Login for a remote session, redirecting to Keycloak if needed. After a user has logged in once online, this user can later also use the app offline. Should only be called if there is an internet connection

Returns : unknown
remoteLoginAvailable
remoteLoginAvailable()
Returns : any

Properties

Readonly RESET_REMOTE_SESSION_KEY
Type : string
Default value : "RESET_REMOTE"
Static Readonly SKIP_NEXT_SSO_CHECK_KEY
Type : string
Default value : "SKIP_NEXT_SSO_CHECK"

sessionStorage key that, when present, makes the next call to checkRemoteSession skip the silent SSO check.

Set right before a logout so that, after Keycloak redirects back to /login, we do not run another (now guaranteed to fail) silent check that would block the UI behind a spinner for several seconds. Consumed (removed) on the next checkRemoteSession call.

import { Injectable, inject } from "@angular/core";

import { SessionInfo, SessionSubject } from "../auth/session-info";
import {
  LoginStateSubject,
  SyncStateSubject,
  hasRemoteSession,
} from "../session-type";
import { SyncState } from "../session-states/sync-state.enum";
import { LoginState } from "../session-states/login-state.enum";
import { Router } from "@angular/router";
import { KeycloakAuthService } from "../auth/keycloak/keycloak-auth.service";
import { LocalAuthService } from "../auth/local/local-auth.service";
import { NAVIGATOR_TOKEN, LOCAL_STORAGE_TOKEN } from "../../../utils/di-tokens";
import { environment } from "../../../../environments/environment";
import { CurrentUserSubject } from "../current-user-subject";
import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service";
import { filter, take, takeUntil } from "rxjs/operators";
import { Subject, Subscription } from "rxjs";
import { Entity } from "../../entity/model/entity";
import { DatabaseResolverService } from "../../database/database-resolver.service";
import { EntityConfigReadyService } from "../../entity/entity-config-ready.service";

/**
 * This service handles the user session.
 * This includes an online and offline login and logout.
 * After a successful login, the database for the current user is initialised.
 */
@Injectable()
export class SessionManagerService {
  private readonly localStorage = inject(LOCAL_STORAGE_TOKEN);
  private remoteAuthService = inject(KeycloakAuthService);
  private localAuthService = inject(LocalAuthService);
  private sessionInfo = inject(SessionSubject);
  private currentUser = inject(CurrentUserSubject);
  private entityMapper = inject(EntityMapperService);
  private loginStateSubject = inject(LoginStateSubject);
  private router = inject(Router);
  private navigator = inject<Navigator>(NAVIGATOR_TOKEN);
  private entityConfigReady = inject(EntityConfigReadyService);
  private databaseResolver = inject(DatabaseResolverService);
  private readonly syncStateSubject = inject(SyncStateSubject);

  readonly RESET_REMOTE_SESSION_KEY = "RESET_REMOTE";
  /**
   * sessionStorage key that, when present, makes the next call to
   * {@link checkRemoteSession} skip the silent SSO check.
   *
   * Set right before a logout so that, after Keycloak redirects back to
   * /login, we do not run another (now guaranteed to fail) silent check
   * that would block the UI behind a spinner for several seconds.
   * Consumed (removed) on the next checkRemoteSession call.
   */
  static readonly SKIP_NEXT_SSO_CHECK_KEY = "SKIP_NEXT_SSO_CHECK";
  private remoteLoggedIn = false;
  private updateSubscription: Subscription;
  /** Subscription waiting for first sync completion before registering the user for offline login. */
  private syncSaveSubscription: Subscription | undefined;
  private readonly logout$ = new Subject<void>();

  /**
   * Silently check for an existing SSO session without redirecting.
   * If a session exists, complete the login. Otherwise, set state to LOGGED_OUT
   * (this is not a failure — the user simply hasn't logged in yet).
   */
  async checkRemoteSession() {
    this.loginStateSubject.next(LoginState.IN_PROGRESS);

    // Skip the silent SSO check on the page load right after a logout.
    // We already know the remote session is gone; running another silent
    // check would only add several seconds of spinner before the user can
    // click "Log in" again.
    if (sessionStorage.getItem(SessionManagerService.SKIP_NEXT_SSO_CHECK_KEY)) {
      sessionStorage.removeItem(SessionManagerService.SKIP_NEXT_SSO_CHECK_KEY);
      this.loginStateSubject.next(LoginState.LOGGED_OUT);
      return;
    }

    if (this.remoteLoginAvailable()) {
      return this.remoteAuthService
        .checkSession()
        .then((user) => {
          if (user) {
            return this.handleRemoteLogin(user);
          }
          this.setLoggedOutIfNotAlready();
        })
        .catch(() => {
          this.setLoggedOutIfNotAlready();
        });
    }
    this.setLoggedOutIfNotAlready();
  }

  /**
   * Only transition to LOGGED_OUT if not already logged in (e.g. via offlineLogin).
   * Prevents a slow-resolving SSO check from overwriting a successful offline login.
   */
  private setLoggedOutIfNotAlready() {
    if (this.loginStateSubject.value !== LoginState.LOGGED_IN) {
      this.loginStateSubject.next(LoginState.LOGGED_OUT);
    }
  }

  /**
   * Login for a remote session, redirecting to Keycloak if needed.
   * After a user has logged in once online, this user can later also use the app offline.
   * Should only be called if there is an internet connection
   */
  async remoteLogin() {
    this.loginStateSubject.next(LoginState.IN_PROGRESS);
    if (this.remoteLoginAvailable()) {
      return this.remoteAuthService
        .login()
        .then((user) => this.handleRemoteLogin(user))
        .catch((err) => {
          this.loginStateSubject.next(LoginState.LOGIN_FAILED);
          throw err;
        });
    }
    this.loginStateSubject.next(LoginState.LOGIN_FAILED);
  }

  remoteLoginAvailable() {
    return navigator.onLine && hasRemoteSession(environment.session_type);
  }

  /**
   * Login an offline session without sync.
   * @param user
   */
  offlineLogin(user: SessionInfo) {
    return this.initializeUser(user);
  }

  private async initializeUser(session: SessionInfo) {
    await this.databaseResolver.initDatabasesForSession(session);
    this.sessionInfo.next(session);
    this.loginStateSubject.next(LoginState.LOGGED_IN);
    this.entityConfigReady.setupCompleted$
      .pipe(takeUntil(this.logout$), take(1))
      .subscribe(() => {
        // requires dynamic entity config to be applied first!
        this.initUserEntity(session.entityId);
      });
  }

  private initUserEntity(entityId: string) {
    if (!entityId) {
      this.currentUser.next(null);
      return;
    }

    const entityType = Entity.extractTypeFromId(entityId);
    this.entityMapper
      .load(entityType, entityId)
      .catch(() => null) // see CurrentUserSubject: emits "null" for non-existing user entity
      .then((res) => this.currentUser.next(res));
    this.updateSubscription = this.entityMapper
      .receiveUpdates(entityType)
      .pipe(
        filter(
          ({ entity }) =>
            entity.getId() === entityId || entity.getId(true) === entityId,
        ),
      )
      .subscribe(({ entity }) => this.currentUser.next(entity));
  }

  /**
   * Get a list of all users that can log in offline (have a local database).
   */
  getOfflineUsers(): Promise<SessionInfo[]> {
    return this.localAuthService.getStoredUsers();
  }

  /**
   * If online, clear the remote session.
   * If offline, reset the state and forward to login page.
   */
  async logout() {
    this.logout$.next();

    if (this.remoteLoggedIn) {
      // Tell the next page load (after the Keycloak logout redirect round-trip)
      // not to run another silent SSO check — it would only delay the login
      // form by several seconds before failing as expected.
      sessionStorage.setItem(
        SessionManagerService.SKIP_NEXT_SSO_CHECK_KEY,
        "1",
      );

      if (this.navigator.onLine) {
        // This will forward to the keycloak logout page
        await this.remoteAuthService.logout();
      } else {
        this.localStorage.setItem(this.RESET_REMOTE_SESSION_KEY, "1");
      }
    }
    // resetting app state
    this.sessionInfo.next(undefined);
    this.updateSubscription?.unsubscribe();
    this.syncSaveSubscription?.unsubscribe();
    this.syncSaveSubscription = undefined;
    this.currentUser.next(undefined);
    this.loginStateSubject.next(LoginState.LOGGED_OUT);
    this.syncStateSubject.next(SyncState.UNSYNCED);
    this.remoteLoggedIn = false;
    await this.databaseResolver.resetDatabases();
    return this.router.navigate(["/login"], {
      queryParams: { redirect_uri: this.router.routerState.snapshot.url },
    });
  }

  clearRemoteSessionIfNecessary() {
    if (this.localStorage.getItem(this.RESET_REMOTE_SESSION_KEY)) {
      this.localStorage.removeItem(this.RESET_REMOTE_SESSION_KEY);
      // The remote logout below redirects through Keycloak and back to /login.
      // Skip the silent SSO check on that next page load to avoid a needless
      // multi-second spinner.
      sessionStorage.setItem(
        SessionManagerService.SKIP_NEXT_SSO_CHECK_KEY,
        "1",
      );
      return this.remoteAuthService.logout();
    }
  }

  private async handleRemoteLogin(user: SessionInfo) {
    this.remoteLoggedIn = true;
    await this.initializeUser(user);

    // Defer saving the offline-login entry until the first sync completes,
    // so the option is only shown when local data is actually available.
    this.syncSaveSubscription?.unsubscribe();
    this.syncSaveSubscription = this.syncStateSubject
      .pipe(
        filter((state) => state === SyncState.COMPLETED),
        take(1),
      )
      .subscribe(() => this.localAuthService.saveUser(user));
  }
}

results matching ""

    No results matching ""