src/app/core/session/session-service/session-manager.service.ts
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.
Properties |
|
Methods |
| clearRemoteSessionIfNecessary |
clearRemoteSessionIfNecessary()
|
|
Returns :
any
|
| getOfflineUsers |
getOfflineUsers()
|
|
Get a list of all users that can log in offline (have a local database).
Returns :
Promise<SessionInfo[]>
|
| 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 :
Returns :
any
|
| remoteLoginAvailable |
remoteLoginAvailable()
|
|
Returns :
any
|
| 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));
}
}