@coolms/core-angular 2.0.0-alpha.3 → 2.0.0-alpha.5

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,17 +1,17 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Injectable, inject, DestroyRef, Component, signal, computed, effect, input, ChangeDetectionStrategy, InjectionToken } from '@angular/core';
3
- import { HttpClient, HttpErrorResponse, HttpContextToken, HttpContext, HttpParams } from '@angular/common/http';
4
- import { Action, Selector, State, Store } from '@ngxs/store';
2
+ import { Injectable, inject, InjectionToken, DestroyRef, signal, untracked, Injector, Component, computed, effect, input, ChangeDetectionStrategy, provideAppInitializer, makeEnvironmentProviders } from '@angular/core';
3
+ import { HttpClient, HttpContextToken, HttpErrorResponse, HttpContext, HttpParams } from '@angular/common/http';
4
+ import { Action, Selector, State, Store, Actions, ofActionDispatched, provideStore } from '@ngxs/store';
5
5
  import { __decorate } from 'tslib';
6
- import { Subject, debounceTime, catchError, of, defer, throwError, shareReplay, finalize, from, firstValueFrom, take, switchMap as switchMap$1, ReplaySubject, tap as tap$1, map } from 'rxjs';
7
- import { tap, switchMap, map as map$1, catchError as catchError$1, shareReplay as shareReplay$1 } from 'rxjs/operators';
6
+ import { Subject, of, tap, switchMap, defer, finalize, shareReplay, debounceTime, catchError, firstValueFrom, throwError, map, from, take, ReplaySubject } from 'rxjs';
7
+ import { tap as tap$1, switchMap as switchMap$1, map as map$1, catchError as catchError$1, shareReplay as shareReplay$1 } from 'rxjs/operators';
8
8
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
9
- import { Router } from '@angular/router';
9
+ import { Router, ActivatedRoute } from '@angular/router';
10
10
  import { Location, NgIf } from '@angular/common';
11
11
  import * as i1 from '@angular/forms';
12
12
  import { FormsModule } from '@angular/forms';
13
13
 
14
- // Typed contract — must mirror ApiManifest PHP VOs exactly.
14
+ // Typed contract -- must mirror ApiManifest PHP VOs exactly.
15
15
  // No `any` anywhere.
16
16
  /**
17
17
  * Replaces {param} tokens in a URL pattern with encoded values.
@@ -24,7 +24,7 @@ function resolvePattern(pattern, params) {
24
24
  return Object.entries(params).reduce((url, [key, value]) => url.replace(`{${key}}`, encodeURIComponent(value)), pattern);
25
25
  }
26
26
 
27
- // Typed contract — mirrors FormRenderDefinition PHP VOs exactly.
27
+ // Typed contract -- mirrors FormRenderDefinition PHP VOs exactly.
28
28
  // No `any` anywhere.
29
29
 
30
30
  // --- Action ------------------------------------------------------------------
@@ -39,11 +39,15 @@ let AppConfigState = class AppConfigState {
39
39
  set(ctx, { config }) {
40
40
  ctx.setState({ config, loaded: true });
41
41
  }
42
+ /** The whole config as last set: the public theme config, with the console's manifest once granted. */
43
+ static config(state) {
44
+ return state.config;
45
+ }
42
46
  static manifest(state) {
43
47
  return state.config?.manifest ?? null;
44
48
  }
45
49
  /**
46
- * F.7 viewer manifest. Null when the backend didn't ship a viewers
50
+ * The viewer manifest. Null when the backend didn't ship a viewers
47
51
  * section (no tagged providers); consumers should fall back to a
48
52
  * "no preview" surface.
49
53
  */
@@ -53,12 +57,15 @@ let AppConfigState = class AppConfigState {
53
57
  static loaded(state) {
54
58
  return state.loaded;
55
59
  }
56
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AppConfigState, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
57
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AppConfigState });
60
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: AppConfigState, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
61
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: AppConfigState });
58
62
  };
59
63
  __decorate([
60
64
  Action(SetAppConfig)
61
65
  ], AppConfigState.prototype, "set", null);
66
+ __decorate([
67
+ Selector()
68
+ ], AppConfigState, "config", null);
62
69
  __decorate([
63
70
  Selector()
64
71
  ], AppConfigState, "manifest", null);
@@ -74,7 +81,7 @@ AppConfigState = __decorate([
74
81
  defaults: { config: null, loaded: false },
75
82
  })
76
83
  ], AppConfigState);
77
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AppConfigState, decorators: [{
84
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: AppConfigState, decorators: [{
78
85
  type: Injectable
79
86
  }], propDecorators: { set: [] } });
80
87
 
@@ -122,47 +129,10 @@ class IdentityApiClient {
122
129
  // are then lost inside the Hydra member array.
123
130
  return this.http.get(this.manifest.identity.settingsUrl, { headers: { Accept: 'application/json' } });
124
131
  }
125
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: IdentityApiClient, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
126
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: IdentityApiClient, providedIn: 'root' });
127
- }
128
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: IdentityApiClient, decorators: [{
129
- type: Injectable,
130
- args: [{ providedIn: 'root' }]
131
- }] });
132
-
133
- /**
134
- * The two realtime endpoints, carved out for the same reason the identity ones
135
- * were: the UI kit's realtime client needed exactly these two of `ApiService`'s
136
- * 117 methods, and depending on the whole thing would have pulled the entire
137
- * application's wire vocabulary underneath the kit.
138
- *
139
- * They belong in core rather than in the kit because both answers are derived
140
- * from things core already owns -- the boot manifest for the URL, and the auth
141
- * interceptor for the bearer token that authorises the request. A token is
142
- * session state, not a widget.
143
- *
144
- * `ApiService` keeps its identical signatures and delegates here, so nothing
145
- * calling it had to change.
146
- */
147
- class RealtimeTokenClient {
148
- http = inject(HttpClient);
149
- store = inject(Store);
150
- get manifest() {
151
- const m = this.store.selectSnapshot(AppConfigState.manifest);
152
- if (!m)
153
- throw new Error('ApiManifest not loaded — call AppInitService.load() first');
154
- return m;
155
- }
156
- connectionToken() {
157
- return this.http.post(this.manifest.apiBase + '/centrifugo/connection-token', {});
158
- }
159
- subscriptionToken(channel) {
160
- return this.http.post(this.manifest.apiBase + '/centrifugo/subscription-token', { channel });
161
- }
162
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: RealtimeTokenClient, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
163
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: RealtimeTokenClient, providedIn: 'root' });
132
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: IdentityApiClient, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
133
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: IdentityApiClient, providedIn: 'root' });
164
134
  }
165
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: RealtimeTokenClient, decorators: [{
135
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: IdentityApiClient, decorators: [{
166
136
  type: Injectable,
167
137
  args: [{ providedIn: 'root' }]
168
138
  }] });
@@ -199,7 +169,447 @@ class PatchCurrentUser {
199
169
  }
200
170
 
