@sinequa/atomic-angular 1.5.1 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,16 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Injectable, inject, HostBinding, Component, Pipe, InjectionToken, computed, ChangeDetectorRef, DestroyRef, LOCALE_ID, Inject, Optional, input, output, signal, effect, assertInInjectionContext, runInInjectionContext, Injector, EventEmitter, Directive, viewChild, ElementRef, afterNextRender, untracked, linkedSignal, model, TemplateRef, HostListener, Renderer2, contentChildren, contentChild, booleanAttribute, resource, ChangeDetectionStrategy, ViewContainerRef, numberAttribute, viewChildren, afterRenderEffect, afterEveryRender } from '@angular/core';
2
+ import { Injectable, inject, HostBinding, Component, Pipe, InjectionToken, computed, ChangeDetectorRef, DestroyRef, LOCALE_ID, Inject, Optional, input, output, signal, effect, assertInInjectionContext, runInInjectionContext, EnvironmentInjector, Injector, EventEmitter, Directive, viewChild, ElementRef, afterNextRender, untracked, linkedSignal, model, TemplateRef, HostListener, Renderer2, contentChildren, contentChild, booleanAttribute, resource, ChangeDetectionStrategy, ViewContainerRef, numberAttribute, viewChildren, afterRenderEffect, afterEveryRender } from '@angular/core';
3
3
  import { BehaviorSubject, Subscription, firstValueFrom, catchError, map, Subject, of, tap, EMPTY, throwError, filter, shareReplay, switchMap, from, fromEvent, debounceTime } from 'rxjs';
4
4
  import { TranslocoService, TranslocoPipe, provideTranslocoScope } from '@jsverse/transloco';
5
- import { DropdownComponent, DropdownContentComponent, InputComponent, ButtonComponent, cn, FaIconComponent, EllipsisIcon, ChevronRightIcon, MenuComponent, MenuContentComponent, MenuItemComponent, LinkComponent, BadgeComponent, DialogComponent, DialogHeaderComponent, DialogTitleComponent, DialogContentComponent, DialogFooterComponent, ListItemComponent, SquareCheckIcon, SquareMinusIcon, SquareIcon, SwitchComponent, SelectOptionDirective, DialogService, XMarkIcon, InboxIcon, SparklesIcon, FileOutputIcon, TabsComponent, TabsListComponent, TabComponent, SidebarMenuComponent, SidebarMenuItemComponent, SidebarMenuButtonComponent, TooltipDirective, FrownIcon, ChevronLeftIcon, ChevronsLeftIcon, ChevronsRightIcon, ArrowUpAzIcon, ArrowDownZaIcon, ArrowUpRightFromSquareIcon, ToggleRightIcon, ToggleLeftIcon, Separator, SheetCloseDirective, ArrowLeftIcon, SheetService, DateRangePickerDirective, DatepickerDirective, FilterIcon, FilterXIcon, IconButtonComponent, HighlighterIcon, TagsIcon, SpinnerIcon, MagnifyingGlassIcon, LoadingCircleIcon, CircleCheckIcon, PopoverComponent, BellIcon, TrashIcon, BarsIcon, CardComponent, CardHeaderComponent, CardContentComponent, CardFooterComponent, InputGroupInput, InputGroupComponent, InputGroupAddonComponent, EyeSlashIcon, EyeIcon, BookmarkIcon, PopoverContentComponent, UserIcon, FolderIcon, VerticalDividerComponent, CommentIcon, ThumbsUpIcon, ThumbsDownIcon, DropdownDirective, SearchIcon, TriangleAlertIcon, ListFilterIcon, BreakpointObserverService, TrashCanIcon, CircleXIcon, InfoCircleIcon, HorizontalDividerComponent, HistoryIcon, StarIcon, FlagEnglishIcon, FlagFrenchIcon, EditIcon, UndoIcon, SaveIcon, AvatarComponent, AvatarFallbackComponent, AvatarImageComponent } from '@sinequa/ui';
5
+ import { DropdownComponent, DropdownContentComponent, InputComponent, ButtonComponent, cn, FaIconComponent, EllipsisIcon, ChevronRightIcon, MenuComponent, MenuContentComponent, MenuItemComponent, LinkComponent, CardComponent, CardHeaderComponent, CardContentComponent, BadgeComponent, DialogComponent, DialogHeaderComponent, DialogTitleComponent, DialogContentComponent, DialogFooterComponent, ListItemComponent, SquareCheckIcon, SquareMinusIcon, SquareIcon, SwitchComponent, SelectOptionDirective, DialogService, XMarkIcon, InboxIcon, SparklesIcon, FileOutputIcon, TabsComponent, TabsListComponent, TabComponent, SidebarMenuComponent, SidebarMenuItemComponent, SidebarMenuButtonComponent, TooltipDirective, FrownIcon, ChevronLeftIcon, ChevronsLeftIcon, ChevronsRightIcon, ArrowUpAzIcon, ArrowDownZaIcon, ArrowUpRightFromSquareIcon, ToggleRightIcon, ToggleLeftIcon, Separator, SheetCloseDirective, ArrowLeftIcon, SheetService, DateRangePickerDirective, DatepickerDirective, FilterIcon, FilterXIcon, IconButtonComponent, HighlighterIcon, TagsIcon, SpinnerIcon, MagnifyingGlassIcon, LoadingCircleIcon, CircleCheckIcon, PopoverComponent, BellIcon, TrashIcon, BarsIcon, CardFooterComponent, InputGroupInput, InputGroupComponent, InputGroupAddonComponent, EyeSlashIcon, EyeIcon, BookmarkIcon, PopoverContentComponent, UserIcon, FolderIcon, VerticalDividerComponent, CommentIcon, ThumbsUpIcon, ThumbsDownIcon, DropdownDirective, SearchIcon, TriangleAlertIcon, ListFilterIcon, BreakpointObserverService, TrashCanIcon, CircleXIcon, InfoCircleIcon, HorizontalDividerComponent, HistoryIcon, StarIcon, FlagEnglishIcon, FlagFrenchIcon, EditIcon, UndoIcon, SaveIcon, AvatarComponent, AvatarFallbackComponent, AvatarImageComponent } from '@sinequa/ui';
6
6
  import highlightWords from 'highlight-words';
7
7
  import { ActivatedRoute, Router, NavigationEnd, RouterLink, RouterLinkActive, RouterModule } from '@angular/router';
8
8
  import { withDevtools } from '@angular-architects/ngrx-toolkit';
9
9
  import { signalStore, signalStoreFeature, withState, withMethods, patchState, getState, withComputed } from '@ngrx/signals';
10
- import { EngineType, fetchApp, extraColumns, sysLang, globalConfig, getQueryParamsFromUrl, clearSessionTokens, login, info, warn, error, setGlobalConfig, notify, addConcepts, queryParamsFromUrl, patchUserSettings, deleteUserSettings, fetchUserSettings, buildPathsAndLevels, escapeExpr, isAuthenticated, isExpired, debug, AuditEventType, fetchSuggest, isObject, Audit, getMetadata, bisect, isNotInputEvent, fetchSponsoredLinks, fetchQuery, translateAggregationToDateOptions, aggItemRegex, parseValueAndOperatorFromItem, fetchSuggestField, fetchSimilarDocuments, logout, fetchChangePassword, fetchSendPasswordResetEmail, expiresSoon, suggestionsToTreeAggregationNodes, labels, fetchLabels, guid, getRelativeDate, createUserProfile, deleteUserProfileProperty, patchUserProfile, isJsonable, addAuditAdditionalInfo, getToken, setToken, createHeaders } from '@sinequa/atomic';
10
+ import { EngineType, fetchApp, extraColumns, sysLang, globalConfig, getQueryParamsFromUrl, clearSessionTokens, login, info, error, setGlobalConfig, initializeAppConfig, notify, addConcepts, queryParamsFromUrl, patchUserSettings, deleteUserSettings, fetchUserSettings, warn, buildPathsAndLevels, escapeExpr, isAuthenticated, isExpired, debug, AuditEventType, fetchSuggest, isObject, Audit, getMetadata, bisect, isNotInputEvent, fetchSponsoredLinks, fetchQuery, translateAggregationToDateOptions, aggItemRegex, parseValueAndOperatorFromItem, fetchSuggestField, fetchSimilarDocuments, logout, fetchChangePassword, fetchSendPasswordResetEmail, expiresSoon, suggestionsToTreeAggregationNodes, labels, fetchLabels, guid, getRelativeDate, createUserProfile, deleteUserProfileProperty, patchUserProfile, isJsonable, addAuditAdditionalInfo, getToken, setToken, createHeaders } from '@sinequa/atomic';
11
11
  import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
