@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.
- package/CHANGELOG.md +134 -0
- package/README.md +16 -15
- package/fesm2022/coolms-core-angular.mjs +1254 -249
- package/fesm2022/coolms-core-angular.mjs.map +1 -1
- package/package.json +4 -1
- package/types/coolms-core-angular.d.ts +831 -69
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { Injectable, inject,
|
|
3
|
-
import { HttpClient,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
57
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1
|
|
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
|
|
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
|
|
126
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
520
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1
|
|
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
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
974
|
+
localStorage.removeItem(STORAGE_KEY$2);
|
|
563
975
|
}
|
|
564
976
|
restore(ctx) {
|
|
565
|
-
const raw = localStorage.getItem(STORAGE_KEY$
|
|
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$
|
|
995
|
+
localStorage.removeItem(STORAGE_KEY$2);
|
|
584
996
|
}
|
|
585
997
|
}
|
|
586
998
|
catch {
|
|
587
|
-
localStorage.removeItem(STORAGE_KEY$
|
|
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$
|
|
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$
|
|
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
|
|
631
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1
|
|
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
|
|
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
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
*
|
|
752
|
-
*
|
|
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
|
|
756
|
-
if (
|
|
757
|
-
|
|
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 =
|
|
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
|
-
*
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
|
|
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()
|
|
781
|
-
return
|
|
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
|
|
1437
|
+
return accessToken;
|
|
784
1438
|
}
|
|
785
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1
|
|
786
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1
|
|
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
|
|
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
|
|
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
|
|
812
|
-
// that would otherwise allow anonymous access
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
931
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
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
|
|
1095
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1
|
|
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
|
|
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
|
|
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
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
1155
|
-
|
|
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
|
|
1162
|
-
this.error
|
|
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
|
|
1171
|
-
this.error
|
|
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
|
|
1176
|
-
this.error
|
|
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
|
|
1181
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1
|
|
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
|
|
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)),
|
|
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
|
|
1220
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1486
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1539
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1
|
|
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
|
|
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
|
|
1734
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1
|
|
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
|
|
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
|
|
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
|
|
1766
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1
|
|
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
|
|
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'
|
|
1806
|
-
* '_blank'
|
|
1807
|
-
* default
|
|
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
|
|
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
|
|
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
|
|
1934
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1
|
|
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
|
|
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
|
|
2711
|
+
* Usage -- static (app.config.ts / bootstrap time):
|
|
1951
2712
|
* ComponentRegistry.register('MediaLibraryPage', MediaLibraryPage);
|
|
1952
2713
|
*
|
|
1953
|
-
* Usage
|
|
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
|
|
1986
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1
|
|
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
|
|
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
|