201
171
  /**
202
- * Unified user preferences service — all preference types share one
172
+ * Optional by design: with nothing bound, a 403 stays a 403 and the state
173
+ * service still answers -- an application without an admin panel has no
174
+ * prompt to show and loses nothing.
175
+ */
176
+ const ELEVATION_PROMPT = new InjectionToken('coolms.elevation-prompt');
177
+ /** Optional, like {@link ELEVATION_PROMPT}: with nothing bound, a refusal is only a 403. */
178
+ const ELEVATION_NOTICE = new InjectionToken('coolms.elevation-notice');
179
+
180
+ /**
181
+ * Set on a request that must never open the elevation prompt: the elevation
182
+ * endpoint's own calls (a wrong password is a 403 and is not a refused action),
183
+ * and any caller that would rather receive its 403 and decide for itself.
184
+ */
185
+ const BYPASS_ELEVATION = new HttpContextToken(() => false);
186
+ /**
187
+ * The `storage` key whose write announces "this session's elevation changed"
188
+ * to the other tabs of the same browser. One token chain is shared across
189
+ * tabs, so a grant or a drop in one tab is true in all of
190
+ * them; the others learn of it here rather than at their next 403.
191
+ */
192
+ const CROSS_TAB_KEY = 'coolms.elevation.changed';
193
+ /**
194
+ * The shortest gap between two reconciling reads. `visibilitychange` and
195
+ * `focus` both fire when a window is re-activated, so without this a single
196
+ * alt-tab costs two identical GETs.
197
+ */
198
+ const RECONCILE_MIN_INTERVAL_MS = 2_000;
199
+ /**
200
+ * The elevation state of this session, read from ONE place: the server.
201
+ *
202
+ * The elevation client requirement, in one service. The state endpoint is the only
203
+ * source -- `elevated`, `expiresAt`, how the last elevation ended -- and every
204
+ * change of it is announced on `changes$` so the Explorer listing can refetch
205
+ * (its capability flags are the server's reading of the same state, computed
206
+ * when the listing was fetched, and a `write: true` fetched while elevated
207
+ * must not outlive the elevation).
208
+ *
209
+ * **Expiry is driven by the READ, not by a timer.** An elevation ends because
210
+ * time passed, and time is not an event; a client that waits to be told has
211
+ * nothing to wait for when the machine was asleep at the moment. So `state()`
212
+ * does the arithmetic every time it is asked, and an answer whose `expiresAt`
213
+ * has gone by reports expired without a request. The timer below still fires
214
+ * at `expiresAt` and the tab still re-asks when it comes back into view -- both
215
+ * are how the SERVER's answer is refreshed, which is a different thing from
216
+ * knowing this one has run out.
217
+ *
218
+ * This is not a second decider. The server said "elevated until T"; after T,
219
+ * continuing to report `elevated: true` asserts something it never said.
220
+ *
221
+ * The prompt is not here: dialogs are the kit's, a layer above core. The
222
+ * service asks the `ELEVATION_PROMPT` port and shares ONE in-flight prompt, so
223
+ * a burst of refused requests (a multi-select delete is one 403 per node)
224
+ * opens one dialog and every caller waits on the same answer.
225
+ */
226
+ /**
227
+ * The header a 403 refused for want of elevation carries, with the gate's name
228
+ * as its value. The server stamps it where the gate refused; the interceptor
229
+ * prompts on it and on nothing else. There is no list of gated URLs on this
230
+ * side any more: the one that stood here lagged the server by every route
231
+ * whose refusal came from the VFS permission service under another module's
232
+ * path (media, document and word templates, content distribution).
233
+ */
234
+ const ELEVATION_REQUIRED_HEADER = 'X-Elevation-Required';
235
+ /**
236
+ * Set on a request no person asked for: a realtime token, a preferences sync, a
237
+ * poll. Its refusal is never announced -- not by a dialog and not by a notice --
238
+ * because nobody is waiting on it and nobody can act on it. The caller still
239
+ * receives the 403 and decides what its own state shows.
240
+ */
241
+ const BACKGROUND_REQUEST = new HttpContextToken(() => false);
242
+ /**
243
+ * Whether a failed request was refused for want of elevation: a 403 carrying
244
+ * the server's stamp. The one test every reader of a refusal uses -- the
245
+ * interceptor, a page's inline state, the error text -- so they cannot disagree.
246
+ */
247
+ function isElevationRefusal(err) {
248
+ return err instanceof HttpErrorResponse && err.status === 403 && !!err.headers.get(ELEVATION_REQUIRED_HEADER);
249
+ }
250
+ class ElevationService {
251
+ http = inject(HttpClient);
252
+ store = inject(Store);
253
+ prompt = inject(ELEVATION_PROMPT, { optional: true });
254
+ destroyRef = inject(DestroyRef);
255
+ /** The last answer the server gave, exactly as it gave it. */
256
+ served = signal(null, /* @ts-ignore */
257
+ ...(ngDevMode ? [{ debugName: "served" }] : /* istanbul ignore next */ []));
258
+ /**
259
+ * The state as it stands NOW; null until the first read from the server.
260
+ *
261
+ * Deliberately NOT a `computed`. A cached derivation is only recomputed
262
+ * when one of its dependencies changes, and nothing changes when an
263
+ * elevation simply runs out -- so it would keep answering `elevated: true`
264
+ * past the moment the server itself named, for as long as the machine
265
+ * happened to be asleep.
266
+ *
267
+ * Reading past `expiresAt` also demotes the STORED answer. The value
268
+ * returned here is already correct without that write; the write is only
269
+ * how cached readers (a `computed`, an OnPush template) find out.
270
+ */
271
+ state = () => {
272
+ const served = this.served();
273
+ if (served === null || !isPast(served))
274
+ return served;
275
+ const expired = expiredView(served);
276
+ // untracked: this read can happen inside a computed or a template, and
277
+ // a signal write is refused there. The demotion is not a state change
278
+ // of its own -- it is the answer we already hold, read correctly.
279
+ untracked(() => this.served.set(expired));
280
+ this.announceExpiryLater();
281
+ return expired;
282
+ };
283
+ /** Convenience over `state`: false until the server has said otherwise. */
284
+ elevated = () => this.state()?.elevated ?? false;
285
+ /** `expiresAt` as a Date, null when not elevated. */
286
+ expiresAt = () => {
287
+ const iso = this.state()?.expiresAt;
288
+ return iso ? new Date(iso) : null;
289
+ };
290
+ /**
291
+ * Every change of elevation state: a grant, a drop (by this person, by the
292
+ * beacon, by the tripwire), an expiry, or an observed change of a live
293
+ * grant's `expiresAt`. A refresh that reports the same state emits nothing,
294
+ * so subscribers that refetch a listing on it never refetch for no reason.
295
+ */
296
+ changes$ = new Subject();
297
+ expiryTimer = null;
298
+ pendingPrompt = null;
299
+ expiryAnnouncePending = false;
300
+ lastReconcileAt = 0;
301
+ constructor() {
302
+ if (typeof window !== 'undefined') {
303
+ window.addEventListener('storage', this.onStorage);
304
+ window.addEventListener('focus', this.onFocus);
305
+ if (typeof document !== 'undefined') {
306
+ document.addEventListener('visibilitychange', this.onVisibilityChange);
307
+ }
308
+ this.destroyRef.onDestroy(() => {
309
+ window.removeEventListener('storage', this.onStorage);
310
+ window.removeEventListener('focus', this.onFocus);
311
+ if (typeof document !== 'undefined') {
312
+ document.removeEventListener('visibilitychange', this.onVisibilityChange);
313
+ }
314
+ this.clearTimer();
315
+ });
316
+ }
317
+ }
318
+ /** The manifest carries the URL; without it the service is inert, not broken. */
319
+ get available() {
320
+ return this.url !== null;
321
+ }
322
+ get url() {
323
+ return this.store.selectSnapshot(AppConfigState.manifest)?.identity?.elevationUrl ?? null;
324
+ }
325
+ /**
326
+ * Whether a URL belongs to THIS API: a relative one under the manifest's
327
+ * `apiBase`, or an absolute one on this origin under it. The interceptor
328
+ * acts on a stamped refusal from here and from nowhere else -- a third
329
+ * party answering 403 with a header of the same name is not an invitation
330
+ * to elevate this session.
331
+ */
332
+ isThisApi(url) {
333
+ const apiBase = this.store.selectSnapshot(AppConfigState.manifest)?.apiBase ?? '/api/v1';
334
+ let path = url.split('?')[0];
335
+ if (/^https?:\/\//.test(path)) {
336
+ try {
337
+ const parsed = new URL(path);
338
+ if (typeof window !== 'undefined' && parsed.host !== window.location.host)
339
+ return false;
340
+ path = parsed.pathname;
341
+ }
342
+ catch {
343
+ return false;
344
+ }
345
+ }
346
+ return path === apiBase || path.startsWith(`${apiBase}/`);
347
+ }
348
+ /** Ask the server. Applies the answer and announces it when it differs. */
349
+ refresh() {
350
+ const url = this.url;
351
+ if (!url)
352
+ return of(this.inert());
353
+ return this.http.get(url, this.options()).pipe(tap(s => this.apply(s)));
354
+ }
355
+ /**
356
+ * Elevate with the person's own password and, when the switch is on, a
357
+ * code from their own authenticator. Errors are the dialog's to branch on:
358
+ * 403 not a member of the admin group, wrong password, or wrong / replayed
359
+ * code, 428 a code is required, 409 this account cannot elevate yet, 429
360
+ * throttled. They pass through
361
+ * untouched, and never open the prompt (BYPASS_ELEVATION).
362
+ */
363
+ elevate(password, code) {
364
+ const url = this.url;
365
+ if (!url)
366
+ return of(this.inert());
367
+ const body = { password };
368
+ if (code)
369
+ body.code = code;
370
+ return this.http.post(url, body, this.options()).pipe(tap(s => this.apply(s, 'granted')));
371
+ }
372
+ /** Drop the elevation now. Idempotent on the server: nothing live is still a 204. */
373
+ drop(reason = 'dropped_by_user') {
374
+ const url = this.url;
375
+ if (!url)
376
+ return of(this.inert());
377
+ return this.http.delete(`${url}?reason=${reason}`, this.options()).pipe(switchMap(() => this.refresh()));
378
+ }
379
+ /**
380
+ * What a person's explicit request for elevation calls -- an Elevate button,
381
+ * or a control that already knows its action needs elevation: ask the
382
+ * server whether this session is elevated, and only when it is not, open
383
+ * the prompt. Nothing calls it on a response by itself (the interceptor
384
+ * only tells {@link ELEVATION_NOTICE}). Elevated already is false, no
385
+ * prompt: elevation would change nothing.
386
+ */
387
+ offerFor(refusal) {
388
+ if (!this.available || !this.prompt)
389
+ return of(false);
390
+ return this.refresh().pipe(switchMap(state => state.elevated ? of(false) : this.requestElevation({ state, refusal })));
391
+ }
392
+ /**
393
+ * Open the prompt, or join the one already open. Resolves to whether the
394
+ * session is elevated once the dialog closes.
395
+ */
396
+ requestElevation(request) {
397
+ if (!this.prompt)
398
+ return of(false);
399
+ if (this.pendingPrompt)
400
+ return this.pendingPrompt;
401
+ const prompt = this.prompt;
402
+ this.pendingPrompt = defer(() => prompt.open(request)).pipe(finalize(() => { this.pendingPrompt = null; }), shareReplay(1));
403
+ return this.pendingPrompt;
404
+ }
405
+ // -- internals ------------------------------------------------------------
406
+ options() {
407
+ return { context: new HttpContext().set(BYPASS_ELEVATION, true) };
408
+ }
409
+ inert() {
410
+ return {
411
+ elevated: false,
412
+ ended: { reason: 'never', at: null },
413
+ lifetimeSeconds: 0,
414
+ lifetimeCeilingSeconds: 0,
415
+ mfaRequired: false,
416
+ warnings: [],
417
+ };
418
+ }
419
+ /**
420
+ * Store the answer, schedule the expiry read, announce a change.
421
+ *
422
+ * `explicit` names the change when the caller knows it (a grant). For a
423
+ * plain refresh the kind is derived from the two states: elevated -> not
424
+ * is `expired` when the server says so and `dropped` otherwise (the person,
425
+ * another tab, the beacon, the tripwire); not -> elevated is a grant seen
426
+ * from another tab; a live grant whose `expiresAt` moved is `observed`.
427
+ *
428
+ * `prev` is read RAW rather than through `state()`: the demotion is not
429
+ * wanted here (this answer supersedes the stored one either way), and a
430
+ * read that demoted would queue an expiry announcement the server's own
431
+ * answer is about to make.
432
+ */
433
+ apply(next, explicit) {
434
+ const prev = untracked(() => this.served());
435
+ this.served.set(next);
436
+ this.schedule(next);
437
+ const kind = explicit ?? this.kindOf(prev, next);
438
+ if (kind === null)
439
+ return;
440
+ // Something is being announced here, so a queued expiry would be a
441
+ // second telling of the same news -- or, after a grant, stale news.
442
+ // A NULL kind must not cancel it: that is the case where a read
443
+ // already demoted the stored answer, leaving this one nothing to
444
+ // compare against, and the queued announcement is the only one there
445
+ // will be.
446
+ this.expiryAnnouncePending = false;
447
+ this.changes$.next({ kind, state: next });
448
+ this.announceToOtherTabs();
449
+ }
450
+ kindOf(prev, next) {
451
+ // Raw: a grant nothing had read yet is still a grant that ENDED, and
452
+ // this answer is the first notice of it. When a read demoted it first,
453
+ // `prev` already says so and this correctly finds no change.
454
+ const was = prev?.elevated ?? false;
455
+ if (was && !next.elevated)
456
+ return next.ended.reason === 'expired' ? 'expired' : 'dropped';
457
+ if (!was && next.elevated)
458
+ return 'granted';
459
+ if (was && next.elevated && (prev?.expiresAt ?? null) !== (next.expiresAt ?? null))
460
+ return 'observed';
461
+ return null;
462
+ }
463
+ /**
464
+ * Tell the listings that this session's elevation ran out, once, and NOT
465
+ * from inside the read that noticed: a subscriber refetches on this, and a
466
+ * refetch started in the middle of a change detection pass is a write
467
+ * during a read. The microtask runs at the end of the current task, before
468
+ * anything can paint.
469
+ *
470
+ * A server answer arriving first clears the flag, so the announcement is
471
+ * made by whichever of the two got there first, never by both. Other tabs
472
+ * are NOT told: each one reaches the same conclusion from the same
473
+ * `expiresAt` on its own read, so the storage write would only buy a
474
+ * thundering herd of GETs.
475
+ */
476
+ announceExpiryLater() {
477
+ if (this.expiryAnnouncePending)
478
+ return;
479
+ this.expiryAnnouncePending = true;
480
+ queueMicrotask(() => {
481
+ if (!this.expiryAnnouncePending)
482
+ return;
483
+ this.expiryAnnouncePending = false;
484
+ const state = untracked(() => this.served());
485
+ if (state !== null && !state.elevated)
486
+ this.changes$.next({ kind: 'expired', state });
487
+ });
488
+ }
489
+ /**
490
+ * Ask the server again at `expiresAt` -- an OPTIMISATION, not the
491
+ * mechanism. `state()` already reports an elapsed grant as expired without
492
+ * being told, so a timer that a sleeping machine swallows, or that a
493
+ * background tab throttles to once a minute, now costs freshness rather
494
+ * than correctness. What it buys is the listing refetch happening at the
495
+ * moment of expiry instead of at the next read.
496
+ *
497
+ * Do not flip a local flag here: the lifetime counts from the grant on the
498
+ * server's clock, and if the server still says elevated at that moment
499
+ * (skew), the honest thing is to ask again shortly.
500
+ */
501
+ schedule(state) {
502
+ this.clearTimer();
503
+ if (!state.elevated || !state.expiresAt)
504
+ return;
505
+ // Floor: never a tight loop on a stale answer. Ceiling: a browser
506
+ // treats a delay past 2^31-1 ms as ZERO and fires at once; the code
507
+ // ceiling is an hour, so only bad data gets there, and it re-schedules.
508
+ const wanted = Date.parse(state.expiresAt) - Date.now() + 250;
509
+ const ms = Math.min(Math.max(1_000, wanted), 2_147_483_647);
510
+ this.expiryTimer = setTimeout(() => {
511
+ this.expiryTimer = null;
512
+ this.refresh().subscribe({
513
+ // apply() has re-scheduled from the fresh answer. Only when the
514
+ // server still says elevated PAST its own expiresAt (clock skew
515
+ // between the two) is a short retry needed instead.
516
+ next: s => {
517
+ if (s.elevated && s.expiresAt && Date.parse(s.expiresAt) <= Date.now())
518
+ this.scheduleRetry();
519
+ },
520
+ error: () => this.scheduleRetry(),
521
+ });
522
+ }, ms);
523
+ }
524
+ /**
525
+ * One more attempt, then stop. Giving up is affordable now and was not
526
+ * before: the state a failed read leaves behind is still read through the
527
+ * arithmetic above, so an unreachable server cannot leave this tab
528
+ * believing it is elevated. What is lost is only news from elsewhere -- a
529
+ * grant or a drop in another session -- and that is what the tab picks up
530
+ * when it next comes into view.
531
+ */
532
+ scheduleRetry() {
533
+ this.clearTimer();
534
+ this.expiryTimer = setTimeout(() => {
535
+ this.expiryTimer = null;
536
+ this.refresh().subscribe({ error: () => undefined });
537
+ }, 5_000);
538
+ }
539
+ clearTimer() {
540
+ if (this.expiryTimer !== null) {
541
+ clearTimeout(this.expiryTimer);
542
+ this.expiryTimer = null;
543
+ }
544
+ }
545
+ announceToOtherTabs() {
546
+ try {
547
+ localStorage.setItem(CROSS_TAB_KEY, String(Date.now()));
548
+ }
549
+ catch {
550
+ // Private mode or storage denied: the other tabs learn at their next 403.
551
+ }
552
+ }
553
+ onStorage = (event) => {
554
+ if (event.key !== CROSS_TAB_KEY || !this.available)
555
+ return;
556
+ this.refresh().subscribe({ error: () => undefined });
557
+ };
558
+ onFocus = () => this.reconcile();
559
+ onVisibilityChange = () => {
560
+ if (document.visibilityState === 'visible')
561
+ this.reconcile();
562
+ };
563
+ /**
564
+ * The tab is back. Read first -- that alone ends an elevation that ran out
565
+ * while it was away, with no request and no network to depend on -- then
566
+ * ask the server for what the read cannot know: a grant or a drop made
567
+ * elsewhere, or a lifetime the server moved.
568
+ */
569
+ reconcile() {
570
+ this.state();
571
+ if (!this.available)
572
+ return;
573
+ const now = Date.now();
574
+ if (now - this.lastReconcileAt < RECONCILE_MIN_INTERVAL_MS)
575
+ return;
576
+ this.lastReconcileAt = now;
577
+ this.refresh().subscribe({ error: () => undefined });
578
+ }
579
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ElevationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
580
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ElevationService, providedIn: 'root' });
581
+ }
582
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ElevationService, decorators: [{
583
+ type: Injectable,
584
+ args: [{ providedIn: 'root' }]
585
+ }], ctorParameters: () => [] });
586
+ /** Whether a stored answer has gone past the moment the server named. */
587
+ function isPast(state) {
588
+ if (!state.elevated || !state.expiresAt)
589
+ return false;
590
+ const at = Date.parse(state.expiresAt);
591
+ // Unparseable is not expired: a date this client cannot read is a reason to
592
+ // keep asking the server, not to end an elevation it may still be granting.
593
+ return Number.isFinite(at) && at <= Date.now();
594
+ }
595
+ /**
596
+ * The same answer, read after its own `expiresAt`: what the server would say
597
+ * if asked now, down to `ended`, so a prompt opened from it explains itself
598
+ * the same way whether the news came from the read or from the endpoint.
599
+ */
600
+ function expiredView(state) {
601
+ return {
602
+ ...state,
603
+ elevated: false,
604
+ expiresAt: null,
605
+ grantedAt: null,
606
+ factorConfirmed: null,
607
+ ended: { reason: 'expired', at: state.expiresAt ?? null },
608
+ };
609
+ }
610
+
611
+ /**
612
+ * Unified user preferences service -- all preference types share one
203
613
  * localStorage key (`coolms_ui_prefs`) and one debounced server-sync pipeline.
204
614
  *
205
615
  * Key naming convention (stored as nested objects, referenced via dot-notation):
@@ -231,7 +641,7 @@ class UserPreferencesService {
231
641
  SYNC_DELAY = 2000; // ms
232
642
  syncSubject = new Subject();
233
643
  constructor() {
234
- // Debounced sync to server — batches rapid changes (e.g. live resize)
644
+ // Debounced sync to server -- batches rapid changes (e.g. live resize)
235
645
  this.syncSubject.pipe(debounceTime(this.SYNC_DELAY), takeUntilDestroyed(this.destroyRef)).subscribe(prefs => this.syncToServer(prefs));
236
646
  }
237
647
  // -- Core helpers ---------------------------------------------------------
@@ -339,11 +749,11 @@ class UserPreferencesService {
339
749
  try {
340
750
  const raw = localStorage.getItem(this.STORAGE_KEY);
341
751
  if (!raw) {
342
- // Fresh device — trust the server entirely
752
+ // Fresh device -- trust the server entirely
343
753
  localStorage.setItem(this.STORAGE_KEY, JSON.stringify(uiPrefs));
344
754
  }
345
755
  else {
346
- // Local prefs exist — server is the base, local namespaces win
756
+ // Local prefs exist -- server is the base, local namespaces win
347
757
  const local = JSON.parse(raw);
348
758
  const merged = { ...uiPrefs, ...local };
349
759
  localStorage.setItem(this.STORAGE_KEY, JSON.stringify(merged));
@@ -360,6 +770,8 @@ class UserPreferencesService {
360
770
  return;
361
771
  this.http.patch(url, { uiPrefs: prefs }, {
362
772
  headers: { 'Content-Type': 'application/merge-patch+json' },
773
+ // Sent on every navigation, by no one's action.
774
+ context: new HttpContext().set(BACKGROUND_REQUEST, true),
363
775
  }).subscribe({
364
776
  error: err => console.warn('Preferences sync failed:', err),
365
777
  });
@@ -499,7 +911,7 @@ class UserPreferencesService {
499
911
  all['page'] = page;
500
912
  this.save(all);
501
913
  }
502
- // -- Media (legacy pass-through — kept for compatibility) -----------------
914
+ // -- Media (legacy pass-through -- kept for compatibility) -----------------
503
915
  /** @deprecated Use getPageState('media') instead */