12
12
  import { DatePipe, DATE_PIPE_DEFAULT_TIMEZONE, DATE_PIPE_DEFAULT_OPTIONS, Location, NgTemplateOutlet, NgStyle, NgClass, NgComponentOutlet } from '@angular/common';
13
- import { HttpClient, HttpParams, httpResource, HttpResponse, HttpHeaders, HttpContextToken } from '@angular/common/http';
13
+ import { HttpClient, HttpParams, httpResource, HttpResponse, HttpContextToken, HttpHeaders } from '@angular/common/http';
14
14
  import { Title, DomSanitizer } from '@angular/platform-browser';
15
15
  import { cva } from 'class-variance-authority';
16
16
  import * as i1 from '@angular/forms';
@@ -1525,100 +1525,174 @@ function withThemes(app, themes) {
1525
1525
  }
1526
1526
 
1527
1527
  /**
1528
- * Signs in the user by checking the global configuration for authentication method and acting accordingly.
1528
+ * Signs the user in according to the resolved {@link globalConfig.authMode}.
1529
1529
  *
1530
- * This function first clears any existing session tokens to ensure a clean authentication state. It then checks the
1531
- * global configuration to determine whether to use credentials-based authentication or Single Sign-On (SSO). If
1532
- * credentials are used, it redirects the user to the login page. If SSO is enabled, it reloads the page to trigger
1533
- * the SSO login process. If neither method is specified, it attempts a standard login and handles any errors that
1534
- * may occur during the process.
1535
- * @returns A promise resolving to a boolean indicating the success of the sign-in operation.
1530
+ * The mode is expected to be resolved beforehand (by `initializeAppConfig`, awaited in
1531
+ * `bootstrapApp`). This function clears any existing session, then:
1532
+ * - `credentials` redirect to the login form;
1533
+ * - `sso` reload the page so the browser/proxy performs the handshake;
1534
+ * - `oauth` / `saml` → delegate to `login()`, which redirects to the provider;
1535
+ * - `bearer` delegate to `login()`;
1536
+ * - `unknown` → `login()` tries SSO silently then resolves to credentials; on failure the login
1537
+ * form is shown.
1538
+ *
1539
+ * @returns A promise resolving to a boolean indicating whether the user is authenticated.
1536
1540
  */
