@zambon-dev/shared 2.0.0 → 2.1.1
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 +133 -1
- package/fesm2022/zambon-dev-shared.mjs +495 -11
- package/fesm2022/zambon-dev-shared.mjs.map +1 -1
- package/i18n/external-content/en.json +12 -0
- package/i18n/external-content/pt.json +12 -0
- package/index.d.ts +1 -0
- package/lib/features/external-content/external-content.component.d.ts +46 -0
- package/lib/features/external-content/external-content.routes.d.ts +25 -0
- package/lib/features/external-content/index.d.ts +2 -0
- package/lib/layouts/main-layout/main-layout.component.d.ts +3 -0
- package/lib/models/current-user-info.d.ts +15 -0
- package/lib/models/external-content.d.ts +28 -0
- package/lib/models/index.d.ts +1 -0
- package/lib/services/external-content.service.d.ts +35 -0
- package/lib/services/external-url-resolver.service.d.ts +49 -0
- package/lib/services/index.d.ts +2 -0
- package/package.json +1 -1
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { Component, inject, Injectable, ViewChild, HostListener, Input, EventEmitter, Output, NgModule, Pipe } from '@angular/core';
|
|
2
|
+
import { Component, InjectionToken, inject, Injectable, ViewChild, HostListener, Input, EventEmitter, Output, NgModule, Pipe } from '@angular/core';
|
|
3
3
|
import * as i1 from '@angular/router';
|
|
4
4
|
import { RouterModule, Router, ActivatedRoute } from '@angular/router';
|
|
5
|
-
import { AuthService, APP_CONFIG, TabService, Tab, TabsComponent, ButtonFiltersComponent, ModalBase, ChildList, ViewBase } from '@zambon-dev/framework';
|
|
6
|
-
import { SidebarService, ModalComponent, SidebarComponent, DataGridDataset, DataGridComponent, DataProviderService } from '@zambon-dev/library';
|
|
7
|
-
import {
|
|
8
|
-
import { map, catchError, interval, mergeMap, tap, BehaviorSubject, Subject,
|
|
5
|
+
import { AuthService, APP_CONFIG, TabService, Tab, TabsComponent, ButtonFiltersComponent, TabViewBase, ButtonComponent, DefaultTabViewComponent, FRAMEWORK_VIEW_TYPE, FrameworkViewType, ModalBase, ChildList, ViewBase } from '@zambon-dev/framework';
|
|
6
|
+
import { SidebarService, toSidebarMenuOpenMode, SidebarMenuOpenMode, ModalComponent, SidebarComponent, RibbonGroupComponent, DataGridDataset, DataGridComponent, DataProviderService } from '@zambon-dev/library';
|
|
7
|
+
import { TranslateService, TranslatePipe, TranslateLoader, provideTranslateService } from '@ngx-translate/core';
|
|
8
|
+
import { map, catchError, interval, mergeMap, tap, of, take, BehaviorSubject, Subject, takeUntil, finalize, shareReplay, switchMap } from 'rxjs';
|
|
9
9
|
import { HttpClient, HttpBackend } from '@angular/common/http';
|
|
10
10
|
import { JwtHelperService, JwtInterceptor } from '@auth0/angular-jwt';
|
|
11
11
|
import { HubConnectionState, HubConnectionBuilder } from '@microsoft/signalr';
|
|
12
|
-
import { AsyncPipe, NgIf, JsonPipe, DatePipe } from '@angular/common';
|
|
12
|
+
import { AsyncPipe, NgIf, DOCUMENT, JsonPipe, DatePipe } from '@angular/common';
|
|
13
13
|
import * as i1$1 from '@angular/forms';
|
|
14
14
|
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
|
15
15
|
import { DomSanitizer } from '@angular/platform-browser';
|
|
@@ -26,6 +26,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImpor
|
|
|
26
26
|
], template: "<div class=\"background\"></div>\n\n<div class=\"container\">\n <router-outlet></router-outlet>\n</div>", styles: [":host{display:block;height:100vh}.background{position:fixed;z-index:-50;height:100vh;width:100%;overflow:hidden}.background,.background:before{background-image:url(/background.jpg);background-size:cover;background-attachment:fixed}.background:before,.background:after{position:absolute;inset:0;--tw-content: \"\";content:var(--tw-content)}.background:before{--tw-blur: blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.background:after{background-color:#ffffff4d}.background *{z-index:10}.container{position:absolute;top:50%;left:50%;--tw-translate-x: -50%;--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}\n"] }]
|
|
27
27
|
}] });
|
|
28
28
|
|
|
29
|
+
/** First segment of the route that hosts embedded external content. */
|
|
30
|
+
const EXTERNAL_CONTENT_ROUTE_PATH = 'external-content';
|
|
31
|
+
class ExternalContentConfigs {
|
|
32
|
+
/**
|
|
33
|
+
* Origins an embedded menu item may point at, for example `['https://reports.example.com']`.
|
|
34
|
+
* Empty — the default — allows any `http`/`https` origin.
|
|
35
|
+
*
|
|
36
|
+
* Populating it is the single highest-value control here after the scheme check: it narrows a
|
|
37
|
+
* compromised menu row from "frame anything on the internet" to "frame one of our report hosts".
|
|
38
|
+
*/
|
|
39
|
+
allowedOrigins = [];
|
|
40
|
+
/** Milliseconds to wait for the frame's first `load` before hinting that framing may be refused. */
|
|
41
|
+
slowFrameHintDelay = 5000;
|
|
42
|
+
constructor(options = {}) {
|
|
43
|
+
Object.assign(this, options);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const EXTERNAL_CONTENT_CONFIGS = new InjectionToken('Embedded external content configuration', {
|
|
47
|
+
providedIn: 'root',
|
|
48
|
+
factory: () => new ExternalContentConfigs(),
|
|
49
|
+
});
|
|
50
|
+
|
|
29
51
|
class AuthenticationService extends AuthService {
|
|
30
52
|
//#region ViewChilds, Inputs, Outputs
|
|
31
53
|
//#endregion
|
|
@@ -95,6 +117,212 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImpor
|
|
|
95
117
|
}]
|
|
96
118
|
}], ctorParameters: () => [] });
|
|
97
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Remembers which external destination sits behind an embedded tab's `/external-content/:menuID`
|
|
122
|
+
* route, and resolves it back when the view is created.
|
|
123
|
+
*
|
|
124
|
+
* The destination never travels in the route, so the id has to be resolved to a URL somehow. That
|
|
125
|
+
* happens in three steps, cheapest first:
|
|
126
|
+
*
|
|
127
|
+
* 1. the in-memory registry — the item was clicked in this browsing session;
|
|
128
|
+
* 2. `sessionStorage` — the user pressed F5 on the embedded tab;
|
|
129
|
+
* 3. the application's `SidebarService.getMenuFromUrl()` — a cold deep link.
|
|
130
|
+
*
|
|
131
|
+
* Step 3 reuses the abstract hook every application already implements, so nothing new is required
|
|
132
|
+
* of a consumer: an application whose menu endpoint cannot resolve `/external-content/:id` simply
|
|
133
|
+
* gets the unavailable state on a cold deep link, which is the documented behaviour.
|
|
134
|
+
*/
|
|
135
|
+
class ExternalContentService {
|
|
136
|
+
//#region Variables
|
|
137
|
+
maxEntries = 20;
|
|
138
|
+
storageKey = 'zambon.externalContent';
|
|
139
|
+
registry = new Map();
|
|
140
|
+
sidebarService = inject(SidebarService);
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region Properties
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region Constructor and Angular life cycle methods
|
|
145
|
+
//#endregion
|
|
146
|
+
//#region Public methods
|
|
147
|
+
/** Resolves the destination behind an embedded tab, or `undefined` when it cannot be recovered. */
|
|
148
|
+
find(menuID) {
|
|
149
|
+
const known = this.registry.get(menuID) ?? this.readFromStorage(menuID);
|
|
150
|
+
if (!!known) {
|
|
151
|
+
return of(known);
|
|
152
|
+
}
|
|
153
|
+
return this.sidebarService.getMenuFromUrl(`/${EXTERNAL_CONTENT_ROUTE_PATH}/${menuID}`)
|
|
154
|
+
.pipe(take(1), map((menu) => !!menu && !!menu.url ? this.toEntry(menu) : undefined),
|
|
155
|
+
// A menu endpoint that does not know this route shape answers 404, which is an expected
|
|
156
|
+
// outcome here rather than a failure: the view falls back to its unavailable state.
|
|
157
|
+
catchError(() => of(undefined)));
|
|
158
|
+
}
|
|
159
|
+
/** Remembers a menu so its embedded tab survives a page refresh. */
|
|
160
|
+
register(menu) {
|
|
161
|
+
const entry = this.toEntry(menu);
|
|
162
|
+
this.registry.set(entry.id, entry);
|
|
163
|
+
this.writeToStorage(entry);
|
|
164
|
+
}
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region Private methods
|
|
167
|
+
readAllFromStorage() {
|
|
168
|
+
try {
|
|
169
|
+
const raw = window.sessionStorage.getItem(this.storageKey);
|
|
170
|
+
if (!raw) {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
const parsed = JSON.parse(raw);
|
|
174
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// A poisoned or unavailable storage entry must never take the application down; the view
|
|
178
|
+
// degrades to asking the user to reopen the item from the menu.
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
readFromStorage(menuID) {
|
|
183
|
+
const entry = this.readAllFromStorage()
|
|
184
|
+
.find((candidate) => candidate?.id === menuID);
|
|
185
|
+
if (!!entry && !!entry.url) {
|
|
186
|
+
this.registry.set(entry.id, entry);
|
|
187
|
+
return entry;
|
|
188
|
+
}
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
toEntry(menu) {
|
|
192
|
+
return {
|
|
193
|
+
id: menu.id,
|
|
194
|
+
label: menu.label,
|
|
195
|
+
url: menu.url ?? '',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
writeToStorage(entry) {
|
|
199
|
+
try {
|
|
200
|
+
const entries = [
|
|
201
|
+
entry,
|
|
202
|
+
...this.readAllFromStorage().filter((candidate) => candidate?.id !== entry.id),
|
|
203
|
+
].slice(0, this.maxEntries);
|
|
204
|
+
window.sessionStorage.setItem(this.storageKey, JSON.stringify(entries));
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Storage can be unavailable (private mode, blocked site data). Losing refresh support is
|
|
208
|
+
// acceptable; failing the click is not.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: ExternalContentService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
212
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: ExternalContentService, providedIn: 'root' });
|
|
213
|
+
}
|
|
214
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: ExternalContentService, decorators: [{
|
|
215
|
+
type: Injectable,
|
|
216
|
+
args: [{
|
|
217
|
+
providedIn: 'root'
|
|
218
|
+
}]
|
|
219
|
+
}] });
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolves the runtime placeholders in an external menu URL, and vets the result before it is
|
|
223
|
+
* handed to the browser.
|
|
224
|
+
*
|
|
225
|
+
* Placeholder syntax is `{name}`, case-sensitive, drawn from a **closed** set:
|
|
226
|
+
*
|
|
227
|
+
* | Token | Source |
|
|
228
|
+
* |--------------|-----------------------------------------------------|
|
|
229
|
+
* | `{email}` | `ICurrentUserInfo.email` |
|
|
230
|
+
* | `{language}` | `TranslateService.currentLang`, then `defaultLang` |
|
|
231
|
+
* | `{userId}` | `ICurrentUserInfo.userID` |
|
|
232
|
+
* | `{userName}` | `ICurrentUserInfo.username` |
|
|
233
|
+
*
|
|
234
|
+
* The set is closed by construction rather than reflected off the stored user info, and that is a
|
|
235
|
+
* security property, not a style choice: `AuthenticationService` persists the entire sign-in
|
|
236
|
+
* response under `userInfo`, tokens included, so a reflective implementation would let a menu URL
|
|
237
|
+
* configured as `?t={token}` hand the JWT to a third party. No authentication token is ever
|
|
238
|
+
* substituted.
|
|
239
|
+
*/
|
|
240
|
+
class ExternalUrlResolverService {
|
|
241
|
+
//#region Variables
|
|
242
|
+
authenticationService = inject(AuthenticationService);
|
|
243
|
+
translate = inject(TranslateService);
|
|
244
|
+
/** Placeholder grammar, used only to *detect* tokens this version does not know. */
|
|
245
|
+
unknownPlaceholderPattern = /\{[A-Za-z][A-Za-z0-9_]*\}/g;
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region Properties
|
|
248
|
+
//#endregion
|
|
249
|
+
//#region Constructor and Angular life cycle methods
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region Public methods
|
|
252
|
+
/**
|
|
253
|
+
* Whether `url` is an absolute `http`/`https` address.
|
|
254
|
+
*
|
|
255
|
+
* Rejects relative paths, malformed values, protocol-relative `//host/path` (which throws
|
|
256
|
+
* without a base), and the schemes that would execute rather than navigate: `javascript:`,
|
|
257
|
+
* `data:`, `blob:`, `file:`.
|
|
258
|
+
*/
|
|
259
|
+
isAllowed(url) {
|
|
260
|
+
let parsed;
|
|
261
|
+
try {
|
|
262
|
+
parsed = new URL(url);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Substitutes every known placeholder, URL-encoding each value.
|
|
271
|
+
*
|
|
272
|
+
* Because every value is percent-encoded, a placeholder must occupy one *whole* value — a full
|
|
273
|
+
* path segment, or a full query-parameter value. Encoding is what stops a display name that
|
|
274
|
+
* contains `&` from injecting an extra query parameter into the destination.
|
|
275
|
+
*
|
|
276
|
+
* A known placeholder with no value becomes an empty string; an unrecognized `{…}` is left
|
|
277
|
+
* exactly as configured, so a report URL that legitimately contains braces is not corrupted.
|
|
278
|
+
*/
|
|
279
|
+
resolve(url) {
|
|
280
|
+
if (!url || url.indexOf('{') === -1) {
|
|
281
|
+
return url;
|
|
282
|
+
}
|
|
283
|
+
const values = this.getPlaceholderValues();
|
|
284
|
+
let resolved = url;
|
|
285
|
+
values.forEach((value, token) => {
|
|
286
|
+
if (resolved.indexOf(token) === -1) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (value.length === 0) {
|
|
290
|
+
console.warn(`External URL placeholder ${token} has no value for the current user; substituting an empty string.`, url);
|
|
291
|
+
}
|
|
292
|
+
// split/join rather than String.replace: replace() interprets `$&` and `$1` in the
|
|
293
|
+
// replacement text, and without a global regex it would only swap the first occurrence.
|
|
294
|
+
resolved = resolved.split(token).join(encodeURIComponent(value));
|
|
295
|
+
});
|
|
296
|
+
this.warnAboutUnknownPlaceholders(url, resolved);
|
|
297
|
+
return resolved;
|
|
298
|
+
}
|
|
299
|
+
//#endregion
|
|
300
|
+
//#region Private methods
|
|
301
|
+
getPlaceholderValues() {
|
|
302
|
+
const user = this.authenticationService.getUserInfo();
|
|
303
|
+
return new Map([
|
|
304
|
+
['{email}', user?.email ?? ''],
|
|
305
|
+
['{language}', this.translate.currentLang || this.translate.defaultLang || ''],
|
|
306
|
+
['{userId}', user?.userID?.toString() ?? ''],
|
|
307
|
+
['{userName}', user?.username ?? ''],
|
|
308
|
+
]);
|
|
309
|
+
}
|
|
310
|
+
warnAboutUnknownPlaceholders(url, resolved) {
|
|
311
|
+
const unknown = resolved.match(this.unknownPlaceholderPattern) ?? [];
|
|
312
|
+
if (unknown.length > 0) {
|
|
313
|
+
console.warn(`External URL contains unrecognized placeholders and was left as configured: ${unknown.join(', ')}`, url);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: ExternalUrlResolverService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
317
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: ExternalUrlResolverService, providedIn: 'root' });
|
|
318
|
+
}
|
|
319
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: ExternalUrlResolverService, decorators: [{
|
|
320
|
+
type: Injectable,
|
|
321
|
+
args: [{
|
|
322
|
+
providedIn: 'root'
|
|
323
|
+
}]
|
|
324
|
+
}] });
|
|
325
|
+
|
|
98
326
|
/**
|
|
99
327
|
* Streams top-bar notifications from a SignalR hub.
|
|
100
328
|
*
|
|
@@ -488,6 +716,8 @@ class MainLayoutComponent {
|
|
|
488
716
|
//#region Variables
|
|
489
717
|
authenticationService = inject(AuthenticationService);
|
|
490
718
|
destroy$ = new Subject();
|
|
719
|
+
externalContentService = inject(ExternalContentService);
|
|
720
|
+
externalUrlResolverService = inject(ExternalUrlResolverService);
|
|
491
721
|
router = inject(Router);
|
|
492
722
|
sidebarService = inject(SidebarService);
|
|
493
723
|
tabService = inject(TabService);
|
|
@@ -506,12 +736,21 @@ class MainLayoutComponent {
|
|
|
506
736
|
if (url !== '/' && !this.tabService.isUrlOpen(url)) {
|
|
507
737
|
this.sidebarService.getMenuFromUrl(url)
|
|
508
738
|
.pipe(take(1))
|
|
509
|
-
.subscribe(
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
739
|
+
.subscribe({
|
|
740
|
+
next: (item) => {
|
|
741
|
+
if (!!item) {
|
|
742
|
+
this.tabService.updateTabTitle(url, item.label);
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
// A deep-linked title is best-effort. Menu endpoints answer 404 for a URL they do not
|
|
746
|
+
// know -- the embedded-content route among them -- and that must not surface as an
|
|
747
|
+
// unhandled rejection.
|
|
748
|
+
error: () => undefined,
|
|
513
749
|
});
|
|
514
750
|
}
|
|
751
|
+
this.sidebarService.menuExternalUrlSelected
|
|
752
|
+
.pipe(takeUntil(this.destroy$))
|
|
753
|
+
.subscribe((item) => this.openExternalMenu(item));
|
|
515
754
|
this.sidebarService.menuUrlSelected
|
|
516
755
|
.pipe(takeUntil(this.destroy$))
|
|
517
756
|
.subscribe((item) => {
|
|
@@ -533,6 +772,33 @@ class MainLayoutComponent {
|
|
|
533
772
|
this.authenticationService.signOut();
|
|
534
773
|
this.router.navigate(['/login']);
|
|
535
774
|
}
|
|
775
|
+
//#endregion
|
|
776
|
+
//#region Private methods
|
|
777
|
+
openExternalMenu(item) {
|
|
778
|
+
const rawUrl = item.url ?? '';
|
|
779
|
+
if (rawUrl.length === 0) {
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
if (toSidebarMenuOpenMode(item.openMode) === SidebarMenuOpenMode.ExternalEmbedded) {
|
|
783
|
+
// The tab has to be a real Angular route, so the destination travels by menu id and is
|
|
784
|
+
// resolved by ExternalContentComponent. A `?url=` query string would collapse every
|
|
785
|
+
// embedded tab into one -- TabService and CustomReuseStrategy key tabs on path segments
|
|
786
|
+
// only -- and would also let anyone frame an arbitrary site inside our own chrome.
|
|
787
|
+
this.externalContentService.register(item);
|
|
788
|
+
const tab = new Tab({
|
|
789
|
+
title: item.label,
|
|
790
|
+
url: `/${EXTERNAL_CONTENT_ROUTE_PATH}/${item.id}`,
|
|
791
|
+
});
|
|
792
|
+
this.tabService.openTab(tab);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const url = this.externalUrlResolverService.resolve(rawUrl);
|
|
796
|
+
if (!this.externalUrlResolverService.isAllowed(url)) {
|
|
797
|
+
console.error(`Sidebar menu "${item.label}" points to an unsupported external address and was not opened.`, url);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
801
|
+
}
|
|
536
802
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: MainLayoutComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
537
803
|
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.1.6", type: MainLayoutComponent, isStandalone: true, selector: "shared-main-layout", viewQueries: [{ propertyName: "logoutModal", first: true, predicate: ["logoutModal"], descendants: true }], ngImport: i0, template: "<div class=\"main-container\">\n <div class=\"sidebar-region\">\n <lib-sidebar>\n @if (appVersion) {\n <span>v{{ appVersion }}</span>\n }\n </lib-sidebar>\n </div>\n\n <shared-top-bar class=\"toolbar\" (logout)=\"onLogoutClick()\"></shared-top-bar>\n\n <div class=\"content\">\n <framework-tabs>\n <router-outlet></router-outlet>\n </framework-tabs>\n </div>\n</div>\n\n<lib-modal #logoutModal position=\"top\" size=\"xl\" [closeButtonText]=\"'Main-Logout-Modal-Message-Cancel' | translate\">\n <div body>\n <h5>{{ 'Main-Logout-Modal-Message' | translate }}</h5>\n </div>\n <div footer>\n <button type=\"button\" class=\"btn red-600\" (click)=\"onLogoutConfirm()\">{{ 'Main-Logout-Modal-Message-Confirm' | translate }}</button>\n </div>\n</lib-modal>", styles: ["@reference \"tailwindcss\";.main-container{display:grid;height:100vh;max-height:100vh;width:100vw;max-width:100%;background:var(--app-backdrop);grid-template-columns:auto minmax(0,1fr);grid-template-rows:auto minmax(0,1fr);grid-template-areas:\"toolbar toolbar\" \"sidebar content\"}.main-container .sidebar-region{padding:.75rem;grid-area:sidebar}.main-container .toolbar{display:flex;justify-content:space-between;border-bottom-width:1px;--tw-border-opacity: 1;border-color:rgb(214 211 209 / var(--tw-border-opacity, 1));background-color:#ffffffe6;padding:.5rem;--tw-shadow: 0 0 4px 0 rgba(0,0,0,.2), 0 3px 20px 0 rgba(0,0,0,.19);--tw-shadow-colored: 0 0 4px 0 var(--tw-shadow-color), 0 3px 20px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow);grid-area:toolbar}.main-container .content{overflow-y:auto;padding:.75rem .75rem .5rem;grid-area:content}.main-container framework-tabs{height:100%}lib-modal h5{margin-bottom:.5rem;font-size:1.125rem;font-weight:500;line-height:1.25rem}\n"], dependencies: [{ kind: "component", type: ModalComponent, selector: "lib-modal", inputs: ["closeButtonText", "dialog", "modalProcessing", "position", "size", "title"], outputs: ["closed"] }, { kind: "ngmodule", type: RouterModule }, { kind: "directive", type: i1.RouterOutlet, selector: "router-outlet", inputs: ["name", "routerOutletData"], outputs: ["activate", "deactivate", "attach", "detach"], exportAs: ["outlet"] }, { kind: "component", type: SidebarComponent, selector: "lib-sidebar" }, { kind: "component", type: TabsComponent, selector: "framework-tabs" }, { kind: "component", type: TopBarComponent, selector: "shared-top-bar", outputs: ["logout"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
|
|
538
804
|
}
|
|
@@ -771,6 +1037,223 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImpor
|
|
|
771
1037
|
args: [ButtonFiltersComponent]
|
|
772
1038
|
}] } });
|
|
773
1039
|
|
|
1040
|
+
/**
|
|
1041
|
+
* Displays an external destination inside an application tab.
|
|
1042
|
+
*
|
|
1043
|
+
* Routed as `/external-content/:menuID` — the destination itself never travels in the URL, so no
|
|
1044
|
+
* one can hand-craft a link that makes the application frame an arbitrary site.
|
|
1045
|
+
*
|
|
1046
|
+
* A `TabViewBase` like any other screen, so its actions live in the ribbon rather than in a
|
|
1047
|
+
* bar of its own: it must be routed under `DefaultTabViewComponent`, which is what renders the
|
|
1048
|
+
* `#ribbon` template this view publishes. Use the exported `externalContentRoutes`.
|
|
1049
|
+
*/
|
|
1050
|
+
class ExternalContentComponent extends TabViewBase {
|
|
1051
|
+
//#region ViewChilds, Inputs, Outputs
|
|
1052
|
+
//#endregion
|
|
1053
|
+
//#region Variables
|
|
1054
|
+
frameUrl;
|
|
1055
|
+
isBlocked = false;
|
|
1056
|
+
isFrameLoading = false;
|
|
1057
|
+
isInsecure = false;
|
|
1058
|
+
isSlow = false;
|
|
1059
|
+
isUnavailable = false;
|
|
1060
|
+
label = '';
|
|
1061
|
+
resolvedUrl = '';
|
|
1062
|
+
activatedRoute = inject(ActivatedRoute);
|
|
1063
|
+
configs = inject(EXTERNAL_CONTENT_CONFIGS);
|
|
1064
|
+
document = inject(DOCUMENT);
|
|
1065
|
+
externalContentService = inject(ExternalContentService);
|
|
1066
|
+
externalUrlResolverService = inject(ExternalUrlResolverService);
|
|
1067
|
+
sanitizer = inject(DomSanitizer);
|
|
1068
|
+
slowHintTimeout;
|
|
1069
|
+
tabService = inject(TabService);
|
|
1070
|
+
//#endregion
|
|
1071
|
+
//#region Properties
|
|
1072
|
+
//#endregion
|
|
1073
|
+
//#region Constructor and Angular life cycle methods
|
|
1074
|
+
constructor() {
|
|
1075
|
+
super();
|
|
1076
|
+
}
|
|
1077
|
+
ngOnDestroy() {
|
|
1078
|
+
this.clearSlowHint();
|
|
1079
|
+
super.ngOnDestroy();
|
|
1080
|
+
}
|
|
1081
|
+
ngOnInit() {
|
|
1082
|
+
const menuID = Number(this.activatedRoute.snapshot.paramMap.get('menuID'));
|
|
1083
|
+
if (!menuID) {
|
|
1084
|
+
this.isUnavailable = true;
|
|
1085
|
+
this.loading = false;
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
this.externalContentService.find(menuID)
|
|
1089
|
+
.pipe(take(1), takeUntil(this.destroy$))
|
|
1090
|
+
.subscribe((entry) => this.show(entry));
|
|
1091
|
+
}
|
|
1092
|
+
//#endregion
|
|
1093
|
+
//#region Event handlers
|
|
1094
|
+
onFrameLoad() {
|
|
1095
|
+
this.clearSlowHint();
|
|
1096
|
+
this.isFrameLoading = false;
|
|
1097
|
+
this.isSlow = false;
|
|
1098
|
+
}
|
|
1099
|
+
onOpenInNewTab() {
|
|
1100
|
+
window.open(this.resolvedUrl, '_blank', 'noopener,noreferrer');
|
|
1101
|
+
}
|
|
1102
|
+
onReload() {
|
|
1103
|
+
const url = this.frameUrl;
|
|
1104
|
+
if (!url || this.isFrameLoading) {
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
// The frame is cross-origin, so its location cannot be touched from here: destroying the
|
|
1108
|
+
// element and building a new one is the only way to make it navigate again. That also clears
|
|
1109
|
+
// the previous render, which is the point -- otherwise a reload leaves the old content on
|
|
1110
|
+
// screen and the user cannot tell whether anything happened.
|
|
1111
|
+
//
|
|
1112
|
+
// The rebuild has to wait for a macrotask, not a microtask. Angular coalesces changes within a
|
|
1113
|
+
// change-detection cycle, so setting frameUrl back before the next tick means @if sees the
|
|
1114
|
+
// value go A -> undefined -> A and never toggles: the element is never destroyed, nothing
|
|
1115
|
+
// navigates, no `load` ever fires, and the view spins until the timeout gives up. Yielding to
|
|
1116
|
+
// a timeout lets a cycle run with the frame unmounted, which is what really tears it down.
|
|
1117
|
+
this.frameUrl = undefined;
|
|
1118
|
+
this.isFrameLoading = true;
|
|
1119
|
+
setTimeout(() => this.displayFrame(url));
|
|
1120
|
+
}
|
|
1121
|
+
//#endregion
|
|
1122
|
+
//#region Public methods
|
|
1123
|
+
//#endregion
|
|
1124
|
+
//#region Private methods
|
|
1125
|
+
clearSlowHint() {
|
|
1126
|
+
if (this.slowHintTimeout !== undefined) {
|
|
1127
|
+
clearTimeout(this.slowHintTimeout);
|
|
1128
|
+
this.slowHintTimeout = undefined;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
isMixedContent(url) {
|
|
1132
|
+
// A browser refuses to embed an http:// frame in an https:// page, unconditionally: an iframe
|
|
1133
|
+
// is active mixed content. Nothing on this side can permit it -- no attribute, no header, and
|
|
1134
|
+
// not CSP, which only ever restricts further. Left alone the frame renders empty, so this is
|
|
1135
|
+
// checked up front to say so instead.
|
|
1136
|
+
//
|
|
1137
|
+
// Unlike a site refusing to be framed, this one IS knowable before trying: both protocols are
|
|
1138
|
+
// in hand. `new URL` cannot throw here -- isAllowed has already parsed the same string -- and
|
|
1139
|
+
// it normalises the scheme's case, which a prefix test would not.
|
|
1140
|
+
return this.document.location.protocol === 'https:' && new URL(url).protocol === 'http:';
|
|
1141
|
+
}
|
|
1142
|
+
isOriginAllowed(url) {
|
|
1143
|
+
if (this.configs.allowedOrigins.length === 0) {
|
|
1144
|
+
return true;
|
|
1145
|
+
}
|
|
1146
|
+
try {
|
|
1147
|
+
return this.configs.allowedOrigins.indexOf(new URL(url).origin) !== -1;
|
|
1148
|
+
}
|
|
1149
|
+
catch {
|
|
1150
|
+
return false;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
show(entry) {
|
|
1154
|
+
// TabViewBase starts every screen loading so its ribbon buttons begin disabled; every exit
|
|
1155
|
+
// from here has to clear it or they never become usable.
|
|
1156
|
+
this.loading = false;
|
|
1157
|
+
if (!entry || !entry.url) {
|
|
1158
|
+
this.isUnavailable = true;
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
this.label = entry.label;
|
|
1162
|
+
// MainLayoutComponent only resolves a deep-linked title through getMenuFromUrl, and a Tab
|
|
1163
|
+
// starts with isTitleLoading true, so a tab re-created by TabsComponent after a refresh would
|
|
1164
|
+
// spin forever if nothing set its title. Setting it here is idempotent.
|
|
1165
|
+
this.tabService.updateActiveTabRootTitle(entry.label);
|
|
1166
|
+
// Order matters: resolve, then validate, then trust. What we vet has to be exactly what the
|
|
1167
|
+
// browser receives, and a placeholder is substituted before the scheme can be inspected.
|
|
1168
|
+
const url = this.externalUrlResolverService.resolve(entry.url);
|
|
1169
|
+
if (!this.externalUrlResolverService.isAllowed(url) || !this.isOriginAllowed(url)) {
|
|
1170
|
+
console.error(`Embedded menu item "${entry.label}" points to an address that is not allowed and was not displayed.`, url);
|
|
1171
|
+
this.isBlocked = true;
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
// Set before the mixed-content check, not after: that state still offers 'open in a new
|
|
1175
|
+
// browser tab', and the button reads this to decide whether it has anywhere to go.
|
|
1176
|
+
this.resolvedUrl = url;
|
|
1177
|
+
if (this.isMixedContent(url)) {
|
|
1178
|
+
this.isInsecure = true;
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
// Trusted once, into a field. From a getter or a pipe this would hand back a new
|
|
1182
|
+
// SafeResourceUrl on every change-detection pass, and Angular would re-set the iframe's src
|
|
1183
|
+
// and reload the destination each time.
|
|
1184
|
+
this.displayFrame(this.sanitizer.bypassSecurityTrustResourceUrl(url));
|
|
1185
|
+
}
|
|
1186
|
+
displayFrame(url) {
|
|
1187
|
+
this.isFrameLoading = true;
|
|
1188
|
+
this.frameUrl = url;
|
|
1189
|
+
this.startSlowHint();
|
|
1190
|
+
}
|
|
1191
|
+
startSlowHint() {
|
|
1192
|
+
this.clearSlowHint();
|
|
1193
|
+
// Not detection, and it must never be turned into one: whether a site refuses to be framed
|
|
1194
|
+
// (X-Frame-Options, CSP frame-ancestors) is not observable from JavaScript. A refused frame
|
|
1195
|
+
// usually fires `load` immediately and renders the browser's own error page, in which case
|
|
1196
|
+
// this hint never appears -- the ribbon's "open in a new browser tab" button is the actual
|
|
1197
|
+
// way out, and it is always present.
|
|
1198
|
+
this.slowHintTimeout = setTimeout(() => {
|
|
1199
|
+
// Stop claiming it is loading. A destination that never reports `load` -- a refused frame
|
|
1200
|
+
// among them -- would otherwise spin forever and keep the actions disabled; hand the
|
|
1201
|
+
// controls back and let the hint do the explaining.
|
|
1202
|
+
this.isFrameLoading = false;
|
|
1203
|
+
this.isSlow = true;
|
|
1204
|
+
}, this.configs.slowFrameHintDelay);
|
|
1205
|
+
}
|
|
1206
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: ExternalContentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1207
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.1.6", type: ExternalContentComponent, isStandalone: true, selector: "shared-external-content", usesInheritance: true, ngImport: i0, template: "<!-- Published to DefaultTabViewComponent through TabViewService, so the actions sit in the\n application's ribbon like every other screen's. -->\n<ng-template #ribbon>\n <lib-ribbon-group [label]=\"'RibbonGroup-Page' | translate\">\n <!-- Same icon and label as framework-button-refresh, so reloading a report reads as the\n same action as refreshing a grid. `loading` is what spins it while the frame loads. -->\n <framework-button\n icon=\"fa-sync-alt\"\n label=\"Button-Refresh\"\n [disabled]=\"!resolvedUrl || isInsecure\"\n [loading]=\"isFrameLoading\"\n (action)=\"onReload()\">\n </framework-button>\n\n <framework-button\n icon=\"fa-arrow-up-right-from-square\"\n label=\"ExternalContent-OpenInNewTab\"\n [disabled]=\"!resolvedUrl\"\n (action)=\"onOpenInNewTab()\">\n </framework-button>\n </lib-ribbon-group>\n</ng-template>\n\n@if (isUnavailable) {\n <div class=\"external-content-message\">\n <i class=\"fa-solid fa-link-slash\"></i>\n <span class=\"external-content-message-title\">{{ 'ExternalContent-Unavailable-Title' | translate }}</span>\n <span>{{ 'ExternalContent-Unavailable-Message' | translate }}</span>\n </div>\n} @else if (isInsecure) {\n <div class=\"external-content-message\">\n <i class=\"fa-solid fa-shield-halved\"></i>\n <span class=\"external-content-message-title\">{{ 'ExternalContent-Insecure-Title' | translate }}</span>\n <span>{{ 'ExternalContent-Insecure-Message' | translate }}</span>\n </div>\n} @else if (isBlocked) {\n <div class=\"external-content-message\">\n <i class=\"fa-solid fa-triangle-exclamation\"></i>\n <span class=\"external-content-message-title\">{{ 'ExternalContent-Blocked-Title' | translate }}</span>\n <span>{{ 'ExternalContent-Blocked-Message' | translate }}</span>\n </div>\n} @else {\n @if (isSlow) {\n <div class=\"external-content-hint\">{{ 'ExternalContent-SlowHint' | translate }}</div>\n }\n\n @if (frameUrl || isFrameLoading) {\n <!--\n The container stays mounted across a reload, which briefly clears frameUrl, so the panel\n does not flicker out and back. What goes away is the frame itself, on purpose.\n\n sandbox and referrerpolicy are STATIC attributes on purpose. Angular rejects `sandbox`\n as a binding on an <iframe> outright (NG0910) \u2014 it may only be a static attribute \u2014 and\n the whole element then fails to render.\n\n The token list is fixed for every destination, and deliberately not configurable per\n menu item:\n - allow-same-origin keeps the frame in the destination's own origin so its cookies and\n storage work; without it an SSO'd report will not render. It grants no access to ours,\n provided the destination is cross-origin -- never point an embedded item at this\n application's own origin, use an internal route for that.\n - allow-top-navigation is absent on purpose: a framed site must not be able to navigate\n the whole application away.\n - allow-popups-to-escape-sandbox keeps print and download popups usable.\n -->\n <div class=\"external-content-frame-container\">\n @if (isFrameLoading) {\n <div class=\"external-content-loading\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n </svg>\n\n <span>{{ 'Loading' | translate }}</span>\n </div>\n }\n\n @if (frameUrl) {\n <iframe class=\"external-content-frame\"\n sandbox=\"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads\"\n referrerpolicy=\"strict-origin-when-cross-origin\"\n [src]=\"frameUrl\"\n [attr.title]=\"label | translate\"\n (load)=\"onFrameLoad()\">\n </iframe>\n }\n </div>\n }\n}\n", styles: [":host{display:flex;flex-grow:1;flex-direction:column;gap:.5rem;overflow:hidden;min-height:0}.external-content-hint{flex:none;border-radius:.5rem;border-width:1px;padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;--tw-border-opacity: 1;border-color:rgb(252 211 77 / var(--tw-border-opacity, 1));--tw-bg-opacity: 1;background-color:rgb(254 243 199 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(120 53 15 / var(--tw-text-opacity, 1))}.external-content-frame-container{border-radius:.5rem;border-width:1px;border-color:#d1d5dbe6;--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1));position:relative;display:flex;flex-grow:1;overflow:hidden;min-height:0}.external-content-loading{position:absolute;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;gap:.5rem;--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.external-content-loading svg{height:1.5rem;min-height:1.5rem;width:1.5rem;min-width:1.5rem}@keyframes spin{to{transform:rotate(360deg)}}.external-content-loading svg{animation:spin 1s linear infinite}.external-content-frame{width:100%;flex-grow:1;border-width:0px;min-height:0}.external-content-message{border-radius:.5rem;border-width:1px;border-color:#d1d5dbe6;--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1));display:flex;flex-grow:1;flex-direction:column;align-items:center;justify-content:center;gap:.5rem;padding:1.5rem;text-align:center}.external-content-message i{font-size:2.25rem;line-height:2.5rem;opacity:.4}.external-content-message .external-content-message-title{font-size:1.125rem;line-height:1.75rem;font-weight:600}\n"], dependencies: [{ kind: "component", type: ButtonComponent, selector: "framework-button", inputs: ["color", "icon", "label"], outputs: ["action"] }, { kind: "component", type: RibbonGroupComponent, selector: "lib-ribbon-group", inputs: ["label"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }] });
|
|
1208
|
+
}
|
|
1209
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: ExternalContentComponent, decorators: [{
|
|
1210
|
+
type: Component,
|
|
1211
|
+
args: [{ selector: 'shared-external-content', imports: [
|
|
1212
|
+
ButtonComponent,
|
|
1213
|
+
RibbonGroupComponent,
|
|
1214
|
+
TranslatePipe,
|
|
1215
|
+
], template: "<!-- Published to DefaultTabViewComponent through TabViewService, so the actions sit in the\n application's ribbon like every other screen's. -->\n<ng-template #ribbon>\n <lib-ribbon-group [label]=\"'RibbonGroup-Page' | translate\">\n <!-- Same icon and label as framework-button-refresh, so reloading a report reads as the\n same action as refreshing a grid. `loading` is what spins it while the frame loads. -->\n <framework-button\n icon=\"fa-sync-alt\"\n label=\"Button-Refresh\"\n [disabled]=\"!resolvedUrl || isInsecure\"\n [loading]=\"isFrameLoading\"\n (action)=\"onReload()\">\n </framework-button>\n\n <framework-button\n icon=\"fa-arrow-up-right-from-square\"\n label=\"ExternalContent-OpenInNewTab\"\n [disabled]=\"!resolvedUrl\"\n (action)=\"onOpenInNewTab()\">\n </framework-button>\n </lib-ribbon-group>\n</ng-template>\n\n@if (isUnavailable) {\n <div class=\"external-content-message\">\n <i class=\"fa-solid fa-link-slash\"></i>\n <span class=\"external-content-message-title\">{{ 'ExternalContent-Unavailable-Title' | translate }}</span>\n <span>{{ 'ExternalContent-Unavailable-Message' | translate }}</span>\n </div>\n} @else if (isInsecure) {\n <div class=\"external-content-message\">\n <i class=\"fa-solid fa-shield-halved\"></i>\n <span class=\"external-content-message-title\">{{ 'ExternalContent-Insecure-Title' | translate }}</span>\n <span>{{ 'ExternalContent-Insecure-Message' | translate }}</span>\n </div>\n} @else if (isBlocked) {\n <div class=\"external-content-message\">\n <i class=\"fa-solid fa-triangle-exclamation\"></i>\n <span class=\"external-content-message-title\">{{ 'ExternalContent-Blocked-Title' | translate }}</span>\n <span>{{ 'ExternalContent-Blocked-Message' | translate }}</span>\n </div>\n} @else {\n @if (isSlow) {\n <div class=\"external-content-hint\">{{ 'ExternalContent-SlowHint' | translate }}</div>\n }\n\n @if (frameUrl || isFrameLoading) {\n <!--\n The container stays mounted across a reload, which briefly clears frameUrl, so the panel\n does not flicker out and back. What goes away is the frame itself, on purpose.\n\n sandbox and referrerpolicy are STATIC attributes on purpose. Angular rejects `sandbox`\n as a binding on an <iframe> outright (NG0910) \u2014 it may only be a static attribute \u2014 and\n the whole element then fails to render.\n\n The token list is fixed for every destination, and deliberately not configurable per\n menu item:\n - allow-same-origin keeps the frame in the destination's own origin so its cookies and\n storage work; without it an SSO'd report will not render. It grants no access to ours,\n provided the destination is cross-origin -- never point an embedded item at this\n application's own origin, use an internal route for that.\n - allow-top-navigation is absent on purpose: a framed site must not be able to navigate\n the whole application away.\n - allow-popups-to-escape-sandbox keeps print and download popups usable.\n -->\n <div class=\"external-content-frame-container\">\n @if (isFrameLoading) {\n <div class=\"external-content-loading\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n </svg>\n\n <span>{{ 'Loading' | translate }}</span>\n </div>\n }\n\n @if (frameUrl) {\n <iframe class=\"external-content-frame\"\n sandbox=\"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads\"\n referrerpolicy=\"strict-origin-when-cross-origin\"\n [src]=\"frameUrl\"\n [attr.title]=\"label | translate\"\n (load)=\"onFrameLoad()\">\n </iframe>\n }\n </div>\n }\n}\n", styles: [":host{display:flex;flex-grow:1;flex-direction:column;gap:.5rem;overflow:hidden;min-height:0}.external-content-hint{flex:none;border-radius:.5rem;border-width:1px;padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;--tw-border-opacity: 1;border-color:rgb(252 211 77 / var(--tw-border-opacity, 1));--tw-bg-opacity: 1;background-color:rgb(254 243 199 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(120 53 15 / var(--tw-text-opacity, 1))}.external-content-frame-container{border-radius:.5rem;border-width:1px;border-color:#d1d5dbe6;--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1));position:relative;display:flex;flex-grow:1;overflow:hidden;min-height:0}.external-content-loading{position:absolute;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;gap:.5rem;--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.external-content-loading svg{height:1.5rem;min-height:1.5rem;width:1.5rem;min-width:1.5rem}@keyframes spin{to{transform:rotate(360deg)}}.external-content-loading svg{animation:spin 1s linear infinite}.external-content-frame{width:100%;flex-grow:1;border-width:0px;min-height:0}.external-content-message{border-radius:.5rem;border-width:1px;border-color:#d1d5dbe6;--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1));display:flex;flex-grow:1;flex-direction:column;align-items:center;justify-content:center;gap:.5rem;padding:1.5rem;text-align:center}.external-content-message i{font-size:2.25rem;line-height:2.5rem;opacity:.4}.external-content-message .external-content-message-title{font-size:1.125rem;line-height:1.75rem;font-weight:600}\n"] }]
|
|
1216
|
+
}], ctorParameters: () => [] });
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Route table for embedded external content. Spread it into `MainLayoutComponent`'s children:
|
|
1220
|
+
*
|
|
1221
|
+
* ```ts
|
|
1222
|
+
* { path: '', component: MainLayoutComponent, canActivate: [AuthGuard], children: [
|
|
1223
|
+
* ...externalContentRoutes,
|
|
1224
|
+
* // the application's own features
|
|
1225
|
+
* ] }
|
|
1226
|
+
* ```
|
|
1227
|
+
*
|
|
1228
|
+
* Three things about the shape are load-bearing and must not be "simplified" away:
|
|
1229
|
+
*
|
|
1230
|
+
* - The path is nested one segment per route. `RouteHelper.getRouteURL` and
|
|
1231
|
+
* `CustomReuseStrategy.getUrlFromRoute` collect segments walking up the parent chain and then
|
|
1232
|
+
* reverse the flat list, so a route declared `'external-content/:menuID'` rebuilds as
|
|
1233
|
+
* `/:menuID/external-content` and every tab URL is wrong.
|
|
1234
|
+
* - `DefaultTabViewComponent` hosts the view. It is what renders the `#ribbon` template the
|
|
1235
|
+
* component publishes, so dropping it leaves the Reload and Open-in-a-new-browser-tab actions
|
|
1236
|
+
* with nowhere to appear.
|
|
1237
|
+
* - `FRAMEWORK_VIEW_TYPE` must be present. `TabsComponent` re-creates the tab after a page refresh
|
|
1238
|
+
* by looking for a `Details` or `List` view in the activated route tree, and navigates to `/`
|
|
1239
|
+
* when it finds neither — the embedded tab would vanish on F5.
|
|
1240
|
+
*/
|
|
1241
|
+
const externalContentRoutes = [
|
|
1242
|
+
{
|
|
1243
|
+
path: EXTERNAL_CONTENT_ROUTE_PATH,
|
|
1244
|
+
children: [
|
|
1245
|
+
{
|
|
1246
|
+
path: ':menuID',
|
|
1247
|
+
component: DefaultTabViewComponent,
|
|
1248
|
+
data: { [FRAMEWORK_VIEW_TYPE]: FrameworkViewType.List },
|
|
1249
|
+
children: [
|
|
1250
|
+
{ path: '', component: ExternalContentComponent },
|
|
1251
|
+
],
|
|
1252
|
+
},
|
|
1253
|
+
],
|
|
1254
|
+
},
|
|
1255
|
+
];
|
|
1256
|
+
|
|
774
1257
|
class OperationsHistoryDataset extends DataGridDataset {
|
|
775
1258
|
//#region ViewChilds, Inputs, Outputs
|
|
776
1259
|
//#endregion
|
|
@@ -1131,6 +1614,7 @@ const ZAMBON_SHARED_I18N_ASSET = {
|
|
|
1131
1614
|
output: ZAMBON_SHARED_I18N_ASSET_PATH,
|
|
1132
1615
|
};
|
|
1133
1616
|
const ZAMBON_SHARED_I18N_RESOURCES = [
|
|
1617
|
+
{ prefix: `/${ZAMBON_SHARED_I18N_ASSET_PATH}/external-content/`, suffix: '.json' },
|
|
1134
1618
|
{ prefix: `/${ZAMBON_SHARED_I18N_ASSET_PATH}/language-selector/`, suffix: '.json' },
|
|
1135
1619
|
{ prefix: `/${ZAMBON_SHARED_I18N_ASSET_PATH}/login/`, suffix: '.json' },
|
|
1136
1620
|
{ prefix: `/${ZAMBON_SHARED_I18N_ASSET_PATH}/operations-history/`, suffix: '.json' },
|
|
@@ -1160,5 +1644,5 @@ function provideZambonSharedTranslateService(config = {}) {
|
|
|
1160
1644
|
* Generated bundle index. Do not edit.
|
|
1161
1645
|
*/
|
|
1162
1646
|
|
|
1163
|
-
export { AuthGuard, AuthInterceptor, AuthenticationService, BrandComponent, BypassHtmlSanitizerPipe, EnumLabelPipe, EnvironmentBadgeComponent, FiltersBase, LoginLayoutComponent, MainLayoutComponent, NotificationsComponent, NotificationsService, OperationsHistoryChildListComponent, OperationsHistoryModalComponent, OperationsHistoryService, ServicesHistoryChildListComponent, ServicesHistoryService, ServicesHistoryViewComponent, SharedAuthModule, TopBarComponent, UserProfileComponent, UtcDatePipe, ZAMBON_SHARED_I18N_ASSET, ZAMBON_SHARED_I18N_ASSET_PATH, ZAMBON_SHARED_I18N_RESOURCES, createZambonSharedTranslateLoader, provideZambonSharedTranslateLoader, provideZambonSharedTranslateService };
|
|
1647
|
+
export { AuthGuard, AuthInterceptor, AuthenticationService, BrandComponent, BypassHtmlSanitizerPipe, EXTERNAL_CONTENT_CONFIGS, EXTERNAL_CONTENT_ROUTE_PATH, EnumLabelPipe, EnvironmentBadgeComponent, ExternalContentComponent, ExternalContentConfigs, ExternalContentService, ExternalUrlResolverService, FiltersBase, LoginLayoutComponent, MainLayoutComponent, NotificationsComponent, NotificationsService, OperationsHistoryChildListComponent, OperationsHistoryModalComponent, OperationsHistoryService, ServicesHistoryChildListComponent, ServicesHistoryService, ServicesHistoryViewComponent, SharedAuthModule, TopBarComponent, UserProfileComponent, UtcDatePipe, ZAMBON_SHARED_I18N_ASSET, ZAMBON_SHARED_I18N_ASSET_PATH, ZAMBON_SHARED_I18N_RESOURCES, createZambonSharedTranslateLoader, externalContentRoutes, provideZambonSharedTranslateLoader, provideZambonSharedTranslateService };
|
|
1164
1648
|
//# sourceMappingURL=zambon-dev-shared.mjs.map
|