504
916
  getMediaViewMode() {
505
917
  return this.getPageState('media')?.viewMode ?? null;
@@ -516,15 +928,15 @@ class UserPreferencesService {
516
928
  setMediaCollectionsPanel(width, collapsed) {
517
929
  this.setPanelState('media_panel_left', width, collapsed);
518
930
  }
519
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: UserPreferencesService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
520
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: UserPreferencesService, providedIn: 'root' });
931
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: UserPreferencesService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
932
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: UserPreferencesService, providedIn: 'root' });
521
933
  }
522
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: UserPreferencesService, decorators: [{
934
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: UserPreferencesService, decorators: [{
523
935
  type: Injectable,
524
936
  args: [{ providedIn: 'root' }]
525
937
  }], ctorParameters: () => [] });
526
938
 
527
- const STORAGE_KEY$1 = 'coolms_token';
939
+ const STORAGE_KEY$2 = 'coolms_token';
528
940
  let AuthState = class AuthState {
529
941
  // `inject()` rather than constructor parameters, and the imports above are
530
942
  // VALUE imports on purpose: an injected type must survive to runtime. As a
@@ -534,7 +946,7 @@ let AuthState = class AuthState {
534
946
  api = inject(IdentityApiClient);
535
947
  prefs = inject(UserPreferencesService);
536
948
  login(ctx, { identifier, password }) {
537
- return this.api.login(identifier, password).pipe(tap((res) => {
949
+ return this.api.login(identifier, password).pipe(tap$1((res) => {
538
950
  const state = {
539
951
  accessToken: res.accessToken,
540
952
  refreshToken: res.refreshToken,
@@ -542,16 +954,16 @@ let AuthState = class AuthState {
542
954
  user: res.user ?? null,
543
955
  };
544
956
  ctx.setState(state);
545
- localStorage.setItem(STORAGE_KEY$1, JSON.stringify(state));
957
+ localStorage.setItem(STORAGE_KEY$2, JSON.stringify(state));
546
958
  }),
547
959
  // Fetch full user data (includes uiPrefs) to seed preferences
548
- switchMap(() => this.api.me().pipe(catchError(() => of(null)))), tap(me => {
960
+ switchMap$1(() => this.api.me().pipe(catchError(() => of(null)))), tap$1(me => {
549
961
  if (!me)
550
962
  return;
551
963
  // Update stored user with full data including uiPrefs
552
964
  const updated = { ...ctx.getState(), user: me };
553
965
  ctx.setState(updated);
554
- localStorage.setItem(STORAGE_KEY$1, JSON.stringify(updated));
966
+ localStorage.setItem(STORAGE_KEY$2, JSON.stringify(updated));
555
967
  if (me.uiPrefs) {
556
968
  this.prefs.loadFromServer(me.uiPrefs);
557
969
  }
@@ -559,10 +971,10 @@ let AuthState = class AuthState {
559
971
  }
560
972
  logout(ctx) {
561
973
  ctx.setState({ accessToken: null, refreshToken: null, expiresAt: null, user: null });
562
- localStorage.removeItem(STORAGE_KEY$1);
974
+ localStorage.removeItem(STORAGE_KEY$2);
563
975
  }
564
976
  restore(ctx) {
565
- const raw = localStorage.getItem(STORAGE_KEY$1);
977
+ const raw = localStorage.getItem(STORAGE_KEY$2);
566
978
  if (!raw)
567
979
  return;
568
980
  try {
@@ -580,11 +992,11 @@ let AuthState = class AuthState {
580
992
  }
581
993
  }
582
994
  else {
583
- localStorage.removeItem(STORAGE_KEY$1);
995
+ localStorage.removeItem(STORAGE_KEY$2);
584
996
  }
585
997
  }
586
998
  catch {
587
- localStorage.removeItem(STORAGE_KEY$1);
999
+ localStorage.removeItem(STORAGE_KEY$2);
588
1000
  }
589
1001
  }
590
1002
  setTokens(ctx, { response }) {
@@ -595,7 +1007,7 @@ let AuthState = class AuthState {
595
1007
  user: response.user ?? ctx.getState().user,
596
1008
  };
597
1009
  ctx.setState(state);
598
- localStorage.setItem(STORAGE_KEY$1, JSON.stringify(state));
1010
+ localStorage.setItem(STORAGE_KEY$2, JSON.stringify(state));
599
1011
  }
600
1012
  patchCurrentUser(ctx, { patch }) {
601
1013
  const current = ctx.getState();
@@ -603,7 +1015,7 @@ let AuthState = class AuthState {
603
1015
  return;
604
1016
  const updated = { ...current, user: { ...current.user, ...patch } };
605
1017
  ctx.setState(updated);
606
- localStorage.setItem(STORAGE_KEY$1, JSON.stringify(updated));
1018
+ localStorage.setItem(STORAGE_KEY$2, JSON.stringify(updated));
607
1019
  }
608
1020
  static isAuthenticated(state) {
609
1021
  return null !== state.accessToken;
@@ -627,8 +1039,8 @@ let AuthState = class AuthState {
627
1039
  static currentUser(state) {
628
1040
  return state.user;
629
1041
  }
630
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AuthState, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
631
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AuthState });
1042
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: AuthState, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1043
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: AuthState });
632
1044
  };
633
1045
  __decorate([
634
1046
  Action(Login)
@@ -666,12 +1078,179 @@ AuthState = __decorate([
666
1078
  defaults: { accessToken: null, refreshToken: null, expiresAt: null, user: null },
667
1079
  })
668
1080
  ], AuthState);
669
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AuthState, decorators: [{
1081
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: AuthState, decorators: [{
670
1082
  type: Injectable
671
1083
  }], propDecorators: { login: [], logout: [], restore: [], setTokens: [], patchCurrentUser: [] } });
672
1084
 
1085
+ /**
1086
+ * Whether the signed-in account may use the console, and the console's endpoint
1087
+ * map when it may.
1088
+ *
1089
+ * The server decides: `GET /api/v1/admin/manifest` answers the map. Since 2026-10-06
1090
+ * (Dmitry: "no host groups"; access is decided by module groups alone) it answers every
1091
+ * signed-in account: the map grants nothing, each call it names is decided by its own
1092
+ * route, and an account in no group sees the admin with an empty menu. A server from
1093
+ * before that answers only at `/api/v1/console/manifest`, to the `console` group, and
1094
+ * 403 to anyone else -- there a refused sign-in is still signed out; this asks the new
1095
+ * address first and the old one only on its 404. The public theme config
1096
+ * read before sign-in carries only what the sign-in page needs; the rest arrives
1097
+ * here, once per sign-in, and is merged into the app config key by key: a key the
1098
+ * console's map carries replaces the public one whole, so the console's copy of a
1099
+ * section is always the complete one.
1100
+ *
1101
+ * `unknown` is a failure to ask (the network, a 5xx), not an answer: it is not kept,
1102
+ * so while the endpoint fails every navigation asks again.
1103
+ *
1104
+ * !! An answer belongs to the sign-in it was asked for. A sign-in, a sign-out or a
1105
+ * restored session starts a new generation; an answer that arrives for a generation
1106
+ * that is gone is kept for nobody -- A's late 403 must not sign B out -- and whoever
1107
+ * is waiting on it is answered for the sign-in there is now.
1108
+ *
1109
+ * !! With nobody signed in there is nothing to ask, and nothing is asked. Without
1110
+ * that, the re-ask after a sign-out went out with no token, its 401 made the
1111
+ * interceptor dispatch Logout, which started another generation, which re-asked:
1112
+ * a loop for as long as the tab lived (found in review).
1113
+ */
1114
+ class ConsoleAccessService {
1115
+ /** The admin manifest's URL: fixed, like the theme config's, because nothing is known before it. */
1116
+ static URL = '/api/v1/admin/manifest';
1117
+ /** The address before 2026-10-06, asked only when a server older than that answers 404 at {@link URL}. */
1118
+ static LEGACY_URL = '/api/v1/console/manifest';
1119
+ /** The login page's query parameter value for "signed out: no console access". */
1120
+ static REFUSED_REASON = 'no-console-access';
1121
+ http = inject(HttpClient);
1122
+ store = inject(Store);
1123
+ answer = 'unknown';
1124
+ pending = null;
1125
+ generation = 0;
1126
+ constructor() {
1127
+ // Another sign-in -- or none -- is another question.
1128
+ inject(Actions).pipe(ofActionDispatched(Login, Logout, RestoreSession)).subscribe(() => {
1129
+ this.generation++;
1130
+ this.answer = 'unknown';
1131
+ this.pending = null;
1132
+ });
1133
+ }
1134
+ /** Ask once per sign-in; the manifest is merged into the app config when granted. */
1135
+ ensure() {
1136
+ if (!this.store.selectSnapshot(AuthState.isAuthenticated)) {
1137
+ return Promise.resolve('unknown');
1138
+ }
1139
+ if ('unknown' !== this.answer) {
1140
+ return Promise.resolve(this.answer);
1141
+ }
1142
+ if (null !== this.pending) {
1143
+ return this.pending;
1144
+ }
1145
+ const askedFor = this.generation;
1146
+ const pending = firstValueFrom(this.http.get(ConsoleAccessService.URL).pipe(catchError((error) => error instanceof HttpErrorResponse && 404 === error.status
1147
+ ? this.http.get(ConsoleAccessService.LEGACY_URL)
1148
+ : throwError(() => error)), map((response) => ({ access: 'granted', response })), catchError((error) => of({
1149
+ access: error instanceof HttpErrorResponse && 403 === error.status ? 'refused' : 'unknown',
1150
+ response: null,
1151
+ })))).then(({ access, response }) => {
1152
+ if (askedFor !== this.generation) {
1153
+ return this.ensure();
1154
+ }
1155
+ if (null !== response) {
1156
+ this.merge(response);
1157
+ }
1158
+ this.pending = null;
1159
+ this.answer = access;
1160
+ return access;
1161
+ });
1162
+ this.pending = pending;
1163
+ return pending;
1164
+ }
1165
+ /**
1166
+ * Sign a refused account out: this sign-in ends on the server (its refresh token
1167
+ * with it), as the account menu's sign-out does, and then in this browser --
1168
+ * whatever the server answered, so a refused request cannot leave it signed in.
1169
+ */
1170
+ signOut() {
1171
+ const logout = this.store.selectSnapshot(AppConfigState.manifest)?.auth?.logout ?? '/api/v1/auth/logout';
1172
+ return this.http.post(logout, null).pipe(catchError(() => of(null)), switchMap(() => this.store.dispatch(new Logout())), map(() => undefined));
1173
+ }
1174
+ merge(response) {
1175
+ const config = this.store.selectSnapshot(AppConfigState.config);
1176
+ this.store.dispatch(new SetAppConfig({
1177
+ ...(config ?? {}),
1178
+ manifest: { ...(config?.manifest ?? {}), ...response.manifest },
1179
+ }));
1180
+ }
1181
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ConsoleAccessService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1182
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ConsoleAccessService, providedIn: 'root' });
1183
+ }
1184
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ConsoleAccessService, decorators: [{
1185
+ type: Injectable,
1186
+ args: [{ providedIn: 'root' }]
1187
+ }], ctorParameters: () => [] });
1188
+
1189
+ /** No person asks for a realtime token: a refusal is the channel's answer, never a prompt or a notice. */
1190
+ const BACKGROUND = { context: new HttpContext().set(BACKGROUND_REQUEST, true) };
1191
+ /**
1192
+ * The two realtime endpoints, carved out for the same reason the identity ones
1193
+ * were: the UI kit's realtime client needed exactly these two of `ApiService`'s
1194
+ * 117 methods, and depending on the whole thing would have pulled the entire
1195
+ * application's wire vocabulary underneath the kit.
1196
+ *
1197
+ * They belong in core rather than in the kit because both answers are derived
1198
+ * from things core already owns -- the boot manifest for the URL, and the auth
1199
+ * interceptor for the bearer token that authorises the request. A token is
1200
+ * session state, not a widget.
1201
+ *
1202
+ * `ApiService` keeps its identical signatures and delegates here, so nothing
1203
+ * calling it had to change.
1204
+ *
1205
+ * !! Access first, then realtime (Dmitry, 2026-09-28): "The console opens a realtime
1206
+ * connection before the access check. Reverse the order ... A refused account opens no
1207
+ * socket." The socket cannot open without the connection token, and anything may ask for
1208
+ * one early -- the ring subscription is built at app init and asks as soon as a user is
1209
+ * set, before the guard's answer is in. So the token waits for the console's answer (the
1210
+ * guard's own request, not a second one) and a refused sign-in gets none: no request, no
1211
+ * socket. A failure to ask is not a refusal, as at the guard.
1212
+ *
1213
+ * Since 2026-10-06 the connection token is every signed-in account's (Dmitry: "The connection
1214
+ * token only opens a socket; each channel is authorized when it is subscribed to"), and a
1215
+ * current server answers the admin manifest to every signed-in account, so no
1216
+ * sign-in is refused and every one gets its token; each subscription is then decided by its
1217
+ * channel. The wait stays for a server from before that, where a refused sign-in still opens
1218
+ * no socket.
1219
+ */
1220
+ class RealtimeTokenClient {
1221
+ /** The error a refused sign-in's connection-token request ends with. */
1222
+ static REFUSED = 'no console access: no realtime connection';
1223
+ http = inject(HttpClient);
1224
+ store = inject(Store);
1225
+ // Resolved when a connection token is asked for, not with this client: ApiService
1226
+ // delegates here and is built in many places that never open a socket.
1227
+ injector = inject(Injector);
1228
+ get manifest() {
1229
+ const m = this.store.selectSnapshot(AppConfigState.manifest);
1230
+ if (!m)
1231
+ throw new Error('ApiManifest not loaded — call AppInitService.load() first');
1232
+ return m;
1233
+ }
1234
+ connectionToken() {
1235
+ return defer(() => this.injector.get(ConsoleAccessService).ensure()).pipe(switchMap(access => 'refused' === access
1236
+ ? throwError(() => new Error(RealtimeTokenClient.REFUSED))
1237
+ : this.http.post(this.manifest.apiBase + '/centrifugo/connection-token', {}, BACKGROUND)));
1238
+ }
1239
+ subscriptionToken(channel) {
1240
+ return this.http.post(this.manifest.apiBase + '/centrifugo/subscription-token', { channel }, BACKGROUND);
1241
+ }
1242
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: RealtimeTokenClient, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1243
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: RealtimeTokenClient, providedIn: 'root' });
1244
+ }
1245
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: RealtimeTokenClient, decorators: [{
1246
+ type: Injectable,
1247
+ args: [{ providedIn: 'root' }]
1248
+ }] });
1249
+
673
1250
  const LOCK_NAME = 'coolms_auth_refresh';
674
1251
  const FRESH_TOKEN_GRACE_MS = 30_000;
1252
+ /** Where AuthState persists the session, shared by every tab of the origin. */
1253
+ const STORAGE_KEY$1 = 'coolms_token';
675
1254
  /**
676
1255
  * Two-layer refresh coordinator: an intra-tab `shareReplay(1)`
677
1256
  * mutex dedupes concurrent callers within one tab, and a
@@ -695,6 +1274,26 @@ const FRESH_TOKEN_GRACE_MS = 30_000;
695
1274
  * (real auth failure). Transient errors (network blips, 5xx,
696
1275
  * CORS, timeouts) propagate without clearing the session so a
697
1276
  * momentary refresh failure does not kill a valid login.
1277
+ *
1278
+ * A token the server REFUSED (2026-09-26). A caller answering a 401
1279
+ * passes the access token that was refused. Until then the lock body
1280
+ * handed back the cached token whenever its expiry looked fresh --
1281
+ * which a revoked token's does: a session ended on the server (a
1282
+ * sign-out everywhere from another device, a password change) was
1283
+ * retried with the same dead token and never refreshed, so the client
1284
+ * stayed signed in, and in its calls, until the token neared expiry
1285
+ * (measured: a 1:1 call kept for the whole 30 s window, every request
1286
+ * answered 401). Now the lock body re-reads the SHARED copy
1287
+ * (`localStorage`, which every tab's `SetTokens` writes synchronously;
1288
+ * this tab's state only follows a sibling's write when its `storage`
1289
+ * event arrives):
1290
+ * - a different access token there -> another tab (or an earlier
1291
+ * refresh here) already rotated past the refused one: use it, no
1292
+ * request;
1293
+ * - the same one -> the server refused this token: refresh, with the
1294
+ * newest refresh token -- the shared one, so a rotation a sibling
1295
+ * made is never replayed with the token it consumed. A 401 there
1296
+ * is the session's end: `Logout`.
698
1297
  */
699
1298
  class AuthRefreshCoordinator {
700
1299
  api = inject(IdentityApiClient);
@@ -705,14 +1304,20 @@ class AuthRefreshCoordinator {
705
1304
  * Return the in-flight refresh Observable, or spawn a new one
706
1305
  * with the supplied refresh-token value.
707
1306
  *
1307
+ * `refusedAccessToken`: the access token the server just answered
1308
+ * 401 to, when the caller is answering one. With it, the lock body
1309
+ * refreshes unless the shared copy already holds another token;
1310
+ * without it (a preemptive refresh), a fresh-looking cached token
1311
+ * is handed back as before.
1312
+ *
708
1313
  * Callers subscribe via `switchMap` / `firstValueFrom` to retry
709
1314
  * their original request with the resolved access token.
710
1315
  */
711
- refresh(refreshToken) {
1316
+ refresh(refreshToken, refusedAccessToken = null) {
712
1317
  if (this.inFlight) {
713
1318
  return this.inFlight;
714
1319
  }
715
- this.inFlight = defer(() => this.refreshWithCrossTabLock(refreshToken)).pipe(catchError(refreshErr => {
1320
+ this.inFlight = defer(() => this.refreshWithCrossTabLock(refreshToken, refusedAccessToken)).pipe(catchError(refreshErr => {
716
1321
  const isAuthFailure = refreshErr instanceof HttpErrorResponse && refreshErr.status === 401;
717
1322
  if (isAuthFailure) {
718
1323
  this.store.dispatch(new Logout());
@@ -732,8 +1337,8 @@ class AuthRefreshCoordinator {
732
1337
  * body checks for state already updated by a sibling tab before
733
1338
  * issuing its own HTTP call.
734
1339
  */
735
- refreshWithCrossTabLock(refreshToken) {
736
- const lockBody = () => this.executeRefresh(refreshToken);
1340
+ refreshWithCrossTabLock(refreshToken, refusedAccessToken) {
1341
+ const lockBody = () => this.executeRefresh(refreshToken, refusedAccessToken);
737
1342
  if (typeof navigator === 'undefined' || !('locks' in navigator)) {
738
1343
  return from(lockBody());
739
1344
  }
@@ -745,31 +1350,77 @@ class AuthRefreshCoordinator {
745
1350
  }));
746
1351
  }
747
1352
  /**
748
- * Lock body. Re-reads `AuthState.expiresAt` first: if another
749
- * tab published fresh tokens while we were queued, return the
750
- * cached access token directly. Otherwise read the latest
751
- * refresh-token value from state (it may have rotated while we
752
- * waited) and issue the HTTP refresh.
1353
+ * Lock body. Re-reads the shared copy first (see the class note).
1354
+ *
1355
+ * Answering a refusal: a shared access token other than the refused
1356
+ * one is a rotation already made -- taken, and mirrored into this
1357
+ * tab's state if its `storage` event has not arrived yet. Otherwise,
1358
+ * or for a preemptive caller whose cached token no longer looks
1359
+ * fresh, the HTTP refresh, with the newest refresh token: shared,
1360
+ * then state, then the caller's.
753
1361
  */
754
- async executeRefresh(originalRefreshToken) {
755
- const cachedAccess = this.tryConsumeFreshState();
756
- if (cachedAccess !== null) {
757
- return cachedAccess;
1362
+ async executeRefresh(originalRefreshToken, refusedAccessToken) {
1363
+ const shared = this.readShared();
1364
+ if (refusedAccessToken !== null) {
1365
+ if (shared !== null && shared.accessToken !== refusedAccessToken) {
1366
+ if (this.store.selectSnapshot(AuthState.accessToken) !== shared.accessToken) {
1367
+ this.store.dispatch(new SetTokens(shared));
1368
+ }
1369
+ return shared.accessToken;
1370
+ }
1371
+ }
1372
+ else {
1373
+ const cachedAccess = this.tryConsumeFreshState(shared);
1374
+ if (cachedAccess !== null) {
1375
+ return cachedAccess;
1376
+ }
758
1377
  }
759
- const currentRefreshToken = this.store.selectSnapshot(AuthState.refreshToken) ?? originalRefreshToken;
1378
+ const currentRefreshToken = shared?.refreshToken
1379
+ ?? this.store.selectSnapshot(AuthState.refreshToken)
1380
+ ?? originalRefreshToken;
760
1381
  const response = await firstValueFrom(this.api.refresh(currentRefreshToken));
761
1382
  this.store.dispatch(new SetTokens(response));
762
1383
  return response.accessToken;
763
1384
  }
764
1385
  /**
765
- * Return the cached access token when `AuthState.expiresAt` has
766
- * more than `FRESH_TOKEN_GRACE_MS` milliseconds remaining --
767
- * signalling that some other tab refreshed for us. Null
768
- * otherwise.
1386
+ * The session as the tabs share it, or null when there is none to
1387
+ * read: no storage, nothing stored (signed out), or a value that is
1388
+ * not a complete token set.
1389
+ */
1390
+ readShared() {
1391
+ let raw;
1392
+ try {
1393
+ raw = localStorage.getItem(STORAGE_KEY$1);
1394
+ }
1395
+ catch {
1396
+ return null;
1397
+ }
1398
+ if (raw === null) {
1399
+ return null;
1400
+ }
1401
+ try {
1402
+ const parsed = JSON.parse(raw);
1403
+ if (typeof parsed?.accessToken !== 'string' || typeof parsed.refreshToken !== 'string' || typeof parsed.expiresAt !== 'string') {
1404
+ return null;
1405
+ }
1406
+ return { accessToken: parsed.accessToken, refreshToken: parsed.refreshToken, expiresAt: parsed.expiresAt };
1407
+ }
1408
+ catch {
1409
+ return null;
1410
+ }
1411
+ }
1412
+ /**
1413
+ * For a preemptive caller only: return the current access token when
1414
+ * its expiry is more than `FRESH_TOKEN_GRACE_MS` milliseconds away --
1415
+ * signalling that some other tab refreshed for us. The shared copy
1416
+ * first, which a sibling's rotation reaches before this tab's state
1417
+ * does (mirrored here when it is newer); this tab's state when there
1418
+ * is none. Null otherwise. Never asked for a token the server
1419
+ * refused: a refused token's expiry looks just as fresh.
769
1420
  */
770
- tryConsumeFreshState() {
771
- const accessToken = this.store.selectSnapshot(AuthState.accessToken);
772
- const expiresAtStr = this.store.selectSnapshot(AuthState.expiresAt);
1421
+ tryConsumeFreshState(shared) {
1422
+ const accessToken = shared?.accessToken ?? this.store.selectSnapshot(AuthState.accessToken);
1423
+ const expiresAtStr = shared?.expiresAt ?? this.store.selectSnapshot(AuthState.expiresAt);
773
1424
  if (accessToken === null || expiresAtStr === null) {
774
1425
  return null;
775
1426
  }
@@ -777,15 +1428,18 @@ class AuthRefreshCoordinator {
777
1428
  if (Number.isNaN(expiresAtMs)) {
778
1429
  return null;
779
1430
  }
780
- if (Date.now() < expiresAtMs - FRESH_TOKEN_GRACE_MS) {
781
- return accessToken;
1431
+ if (Date.now() >= expiresAtMs - FRESH_TOKEN_GRACE_MS) {
1432
+ return null;
1433
+ }
1434
+ if (shared !== null && this.store.selectSnapshot(AuthState.accessToken) !== shared.accessToken) {
1435
+ this.store.dispatch(new SetTokens(shared));
782
1436
  }
783
- return null;
1437
+ return accessToken;
784
1438
  }
785
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AuthRefreshCoordinator, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
786
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AuthRefreshCoordinator, providedIn: 'root' });
1439
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: AuthRefreshCoordinator, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1440
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: AuthRefreshCoordinator, providedIn: 'root' });
787
1441
  }
788
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AuthRefreshCoordinator, decorators: [{
1442
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: AuthRefreshCoordinator, decorators: [{
789
1443
  type: Injectable,
790
1444
  args: [{ providedIn: 'root' }]
791
1445
  }] });
@@ -803,13 +1457,13 @@ const authInterceptor = (req, next) => {
803
1457
  const coordinator = inject(AuthRefreshCoordinator);
804
1458
  const initService = inject(AppInitService);
805
1459
  // Caller explicitly requested no auth header (e.g. the bootstrap config
806
- // fetch — the endpoint is public and a stale token would cause 401).
1460
+ // fetch -- the endpoint is public and a stale token would cause 401).
807
1461
  if (req.context.get(BYPASS_AUTH)) {
808
1462
  return next(req);
809
1463
  }
810
1464
  // Auth endpoints are public by design. The backend's JWT firewall rejects any
811
- // request that carries an invalid/expired Bearer token — even on endpoints
812
- // that would otherwise allow anonymous access — so never forward a stored
1465
+ // request that carries an invalid/expired Bearer token -- even on endpoints
1466
+ // that would otherwise allow anonymous access -- so never forward a stored
813
1467
  // token to login or refresh.
814
1468
  if (req.url.includes('/auth/login') || req.url.includes('/auth/refresh')) {
815
1469
  return next(req);
@@ -824,7 +1478,7 @@ const authInterceptor = (req, next) => {
824
1478
  // ready$ is a ReplaySubject(1) that completes after load() finishes, so:
825
1479
  // - requests fired during init queue here and flush the moment ready$ emits;
826
1480
  // - requests fired after init pass through take(1) immediately (replayed value).
827
- return initService.ready$.pipe(take(1), switchMap$1(() => {
1481
+ return initService.ready$.pipe(take(1), switchMap(() => {
828
1482
  const token = store.selectSnapshot(AuthState.accessToken);
829
1483
  const authed = token
830
1484
  ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
@@ -833,7 +1487,7 @@ const authInterceptor = (req, next) => {
833
1487
  if (!(err instanceof HttpErrorResponse) || err.status !== 401) {
834
1488
  return throwError(() => err);
835
1489
  }
836
- // Avoid infinite loop — skip refresh for auth endpoints themselves.
1490
+ // Avoid infinite loop -- skip refresh for auth endpoints themselves.
837
1491
  if (req.url.includes('/auth/refresh') || req.url.includes('/auth/login')) {
838
1492
  return throwError(() => err);
839
1493
  }
@@ -854,7 +1508,12 @@ const authInterceptor = (req, next) => {
854
1508
  // would send the same refresh-token twice and trigger the
855
1509
  // backend's `isUsed` replay branch on the second arrival,
856
1510
  // wiping the whole session.
857
- return coordinator.refresh(refreshToken).pipe(switchMap$1(newToken => next(req.clone({ setHeaders: { Authorization: `Bearer ${newToken}` } }))));
1511
+ //
1512
+ // With the token that was refused, so the coordinator
1513
+ // refreshes rather than hand the same dead token back
1514
+ // because its expiry still looks fresh (a session ended
1515
+ // on the server went unnoticed until then, 2026-09-26).
1516
+ return coordinator.refresh(refreshToken, token).pipe(switchMap(newToken => next(req.clone({ setHeaders: { Authorization: `Bearer ${newToken}` } }))));
858
1517
  }));
859
1518
  }));
860
1519
  };
@@ -927,10 +1586,10 @@ class CrossTabAuthSyncService {
927
1586
  };
928
1587
  this.store.dispatch(new SetTokens(tokenResponse));
929
1588
  };
930
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: CrossTabAuthSyncService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
931
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: CrossTabAuthSyncService, providedIn: 'root' });
1589
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: CrossTabAuthSyncService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1590
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: CrossTabAuthSyncService, providedIn: 'root' });
932
1591
  }
933
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: CrossTabAuthSyncService, decorators: [{
1592
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: CrossTabAuthSyncService, decorators: [{
934
1593
  type: Injectable,
935
1594
  args: [{ providedIn: 'root' }]
936
1595
  }] });
@@ -946,10 +1605,10 @@ class AppInitService {
946
1605
  prefs = inject(UserPreferencesService);
947
1606
  crossTabSync = inject(CrossTabAuthSyncService);
948
1607
  refreshCoordinator = inject(AuthRefreshCoordinator);
949
- /** The only hardcoded URL — everything else comes from the manifest. */
1608
+ /** The only hardcoded URL -- everything else comes from the manifest. */
950
1609
  configUrl = '/api/v1/theme/config';
951
1610
  /**
952
- * Emits (once) when load() has fully completed — tokens settled, manifest
1611
+ * Emits (once) when load() has fully completed -- tokens settled, manifest
953
1612
  * set (or swallowed on error). authGuard subscribes to this so it never
954
1613
  * evaluates isAuthenticated against a partially-restored stale token.
955
1614
  */
@@ -967,20 +1626,20 @@ class AppInitService {
967
1626
  //
968
1627
  // RestoreSession is synchronous (ctx.setState from localStorage), so
969
1628
  // this await resolves in the same microtask. Tokens are in NGXS state
970
- // before ANY subsequent HTTP request fires — the auth interceptor reads
1629
+ // before ANY subsequent HTTP request fires -- the auth interceptor reads
971
1630
  // the token from state when attaching Authorization headers, so this
972
1631
  // ordering prevents unauthenticated requests on page reload (F5).
973
1632
  await firstValueFrom(this.store.dispatch(new RestoreSession()));
974
1633
  // -- Step 2: fetch the manifest ----------------------------------------
975
1634
  //
976
- // BYPASS_AUTH is required here — not optional. The manifest endpoint
1635
+ // BYPASS_AUTH is required here -- not optional. The manifest endpoint
977
1636
  // is public, but two specific failure modes make bypassing necessary:
978
1637
  //
979
1638
  // a) The stored access token may be expired. Without BYPASS_AUTH the
980
1639
  // interceptor attaches the expired token, gets a 401, and tries to
981
1640
  // refresh. But the refresh endpoint URL comes from the manifest
982
1641
  // (api.refresh() reads manifest.auth.refresh), which hasn't been
983
- // loaded yet — triggering a null-dereference crash before the app
1642
+ // loaded yet -- triggering a null-dereference crash before the app
984
1643
  // can recover.
985
1644
  //
986
1645
  // b) Even if the token is valid, attaching it to a public endpoint
@@ -991,14 +1650,14 @@ class AppInitService {
991
1650
  // restored (or refreshed) token normally.
992
1651
  const config = await firstValueFrom(this.http.get(this.configUrl, {
993
1652
  context: new HttpContext().set(BYPASS_AUTH, true),
994
- }).pipe(tap$1(cfg => this.store.dispatch(new SetAppConfig(cfg))), catchError(() => {
1653
+ }).pipe(tap(cfg => this.store.dispatch(new SetAppConfig(cfg))), catchError(() => {
995
1654
  console.warn('CoolMS: theme/config unreachable, using safe defaults');
996
1655
  return of(null);
997
1656
  })));
998
1657
  // -- Step 3: validate the restored session -----------------------------
999
1658
  //
1000
1659
  // RestoreSession trusts whatever tokens are in localStorage, but they may
1001
- // have been revoked out-of-band — most notably, signing out on the public
1660
+ // have been revoked out-of-band -- most notably, signing out on the public
1002
1661
  // SSR site revokes the user's WHOLE token set. We MUST settle the real
1003
1662
  // auth state here, BEFORE step 4 signals ready$: both the auth interceptor
1004
1663
  // AND authGuard gate on ready$, so if we signalled first, the guard would
@@ -1029,7 +1688,7 @@ class AppInitService {
1029
1688
  // win over the saved route. The router cannot tell us which one this is:
1030
1689
  // withEnabledBlockingInitialNavigation holds the initial navigation until
1031
1690
  // this initializer resolves, so router.url is still '/'. The browser
1032
- // location is the only record of what was requested — Location.path()
1691
+ // location is the only record of what was requested -- Location.path()
1033
1692
  // reads it with the /admin base href stripped, so the bare root (with or
1034
1693
  // without trailing slash) yields ''. Anything else, including a root URL
1035
1694
  // carrying query params, is treated as an explicit destination.
@@ -1068,18 +1727,17 @@ class AppInitService {
1068
1727
  }
1069
1728
  let refreshedToken = null;
1070
1729
  try {
1071
- refreshedToken = await firstValueFrom(this.refreshCoordinator.refresh(refreshToken));
1730
+ refreshedToken = await firstValueFrom(this.refreshCoordinator.refresh(refreshToken, token));
1072
1731
  }
1073
1732
  catch {
1074
1733
  // 401 -> coordinator already dispatched Logout; transient -> session kept.
1075
1734
  return;
1076
1735
  }
1077
- // Refresh resolved — but the coordinator's fresh-state optimisation can hand
1078
- // back the SAME access token (without a network round-trip) when its stored
1079
- // expiry still looks fresh. For a token that was revoked but not yet expired
1080
- // (signing out on the SSR site while the access token is still in its TTL),
1081
- // that token is dead despite looking fresh. Re-probe with whatever we now
1082
- // hold; a second rejection means the session is genuinely gone.
1736
+ // Refresh resolved. Handed the refused token, the coordinator no longer
1737
+ // returns that same token (2026-09-26) -- it did when its stored expiry
1738
+ // looked fresh, which a revoked token's does. The re-probe stays: a
1739
+ // second rejection means the session is genuinely gone, whatever path
1740
+ // the coordinator took.
1083
1741
  if (!(await this.probe(meUrl, refreshedToken))) {
1084
1742
  this.store.dispatch(new Logout());
1085
1743
  }
@@ -1091,10 +1749,10 @@ class AppInitService {
1091
1749
  headers: token ? { Authorization: `Bearer ${token}` } : {},
1092
1750
  }).pipe(map(() => true), catchError(() => of(false))));
1093
1751
  }
1094
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AppInitService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1095
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AppInitService, providedIn: 'root' });
1752
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: AppInitService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1753
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: AppInitService, providedIn: 'root' });
1096
1754
  }
1097
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AppInitService, decorators: [{
1755
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: AppInitService, decorators: [{
1098
1756
  type: Injectable,
1099
1757
  args: [{ providedIn: 'root' }]
1100
1758
  }] });
@@ -1104,20 +1762,35 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
1104
1762
  *
1105
1763
  * The guard is intentionally ASYNC: it waits for AppInitService.ready$ before
1106
1764
  * evaluating auth state. Without this, Angular's router can evaluate
1107
- * isAuthenticated while APP_INITIALIZER is still in-flight — RestoreSession
1765
+ * isAuthenticated while APP_INITIALIZER is still in-flight -- RestoreSession
1108
1766
  * will have placed a non-null (but expired) token in state, causing the guard
1109
1767
  * to pass even though the subsequent config/refresh requests will fail and
1110
1768
  * dispatch Logout. Waiting for ready$ guarantees the auth state is settled.
1769
+ *
1770
+ * Signed in is not enough: the console is granted, and the server says to whom
1771
+ * (ConsoleAccessService). An account it refuses -- a customer who registered on the
1772
+ * public site -- is signed out of this sign-in, on the server as well as here, and
1773
+ * sent to the login page, which says why. A failure to ask is not a refusal: the console opens, and every request it
1774
+ * makes is still decided by the server.
1111
1775
  */
1112
1776
  const authGuard = () => {
1113
1777
  const init = inject(AppInitService);
1114
1778
  const store = inject(Store);
1115
1779
  const router = inject(Router);
1116
- return init.ready$.pipe(take(1), map(() => {
1117
- if (store.selectSnapshot(AuthState.isAuthenticated)) {
1118
- return true;
1119
- }
1120
- return router.createUrlTree(['/login']);
1780
+ const consoleAccess = inject(ConsoleAccessService);
1781
+ return init.ready$.pipe(take(1), switchMap(() => {
1782
+ if (!store.selectSnapshot(AuthState.isAuthenticated)) {
1783
+ return of(router.createUrlTree(['/login']));
1784
+ }
1785
+ return from(consoleAccess.ensure()).pipe(switchMap(access => {
1786
+ // Signed out while asking: nobody to open the console for.
1787
+ if (!store.selectSnapshot(AuthState.isAuthenticated)) {
1788
+ return of(router.createUrlTree(['/login']));
1789
+ }
1790
+ return 'refused' !== access
1791
+ ? of(true)
1792
+ : consoleAccess.signOut().pipe(map(() => router.createUrlTree(['/login'], { queryParams: { reason: ConsoleAccessService.REFUSED_REASON } })));
1793
+ }));
1121
1794
  }));
1122
1795
  };
1123
1796
 
@@ -1148,18 +1821,153 @@ const loginPageGuard = () => {
1148
1821
  }));
1149
1822
  };
1150
1823
 
1824
+ const ELEVATION_WARNING_PASSWORD_NOT_SET = 'elevation.password_not_set';
1825
+ const ELEVATION_WARNING_FACTOR_MISSING = 'elevation.factor_missing';
1826
+ const ELEVATION_WARNING_MFA_OFF = 'elevation.mfa_off';
1827
+
1828
+ /** The refusal text the server's framework writes when no one wrote a better one. */
1829
+ const FRAMEWORK_REFUSAL = /^\s*access denied\.?\s*$/i;
1830
+ /**
1831
+ * Converts any caught error into a human-readable message.
1832
+ *
1833
+ * Priority order for backend errors:
1834
+ * 1. error.detail (RFC 7807 problem detail)
1835
+ * 2. error['hydra:description'] (Hydra validation errors)
1836
+ * 3. error.message (generic)
1837
+ * 4. error.violations[] (constraint violations)
1838
+ * 5. Status-based fallback
1839
+ */
1840
+ class ErrorHandlerService {
1841
+ humanize(err) {
1842
+ if (!(err instanceof HttpErrorResponse)) {
1843
+ return err instanceof Error ? err.message : 'An unexpected error occurred.';
1844
+ }
1845
+ const elevationRefusal = isElevationRefusal(err);
1846
+ const body = err.error;
1847
+ if (body && typeof body === 'object') {
1848
+ const detail = body['detail'] ?? body['hydra:description'] ?? body['message'];
1849
+ // The framework's own refusal text ("Access Denied.") says nothing a person can act
1850
+ // on, so it is replaced by the status sentence; a sentence the server wrote for the
1851
+ // refused action ("Permission denied: cannot write '/docs/a'") is kept.
1852
+ if (typeof detail === 'string' && detail.trim() && !FRAMEWORK_REFUSAL.test(detail)) {
1853
+ return detail.trim();
1854
+ }
1855
+ if (Array.isArray(body['violations'])) {
1856
+ const msgs = body['violations']
1857
+ .map(v => v.message)
1858
+ .filter(Boolean);
1859
+ if (msgs.length)
1860
+ return msgs.join(' ');
1861
+ }
1862
+ }
1863
+ return elevationRefusal ? 'This needs an elevated session.' : this.statusMessage(err.status);
1864
+ }
1865
+ statusMessage(status) {
1866
+ switch (status) {
1867
+ case 0: return 'Network error. Check your connection.';
1868
+ case 400: return 'Invalid request.';
1869
+ case 401: return 'Session expired. Please sign in again.';
1870
+ case 403: return 'You don\'t have access to this.';
1871
+ case 404: return 'Resource not found.';
1872
+ case 409: return 'Conflict — resource already exists.';
1873
+ case 422: return 'Validation failed.';
1874
+ case 429: return 'Too many requests. Please wait a moment.';
1875
+ case 500: return 'Server error. Please try again later.';
1876
+ case 503: return 'Service unavailable. Please try again later.';
1877
+ default: return `Unexpected error (${status}).`;
1878
+ }
1879
+ }
1880
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ErrorHandlerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1881
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ErrorHandlerService, providedIn: 'root' });
1882
+ }
1883
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ErrorHandlerService, decorators: [{
1884
+ type: Injectable,
1885
+ args: [{ providedIn: 'root' }]
1886
+ }] });
1887
+
1888
+ /** Methods that read. A read is made by a page as it loads or by a poll, never as an action. */
1889
+ const READS = new Set(['GET', 'HEAD', 'OPTIONS']);
1890
+ /**
1891
+ * A refusal for want of elevation is a 403 the server stamps `X-Elevation-Required`
1892
+ * where its gate refused a member who has not elevated ({@link isElevationRefusal}).
1893
+ * This interceptor never opens the elevation prompt. The prompt opens only when a
1894
+ * person asks for it: an Elevate button, or an action whose control already knows it
1895
+ * needs elevation and offers it ({@link ElevationService.offerFor}).
1896
+ *
1897
+ * What it does with a stamped 403:
1898
+ * - On a write a person made (POST, PUT, PATCH, DELETE without
1899
+ * {@link BACKGROUND_REQUEST}): tells {@link ELEVATION_NOTICE}, which shows the
1900
+ * refusal with an Elevate button. The request is not held and not repeated; the
1901
+ * caller receives the 403 at once, and after elevating the person repeats the action.
1902
+ * - On a read: nothing. Reads are what a page makes as it loads and what polls make in
1903
+ * the background, and the page shows its own state for a refused one -- an inline
1904
+ * "needs an elevated session" with an Elevate button, or the feature hidden.
1905
+ * - On a background request: nothing. Nobody is waiting on it.
1906
+ *
1907
+ * Until 2026-10-08 any stamped 403 opened the prompt, so the setup reads of almost every
1908
+ * console page, and a realtime token at every load, opened it for an administrator who
1909
+ * had not elevated: 13 of 15 pages on a menu click, 15 of 15 on a reload (measured).
1910
+ *
1911
+ * The elevation endpoint's own calls carry {@link BYPASS_ELEVATION}: a wrong password is a
1912
+ * 403 and is not a refused action. A stamped 403 from anywhere but this API is ignored:
1913
+ * the header is the server's word, and only this server's counts.
1914
+ */
1915
+ const elevationInterceptor = (req, next) => {
1916
+ const elevation = inject(ElevationService);
1917
+ if (req.context.get(BYPASS_ELEVATION)
1918
+ || req.context.get(BACKGROUND_REQUEST)
1919
+ || READS.has(req.method.toUpperCase())
1920
+ || !elevation.isThisApi(req.url)) {
1921
+ return next(req);
1922
+ }
1923
+ const notice = inject(ELEVATION_NOTICE, { optional: true });
1924
+ if (!notice) {
1925
+ return next(req);
1926
+ }
1927
+ const errors = inject(ErrorHandlerService);
1928
+ return next(req).pipe(tap({
1929
+ error: (err) => {
1930
+ if (isElevationRefusal(err)) {
1931
+ notice.refused(errors.humanize(err));
1932
+ }
1933
+ },
1934
+ }));
1935
+ };
1936
+
1151
1937
  class LoginComponent {
1152
1938
  identifier = '';
1153
1939
  password = '';
1154
- loading = false;
1155
- error = null;
1940
+ // !! SIGNALS, not fields. Angular 22 renders a component that names no change-detection
1941
+ // strategy as OnPush (`onPush: changeDetection !== ChangeDetectionStrategy.Eager`), and
1942
+ // this one names none. So a plain field set from an asynchronous answer -- a wrong
1943
+ // password, or the console's refusal the guard sends back -- was set and never drawn: the
1944
+ // page said "Signing in..." until the person typed a character, which marked the view dirty
1945
+ // (measured in a browser on the served admin, 2026-09-28). A signal marks it dirty itself.
1946
+ // The zone was blamed first (core-angular#21) and running the answers inside it changed
1947
+ // nothing on the served admin: it re-ran change detection, which skipped this clean view.
1948
+ loading = signal(false, /* @ts-ignore */
1949
+ ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
1950
+ error = signal(null, /* @ts-ignore */
1951
+ ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
1156
1952
  store = inject(Store);
1157
1953
  router = inject(Router);
1954
+ /** Shown when the guard signed an account out because the console is not granted to it. */
1955
+ static NO_CONSOLE_ACCESS = 'This account has no access to the console.';
1956
+ constructor() {
1957
+ // Observed, not read once: after a sign-in the guard's redirect lands on this same
1958
+ // page, and the component is reused rather than made again.
1959
+ inject(ActivatedRoute).queryParamMap.pipe(takeUntilDestroyed()).subscribe(params => {
1960
+ if (ConsoleAccessService.REFUSED_REASON === params.get('reason')) {
1961
+ this.loading.set(false);
1962
+ this.error.set(LoginComponent.NO_CONSOLE_ACCESS);
1963
+ }
1964
+ });
1965
+ }
1158
1966
  submit() {
1159
1967
  if (!this.identifier || !this.password)
1160
1968
  return;
1161
- this.loading = true;
1162
- this.error = null;
1969
+ this.loading.set(true);
1970
+ this.error.set(null);
1163
1971
  this.store.dispatch(new Login(this.identifier, this.password)).subscribe({
1164
1972
  next: () => {
1165
1973
  // A rejected navigation used to vanish: nothing resets `loading`
@@ -1167,29 +1975,30 @@ class LoginComponent {
1167
1975
  // away. If it does not, the form spins for ever with no reason
1168
1976
  // given, so say so instead.
1169
1977
  void this.router.navigate(['/']).catch(() => {
1170
- this.loading = false;
1171
- this.error = 'Signed in, but the admin could not be opened. Please reload.';
1978
+ this.loading.set(false);
1979
+ this.error.set('Signed in, but the admin could not be opened. Please reload.');
1172
1980
  });
1173
1981
  },
1174
1982
  error: (err) => {
1175
- this.loading = false;
1176
- this.error = err?.error?.detail ?? err?.error?.message ?? err?.message ?? 'Invalid credentials. Please try again.';
1983
+ this.loading.set(false);
1984
+ this.error.set(err?.error?.detail ?? err?.error?.message ?? err?.message ?? 'Invalid credentials. Please try again.');
1177
1985
  },
1178
1986
  });
1179
1987
  }
1180
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: LoginComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1181
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.3", type: LoginComponent, isStandalone: true, selector: "coolms-admin-login", ngImport: i0, template: "<div class=\"d-flex align-items-center justify-content-center min-vh-100 bg-light\">\n <div class=\"card shadow-sm\" style=\"width: 360px\">\n <div class=\"card-body p-4\">\n <h1 class=\"h5 mb-1\">CoolMS Admin</h1>\n <p class=\"text-muted small mb-4\">Sign in to continue</p>\n\n <div *ngIf=\"error\" class=\"alert alert-danger py-2 small\">{{ error }}</div>\n\n <form (ngSubmit)=\"submit()\" #loginForm=\"ngForm\">\n <div class=\"mb-3\">\n <label class=\"form-label\">Email or username</label>\n <input\n type=\"text\"\n class=\"form-control\"\n [(ngModel)]=\"identifier\"\n name=\"identifier\"\n required\n autocomplete=\"username\"\n />\n </div>\n <div class=\"mb-3\">\n <label class=\"form-label\">Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n [(ngModel)]=\"password\"\n name=\"password\"\n required\n autocomplete=\"current-password\"\n />\n </div>\n <button\n type=\"submit\"\n class=\"cms-btn cms-btn-primary w-100 justify-content-center\"\n [disabled]=\"loading || loginForm.invalid\"\n >\n {{ loading ? 'Signing in\u2026' : 'Sign in' }}\n </button>\n </form>\n </div>\n </div>\n</div>\n", dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] });
1988
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: LoginComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1989
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.2.1", type: LoginComponent, isStandalone: true, selector: "coolms-admin-login", ngImport: i0, template: "<div class=\"d-flex align-items-center justify-content-center min-vh-100 bg-light\">\n <div class=\"card shadow-sm\" style=\"width: 360px\">\n <div class=\"card-body p-4\">\n <h1 class=\"h5 mb-1\">CoolMS Admin</h1>\n <p class=\"text-muted small mb-4\">Sign in to continue</p>\n\n <div *ngIf=\"error()\" class=\"alert alert-danger py-2 small\">{{ error() }}</div>\n\n <form (ngSubmit)=\"submit()\" #loginForm=\"ngForm\">\n <div class=\"mb-3\">\n <label class=\"form-label\">Email or username</label>\n <input\n type=\"text\"\n class=\"form-control\"\n [(ngModel)]=\"identifier\"\n name=\"identifier\"\n required\n autocomplete=\"username\"\n />\n </div>\n <div class=\"mb-3\">\n <label class=\"form-label\">Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n [(ngModel)]=\"password\"\n name=\"password\"\n required\n autocomplete=\"current-password\"\n />\n </div>\n <button\n type=\"submit\"\n class=\"cms-btn cms-btn-primary w-100 justify-content-center\"\n [disabled]=\"loading() || loginForm.invalid\"\n >\n {{ loading() ? 'Signing in\u2026' : 'Sign in' }}\n </button>\n </form>\n </div>\n </div>\n</div>\n", dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] });
1182
1990
  }