1537
1541
  async function signIn() {
1538
1542
  assertInInjectionContext(signIn);
1539
1543
  const router = inject(Router);
1540
1544
  const lastUrlAfterNavigation = inject(NavigationService).urlAfterNavigation;
1541
- const { useCredentials, loginPath, useSSO } = globalConfig;
1545
+ const { loginPath, authMode } = globalConfig;
1542
1546
  // Always clear authentication tokens first to clear any existing session
1543
1547
  clearSessionTokens();
1544
- // If credentials are used, redirect to the login page
1545
- if (useCredentials) {
1548
+ // Credentials: show the login form.
1549
+ if (authMode?.kind === "credentials") {
1546
1550
  router.navigate([loginPath], { queryParams: { returnUrl: lastUrlAfterNavigation } });
1547
1551
  return false; // prevent further execution
1548
1552
  }
1549
- // SSO is set to true when the browser handles authentication automatically
1550
- // If SSO is used, reload the page to trigger SSO login
1551
- if (useSSO) {
1552
- // reload the page to trigger SSO login
1553
+ // SSO: the browser/proxy handles authentication reload to trigger it.
1554
+ if (authMode?.kind === "sso") {
1553
1555
  window.location.reload();
1554
1556
  return false; // prevent further execution
1555
1557
  }
1556
- // Otherwise, perform a standard login
1558
+ // oauth / saml / bearer / unknown — let login() drive the handshake.
1557
1559
  try {
1558
1560
  const response = await login();
1559
1561
  if (response) {
1560
1562
  info("Response from login", response);
1561
1563
  return true;
1562
1564
  }
1563
- else {
1564
- warn("Response from login", response);
1565
+ // Not authenticated. For provider redirects (oauth/saml) the page is already navigating away,
1566
+ // so we don't touch the router. Otherwise (unknown resolved to credentials, or bearer failed)
1567
+ // show the login form.
1568
+ if (authMode?.kind !== "oauth" && authMode?.kind !== "saml") {
1569
+ router.navigate([loginPath], { queryParams: { returnUrl: lastUrlAfterNavigation } });
1565
1570
  }
1571
+ return false;
1566
1572
  }
1567
1573
  catch (err) {
1568
1574
  error("Error during login", err);
1569
- if (err.status === 401) {
1575
+ // A 401 is recoverable: the user just needs to authenticate → show the login form.
1576
+ if (err?.status === 401) {
1570
1577
  error("Unauthorized access - please check your credentials:", err?.errorMessage);
1571
- router.navigate([loginPath]);
1578
+ router.navigate([loginPath], { queryParams: { returnUrl: lastUrlAfterNavigation } });
1579
+ return false;
1572
1580
  }
1573
- throw err;
1581
+ // Any other error (e.g. a misconfigured OAuth/SAML provider → 5xx) is fatal and not something
1582
+ // the user can resolve from the login form. Stop here: navigate straight to the error page with
1583
+ // the reason. We return false (instead of rethrowing) so the application does NOT initialize and
1584
+ // fire further authenticated API calls (e.g. usersettings → 401).
1585
+ const message = (err?.errorMessage ?? err?.message) || undefined;
1586
+ router.navigate(["/error"], { queryParams: { message } });
1587
+ return false;
1574
1588
  }
1575
- return false; // prevent further execution
1576
1589
  }
1577
1590
 
1578
1591
  /**
1579
1592
  * Bootstraps the application by ensuring the user is authenticated and initializing the application.
1580
1593
  *
1581
- * This function first attempts to sign in the user using the provided `Router`. If authentication is successful,
1582
- * it proceeds to initialize the application and create routes using the `applicationService`. Any errors during
1583
- * authentication or initialization are logged to the console, but the returned promise always resolves.
1594
+ * This function first attempts to sign in the user via `signIn()`. If authentication is successful,
1595
+ * it initializes the application (stores and, optionally, dynamic routes) through `ApplicationService`
1596
+ * and waits for the initialization to complete before resolving. Any errors during authentication or
1597
+ * initialization are logged to the console, but the returned promise never rejects.
1584
1598
  *
1585
- * @param Router - The application's router instance used for navigation and authentication.
1586
- * @param applicationService - The service responsible for initializing the application and creating routes.
1587
- * @returns A promise that resolves when the application is ready to be initialized, regardless of success or failure.
1599
+ * Note: this function relies on Angular's injection context, so it must be called within one
1600
+ * (e.g. from `provideAppInitializer`).
1601
+ *
1602
+ * @param options - Configuration options for the bootstrap process.
1603
+ * @param options.createRoutes - Whether to create routes during initialization. Defaults to `true`.
1604
+ * @returns A promise that resolves to `true` when the application has been fully initialized,
1605
+ * or `false` when the user is not authenticated or an error occurred.
1588
1606
  */
1589
- async function withBootstrapApp(applicationService, { createRoutes = true }) {
1590
- // This function is used to test if the application is ready to be initialized
1591
- // set the createRoutes flag in the global config
1607
+ async function bootstrapApp({ createRoutes = true } = {}) {
1608
+ assertInInjectionContext(bootstrapApp);
1609
+ // Capture the injector synchronously: Angular's injection context does not survive across an
1610
+ // `await`, so after awaiting `initializeAppConfig()` we must re-enter it to inject services and
1611
+ // run `signIn()` (which use `inject()` internally).
1612
+ const injector = inject(EnvironmentInjector);
1613
+ // set the createRoutes flag early so it is defined even when sign-in fails
1614
+ // (ApplicationService.initialize() sets it again when it runs)
1592
1615
  setGlobalConfig({ createRoutes });
1593
- return new Promise(resolve => {
1594
- // Check if the user is authenticated
1595
- signIn()
1596
- .then(async (response) => {
1597
- if (response) {
1598
- info('User authenticated, initializing application...');
1599
- // Initialize the application.
1600
- // Awaited so the APP_INITIALIZER does not resolve (and bootstrap does not
1601
- // complete) until initialization is done. Otherwise routed components render
1602
- // and set their page title before `initialize()` runs `setGeneralApp()`, which
1603
- // would then overwrite the page title with the bare application name.
1604
- try {
1605
- await applicationService.initialize(createRoutes);
1606
- info(`Application initialized with routes: ${createRoutes} successfully.`);
1607
- }
1608
- catch (err) {
1609
- error(`Error initializing application with routes: ${createRoutes}:`, err);
1610
- }
1611
- }
1612
- else {
1613
- info('User not authenticated, skipping application initialization.');
1614
- }
1615
- ;
1616
- })
1617
- .catch(err => {
1618
- error('Error while signing in:', err);
1619
- })
1620
- .finally(() => resolve());
1621
- });
1616
+ // Resolve the authentication mode (queries the server pre-login when needed) BEFORE signing in.
1617
+ // Doing it here rather than as a separate, concurrent APP_INITIALIZER — guarantees that
1618
+ // signIn() reads a fully resolved `authMode` and removes the long-standing bootstrap race.
1619
+ // Crucially, this also sets `backendUrl` BEFORE any service/store is constructed, so those that
1620
+ // derive their API URL from `globalConfig.backendUrl` (e.g. PrincipalStore) don't capture
1621
+ // `undefined` (which produced requests to `/undefined/api/v1/...`).
1622
+ try {
1623
+ await initializeAppConfig();
1624
+ }
1625
+ catch (err) {
1626
+ error('Error while initializing application config:', err);
1627
+ // Pre-login failed (e.g. a 500 "app not found: 'mint_rnd-yoyo'" for a bad app name). This is
1628
+ // fatal — backendUrl/authMode can't be resolved — so route to the error page with the reason
1629
+ // instead of continuing to signIn() (which would otherwise fail with a less meaningful error).
1630
+ const message = err?.errorMessage
1631
+ ?? err?.message;
1632
+ runInInjectionContext(injector, () => inject(Router).navigate(['/error'], { queryParams: { message } }));
1633
+ return false;
1634
+ }
1635
+ // Inject ApplicationService AFTER initializeAppConfig (re-entering the injection context lost
1636
+ // across the await), so its dependent services/stores are constructed with `backendUrl` set.
1637
+ const applicationService = runInInjectionContext(injector, () => inject(ApplicationService));
1638
+ let authenticated = false;
1639
+ try {
1640
+ // Re-enter the injection context lost across the await above.
1641
+ authenticated = await runInInjectionContext(injector, () => signIn());
1642
+ }
1643
+ catch (err) {
1644
+ error('Error while signing in:', err);
1645
+ return false;
1646
+ }
1647
+ if (!authenticated) {
1648
+ info('User not authenticated, skipping application initialization.');
1649
+ return false;
1650
+ }
1651
+ info('User authenticated, initializing application...');
1652
+ try {
1653
+ await applicationService.initialize(createRoutes);
1654
+ info(`Application initialized successfully (createRoutes: ${createRoutes}).`);
1655
+ return true;
1656
+ }
1657
+ catch (err) {
1658
+ error(`Error initializing application (createRoutes: ${createRoutes}):`, err);
1659
+ // Authenticated, but the application failed to initialize (e.g. fetchApp/usersettings failed).
1660
+ // Route to the error page with the failure reason instead of leaving a half-initialized app.
1661
+ const message = err?.errorMessage
1662
+ ?? err?.message;
1663
+ runInInjectionContext(injector, () => inject(Router).navigate(['/error'], { queryParams: { message } }));
1664
+ return false;
1665
+ }
1666
+ }
1667
+ /**
1668
+ * Bootstraps the application by ensuring the user is authenticated and initializing the application.
1669
+ *
1670
+ * @deprecated Use {@link bootstrapApp} instead, and let it inject `ApplicationService` itself.
1671
+ *
1672
+ * Migration — in your `app.config.ts`, replace:
1673
+ * ```ts
1674
+ * // ❌ Deprecated: eagerly injecting ApplicationService in the factory constructs it (and its
1675
+ * // dependent stores/services) BEFORE bootstrapApp resolves the config, so services that build
1676
+ * // their API URL from `globalConfig.backendUrl` capture `undefined` (→ `/undefined/api/v1/...`).
1677
+ * provideAppInitializer(() => withBootstrapApp(inject(ApplicationService), { createRoutes: true })),
1678
+ * ```
1679
+ * with:
1680
+ * ```ts
1681
+ * // ✅ bootstrapApp injects ApplicationService internally, AFTER initializeAppConfig() has set
1682
+ * // `backendUrl` and resolved the auth mode.
1683
+ * provideAppInitializer(() => bootstrapApp({ createRoutes: true })),
1684
+ * ```
1685
+ * (Remove the now-unused `inject` / `ApplicationService` imports.)
1686
+ *
1687
+ * @param applicationService - Ignored; kept for backward compatibility. `ApplicationService` is
1688
+ * provided in root and injected internally by {@link bootstrapApp}, so the instance is the same.
1689
+ * Passing `inject(ApplicationService)` here is discouraged — see the migration note above.
1690
+ * @param options - Configuration options for the bootstrap process.
1691
+ * @param options.createRoutes - Whether to create routes during initialization. Defaults to `true`.
1692
+ * @returns A promise that resolves when the bootstrap process is complete, regardless of success or failure.
1693
+ */
1694
+ async function withBootstrapApp(_applicationService, { createRoutes = true } = {}) {
1695
+ await bootstrapApp({ createRoutes });
1622
1696
  }
1623
1697
 
1624
1698
  /**
@@ -3384,8 +3458,9 @@ class AppService {
3384
3458
  *
3385
3459
  * @remarks
3386
3460
  * This method constructs an HTTP GET request to fetch the application configuration
3387
- * using the `app` parameter from the global configuration. If the request fails,
3388
- * it logs the error to the console and returns an empty observable.
3461
+ * using the `app` parameter from the global configuration. If the request fails, it logs the
3462
+ * error and re-throws a normalized `Error` carrying the server's `errorMessage` when available
3463
+ * (e.g. "app not found: '...'"), so callers can surface the reason on the error page.
3389
3464
  *
3390
3465
  * @example
3391
3466
  * ```typescript
@@ -3397,9 +3472,19 @@ class AppService {
3397
3472
  getApp(appName) {
3398
3473
  const app = appName || globalConfig.app;
3399
3474
  const params = new HttpParams().set('app', app || '');
3400
- return this.http.get(this.API_URL + '/app', { params }).pipe(catchError(error => {
3401
- console.error('AppService.getApp failure - error: ', error);
3402
- return EMPTY;
3475
+ return this.http.get(this.API_URL + '/app', { params }).pipe(catchError((err) => {
3476
+ console.error('AppService.getApp failure - error: ', err);
3477
+ // Propagate the failure (previously swallowed with EMPTY, which hid the cause and surfaced a
3478
+ // generic "no elements in sequence" downstream). Surface the Sinequa error-envelope message
3479
+ // when present — e.g. a 500 with { errorMessage: "app not found: 'mint_rnd-yoyo'" } — so the
3480
+ // bootstrap/login flow can route to the error page with the actual reason.
3481
+ const e = err;
3482
+ const message = e?.error?.errorMessage ??
3483
+ e?.error?.message ??
3484
+ e?.errorMessage ??
3485
+ e?.message ??
3486
+ 'Failed to fetch application configuration';
3487
+ return throwError(() => new Error(message));
3403
3488
  }));
3404
3489
  }
3405
3490
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.18", ngImport: i0, type: AppService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
@@ -3421,12 +3506,13 @@ function AuthGuard() {
3421
3506
  return async (_, state) => {
3422
3507
  const router = inject(Router);
3423
3508
  const principalStore = inject(PrincipalStore);
3424
- const { loginPath, useCredentials, useSSO } = globalConfig;
3509
+ const { loginPath, authMode } = globalConfig;
3425
3510
  if (state.url.startsWith("/login"))
3426
3511
  return true;
3427
3512
  // If the user is not authenticated, navigate to the login page.
3428
3513
  // The login page handles every authentication method (credentials, OAuth, SAML).
3429
- if (!isAuthenticated() && !useSSO) {
3514
+ // In SSO mode the browser/proxy carries the auth, so we let it through.
3515
+ if (!isAuthenticated() && authMode?.kind !== "sso") {
3430
3516
  router.navigate([loginPath], { queryParams: { returnUrl: state.url } });
3431
3517
  return false;
3432
3518
  }
@@ -3452,7 +3538,7 @@ function AuthGuard() {
3452
3538
  // check if the password is expired and if the partition is editable
3453
3539
  // changing password is only possible when user use credentials to auhtenticate
3454
3540
  // only in credentials mode
3455
- if (useCredentials) {
3541
+ if (authMode?.kind === "credentials") {
3456
3542
  const exp = passwordExpirationDate;
3457
3543
  const editable = !!editablePartition;
3458
3544
  if (editable && exp && isExpired(exp)) {
@@ -5278,101 +5364,160 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.18", ngImpo
5278
5364
  }], ctorParameters: () => [], propDecorators: { article: [{ type: i0.Input, args: [{ isSignal: true, alias: "article", required: true }] }], aggregation: [{ type: i0.Input, args: [{ isSignal: true, alias: "aggregation", required: true }] }], shadow: [{ type: i0.ViewChild, args: ["shadowRender", { ...{ read: ElementRef }, isSignal: true }] }], client: [{ type: i0.ViewChild, args: ["documentLocator", { ...{ read: ElementRef }, isSignal: true }] }] } });
5279
5365
 
5280
5366
  class ErrorComponent {
5367
+ route = inject(ActivatedRoute);
5281
5368
  router = inject(Router);
5282
- reload() {
5369
+ /**
5370
+ * Human-readable error detail shown on the page, taken from the `message` query param.
5371
+ * Callers navigating here can pass it, e.g. on an auth failure:
5372
+ * `router.navigate(["error"], { queryParams: { message: err.message } })`.
5373
+ */
5374
+ message = (() => {
5375
+ const value = this.route.snapshot.queryParams['message'];
5376
+ return typeof value === 'string' && value.trim().length ? value : undefined;
5377
+ })();
5378
+ /** Navigate home and re-bootstrap the application (fresh authentication attempt). */
5379
+ goHome() {
5283
5380
  this.router.navigate(['/']).then(() => window.location.reload());
5284
5381
  }
5285
5382
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.18", ngImport: i0, type: ErrorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
5286
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.18", type: ErrorComponent, isStandalone: true, selector: "error-component, ErrorComponent", ngImport: i0, template: `
5287
- <div class="bg-background text-foreground flex min-h-screen flex-col items-center justify-center">
5288
- <svg
5289
- class="mb-8 h-20 w-20 text-red-600"
5290
- xmlns="http://www.w3.org/2000/svg"
5291
- width="24"
5292
- height="24"
5293
- viewBox="0 0 24 24"
5294
- fill="none"
5295
- stroke="currentColor"
5296
- stroke-width="2"
5297
- stroke-linecap="round"
5298
- stroke-linejoin="round">
5299
- <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
5300
- <path d="M12 9v4" />
5301
- <path d="M12 17h.01" />
5302
- </svg>
5303
- <h1 class="mb-4 text-4xl font-bold">Oops! Something went wrong</h1>
5304
- <p class="text-muted-foreground mb-8 text-xl">We apologize for the inconvenience.</p>
5305
- <div class="flex space-x-4">
5306
- <button
5307
- (click)="reload()"
5308
- class="btn btn-outline rounded-lg border-neutral-300 text-neutral-700 transition hover:bg-neutral-200 hover:text-neutral-700">
5383
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.18", type: ErrorComponent, isStandalone: true, selector: "error-component, ErrorComponent", host: { classAttribute: "bg-background text-foreground grid min-h-dvh w-full place-content-center p-6" }, ngImport: i0, template: `
5384
+ <Card
5385
+ hover="no"
5386
+ class="bg-background border-(--popover-border) w-full max-w-md rounded-3xl border shadow-2xl">
5387
+ <CardHeader class="flex flex-col items-center gap-4 pt-8 text-center">
5388
+ <span
5389
+ class="bg-destructive/10 text-destructive inline-flex h-16 w-16 items-center justify-center rounded-full">
5309
5390
  <svg
5310
- class="mr-2 h-4 w-4"
5311
5391
  xmlns="http://www.w3.org/2000/svg"
5312
- width="24"
5313
- height="24"
5392
+ class="h-8 w-8"
5314
5393
  viewBox="0 0 24 24"
5315
5394
  fill="none"
5316
5395
  stroke="currentColor"
5317
5396
  stroke-width="2"
5318
5397
  stroke-linecap="round"
5319
- stroke-linejoin="round">
5320
- <path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
5321
- <path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
5398
+ stroke-linejoin="round"
5399
+ aria-hidden="true">
5400
+ <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
5401
+ <path d="M12 9v4" />
5402
+ <path d="M12 17h.01" />
5322
5403
  </svg>
5323
- Go to Homepage
5324
- </button>
5325
- </div>
5326
- </div>
5327
- `, isInline: true });
5404
+ </span>
5405
+
5406
+ <div class="grid gap-1.5">
5407
+ <h1 class="text-2xl font-semibold tracking-tight">Something went wrong</h1>
5408
+ <p class="text-muted-foreground text-sm">
5409
+ We're sorry — the application ran into a problem and couldn't continue.
5410
+ </p>
5411
+ </div>
5412
+ </CardHeader>
5413
+
5414
+ <CardContent class="grid gap-5 pb-8">
5415
+ @if (message) {
5416
+ <div class="border-border bg-muted/40 grid gap-1 rounded-lg border p-3 text-left">
5417
+ <span class="text-muted-foreground text-[0.7rem] font-medium uppercase tracking-wider">
5418
+ Details
5419
+ </span>
5420
+ <p class="text-foreground text-sm break-words" role="alert">{{ message }}</p>
5421
+ </div>
5422
+ }
5423
+
5424
+ <div class="flex justify-center">
5425
+ <button variant="primary" (click)="goHome()">
5426
+ <svg
5427
+ xmlns="http://www.w3.org/2000/svg"
5428
+ class="mr-2 h-4 w-4"
5429
+ viewBox="0 0 24 24"
5430
+ fill="none"
5431
+ stroke="currentColor"
5432
+ stroke-width="2"
5433
+ stroke-linecap="round"
5434
+ stroke-linejoin="round"
5435
+ aria-hidden="true">
5436
+ <path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
5437
+ <path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
5438
+ </svg>
5439
+ Go to homepage
5440
+ </button>
5441
+ </div>
5442
+ </CardContent>
5443
+ </Card>
5444
+ `, isInline: true, dependencies: [{ kind: "directive", type: ButtonComponent, selector: "button", inputs: ["class", "variant", "decoration", "scheme", "iconOnly", "size", "solid"] }, { kind: "directive", type: CardComponent, selector: ".card, card, Card", inputs: ["class", "variant", "hover"] }, { kind: "directive", type: CardHeaderComponent, selector: ".card-header, card-header, CardHeader, cardheader", inputs: ["class"] }, { kind: "directive", type: CardContentComponent, selector: ".card-content, card-content, CardContent, cardcontent", inputs: ["class"] }] });
5328
5445
  }
5329
5446
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.18", ngImport: i0, type: ErrorComponent, decorators: [{
5330
5447
  type: Component,
5331
5448
  args: [{
5332
5449
  selector: 'error-component, ErrorComponent',
5333
5450
  standalone: true,
5334
- imports: [],
5451
+ imports: [
5452
+ ButtonComponent,
5453
+ CardComponent,
5454
+ CardHeaderComponent,
5455
+ CardContentComponent
5456
+ ],
5457
+ host: {
5458
+ class: 'bg-background text-foreground grid min-h-dvh w-full place-content-center p-6'
5459
+ },
5335
5460
  template: `
5336
- <div class="bg-background text-foreground flex min-h-screen flex-col items-center justify-center">
5337
- <svg
5338
- class="mb-8 h-20 w-20 text-red-600"
5339
- xmlns="http://www.w3.org/2000/svg"
5340
- width="24"
5341
- height="24"
5342
- viewBox="0 0 24 24"
5343
- fill="none"
5344
- stroke="currentColor"
5345
- stroke-width="2"
5346
- stroke-linecap="round"
5347
- stroke-linejoin="round">
5348
- <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
5349
- <path d="M12 9v4" />
5350
- <path d="M12 17h.01" />
5351
- </svg>
5352
- <h1 class="mb-4 text-4xl font-bold">Oops! Something went wrong</h1>
5353
- <p class="text-muted-foreground mb-8 text-xl">We apologize for the inconvenience.</p>
5354
- <div class="flex space-x-4">
5355
- <button
5356
- (click)="reload()"
5357
- class="btn btn-outline rounded-lg border-neutral-300 text-neutral-700 transition hover:bg-neutral-200 hover:text-neutral-700">
5461
+ <Card
5462
+ hover="no"
5463
+ class="bg-background border-(--popover-border) w-full max-w-md rounded-3xl border shadow-2xl">
5464
+ <CardHeader class="flex flex-col items-center gap-4 pt-8 text-center">
5465
+ <span
5466
+ class="bg-destructive/10 text-destructive inline-flex h-16 w-16 items-center justify-center rounded-full">
5358
5467
  <svg
5359
- class="mr-2 h-4 w-4"
5360
5468
  xmlns="http://www.w3.org/2000/svg"
5361
- width="24"
5362
- height="24"
5469
+ class="h-8 w-8"
5363
5470
  viewBox="0 0 24 24"
5364
5471
  fill="none"
5365
5472
  stroke="currentColor"
5366
5473
  stroke-width="2"
5367
5474
  stroke-linecap="round"
5368
- stroke-linejoin="round">
5369
- <path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
5370
- <path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
5475
+ stroke-linejoin="round"
5476
+ aria-hidden="true">
5477
+ <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
5478
+ <path d="M12 9v4" />
5479
+ <path d="M12 17h.01" />
5371
5480
  </svg>
5372
- Go to Homepage
5373
- </button>
5374
- </div>
5375
- </div>
5481
+ </span>
5482
+
5483
+ <div class="grid gap-1.5">
5484
+ <h1 class="text-2xl font-semibold tracking-tight">Something went wrong</h1>
5485
+ <p class="text-muted-foreground text-sm">
5486
+ We're sorry — the application ran into a problem and couldn't continue.
5487
+ </p>
5488
+ </div>
5489
+ </CardHeader>
5490
+
5491
+ <CardContent class="grid gap-5 pb-8">
5492
+ @if (message) {
5493
+ <div class="border-border bg-muted/40 grid gap-1 rounded-lg border p-3 text-left">
5494
+ <span class="text-muted-foreground text-[0.7rem] font-medium uppercase tracking-wider">
5495
+ Details
5496
+ </span>
5497
+ <p class="text-foreground text-sm break-words" role="alert">{{ message }}</p>
5498
+ </div>
5499
+ }
5500
+
5501
+ <div class="flex justify-center">
5502
+ <button variant="primary" (click)="goHome()">
5503
+ <svg
5504
+ xmlns="http://www.w3.org/2000/svg"
5505
+ class="mr-2 h-4 w-4"
5506
+ viewBox="0 0 24 24"
5507
+ fill="none"
5508
+ stroke="currentColor"
5509
+ stroke-width="2"
5510
+ stroke-linecap="round"
5511
+ stroke-linejoin="round"
5512
+ aria-hidden="true">
5513
+ <path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
5514
+ <path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
5515
+ </svg>
5516
+ Go to homepage
5517
+ </button>
5518
+ </div>
5519
+ </CardContent>
5520
+ </Card>
5376
5521
  `
5377
5522
  }]
5378
5523
  }] });
