File

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.

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 } 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 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 {
        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 (localStorage.getItem(this.RESET_REMOTE_SESSION_KEY)) {
      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 ""