1183
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: LoginComponent, decorators: [{
1991
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: LoginComponent, decorators: [{
1184
1992
  type: Component,
1185
- args: [{ selector: 'coolms-admin-login', standalone: true, imports: [FormsModule, NgIf], template: "<div class=\"d-flex align-items-center justify-content-center min-vh-100 bg-light\">\n <div class=\"card shadow-sm\" style=\"width: 360px\">\n <div class=\"card-body p-4\">\n <h1 class=\"h5 mb-1\">CoolMS Admin</h1>\n <p class=\"text-muted small mb-4\">Sign in to continue</p>\n\n <div *ngIf=\"error\" class=\"alert alert-danger py-2 small\">{{ error }}</div>\n\n <form (ngSubmit)=\"submit()\" #loginForm=\"ngForm\">\n <div class=\"mb-3\">\n <label class=\"form-label\">Email or username</label>\n <input\n type=\"text\"\n class=\"form-control\"\n [(ngModel)]=\"identifier\"\n name=\"identifier\"\n required\n autocomplete=\"username\"\n />\n </div>\n <div class=\"mb-3\">\n <label class=\"form-label\">Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n [(ngModel)]=\"password\"\n name=\"password\"\n required\n autocomplete=\"current-password\"\n />\n </div>\n <button\n type=\"submit\"\n class=\"cms-btn cms-btn-primary w-100 justify-content-center\"\n [disabled]=\"loading || loginForm.invalid\"\n >\n {{ loading ? 'Signing in\u2026' : 'Sign in' }}\n </button>\n </form>\n </div>\n </div>\n</div>\n" }]
1186
- }] });
1993
+ args: [{ selector: 'coolms-admin-login', standalone: true, imports: [FormsModule, NgIf], template: "<div class=\"d-flex align-items-center justify-content-center min-vh-100 bg-light\">\n <div class=\"card shadow-sm\" style=\"width: 360px\">\n <div class=\"card-body p-4\">\n <h1 class=\"h5 mb-1\">CoolMS Admin</h1>\n <p class=\"text-muted small mb-4\">Sign in to continue</p>\n\n <div *ngIf=\"error()\" class=\"alert alert-danger py-2 small\">{{ error() }}</div>\n\n <form (ngSubmit)=\"submit()\" #loginForm=\"ngForm\">\n <div class=\"mb-3\">\n <label class=\"form-label\">Email or username</label>\n <input\n type=\"text\"\n class=\"form-control\"\n [(ngModel)]=\"identifier\"\n name=\"identifier\"\n required\n autocomplete=\"username\"\n />\n </div>\n <div class=\"mb-3\">\n <label class=\"form-label\">Password</label>\n <input\n type=\"password\"\n class=\"form-control\"\n [(ngModel)]=\"password\"\n name=\"password\"\n required\n autocomplete=\"current-password\"\n />\n </div>\n <button\n type=\"submit\"\n class=\"cms-btn cms-btn-primary w-100 justify-content-center\"\n [disabled]=\"loading() || loginForm.invalid\"\n >\n {{ loading() ? 'Signing in\u2026' : 'Sign in' }}\n </button>\n </form>\n </div>\n </div>\n</div>\n" }]
1994
+ }], ctorParameters: () => [] });
1187
1995
 