@@ -7266,6 +7411,7 @@ class NavbarTabsComponent {
7266
7411
  cn = cn;
7267
7412
  class = input(...(ngDevMode ? [undefined, { debugName: "class" }] : []));
7268
7413
  router = inject(Router);
7414
+ appStore = inject(AppStore);
7269
7415
  showCount = input(true, ...(ngDevMode ? [{ debugName: "showCount", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
7270
7416
  /**
7271
7417
  * When enabled (default), tab labels are never clipped: tabs that do not fit
@@ -7295,6 +7441,42 @@ class NavbarTabsComponent {
7295
7441
  return undefined;
7296
7442
  return hasIcon ? "calc(1.5rem + 16px)" : "calc(1.5rem + 4ch)";
7297
7443
  }
7444
+ persistFiltersAcrossTabs = computed(() => {
7445
+ const general = this.appStore.general?.();
7446
+ const appFeature = general?.features?.persistFiltersAcrossTabs;
7447
+ if (appFeature !== undefined)
7448
+ return appFeature;
7449
+ return false;
7450
+ }, ...(ngDevMode ? [{ debugName: "persistFiltersAcrossTabs" }] : []));
7451
+ // Determine how query params are applied on tab change.
7452
+ // - persist: 'merge' so the tab identity params (n/t/q) are updated while
7453
+ // the filter params (f/sort/id/page) already in the URL are kept.
7454
+ // - default: 'replace' so everything is rewritten from scratch.
7455
+ getQueryParamsHandling() {
7456
+ return this.persistFiltersAcrossTabs() ? 'merge' : 'replace';
7457
+ }
7458
+ // Get query params conditionally
7459
+ getQueryParams(tab) {
7460
+ if (this.persistFiltersAcrossTabs()) {
7461
+ // When preserving filters, still update the tab identity params so the
7462
+ // store rebuilds the query for the new tab. 'merge' keeps f/sort/id/page.
7463
+ return {
7464
+ n: tab.queryName,
7465
+ q: this.nav.searchText(),
7466
+ t: tab.wsQueryTab
7467
+ };
7468
+ }
7469
+ // When replacing, explicitly set the filters to undefined to clear them
7470
+ return {
7471
+ n: tab.queryName,
7472
+ q: this.nav.searchText(),
7473
+ t: tab.wsQueryTab,
7474
+ f: undefined,
7475
+ sort: undefined,
7476
+ id: undefined,
7477
+ page: undefined
7478
+ };
7479
+ }
7298
7480
  changeTab() { }
7299
7481
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.18", ngImport: i0, type: NavbarTabsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
7300
7482
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.18", type: NavbarTabsComponent, isStandalone: true, selector: "navbar-tabs", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, showCount: { classPropertyName: "showCount", publicName: "showCount", isSignal: true, isRequired: false, transformFunction: null }, noTruncate: { classPropertyName: "noTruncate", publicName: "noTruncate", isSignal: true, isRequired: false, transformFunction: null }, minTabWidth: { classPropertyName: "minTabWidth", publicName: "minTabWidth", isSignal: true, isRequired: false, transformFunction: null }, path: { classPropertyName: "path", publicName: "path", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "cn('block', class())" } }, ngImport: i0, template: `
@@ -7315,9 +7497,10 @@ class NavbarTabsComponent {
7315
7497
  [attr.disabled]="showCount() && tab.count === 0 ? '' : null"
7316
7498
  [active]="nav.currentPath() === tab.path"
7317
7499
  [routerLink]="[tab.routerLink]"
7318
- [queryParams]="{ n: tab.queryName, q: nav.searchText(), t: tab.wsQueryTab, f: undefined, sort: undefined, id: undefined, page: undefined }"
7500
+ [queryParams]="getQueryParams(tab)"
7501
+ [queryParamsHandling]="getQueryParamsHandling()"
7319
7502
  (click)="changeTab()"
7320
- (keydown.enter)="router.navigate([tab.routerLink], { queryParams: { n: tab.queryName, q: nav.searchText(), t: tab.wsQueryTab, f: undefined, sort: undefined, id: undefined, page: undefined } })"
7503
+ (keydown.enter)="router.navigate([tab.routerLink], { queryParams: getQueryParams(tab), queryParamsHandling: getQueryParamsHandling() })"
7321
7504
  >
7322
7505
  <div [class]="cn('flex items-center content-start w-full gap-1', !noTruncate() && '@container overflow-hidden min-w-0')">
7323
7506
  @if (tab.icon) {
@@ -7355,7 +7538,8 @@ class NavbarTabsComponent {
7355
7538
  <MenuItem>
7356
7539
  <a class="inline-flex items-center gap-1 whitespace-nowrap first-letter:capitalize"
7357
7540
  [routerLink]="[tab.routerLink]"
7358
- [queryParams]="{ n: tab.queryName, q: nav.searchText(), t: tab.wsQueryTab, f: undefined, sort: undefined, id: undefined, page: undefined }"
7541
+ [queryParams]="getQueryParams(tab)"
7542
+ [queryParamsHandling]="getQueryParamsHandling()"
7359
7543
  [attr.aria-selected]="nav.currentPath() === tab.path"
7360
7544
  [attr.aria-label]="tab.display | syslang | transloco"
7361
7545
  (click)="changeTab()">
@@ -7413,9 +7597,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.18", ngImpo
7413
7597
  [attr.disabled]="showCount() && tab.count === 0 ? '' : null"
7414
7598
  [active]="nav.currentPath() === tab.path"
7415
7599
  [routerLink]="[tab.routerLink]"
7416
- [queryParams]="{ n: tab.queryName, q: nav.searchText(), t: tab.wsQueryTab, f: undefined, sort: undefined, id: undefined, page: undefined }"
7600
+ [queryParams]="getQueryParams(tab)"
7601
+ [queryParamsHandling]="getQueryParamsHandling()"
7417
7602
  (click)="changeTab()"
7418
- (keydown.enter)="router.navigate([tab.routerLink], { queryParams: { n: tab.queryName, q: nav.searchText(), t: tab.wsQueryTab, f: undefined, sort: undefined, id: undefined, page: undefined } })"
7603
+ (keydown.enter)="router.navigate([tab.routerLink], { queryParams: getQueryParams(tab), queryParamsHandling: getQueryParamsHandling() })"
7419
7604
  >
7420
7605
  <div [class]="cn('flex items-center content-start w-full gap-1', !noTruncate() && '@container overflow-hidden min-w-0')">
7421
7606
  @if (tab.icon) {
@@ -7453,7 +7638,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.18", ngImpo
7453
7638
  <MenuItem>
7454
7639
  <a class="inline-flex items-center gap-1 whitespace-nowrap first-letter:capitalize"
7455
7640
  [routerLink]="[tab.routerLink]"
7456
- [queryParams]="{ n: tab.queryName, q: nav.searchText(), t: tab.wsQueryTab, f: undefined, sort: undefined, id: undefined, page: undefined }"
7641
+ [queryParams]="getQueryParams(tab)"
7642
+ [queryParamsHandling]="getQueryParamsHandling()"
7457
7643
  [attr.aria-selected]="nav.currentPath() === tab.path"
7458
7644
  [attr.aria-label]="tab.display | syslang | transloco"
7459
7645
  (click)="changeTab()">
@@ -10557,13 +10743,18 @@ class SignInComponent {
10557
10743
  config = globalConfig;
10558
10744
  /**
10559
10745
  * True when authentication is handled outside the credentials form — i.e. by the
10560
- * browser/proxy (`useSSO`) or by an auto-configured OAuth/SAML provider. In those
10561
- * modes this screen shows a loader instead of a login form and initiates the
10562
- * handshake automatically by calling `handleLogin()`.
10563
- */
10564
- externalAuth = !!(globalConfig.useSSO ||
10565
- globalConfig.autoOAuthProvider ||
10566
- globalConfig.autoSAMLProvider);
10746
+ * browser/proxy (`sso`) or by an auto-configured OAuth/SAML provider. In those modes
10747
+ * this screen shows a loader instead of a login form and initiates the handshake
10748
+ * automatically by calling `handleLogin()`.
10749
+ *
10750
+ * Note: the ambiguous `unknown` mode is intentionally excluded — it is resolved upstream
10751
+ * (in `login()`/`signIn()`) to either `sso` or `credentials` before this screen renders,
10752
+ * so reaching here in `unknown` should still show the form, never a dead-end loader.
10753
+ */
10754
+ externalAuth = (() => {
10755
+ const kind = globalConfig.authMode?.kind;
10756
+ return kind === "sso" || kind === "oauth" || kind === "saml";
10757
+ })();
10567
10758
  class = input(...(ngDevMode ? [undefined, { debugName: "class" }] : []));
10568
10759
  forgotPassword = output();
10569
10760
  username = model("", ...(ngDevMode ? [{ debugName: "username" }] : []));
@@ -10659,10 +10850,13 @@ class SignInComponent {
10659
10850
  this.auditService.notifyLogin();
10660
10851
  }
10661
10852
  return result;
10662
- }).catch(error => {
10663
- warn("An error occurred while logging in", error);
10853
+ }).catch((err) => {
10854
+ warn("An error occurred while logging in", err);
10664
10855
  this.auditService.notify({ type: 'Login_Denied' });
10665
- this.router.navigate(["error"]);
10856
+ // Surface the failure reason on the error page (e.g. "OAuth provider not found: identity-dev")
10857
+ // so the user/admin knows what to fix, instead of a bare generic error screen.
10858
+ const message = (err?.errorMessage ?? err?.message) || undefined;
10859
+ this.router.navigate(["error"], { queryParams: { message } });
10666
10860
  return false;
10667
10861
  });
10668
10862
  }
@@ -10677,7 +10871,16 @@ class SignInComponent {
10677
10871
  }
10678
10872
  this.auditService.notifyLogin();
10679
10873
  const { createRoutes = false } = globalConfig;
10680
- await this.applicationService.initialize(createRoutes);
10874
+ try {
10875
+ await this.applicationService.initialize(createRoutes);
10876
+ }
10877
+ catch (initErr) {
10878
+ // Authenticated, but the application failed to initialize (e.g. fetchApp failed). Surface the
10879
+ // reason on the error page rather than leaving the user stuck on the login form.
10880
+ const { errorMessage, message } = (initErr ?? {});
10881
+ this.router.navigate(["/error"], { queryParams: { message: errorMessage ?? message } });
10882
+ return;
10883
+ }
10681
10884
  this.checkPasswordExpiresSoon();
10682
10885
  const url = this.route.snapshot.queryParams["returnUrl"] || "/";
10683
10886
  this.router.navigateByUrl(url);
@@ -11231,50 +11434,29 @@ class OverrideUserDialogComponent {
11231
11434
  }
11232
11435
  }
11233
11436
  handleOverrideUser(username, domain) {
11234
- const { useSSO, createRoutes, useCredentials } = globalConfig;
11437
+ const { createRoutes } = globalConfig;
11235
11438
  if (username === undefined || domain === undefined) {
11236
11439
  setGlobalConfig({ userOverrideActive: false, userOverride: undefined });
11237
11440
  }
11238
11441
  else {
11239
11442
  setGlobalConfig({ userOverrideActive: true, userOverride: { username, domain } });
11240
11443
  }
11241
- // Login with the new user
11242
- if (useSSO && !useCredentials) {
11243
- this.appService
11244
- .initialize(createRoutes)
11245
- .then(() => {
11246
- const fullName = this.principalStore.fullName();
11247
- notify.success(`Welcome back ${fullName}!`, { duration: 2000 });
11248
- })
11249
- .catch((err) => {
11250
- error("An error occured while overriding (SSO - initialize)", err);
11251
- notify.error("An error occurred while overriding (SSO - initialize)", { duration: 2000 });
11252
- setGlobalConfig({ userOverrideActive: false, userOverride: undefined });
11253
- });
11254
- }
11255
- else {
11256
- login()
11257
- .then((value) => {
11258
- if (value) {
11259
- this.appService
11260
- .initialize(createRoutes)
11261
- .then(() => {
11262
- const fullName = this.principalStore.fullName();
11263
- notify.success(`Welcome back ${fullName}!`, { duration: 2000 });
11264
- })
11265
- .catch((err) => {
11266
- error("An error occured while overriding (initialize)", err);
11267
- notify.error(err.message, { duration: 2000 });
11268
- setGlobalConfig({ userOverrideActive: false, userOverride: undefined });
11269
- });
11270
- }
11271
- })
11272
- .catch((err) => {
11273
- error("An error occured while overriding (login)", err);
11274
- notify.error("An error occured while overriding (login)", { duration: 2000 });
11275
- setGlobalConfig({ userOverrideActive: false, userOverride: undefined });
11276
- });
11277
- }
11444
+ // Impersonation is header-driven: `createHeaders` adds `sinequa-override-user`/`-domain` to every
11445
+ // request while `userOverrideActive`, on top of the current (admin) session. We therefore do NOT
11446
+ // need to re-authenticate — re-initializing the stores refetches the principal/usersettings as the
11447
+ // overridden user. This works in every auth mode, including `credentials` where `login()` (without
11448
+ // credentials) is intentionally a no-op in atomic 2.0 and would otherwise skip initialization.
11449
+ this.appService
11450
+ .initialize(createRoutes)
11451
+ .then(() => {
11452
+ const fullName = this.principalStore.fullName();
11453
+ notify.success(`Welcome back ${fullName}!`, { duration: 2000 });
11454
+ })
11455
+ .catch((err) => {
11456
+ error("An error occurred while overriding (initialize)", err);
11457
+ notify.error("An error occurred while overriding (initialize)", { duration: 2000 });
11458
+ setGlobalConfig({ userOverrideActive: false, userOverride: undefined });
11459
+ });
11278
11460
  }
11279
11461
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.18", ngImport: i0, type: OverrideUserDialogComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
11280
11462
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "20.3.18", type: OverrideUserDialogComponent, isStandalone: true, selector: "override-user-dialog", inputs: { overrideUser: { classPropertyName: "overrideUser", publicName: "overrideUser", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { overrideUser: "overrideUserChange" }, providers: [provideTranslocoScope("dialogs")], viewQueries: [{ propertyName: "dialog", first: true, predicate: DialogComponent, descendants: true, isSignal: true }], ngImport: i0, template: `
@@ -15965,8 +16147,18 @@ const bodyInterceptorFn = (request, next) => {
15965
16147
  };
15966
16148
 
15967
16149
  /**
15968
- * Interceptor function that handles HTTP 401 errors by refreshing authentication
15969
- * and retrying the original request. For 403 errors, logs out the user.
16150
+ * Marks a request that has already been retried once after a re-authentication attempt.
16151
+ * Carried on the request's HttpContext (not sent to the server) so a second 401 on the retry
16152
+ * propagates instead of triggering another sign-in — which would loop forever.
16153
+ */
16154
+ const AUTH_RETRIED = new HttpContextToken(() => false);
16155
+ /**
16156
+ * Interceptor function that handles HTTP 401 errors by refreshing authentication and retrying the
16157
+ * original request once. For 403 errors, the error is propagated as a permanent auth failure.
16158
+ *
16159
+ * The retry happens ONLY when `signIn()` reports the user is authenticated, and AT MOST once per
16160
+ * request. Without these two guards a persistent 401 (credentials required, or an endpoint that
16161
+ * keeps rejecting even after a successful CSRF handshake) would retry endlessly.
15970
16162
  *
15971
16163
  * @param request - The HTTP request object.
15972
16164
  * @param next - The HTTP handler function.
@@ -15979,14 +16171,23 @@ const errorInterceptorFn = (request, next) => {
15979
16171
  }
15980
16172
  return next(request).pipe(catchError$1((err) => {
15981
16173
  if (err.status === 401) {
15982
- error("ErrorInterceptor: 401 error detected, attempting to refresh auth and retry");
15983
- // Handle 401 by refreshing auth and retrying the request
15984
- return runInInjectionContext(injector, () => from(signIn()).pipe(switchMap$1(() => {
15985
- error("Auth refreshed, retrying original request");
15986
- // Retry the original request with cloned copy
15987
- const headersObj = createHeaders();
15988
- const headers = new HttpHeaders(headersObj);
15989
- return next(request.clone({ headers }));
16174
+ // Already retried once after a re-auth — give up to avoid an infinite loop.
16175
+ if (request.context.get(AUTH_RETRIED)) {
16176
+ error("ErrorInterceptor: 401 again after re-auth retry, giving up");
16177
+ return throwError(() => err);
16178
+ }
16179
+ error("ErrorInterceptor: 401 detected, attempting to refresh auth");
16180
+ return runInInjectionContext(injector, () => from(signIn()).pipe(switchMap$1((authenticated) => {
16181
+ // signIn() could not authenticate (e.g. credentials required → redirected to login,
16182
+ // or a provider redirect is in progress). Do NOT retry — propagate the 401 instead,
16183
+ // otherwise every subsequent 401 re-triggers sign-in and loops.
16184
+ if (!authenticated) {
16185
+ error("ErrorInterceptor: re-auth did not authenticate, not retrying");
16186
+ return throwError(() => err);
16187
+ }
16188
+ error("Auth refreshed, retrying original request once");
16189
+ const headers = new HttpHeaders(createHeaders());
16190
+ return next(request.clone({ headers, context: request.context.set(AUTH_RETRIED, true) }));
15990
16191
  }), catchError$1((signInErr) => {
15991
16192
  error("Failed to refresh auth, redirecting to login", signInErr);
15992
16193
  return throwError(() => signInErr);
@@ -16069,5 +16270,5 @@ const queryNameResolver = () => {
16069
16270
  * Generated bundle index. Do not edit.
16070
16271
  */
16071
16272
 
16072
- export { AGGREGATIONS_NAMES, AGGREGATIONS_NAMES_PRESET_DEFAULT, APP_FEATURES, AdvancedFiltersComponent, AdvancedSearch, AdvancedSearchComponent, AggregationComponent, AggregationDateComponent, AggregationDateRangeDialogComponent, AggregationListComponent, AggregationPanelComponent, AggregationTreeComponent, AggregationsService, AggregationsStore, Alert, AlertDialog, AlertsComponent, AppService, AppStore, ApplicationService, ApplicationStore, ArticleEntities, ArticleExtracts, ArticleLabels, ArticleSimilarDocuments, AsideFiltersComponent, AuditFeedbackType, AuditService, AuthGuard, AuthPageComponent, AutocompleteService, BOOKMARKS_CONFIG, BOOKMARKS_OPTIONS, BackdropComponent, BackdropService, BookmarkButtonComponent, BookmarksComponent, COLLECTIONS_CONFIG, COLLECTIONS_OPTIONS, COMPONENTS_FOR_DOCUMENT_TYPE, ChangePasswordComponent, ChildMarkerDirective, CollectionsComponent, CollectionsDialog, DRAWER_COMPONENT, DRAWER_STACK_MAX_COUNT, DateComponent, DeleteCollectionDialog, DidYouMeanComponent, DocumentLocatorComponent, DrawerAdvancedFiltersComponent, DrawerComponent, DrawerNavbarComponent, DrawerPreviewComponent, DrawerService, DrawerStackComponent, DrawerStackService, DropdownInputComponent, DropdownListComponent, ErrorComponent, ExportDialog, ExportService, FILTERS_BREAKPOINT, FILTER_DATE_ALLOW_CUSTOM_RANGE, FeedbackDialogComponent, FileSizePipe, FilterButtonComponent, FiltersBarComponent, HIGHLIGHTS, HighlightWordPipe, InfinityScrollDirective, InlineWorker, JsonMethodPluginService, KeyboardNavigatorDirective, LabelService, LabelsEditDialog, LoadingComponent, MetadataComponent, MissingTermsComponent, MoreButtonComponent, MoreComponent, MultiSelectLabelsComponent, MultiSelectionToolbarComponent, NON_SEARCHABLE_COLUMNS, NON_SEARCHABLE_DEFAULTS, NavbarTabsComponent, NavigationService, NoResultComponent, OpenArticleOnCtrlEnterDirective, OperatorPipe, OverflowItemDirective, OverflowManagerDirective, OverflowStopDirective, OverrideUserDialogComponent, PREVIEW_CONFIG, PagerComponent, PreviewNavigator, PreviewService, PrincipalService, PrincipalStore, QueryParamsStore, QueryService, RECENT_SEARCHES_CONFIG, RECENT_SEARCHES_OPTIONS, ROUTE_COMPONENTS, RecentSearchesComponent, ResetUserSettingsDialogComponent, SAVED_SEARCHES_CONFIG, SAVED_SEARCHES_OPTIONS, SavedSearchDialog, SavedSearchesComponent, SavedSearchesService, SearchFeedbackComponent, SearchInputFooter, SearchService, SelectArticleDirective, SelectArticleOnClickDirective, SelectionHistoryService, SelectionService, SelectionStore, ShowBookmarkDirective, SidebarNavComponent, SignInComponent, SortSelectorComponent, SourceComponent, SourceIconPipe, SponsoredResultsComponent, SyslangPipe, THEMES, TextChunkService, ThemeProviderDirective, ThemeSelectorComponent, ThemeStore, ThemeToggleComponent, TranslocoDateImpurePipe, UserProfileDialog, UserProfileFormComponent, UserProfileService, UserSettingsStore, applyThemeToNativeElement, auditInterceptorFn, authInterceptorFn, bodyInterceptorFn, buildQuery, debouncedSignal, errorInterceptorFn, getCurrentPath, getCurrentQueryName, getQueryNameFromRoute, injectRouteNavigation, processCssVars, queryNameResolver, signIn, themeColorNameToCssVariable, themeColorsToCssVariables, toastInterceptorFn, withAggregationsFeatures, withAlertsFeatures, withAppFeatures, withApplicationFeatures, withAssistantFeatures, withBasketsFeatures, withBookmarkFeatures, withBootstrapApp, withExtractsFeatures, withFetch, withMultiSelectionFeatures, withPrincipalFeatures, withQueryParamsFeatures, withRecentSearchesFeatures, withSavedSearchesFeatures, withSelectionFeatures, withThemeBodyHook, withThemes, withThemesFeatures, withUserSettingsFeatures };
16273
+ export { AGGREGATIONS_NAMES, AGGREGATIONS_NAMES_PRESET_DEFAULT, APP_FEATURES, AdvancedFiltersComponent, AdvancedSearch, AdvancedSearchComponent, AggregationComponent, AggregationDateComponent, AggregationDateRangeDialogComponent, AggregationListComponent, AggregationPanelComponent, AggregationTreeComponent, AggregationsService, AggregationsStore, Alert, AlertDialog, AlertsComponent, AppService, AppStore, ApplicationService, ApplicationStore, ArticleEntities, ArticleExtracts, ArticleLabels, ArticleSimilarDocuments, AsideFiltersComponent, AuditFeedbackType, AuditService, AuthGuard, AuthPageComponent, AutocompleteService, BOOKMARKS_CONFIG, BOOKMARKS_OPTIONS, BackdropComponent, BackdropService, BookmarkButtonComponent, BookmarksComponent, COLLECTIONS_CONFIG, COLLECTIONS_OPTIONS, COMPONENTS_FOR_DOCUMENT_TYPE, ChangePasswordComponent, ChildMarkerDirective, CollectionsComponent, CollectionsDialog, DRAWER_COMPONENT, DRAWER_STACK_MAX_COUNT, DateComponent, DeleteCollectionDialog, DidYouMeanComponent, DocumentLocatorComponent, DrawerAdvancedFiltersComponent, DrawerComponent, DrawerNavbarComponent, DrawerPreviewComponent, DrawerService, DrawerStackComponent, DrawerStackService, DropdownInputComponent, DropdownListComponent, ErrorComponent, ExportDialog, ExportService, FILTERS_BREAKPOINT, FILTER_DATE_ALLOW_CUSTOM_RANGE, FeedbackDialogComponent, FileSizePipe, FilterButtonComponent, FiltersBarComponent, HIGHLIGHTS, HighlightWordPipe, InfinityScrollDirective, InlineWorker, JsonMethodPluginService, KeyboardNavigatorDirective, LabelService, LabelsEditDialog, LoadingComponent, MetadataComponent, MissingTermsComponent, MoreButtonComponent, MoreComponent, MultiSelectLabelsComponent, MultiSelectionToolbarComponent, NON_SEARCHABLE_COLUMNS, NON_SEARCHABLE_DEFAULTS, NavbarTabsComponent, NavigationService, NoResultComponent, OpenArticleOnCtrlEnterDirective, OperatorPipe, OverflowItemDirective, OverflowManagerDirective, OverflowStopDirective, OverrideUserDialogComponent, PREVIEW_CONFIG, PagerComponent, PreviewNavigator, PreviewService, PrincipalService, PrincipalStore, QueryParamsStore, QueryService, RECENT_SEARCHES_CONFIG, RECENT_SEARCHES_OPTIONS, ROUTE_COMPONENTS, RecentSearchesComponent, ResetUserSettingsDialogComponent, SAVED_SEARCHES_CONFIG, SAVED_SEARCHES_OPTIONS, SavedSearchDialog, SavedSearchesComponent, SavedSearchesService, SearchFeedbackComponent, SearchInputFooter, SearchService, SelectArticleDirective, SelectArticleOnClickDirective, SelectionHistoryService, SelectionService, SelectionStore, ShowBookmarkDirective, SidebarNavComponent, SignInComponent, SortSelectorComponent, SourceComponent, SourceIconPipe, SponsoredResultsComponent, SyslangPipe, THEMES, TextChunkService, ThemeProviderDirective, ThemeSelectorComponent, ThemeStore, ThemeToggleComponent, TranslocoDateImpurePipe, UserProfileDialog, UserProfileFormComponent, UserProfileService, UserSettingsStore, applyThemeToNativeElement, auditInterceptorFn, authInterceptorFn, bodyInterceptorFn, bootstrapApp, buildQuery, debouncedSignal, errorInterceptorFn, getCurrentPath, getCurrentQueryName, getQueryNameFromRoute, injectRouteNavigation, processCssVars, queryNameResolver, signIn, themeColorNameToCssVariable, themeColorsToCssVariables, toastInterceptorFn, withAggregationsFeatures, withAlertsFeatures, withAppFeatures, withApplicationFeatures, withAssistantFeatures, withBasketsFeatures, withBookmarkFeatures, withBootstrapApp, withExtractsFeatures, withFetch, withMultiSelectionFeatures, withPrincipalFeatures, withQueryParamsFeatures, withRecentSearchesFeatures, withSavedSearchesFeatures, withSelectionFeatures, withThemeBodyHook, withThemes, withThemesFeatures, withUserSettingsFeatures };
16073
16274
  //# sourceMappingURL=sinequa-atomic-angular.mjs.map