1188
1996
  /**
1189
1997
  * Fetches config YAMLs (served as JSON) from GET /api/v1/config/{type}/{id}.
1190
1998
  *
1191
1999
  * Responses are cached for the lifetime of the service (Map + shareReplay(1))
1192
- * so repeated calls never re-fetch the same resource.
2000
+ * so repeated calls never re-fetch the same resource. A failed read is dropped
2001
+ * from the cache, so the next call asks the server again.
1193
2002
  *
1194
2003
  * configBase is read from the API manifest (manifest.configBase) and falls
1195
2004
  * back to '/api/v1/config' when the manifest is not yet loaded.
@@ -1211,69 +2020,21 @@ class ConfigService {
1211
2020
  // The config endpoint wraps the YAML payload in a `data` field:
1212
2021
  // { type, id, data: { ...yaml content... } }
1213
2022
  // Unwrap so callers always receive the config payload directly.
1214
- const obs = this.http.get(`${base}/${type}/${id}`).pipe(map(r => (r.data ?? r)), shareReplay(1));
2023
+ const obs = this.http.get(`${base}/${type}/${id}`).pipe(map(r => (r.data ?? r)),
2024
+ // A failed read is not kept: a page that was refused for want of elevation reads
2025
+ // again once the person elevates, and must reach the server, not this cache.
2026
+ catchError((err) => {
2027
+ this.cache.delete(key);
2028
+ return throwError(() => err);
2029
+ }), shareReplay(1));
1215
2030
  this.cache.set(key, obs);
1216
2031
  }
1217
2032
  return this.cache.get(key);
1218
2033
  }
1219
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ConfigService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1220
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ConfigService, providedIn: 'root' });
1221
- }
1222
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ConfigService, decorators: [{
1223
- type: Injectable,
1224
- args: [{ providedIn: 'root' }]
1225
- }] });
1226
-
1227
- /**
1228
- * Converts any caught error into a human-readable message.
1229
- *
1230
- * Priority order for backend errors:
1231
- * 1. error.detail (RFC 7807 problem detail)
1232
- * 2. error['hydra:description'] (Hydra validation errors)
1233
- * 3. error.message (generic)
1234
- * 4. error.violations[] (constraint violations)
1235
- * 5. Status-based fallback
1236
- */
1237
- class ErrorHandlerService {
1238
- humanize(err) {
1239
- if (!(err instanceof HttpErrorResponse)) {
1240
- return err instanceof Error ? err.message : 'An unexpected error occurred.';
1241
- }
1242
- const body = err.error;
1243
- if (body && typeof body === 'object') {
1244
- const detail = body['detail'] ?? body['hydra:description'] ?? body['message'];
1245
- if (typeof detail === 'string' && detail.trim()) {
1246
- return detail.trim();
1247
- }
1248
- if (Array.isArray(body['violations'])) {
1249
- const msgs = body['violations']
1250
- .map(v => v.message)
1251
- .filter(Boolean);
1252
- if (msgs.length)
1253
- return msgs.join(' ');
1254
- }
1255
- }
1256
- return this.statusMessage(err.status);
1257
- }
1258
- statusMessage(status) {
1259
- switch (status) {
1260
- case 0: return 'Network error. Check your connection.';
1261
- case 400: return 'Invalid request.';
1262
- case 401: return 'Session expired. Please sign in again.';
1263
- case 403: return 'Access denied.';
1264
- case 404: return 'Resource not found.';
1265
- case 409: return 'Conflict — resource already exists.';
1266
- case 422: return 'Validation failed.';
1267
- case 429: return 'Too many requests. Please wait a moment.';
1268
- case 500: return 'Server error. Please try again later.';
1269
- case 503: return 'Service unavailable. Please try again later.';
1270
- default: return `Unexpected error (${status}).`;
1271
- }
1272
- }
1273
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ErrorHandlerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1274
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ErrorHandlerService, providedIn: 'root' });
2034
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ConfigService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2035
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ConfigService, providedIn: 'root' });
1275
2036
  }
1276
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ErrorHandlerService, decorators: [{
2037
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ConfigService, decorators: [{
1277
2038
  type: Injectable,
1278
2039
  args: [{ providedIn: 'root' }]
1279
2040
  }] });
@@ -1286,7 +2047,7 @@ const CHOICES = ['light', 'dark', 'system'];
1286
2047
  * the Profile -> Preferences tab already writes. This copy exists only so the
1287
2048
  * first paint of a reload is the right colour: the settings request is async,
1288
2049
  * so without it every load would start light and flip. It is deliberately NOT
1289
- * stored via UserPreferencesService, which syncs its own bag to the server —
2050
+ * stored via UserPreferencesService, which syncs its own bag to the server --
1290
2051
  * that would give one setting two server-side homes.
1291
2052
  */
1292
2053
  const CACHE_KEY = 'coolms_theme';
@@ -1294,7 +2055,7 @@ const ACCENT_CACHE_KEY = 'coolms_accent';
1294
2055
  /**
1295
2056
  * Six-digit hex only, checked again HERE even though the server already vets it
1296
2057
  * on write. This value is substituted into a `--cms-*` custom property,
1297
- * and a stored setting is data from the network like any other — the write-side
2058
+ * and a stored setting is data from the network like any other -- the write-side
1298
2059
  * guard protects what the admin stores, this one protects what it renders.
1299
2060
  */
1300
2061
  const HEX = /^#[0-9a-fA-F]{6}$/;
@@ -1309,7 +2070,7 @@ function isAccent(v) {
1309
2070
  *
1310
2071
  * The stylesheet does the rest: `:root[data-theme='dark']` re-points the
1311
2072
  * `--cms-*` palette and every colour in the admin arrives through those names.
1312
- * Nothing here knows a single colour, which is the point — a second
2073
+ * Nothing here knows a single colour, which is the point -- a second
1313
2074
  * theme is a block of tokens, not a code change.
1314
2075
  *
1315
2076
  * Follows the shape of UserCalendarPreferencesService and
@@ -1337,7 +2098,7 @@ class ThemeService {
1337
2098
  platformAccent = this._platformAccent.asReadonly();
1338
2099
  /**
1339
2100
  * The colour actually painted: personal override, else the deployment's,
1340
- * else null — which leaves the stylesheet's own accent in place.
2101
+ * else null -- which leaves the stylesheet's own accent in place.
1341
2102
  *
1342
2103
  * Three rungs and each is a real state. A user who has chosen nothing is
1343
2104
  * NOT the same as one who chose the deployment's colour: clear the
@@ -1368,28 +2129,28 @@ class ThemeService {
1368
2129
  }
1369
2130
  /**
1370
2131
  * One-shot load of the stored preference. Safe to call more than once and
1371
- * safe for anonymous users — a failure leaves the cached/system value in
2132
+ * safe for anonymous users -- a failure leaves the cached/system value in
1372
2133
  * place rather than forcing light, so the login screen still matches the OS.
1373
2134
  */
1374
2135
  ensureLoaded() {
1375
2136
  if (this.loadOnce$)
1376
2137
  return this.loadOnce$;
1377
2138
  // Read the deployment's colour FIRST and synchronously, so it is in
1378
- // place whether or not the settings request succeeds — an anonymous
2139
+ // place whether or not the settings request succeeds -- an anonymous
1379
2140
  // visitor on the login screen still sees the deployment's brand.
1380
2141
  this.readPlatformAccent();
1381
2142
  this.loadOnce$ = this.api.getSettings().pipe(map$1(all => {
1382
2143
  const prefs = all['preferences'];
1383
- // The accent rides along on the same response — it is a field of
2144
+ // The accent rides along on the same response -- it is a field of
1384
2145
  // the same section, so it costs no extra request.
1385
2146
  this.setAccent(isAccent(prefs?.['accentColor']) ? prefs['accentColor'] : null);
1386
2147
  return isChoice(prefs?.['theme']) ? prefs['theme'] : this._choice();
1387
- }), tap(choice => this.set(choice)), catchError$1(() => of(this._choice())), shareReplay$1({ bufferSize: 1, refCount: false }));
2148
+ }), tap$1(choice => this.set(choice)), catchError$1(() => of(this._choice())), shareReplay$1({ bufferSize: 1, refCount: false }));
1388
2149
  return this.loadOnce$;
1389
2150
  }
1390
2151
  /**
1391
2152
  * Called by the Profile page after the Preferences tab saves, so the admin
1392
- * re-themes on the spot. Ignores anything that is not a known choice —
2153
+ * re-themes on the spot. Ignores anything that is not a known choice --
1393
2154
  * the section PATCH returns the whole merged bag, not just this field.
1394
2155
  */
1395
2156
  update(value) {
@@ -1414,13 +2175,13 @@ class ThemeService {
1414
2175
  }
1415
2176
  catch {
1416
2177
  // A blocked or full localStorage costs the pre-paint hint, nothing
1417
- // more — the server value still arrives and applies.
2178
+ // more -- the server value still arrives and applies.
1418
2179
  }
1419
2180
  }
1420
2181
  /**
1421
2182
  * The manifest is loaded at bootstrap, well before the authenticated shell
1422
2183
  * calls this, so a snapshot read is enough and no subscription is needed.
1423
- * A malformed value is ignored rather than applied — the container refuses
2184
+ * A malformed value is ignored rather than applied -- the container refuses
1424
2185
  * to build with one, but the manifest crosses the network all the same.
1425
2186
  */
1426
2187
  readPlatformAccent() {
@@ -1441,7 +2202,7 @@ class ThemeService {
1441
2202
  }
1442
2203
  /**
1443
2204
  * Writes the override onto the document element, where an inline style
1444
- * outranks every stylesheet rule — including the dark block — so one
2205
+ * outranks every stylesheet rule -- including the dark block -- so one
1445
2206
  * declaration re-points the accent in both themes.
1446
2207
  *
1447
2208
  * Only --cms-accent is set, not the -hover/-light/-text members of the
@@ -1482,10 +2243,10 @@ class ThemeService {
1482
2243
  return null;
1483
2244
  }
1484
2245
  }
1485
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ThemeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1486
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ThemeService, providedIn: 'root' });
2246
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ThemeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2247
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ThemeService, providedIn: 'root' });
1487
2248
  }
1488
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ThemeService, decorators: [{
2249
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ThemeService, decorators: [{
1489
2250
  type: Injectable,
1490
2251
  args: [{ providedIn: 'root' }]
1491
2252
  }], ctorParameters: () => [] });
@@ -1499,7 +2260,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
1499
2260
  * A spinner says "something is happening" and nothing else; every product has
1500
2261
  * one and none of them are yours. The bang is already the one glyph in the
1501
2262
  * wordmark that carries the brand, so a loader built from it says whose
1502
- * software is thinking — for free, in the place a user is already looking.
2263
+ * software is thinking -- for free, in the place a user is already looking.
1503
2264
  *
1504
2265
  * ## Why one component rather than a snippet per surface
1505
2266
  *
@@ -1510,7 +2271,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
1510
2271
  *
1511
2272
  * ## The animation
1512
2273
  *
1513
- * The stem wipes upward and the dot lands after it, on a loop — the shape being
2274
+ * The stem wipes upward and the dot lands after it, on a loop -- the shape being
1514
2275
  * WRITTEN rather than spun. It is deliberately calm: a loader is background
1515
2276
  * furniture, and anything with a hard beat gets irritating on the third viewing.
1516
2277
  * Under `prefers-reduced-motion` it holds still and only breathes, because
@@ -1531,12 +2292,12 @@ class CmsLoaderComponent {
1531
2292
  ...(ngDevMode ? [{ debugName: "inline" }] : /* istanbul ignore next */ []));
1532
2293
  /**
1533
2294
  * A clip-path needs a document-unique id, and this component is used more
1534
- * than once per page — two loaders sharing an id makes the second one clip
2295
+ * than once per page -- two loaders sharing an id makes the second one clip
1535
2296
  * against the first one's rectangle and stop animating.
1536
2297
  */
1537
2298
  clipId = 'cms-loader-' + Math.random().toString(36).slice(2, 9);
1538
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: CmsLoaderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1539
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.3", type: CmsLoaderComponent, isStandalone: true, selector: "cms-loader", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, overlay: { classPropertyName: "overlay", publicName: "overlay", isSignal: true, isRequired: false, transformFunction: null }, inline: { classPropertyName: "inline", publicName: "inline", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
2299
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: CmsLoaderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2300
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.2.1", type: CmsLoaderComponent, isStandalone: true, selector: "cms-loader", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, overlay: { classPropertyName: "overlay", publicName: "overlay", isSignal: true, isRequired: false, transformFunction: null }, inline: { classPropertyName: "inline", publicName: "inline", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
1540
2301
  <div class="cms-loader"
1541
2302
  [class.cms-loader--overlay]="overlay()"
1542
2303
  [class.cms-loader--inline]="inline()"
@@ -1563,7 +2324,7 @@ class CmsLoaderComponent {
1563
2324
  </div>
1564
2325
  `, isInline: true, styles: [".cms-loader{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:24px;color:var(--cms-text-muted)}.cms-loader--overlay{position:absolute;inset:0;z-index:5;background:var(--cms-surface)}.cms-loader--inline{flex-direction:row;gap:8px;padding:0}.cms-loader--inline .cms-loader__mark{width:var(--cms-loader-size, 14px)}.cms-loader__mark{width:var(--cms-loader-size, 40px);height:auto;overflow:visible}.cms-loader__ghost{fill:var(--cms-border)}.cms-loader__stem,.cms-loader__dot{fill:var(--cms-accent)}.cms-loader__wipe{animation:cms-loader-wipe 1.6s cubic-bezier(.65,0,.35,1) infinite;transform-origin:12px 32px}.cms-loader__dot{animation:cms-loader-dot 1.6s cubic-bezier(.65,0,.35,1) infinite;transform-origin:12px 42px}@keyframes cms-loader-wipe{0%{transform:scaleY(0)}45%,70%{transform:scaleY(1)}to{transform:scaleY(0)}}@keyframes cms-loader-dot{0%,40%{opacity:0;transform:scale(.4)}55%,70%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.4)}}.cms-loader__label{font-size:.8125rem}@media(prefers-reduced-motion:reduce){.cms-loader__wipe{animation:none;transform:scaleY(1)}.cms-loader__dot{animation:cms-loader-breathe 2s ease-in-out infinite;opacity:1}}@keyframes cms-loader-breathe{0%,to{opacity:.35}50%{opacity:1}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1565
2326
  }
1566
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: CmsLoaderComponent, decorators: [{
2327
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: CmsLoaderComponent, decorators: [{
1567
2328
  type: Component,
1568
2329
  args: [{ selector: 'cms-loader', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, template: `
1569
2330
  <div class="cms-loader"
@@ -1730,10 +2491,10 @@ class SidebarStateService {
1730
2491
  // Non-persistence acceptable; toggle still works in-session.
1731
2492
  }
1732
2493
  }
1733
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: SidebarStateService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1734
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: SidebarStateService, providedIn: 'root' });
2494
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: SidebarStateService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2495
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: SidebarStateService, providedIn: 'root' });
1735
2496
  }
1736
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: SidebarStateService, decorators: [{
2497
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: SidebarStateService, decorators: [{
1737
2498
  type: Injectable,
1738
2499
  args: [{ providedIn: 'root' }]
1739
2500
  }] });
@@ -1741,7 +2502,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
1741
2502
  /**
1742
2503
  * HTTP client for the Phase 2a `/api/entity-search` endpoint.
1743
2504
  * Returns a stream of search results for the given entity FQCN.
1744
- * Permission filtering happens at the backend resolver layer —
2505
+ * Permission filtering happens at the backend resolver layer --
1745
2506
  * this service is a thin RPC.
1746
2507
  */
1747
2508
  class EntitySearchService {
@@ -1750,7 +2511,7 @@ class EntitySearchService {
1750
2511
  * @param entityType Fully-qualified PHP class name of the entity
1751
2512
  * being searched (e.g. the backend's User entity).
1752
2513
  * @param query Free-text search input. Empty string is
1753
- * valid — the backend returns the default
2514
+ * valid -- the backend returns the default
1754
2515
  * listing for that entity type.
1755
2516
  * @param limit Max rows to return; clamped server-side to
1756
2517
  * [1, 100].
@@ -1762,10 +2523,10 @@ class EntitySearchService {
1762
2523
  .set('limit', String(limit));
1763
2524
  return this.http.get('/api/entity-search', { params });
1764
2525
  }
1765
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: EntitySearchService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1766
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: EntitySearchService, providedIn: 'root' });
2526
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: EntitySearchService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2527
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: EntitySearchService, providedIn: 'root' });
1767
2528
  }
1768
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: EntitySearchService, decorators: [{
2529
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: EntitySearchService, decorators: [{
1769
2530
  type: Injectable,
1770
2531
  args: [{ providedIn: 'root' }]
1771
2532
  }] });
@@ -1802,9 +2563,9 @@ class NaviGraphService {
1802
2563
  * Handle a nav-node click.
1803
2564
  *
1804
2565
  * Routing logic based on meta.target:
1805
- * 'action.logout' — dispatch Logout
1806
- * '_blank' — window.open (uses node.path as href)
1807
- * default — router.navigate with meta.routerLink or node.path
2566
+ * 'action.logout' -- dispatch Logout
2567
+ * '_blank' -- window.open (uses node.path as href)
2568
+ * default -- router.navigate with meta.routerLink or node.path
1808
2569
  */
1809
2570
  handleClick(node) {
1810
2571
  const target = node.meta.target;
@@ -1835,7 +2596,7 @@ class NaviGraphService {
1835
2596
  }
1836
2597
  /**
1837
2598
  * Standalone `showWhen` evaluator for callers that don't have a
1838
- * full `NaviGraphNode` (e.g. a grid's row context menu — a
2599
+ * full `NaviGraphNode` (e.g. a grid's row context menu -- a
1839
2600
  * `rowAction.showWhen` predicate is evaluated against the row's
1840
2601
  * own projection). `null`/`undefined` predicates default to
1841
2602
  * visible, mirroring `isVisible`'s "no rule = always shown"
@@ -1870,7 +2631,7 @@ class NaviGraphService {
1870
2631
  case 'ne': return val !== cond['value'];
1871
2632
  case 'in': return Array.isArray(cond['value']) && cond['value'].includes(val);
1872
2633
  case 'nin': return Array.isArray(cond['value']) && !cond['value'].includes(val);
1873
- // Prefix match for strings — primary use case is filtering
2634
+ // Prefix match for strings -- primary use case is filtering
1874
2635
  // actions by MIME-type major (e.g., "image/" matches all
1875
2636
  // image MIMEs without enumerating each subtype).
1876
2637
  case 'startsWith':
@@ -1930,10 +2691,10 @@ class NaviGraphService {
1930
2691
  sort(roots);
1931
2692
  return roots;
1932
2693
  }
1933
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: NaviGraphService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1934
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: NaviGraphService, providedIn: 'root' });
2694
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: NaviGraphService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2695
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: NaviGraphService, providedIn: 'root' });
1935
2696
  }
1936
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: NaviGraphService, decorators: [{
2697
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: NaviGraphService, decorators: [{
1937
2698
  type: Injectable,
1938
2699
  args: [{ providedIn: 'root' }]
1939
2700
  }] });
@@ -1947,10 +2708,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
1947
2708
  * Backed by a static Map so components registered at module load time
1948
2709
  * (e.g. in app.config.ts) are visible to all injected instances.
1949
2710
  *
1950
- * Usage — static (app.config.ts / bootstrap time):
2711
+ * Usage -- static (app.config.ts / bootstrap time):
1951
2712
  * ComponentRegistry.register('MediaLibraryPage', MediaLibraryPage);
1952
2713
  *
1953
- * Usage — injected (SlotComponent / runtime):
2714
+ * Usage -- injected (SlotComponent / runtime):
1954
2715
  * private readonly registry = inject(ComponentRegistry);
1955
2716
  * const cls = this.registry.get('MediaLibraryPage');
1956
2717
  */
@@ -1982,14 +2743,258 @@ class ComponentRegistry {
1982
2743
  keys() {
1983
2744
  return ComponentRegistry.keys();
1984
2745
  }
1985
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ComponentRegistry, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1986
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ComponentRegistry, providedIn: 'root' });
2746
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ComponentRegistry, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2747
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ComponentRegistry, providedIn: 'root' });
1987
2748
  }
1988
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ComponentRegistry, decorators: [{
2749
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ComponentRegistry, decorators: [{
1989
2750
  type: Injectable,
1990
2751
  args: [{ providedIn: 'root' }]
1991
2752
  }] });
1992
2753
 
2754
+ /**
2755
+ * `console` -- the administration host contract (the platform rule: hosts implement contracts, modules offer entries).
2756
+ *
2757
+ * A module contributes to the console with ONE object, its entry: data the
2758
+ * host reads, never a call the module makes into the host. Six lists, each
2759
+ * optional; an absent list means "nothing here", never an error. The host
2760
+ * assembles the entries (build time today, install time under federation),
2761
+ * refuses collisions by name, and activates only the modules the backend's
2762
+ * manifest says are installed.
2763
+ *
2764
+ * The version is MAJOR.MINOR: a minor adds an optional list or field, a major
2765
+ * changes or removes one. An entry declares the range it was written for
2766
+ * (`^1.2` -- major 1, minor at least 2), checked at build against this
2767
+ * constant and at install against the theme's declaration.
2768
+ */
2769
+ const CONSOLE_CONTRACT = { name: 'console', version: '1.0' };
2770
+ function isPortProvision(p) {
2771
+ return typeof p === 'object' && 'port' in p && 'useExisting' in p;
2772
+ }
2773
+ /** The identity function with the type: an entry file exports data, nothing runs. */
2774
+ function consoleEntry(entry) {
2775
+ return entry;
2776
+ }
2777
+ /** MAJOR.MINOR, both non-negative integers. */
2778
+ const VERSION = /^(\d+)\.(\d+)$/;
2779
+ /** `^MAJOR.MINOR` -- the caret is required: an entry names what it needs, never a bare number. */
2780
+ const RANGE = /^\^(\d+)\.(\d+)$/;
2781
+ /**
2782
+ * Whether a caret range over MAJOR.MINOR admits a version: same major, minor
2783
+ * at least the range's. Malformed input admits nothing -- a range that cannot
2784
+ * be read must refuse, never pass.
2785
+ */
2786
+ function satisfiesRange(range, version) {
2787
+ const r = RANGE.exec(range);
2788
+ const v = VERSION.exec(version);
2789
+ if (!r || !v)
2790
+ return false;
2791
+ return Number(r[1]) === Number(v[1]) && Number(v[2]) >= Number(r[2]);
2792
+ }
2793
+ /** The refusal an assembly raises: one message per collision, all of them, by name. */
2794
+ class ConsoleAssemblyError extends Error {
2795
+ problems;
2796
+ constructor(problems) {
2797
+ super(`console assembly refused:\n ${problems.join('\n ')}`);
2798
+ this.problems = problems;
2799
+ this.name = 'ConsoleAssemblyError';
2800
+ }
2801
+ }
2802
+ /**
2803
+ * The checks every assembly runs, in one place so the build-time spec, the
2804
+ * bootstrap and a future install-time loader agree: entry ranges against
2805
+ * this contract, and the collision rules of the six lists. Returns the
2806
+ * entries unchanged when nothing is wrong.
2807
+ */
2808
+ function assembleConsole(entries) {
2809
+ const problems = [];
2810
+ const seenModules = new Map();
2811
+ const paths = new Map();
2812
+ const bindings = new Map();
2813
+ const topbar = new Map();
2814
+ const overlays = new Map();
2815
+ const panels = new Map();
2816
+ const states = new Map();
2817
+ const ports = new Map();
2818
+ const claim = (map, key, owner, what) => {
2819
+ const prev = map.get(key);
2820
+ if (prev !== undefined && prev !== owner) {
2821
+ problems.push(`${what} '${String(key)}' is claimed by modules '${prev}' and '${owner}'`);
2822
+ }
2823
+ else {
2824
+ map.set(key, owner);
2825
+ }
2826
+ };
2827
+ for (const e of entries) {
2828
+ if (!satisfiesRange(e.range, CONSOLE_CONTRACT.version)) {
2829
+ problems.push(`module '${e.module}' offers console ${e.range}; this host implements console ${CONSOLE_CONTRACT.version} -- refused`);
2830
+ }
2831
+ seenModules.set(e.module, (seenModules.get(e.module) ?? 0) + 1);
2832
+ for (const r of e.routes ?? [])
2833
+ claim(paths, r.path, e.module, 'route path');
2834
+ for (const b of e.bindings ?? [])
2835
+ claim(bindings, b.name, e.module, 'binding');
2836
+ for (const t of e.topbar ?? [])
2837
+ claim(topbar, t.id, e.module, 'top-bar item');
2838
+ for (const o of e.overlays ?? [])
2839
+ claim(overlays, o.id, e.module, 'overlay');
2840
+ for (const p of e.panels ?? [])
2841
+ claim(panels, p.id, e.module, 'panel');
2842
+ for (const s of e.states ?? [])
2843
+ claim(states, s, e.module, 'state');
2844
+ for (const p of e.providers ?? []) {
2845
+ if (isPortProvision(p))
2846
+ claim(ports, p.port, e.module, 'port');
2847
+ }
2848
+ }
2849
+ for (const [module, n] of seenModules) {
2850
+ if (n > 1)
2851
+ problems.push(`module '${module}' has ${n} entries; one entry per module`);
2852
+ }
2853
+ if (problems.length)
2854
+ throw new ConsoleAssemblyError(problems);
2855
+ return entries;
2856
+ }
2857
+
2858
+ /**
2859
+ * The host side of `console@1`: what a theme calls to turn the assembled
2860
+ * entries into routes, providers and the lists its shell renders.
2861
+ *
2862
+ * Activation is the backend's: `GET /app-config` carries `ui.modules`, the
2863
+ * modules whose console entry the installer matched against the theme's
2864
+ * declaration. A module in the bundle but not in that list is dormant -- no
2865
+ * route matches, no tile, no overlay; a module in the list but not in the
2866
+ * bundle is reported by name ({@link ConsoleActivation.missing}), never
2867
+ * silently absent.
2868
+ */
2869
+ /** Every entry this build assembled, after {@link assembleConsole}'s checks. */
2870
+ const CONSOLE_ENTRIES = new InjectionToken('coolms.console.entries');
2871
+ /**
2872
+ * The port a dock panel talks back through: the layout provides one per
2873
+ * panel it mounts, so the panel never needs an output the slot cannot carry.
2874
+ */
2875
+ class ConsolePanelHost {
2876
+ }
2877
+ /** The console modules the manifest says are installed here. */
2878
+ function installedConsoleModules(manifest) {
2879
+ return new Set((manifest?.ui?.modules ?? [])
2880
+ .filter(m => m.contract === CONSOLE_CONTRACT.name)
2881
+ .map(m => m.module));
2882
+ }
2883
+ class ConsoleActivation {
2884
+ entries = inject(CONSOLE_ENTRIES, { optional: true }) ?? [];
2885
+ manifest = inject(Store).selectSignal(AppConfigState.manifest);
2886
+ installed = computed(() => installedConsoleModules(this.manifest()), /* @ts-ignore */
2887
+ ...(ngDevMode ? [{ debugName: "installed" }] : /* istanbul ignore next */ []));
2888
+ /** The entries of installed modules, in assembly order. */
2889
+ active = computed(() => this.entries.filter(e => this.installed().has(e.module)), /* @ts-ignore */
2890
+ ...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
2891
+ topbar = computed(() => this.active().flatMap(e => e.topbar ?? [])
2892
+ .slice()
2893
+ .sort((a, b) => a.order - b.order || a.id.localeCompare(b.id)), /* @ts-ignore */
2894
+ ...(ngDevMode ? [{ debugName: "topbar" }] : /* istanbul ignore next */ []));
2895
+ overlays = computed(() => this.active().flatMap(e => e.overlays ?? []), /* @ts-ignore */
2896
+ ...(ngDevMode ? [{ debugName: "overlays" }] : /* istanbul ignore next */ []));
2897
+ panels = computed(() => this.active().flatMap(e => e.panels ?? []), /* @ts-ignore */
2898
+ ...(ngDevMode ? [{ debugName: "panels" }] : /* istanbul ignore next */ []));
2899
+ /** Modules the manifest names that this build has no entry for: what a diagnostics page shows by name. */
2900
+ missing = computed(() => [...this.installed()].filter(m => !this.entries.some(e => e.module === m)).sort(), /* @ts-ignore */
2901
+ ...(ngDevMode ? [{ debugName: "missing" }] : /* istanbul ignore next */ []));
2902
+ isInstalled(module) {
2903
+ return this.installed().has(module);
2904
+ }
2905
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ConsoleActivation, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2906
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ConsoleActivation, providedIn: 'root' });
2907
+ }
2908
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.2.1", ngImport: i0, type: ConsoleActivation, decorators: [{
2909
+ type: Injectable,
2910
+ args: [{ providedIn: 'root' }]
2911
+ }] });
2912
+ /**
2913
+ * `canMatch` for a module's mount: the route exists only where the module is
2914
+ * installed. Answered once the manifest is in: on a cold deep link the router
2915
+ * recognises the URL while the initializer is still fetching `/theme/config`
2916
+ * (the two run as parallel initializers), and a synchronous read then found
2917
+ * no module installed, so every pasted module URL fell to the wildcard and
2918
+ * landed on the dashboard (measured 2026-09-22: no chunk requested for the
2919
+ * mount, the dashboard's instead). `ready$` is what the auth guard waits on
2920
+ * too, and it fires whether or not the config fetch succeeded.
2921
+ *
2922
+ * !! Then the console's own manifest, when someone is signed in: `ui.modules` is
2923
+ * the console's, not the public config's, and arrives with it
2924
+ * ({@link ConsoleAccessService}). canMatch runs before the layout's auth guard,
2925
+ * which asks the same question, so answering from the public config alone said
2926
+ * "no" to every module once that config stopped carrying them. Nobody signed in:
2927
+ * nothing is asked, the answer is the public config's, and the layout's guard
2928
+ * sends the person to sign in. A refusal merges nothing, so the answer is no.
2929
+ */
2930
+ function consoleModuleInstalled(module) {
2931
+ return () => {
2932
+ const activation = inject(ConsoleActivation);
2933
+ const consoleAccess = inject(ConsoleAccessService);
2934
+ return inject(AppInitService).ready$.pipe(take(1), switchMap(() => from(consoleAccess.ensure())), map(() => activation.isInstalled(module)));
2935
+ };
2936
+ }
2937
+ /**
2938
+ * The children a theme mounts under its authenticated layout for the
2939
+ * assembled entries: one `Route` per mount, lazy as written, `data` from the
2940
+ * entry's nav hint, `canMatch` from the manifest. The theme keeps its own
2941
+ * shape around them (login, the layout, `canActivate: [authGuard]`, the
2942
+ * wildcard).
2943
+ */
2944
+ function consoleChildren(entries) {
2945
+ const routes = [];
2946
+ for (const e of assembleConsole(entries)) {
2947
+ for (const m of e.routes ?? []) {
2948
+ if ('redirectTo' in m) {
2949
+ routes.push({ path: m.path, redirectTo: m.redirectTo, pathMatch: 'full' });
2950
+ continue;
2951
+ }
2952
+ const route = {
2953
+ path: m.path,
2954
+ canMatch: [consoleModuleInstalled(e.module)],
2955
+ ...(m.nav ? { data: { ...m.nav } } : {}),
2956
+ ...(m.providers ? { providers: [...m.providers] } : {}),
2957
+ };
2958
+ if ('children' in m) {
2959
+ route.loadChildren = m.children;
2960
+ }
2961
+ else {
2962
+ route.loadComponent = m.component;
2963
+ }
2964
+ routes.push(route);
2965
+ }
2966
+ }
2967
+ return routes;
2968
+ }
2969
+ /**
2970
+ * The providers a theme adds for the assembled entries: the store with the
2971
+ * host's states and every module state, the registry bindings (an
2972
+ * initializer, so they exist before the first render), every module
2973
+ * provision, and {@link CONSOLE_ENTRIES} for the shell. Place it AFTER the
2974
+ * library providers a module's provisions extend (the editor bridge, the
2975
+ * PDF viewer), as the hand-written lines were.
2976
+ */
2977
+ function provideConsole(entries, options) {
2978
+ const checked = assembleConsole(entries);
2979
+ const providers = [
2980
+ { provide: CONSOLE_ENTRIES, useValue: checked },
2981
+ provideStore([...options.hostStates, ...checked.flatMap(e => e.states ?? [])]),
2982
+ provideAppInitializer(() => {
2983
+ const registry = inject(ComponentRegistry);
2984
+ for (const e of checked) {
2985
+ for (const b of e.bindings ?? [])
2986
+ registry.register(b.name, b.component);
2987
+ }
2988
+ }),
2989
+ ];
2990
+ for (const e of checked) {
2991
+ for (const p of e.providers ?? []) {
2992
+ providers.push(isPortProvision(p) ? { provide: p.port, useExisting: p.useExisting } : p);
2993
+ }
2994
+ }
2995
+ return makeEnvironmentProviders(providers);
2996
+ }
2997
+
1993
2998
  /**
1994
2999
  * Optional by design: an application assembled without a Sections module binds
1995
3000
  * nothing and simply sends no `X-CoolMS-Section` header, which is exactly what
@@ -2081,5 +3086,5 @@ function shouldStamp(url) {
2081
3086
  * Generated bundle index. Do not edit.
2082
3087
  */
2083
3088
 
2084
- export { AppConfigState, AppInitService, AuthRefreshCoordinator, AuthState, BYPASS_AUTH, CURRENT_SECTION, CmsLoaderComponent, ComponentRegistry, ConfigService, CrossTabAuthSyncService, EntitySearchService, ErrorHandlerService, IdentityApiClient, Login, LoginComponent, Logout, NaviGraphService, PatchCurrentUser, RealtimeTokenClient, RestoreSession, SetAppConfig, SetTokens, SidebarStateService, ThemeService, UserPreferencesService, authGuard, authInterceptor, loginPageGuard, resolvePattern, sectionInterceptor };
3089
+ export { AppConfigState, AppInitService, AuthRefreshCoordinator, AuthState, BACKGROUND_REQUEST, BYPASS_AUTH, BYPASS_ELEVATION, CONSOLE_CONTRACT, CONSOLE_ENTRIES, CURRENT_SECTION, CmsLoaderComponent, ComponentRegistry, ConfigService, ConsoleAccessService, ConsoleActivation, ConsoleAssemblyError, ConsolePanelHost, CrossTabAuthSyncService, ELEVATION_NOTICE, ELEVATION_PROMPT, ELEVATION_REQUIRED_HEADER, ELEVATION_WARNING_FACTOR_MISSING, ELEVATION_WARNING_MFA_OFF, ELEVATION_WARNING_PASSWORD_NOT_SET, ElevationService, EntitySearchService, ErrorHandlerService, IdentityApiClient, Login, LoginComponent, Logout, NaviGraphService, PatchCurrentUser, RealtimeTokenClient, RestoreSession, SetAppConfig, SetTokens, SidebarStateService, ThemeService, UserPreferencesService, assembleConsole, authGuard, authInterceptor, consoleChildren, consoleEntry, consoleModuleInstalled, elevationInterceptor, installedConsoleModules, isElevationRefusal, isPortProvision, loginPageGuard, provideConsole, resolvePattern, satisfiesRange, sectionInterceptor };
2085
3090
  //# sourceMappingURL=coolms-core-angular.mjs.map