tin-spa 20.8.1 → 20.8.2
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/fesm2022/tin-spa.mjs +1035 -998
- package/fesm2022/tin-spa.mjs.map +1 -1
- package/index.d.ts +5 -3
- package/package.json +1 -1
package/fesm2022/tin-spa.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { Injectable, Inject, Component, InjectionToken, makeEnvironmentProviders, Optional, EventEmitter, Output, Input, inject, ViewChild, Pipe, ViewEncapsulation, Directive, NgModule, forwardRef, HostListener, ContentChild } from '@angular/core';
|
|
2
|
+
import { Injectable, Inject, Component, InjectionToken, makeEnvironmentProviders, Optional, EventEmitter, Output, Input, inject, ViewChild, Pipe, ViewEncapsulation, Directive, NgModule, forwardRef, HostListener, SecurityContext, ContentChild } from '@angular/core';
|
|
3
3
|
import * as i1 from '@angular/material/dialog';
|
|
4
4
|
import { MAT_DIALOG_DATA, MatDialogModule, MAT_DIALOG_DEFAULT_OPTIONS, MatDialog } from '@angular/material/dialog';
|
|
5
5
|
import * as i2 from '@angular/common';
|
|
@@ -14,17 +14,17 @@ import { of, BehaviorSubject, Subject, from, throwError, timeout, firstValueFrom
|
|
|
14
14
|
import { mergeMap, map, tap, catchError, filter, startWith as startWith$1, finalize as finalize$1, take, switchMap as switchMap$1 } from 'rxjs/operators';
|
|
15
15
|
import * as i2$1 from '@angular/material/snack-bar';
|
|
16
16
|
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
|
17
|
+
import Dexie, { liveQuery } from 'dexie';
|
|
18
|
+
import * as i10 from '@azure/msal-angular';
|
|
19
|
+
import { MSAL_INSTANCE, MsalService } from '@azure/msal-angular';
|
|
20
|
+
import { PublicClientApplication, BrowserCacheLocation } from '@azure/msal-browser';
|
|
21
|
+
import * as i1$1 from '@angular/common/http';
|
|
22
|
+
import { HttpHeaders, HttpClientModule, HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
|
17
23
|
import * as i1$2 from '@angular/router';
|
|
18
24
|
import { NavigationEnd, Router, RouterModule } from '@angular/router';
|
|
25
|
+
import * as signalR from '@microsoft/signalr';
|
|
19
26
|
import * as i2$2 from '@abacritt/angularx-social-login';
|
|
20
27
|
import { SocialLoginModule } from '@abacritt/angularx-social-login';
|
|
21
|
-
import * as i1$1 from '@angular/common/http';
|
|
22
|
-
import { HttpHeaders, HttpClientModule, HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
|
23
|
-
import * as signalR from '@microsoft/signalr';
|
|
24
|
-
import * as i10 from '@azure/msal-angular';
|
|
25
|
-
import { MSAL_INSTANCE, MsalService } from '@azure/msal-angular';
|
|
26
|
-
import { PublicClientApplication, BrowserCacheLocation } from '@azure/msal-browser';
|
|
27
|
-
import Dexie, { liveQuery } from 'dexie';
|
|
28
28
|
import * as i6$1 from 'ngx-doc-viewer';
|
|
29
29
|
import { NgxDocViewerModule } from 'ngx-doc-viewer';
|
|
30
30
|
import * as i6 from '@angular/material/chips';
|
|
@@ -1346,153 +1346,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
1346
1346
|
}]
|
|
1347
1347
|
}], ctorParameters: () => [] });
|
|
1348
1348
|
|
|
1349
|
-
class HttpService {
|
|
1350
|
-
constructor(httpClient, router) {
|
|
1351
|
-
this.httpClient = httpClient;
|
|
1352
|
-
this.router = router;
|
|
1353
|
-
//----Set apiUrl from the data service of the project---//
|
|
1354
|
-
this.apiUrl = "https://api.solinc.co.zw/"; //default
|
|
1355
|
-
this.httpOptions_Legacy = {
|
|
1356
|
-
headers: new HttpHeaders({
|
|
1357
|
-
Accept: "application/json",
|
|
1358
|
-
"Content-Type": "application/json",
|
|
1359
|
-
Authorization: "Basic " + btoa("emptyUser:emptyToken"),
|
|
1360
|
-
}),
|
|
1361
|
-
resposeType: "json",
|
|
1362
|
-
};
|
|
1363
|
-
this.httpOptions_Files = {
|
|
1364
|
-
headers: new HttpHeaders({
|
|
1365
|
-
Authorization: "Basic " + btoa("tnyagato:j5Toszfr@ys"),
|
|
1366
|
-
}),
|
|
1367
|
-
resposeType: "json",
|
|
1368
|
-
};
|
|
1369
|
-
}
|
|
1370
|
-
//POST
|
|
1371
|
-
Post(service, data) {
|
|
1372
|
-
return this.httpClient.post(this.apiUrl + service, data, this.httpOptions_Legacy);
|
|
1373
|
-
}
|
|
1374
|
-
//PUT
|
|
1375
|
-
Put(service, data) {
|
|
1376
|
-
return this.httpClient.put(this.apiUrl + service, data, this.httpOptions_Legacy);
|
|
1377
|
-
}
|
|
1378
|
-
//POST RAW
|
|
1379
|
-
Post_Raw(url, data) {
|
|
1380
|
-
return this.httpClient.post(url, data, this.httpOptions_Legacy);
|
|
1381
|
-
}
|
|
1382
|
-
//POST with file
|
|
1383
|
-
Post_WithFile(service, data, file) {
|
|
1384
|
-
let formData = new FormData();
|
|
1385
|
-
if (file) {
|
|
1386
|
-
formData.append("uploadFile", file, file.name);
|
|
1387
|
-
}
|
|
1388
|
-
formData.append("data", JSON.stringify(data));
|
|
1389
|
-
return this.httpClient.post(this.apiUrl + service, formData, this.httpOptions_Files);
|
|
1390
|
-
}
|
|
1391
|
-
// //POST with files
|
|
1392
|
-
Post_FormData(service, formData) {
|
|
1393
|
-
return this.httpClient.post(this.apiUrl + service, formData, this.httpOptions_Files);
|
|
1394
|
-
}
|
|
1395
|
-
//GET
|
|
1396
|
-
Get(service) {
|
|
1397
|
-
return this.httpClient.get(this.apiUrl + service, this.httpOptions_Legacy);
|
|
1398
|
-
}
|
|
1399
|
-
//Get RAW
|
|
1400
|
-
Get_Raw(url) {
|
|
1401
|
-
return this.httpClient.get(url, this.httpOptions_Legacy);
|
|
1402
|
-
}
|
|
1403
|
-
//ERROR
|
|
1404
|
-
Error(error) {
|
|
1405
|
-
if (error.statusText == "Unauthorized") {
|
|
1406
|
-
this.router.navigate(["login"]);
|
|
1407
|
-
return "Unauthorized";
|
|
1408
|
-
}
|
|
1409
|
-
else {
|
|
1410
|
-
return "Connection failed";
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: HttpService, deps: [{ token: i1$1.HttpClient }, { token: i1$2.Router }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
1414
|
-
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: HttpService, providedIn: "root" }); }
|
|
1415
|
-
}
|
|
1416
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: HttpService, decorators: [{
|
|
1417
|
-
type: Injectable,
|
|
1418
|
-
args: [{
|
|
1419
|
-
providedIn: "root",
|
|
1420
|
-
}]
|
|
1421
|
-
}], ctorParameters: () => [{ type: i1$1.HttpClient }, { type: i1$2.Router }] });
|
|
1422
|
-
|
|
1423
|
-
// import { Core } from 'tin-core';
|
|
1424
|
-
class LogService {
|
|
1425
|
-
constructor() {
|
|
1426
|
-
this.level = LogLevel.All;
|
|
1427
|
-
this.logToConsole = true;
|
|
1428
|
-
this.logToApi = false;
|
|
1429
|
-
}
|
|
1430
|
-
debug(msg, ...optionalParams) {
|
|
1431
|
-
this.writeToLog(msg, LogLevel.Debug, optionalParams);
|
|
1432
|
-
}
|
|
1433
|
-
info(msg, ...optionalParams) {
|
|
1434
|
-
this.writeToLog(msg, LogLevel.Info, optionalParams);
|
|
1435
|
-
}
|
|
1436
|
-
warn(msg, ...optionalParams) {
|
|
1437
|
-
this.writeToLog(msg, LogLevel.Warn, optionalParams);
|
|
1438
|
-
}
|
|
1439
|
-
error(msg, ...optionalParams) {
|
|
1440
|
-
this.writeToLog(msg, LogLevel.Error, optionalParams);
|
|
1441
|
-
}
|
|
1442
|
-
fatal(msg, ...optionalParams) {
|
|
1443
|
-
this.writeToLog(msg, LogLevel.Fatal, optionalParams);
|
|
1444
|
-
}
|
|
1445
|
-
log(msg, ...optionalParams) {
|
|
1446
|
-
this.writeToLog(msg, LogLevel.All, optionalParams);
|
|
1447
|
-
}
|
|
1448
|
-
writeToLog(msg, level, params) {
|
|
1449
|
-
if (this.shouldLog(level)) {
|
|
1450
|
-
// Build log string
|
|
1451
|
-
let value = Core.getFormatedDate(Date(), false).replace("T", " ");
|
|
1452
|
-
// value += " - Type: " + LogLevel[this.level];
|
|
1453
|
-
value += " - Message: " + msg;
|
|
1454
|
-
if (params.length) {
|
|
1455
|
-
value += " - Extra Info: " + this.formatParams(params);
|
|
1456
|
-
}
|
|
1457
|
-
// Log the value
|
|
1458
|
-
if (this.logToConsole) {
|
|
1459
|
-
console.log(value);
|
|
1460
|
-
}
|
|
1461
|
-
if (this.logToApi) {
|
|
1462
|
-
//Call API post
|
|
1463
|
-
}
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
1466
|
-
formatParams(params) {
|
|
1467
|
-
let ret = params.join(",");
|
|
1468
|
-
// Is there at least one object in the array?
|
|
1469
|
-
if (params.some((p) => typeof p == "object")) {
|
|
1470
|
-
ret = "";
|
|
1471
|
-
// Build comma-delimited string
|
|
1472
|
-
for (let item of params) {
|
|
1473
|
-
ret += JSON.stringify(item) + ",";
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
return ret;
|
|
1477
|
-
}
|
|
1478
|
-
shouldLog(level) {
|
|
1479
|
-
let ret = false;
|
|
1480
|
-
if ((level >= this.level && level !== LogLevel.Off) ||
|
|
1481
|
-
this.level === LogLevel.All) {
|
|
1482
|
-
ret = true;
|
|
1483
|
-
}
|
|
1484
|
-
return ret;
|
|
1485
|
-
}
|
|
1486
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: LogService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
1487
|
-
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: LogService, providedIn: "root" }); }
|
|
1488
|
-
}
|
|
1489
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: LogService, decorators: [{
|
|
1490
|
-
type: Injectable,
|
|
1491
|
-
args: [{
|
|
1492
|
-
providedIn: "root",
|
|
1493
|
-
}]
|
|
1494
|
-
}], ctorParameters: () => [] });
|
|
1495
|
-
|
|
1496
1349
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1497
1350
|
// tin-spa runtime configuration
|
|
1498
1351
|
//
|
|
@@ -1578,6 +1431,80 @@ function provideTinSpaRuntime(config) {
|
|
|
1578
1431
|
]);
|
|
1579
1432
|
}
|
|
1580
1433
|
|
|
1434
|
+
class HttpService {
|
|
1435
|
+
constructor(httpClient, router) {
|
|
1436
|
+
this.httpClient = httpClient;
|
|
1437
|
+
this.router = router;
|
|
1438
|
+
//----Set apiUrl from the data service of the project---//
|
|
1439
|
+
this.apiUrl = "https://api.solinc.co.zw/"; //default
|
|
1440
|
+
// Changed: removed hardcoded Basic-auth credentials. Authentication is handled centrally by
|
|
1441
|
+
// LoaderInterceptor, which attaches the Bearer token to every request. Embedding a credential here
|
|
1442
|
+
// shipped it in the JS bundle where anyone could recover it.
|
|
1443
|
+
this.httpOptions_Legacy = {
|
|
1444
|
+
headers: new HttpHeaders({
|
|
1445
|
+
Accept: "application/json",
|
|
1446
|
+
"Content-Type": "application/json",
|
|
1447
|
+
}),
|
|
1448
|
+
responseType: "json",
|
|
1449
|
+
};
|
|
1450
|
+
// No Content-Type here — the browser sets the multipart boundary for FormData uploads.
|
|
1451
|
+
this.httpOptions_Files = {
|
|
1452
|
+
responseType: "json",
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
//POST
|
|
1456
|
+
Post(service, data) {
|
|
1457
|
+
return this.httpClient.post(this.apiUrl + service, data, this.httpOptions_Legacy);
|
|
1458
|
+
}
|
|
1459
|
+
//PUT
|
|
1460
|
+
Put(service, data) {
|
|
1461
|
+
return this.httpClient.put(this.apiUrl + service, data, this.httpOptions_Legacy);
|
|
1462
|
+
}
|
|
1463
|
+
//POST RAW
|
|
1464
|
+
Post_Raw(url, data) {
|
|
1465
|
+
return this.httpClient.post(url, data, this.httpOptions_Legacy);
|
|
1466
|
+
}
|
|
1467
|
+
//POST with file
|
|
1468
|
+
Post_WithFile(service, data, file) {
|
|
1469
|
+
let formData = new FormData();
|
|
1470
|
+
if (file) {
|
|
1471
|
+
formData.append("uploadFile", file, file.name);
|
|
1472
|
+
}
|
|
1473
|
+
formData.append("data", JSON.stringify(data));
|
|
1474
|
+
return this.httpClient.post(this.apiUrl + service, formData, this.httpOptions_Files);
|
|
1475
|
+
}
|
|
1476
|
+
// //POST with files
|
|
1477
|
+
Post_FormData(service, formData) {
|
|
1478
|
+
return this.httpClient.post(this.apiUrl + service, formData, this.httpOptions_Files);
|
|
1479
|
+
}
|
|
1480
|
+
//GET
|
|
1481
|
+
Get(service) {
|
|
1482
|
+
return this.httpClient.get(this.apiUrl + service, this.httpOptions_Legacy);
|
|
1483
|
+
}
|
|
1484
|
+
//Get RAW
|
|
1485
|
+
Get_Raw(url) {
|
|
1486
|
+
return this.httpClient.get(url, this.httpOptions_Legacy);
|
|
1487
|
+
}
|
|
1488
|
+
//ERROR
|
|
1489
|
+
Error(error) {
|
|
1490
|
+
if (error.statusText == "Unauthorized") {
|
|
1491
|
+
this.router.navigate(["login"]);
|
|
1492
|
+
return "Unauthorized";
|
|
1493
|
+
}
|
|
1494
|
+
else {
|
|
1495
|
+
return "Connection failed";
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: HttpService, deps: [{ token: i1$1.HttpClient }, { token: i1$2.Router }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
1499
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: HttpService, providedIn: "root" }); }
|
|
1500
|
+
}
|
|
1501
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: HttpService, decorators: [{
|
|
1502
|
+
type: Injectable,
|
|
1503
|
+
args: [{
|
|
1504
|
+
providedIn: "root",
|
|
1505
|
+
}]
|
|
1506
|
+
}], ctorParameters: () => [{ type: i1$1.HttpClient }, { type: i1$2.Router }] });
|
|
1507
|
+
|
|
1581
1508
|
// Changed: Consolidated SignalR service — single hub connection for both notifications and entity broadcasts
|
|
1582
1509
|
class SignalRService {
|
|
1583
1510
|
// Changed: real-time is now driven centrally by tin-spa runtime config (provideTinSpaRuntime).
|
|
@@ -1744,946 +1671,1048 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
|
|
|
1744
1671
|
args: [TIN_SPA_RUNTIME_CONFIG]
|
|
1745
1672
|
}] }] });
|
|
1746
1673
|
|
|
1747
|
-
class
|
|
1748
|
-
constructor(
|
|
1749
|
-
this._route = _route;
|
|
1750
|
-
this.socialService = socialService;
|
|
1751
|
-
this.storage = storage;
|
|
1752
|
-
this.router = router;
|
|
1674
|
+
class OfflineService {
|
|
1675
|
+
constructor(httpService, authService, storage, messageService, signalR, zone, runtimeConfig) {
|
|
1753
1676
|
this.httpService = httpService;
|
|
1677
|
+
this.authService = authService;
|
|
1678
|
+
this.storage = storage;
|
|
1754
1679
|
this.messageService = messageService;
|
|
1755
|
-
this.
|
|
1756
|
-
this.
|
|
1757
|
-
this.
|
|
1758
|
-
|
|
1759
|
-
this.
|
|
1760
|
-
|
|
1761
|
-
this.
|
|
1762
|
-
|
|
1763
|
-
this.
|
|
1764
|
-
|
|
1765
|
-
this.
|
|
1766
|
-
|
|
1767
|
-
this.
|
|
1768
|
-
this.
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
this.
|
|
1772
|
-
this.
|
|
1773
|
-
this.
|
|
1774
|
-
|
|
1775
|
-
this.
|
|
1776
|
-
this.tokenExpireSource = new BehaviorSubject(this.tokenExpire);
|
|
1777
|
-
this.tokenExpireObserv = this.tokenExpireSource.asObservable();
|
|
1778
|
-
//Current User
|
|
1779
|
-
this.currentUser = "tnyagato";
|
|
1780
|
-
this.currentUserSource = new BehaviorSubject(this.currentUser);
|
|
1781
|
-
this.currentUserObserv = this.currentUserSource.asObservable();
|
|
1782
|
-
//Line Manager
|
|
1783
|
-
this.islineManager = false;
|
|
1784
|
-
this.isLineManagerSource = new BehaviorSubject(this.islineManager);
|
|
1785
|
-
this.isLineManagerObserv = this.isLineManagerSource.asObservable();
|
|
1786
|
-
//Roles
|
|
1787
|
-
this.currentRole = new Role;
|
|
1788
|
-
this.currentRoleSource = new BehaviorSubject(this.currentRole);
|
|
1789
|
-
this.myRoleObserv = this.currentRoleSource.asObservable();
|
|
1790
|
-
//Logged in FirstName
|
|
1791
|
-
this.loggedUserSource = new BehaviorSubject("tnyagato");
|
|
1792
|
-
this.loggedUserFullName = this.loggedUserSource.asObservable();
|
|
1793
|
-
//TenantName
|
|
1794
|
-
this.tenantNameSource = new BehaviorSubject("Tenant Name");
|
|
1795
|
-
this.tenantNameObserv = this.tenantNameSource.asObservable();
|
|
1796
|
-
this.initializeGoogleAuth();
|
|
1797
|
-
this.initVisibilityRefresh(); // Changed: Refresh token when tab becomes visible after being hidden
|
|
1680
|
+
this.signalR = signalR;
|
|
1681
|
+
this.zone = zone;
|
|
1682
|
+
this.runtimeConfig = runtimeConfig;
|
|
1683
|
+
//================ Connectivity (public) ================
|
|
1684
|
+
this.online$ = new BehaviorSubject(typeof navigator === 'undefined' ? true : navigator.onLine);
|
|
1685
|
+
// Entities whose ops synced in the last drain — tables listen to refetch (push-then-pull)
|
|
1686
|
+
this.drained$ = new Subject();
|
|
1687
|
+
//---------------- Dexie ----------------
|
|
1688
|
+
this.db = null;
|
|
1689
|
+
//---------------- Registry ----------------
|
|
1690
|
+
this.entries = new Map();
|
|
1691
|
+
this.readUrls = new Map();
|
|
1692
|
+
this.readPrefixes = [];
|
|
1693
|
+
this.registered$ = new BehaviorSubject(false);
|
|
1694
|
+
//---------------- Runtime flags ----------------
|
|
1695
|
+
this.connectivityStarted = false;
|
|
1696
|
+
this.syncStarted = false;
|
|
1697
|
+
this.draining = false;
|
|
1698
|
+
this.backoffTimer = null;
|
|
1699
|
+
this.periodicTimer = null;
|
|
1700
|
+
this.recoveryTimer = null;
|
|
1798
1701
|
}
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
error: (error) => {
|
|
1809
|
-
console.debug('Google Sign-In initialization error (expected if not using Google auth):', error?.message);
|
|
1810
|
-
}
|
|
1811
|
-
});
|
|
1702
|
+
/** App-level gate: offline capability only runs when the app opts in via provideTinSpaRuntime({ offline: true }).
|
|
1703
|
+
* When off, register()/ensureConnectivity() no-op, so a consumer that never uses offline runs none of this. */
|
|
1704
|
+
get enabled() { return !!this.runtimeConfig?.offline; }
|
|
1705
|
+
get hasAny() { return this.entries.size > 0; }
|
|
1706
|
+
get online() { return this.online$.value; }
|
|
1707
|
+
//================ Sync page view ================
|
|
1708
|
+
// Live, display-ready outbox rows (summary + status label projected on) for the sync page's spa-table.
|
|
1709
|
+
outboxView$() {
|
|
1710
|
+
return this.activeOps$().pipe(map(ops => ops.map(op => ({ ...op, summary: this.summarize(op), statusLabel: this.statusLabel(op) }))));
|
|
1812
1711
|
}
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
if (
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
} // Changed: Cancel proactive refresh timer
|
|
1828
|
-
this.signalRService.stopConnection(); // Changed: Stop consolidated SignalR connection before clearing session
|
|
1829
|
-
this.Updateloggedin(false);
|
|
1830
|
-
this.UpdateAutoLogin(false);
|
|
1831
|
-
this.UpdateRole(new Role());
|
|
1832
|
-
this.UpdateToken(''); // Changed: Clear in-memory token
|
|
1833
|
-
this.UpdateTokenExpire(''); // Changed: Clear in-memory token expiry
|
|
1834
|
-
if (revokeOnServer) {
|
|
1835
|
-
this.storage.clearAll(); // Changed: Full logout clears everything including refresh token
|
|
1712
|
+
summarize(op) {
|
|
1713
|
+
const p = op.payload ?? {};
|
|
1714
|
+
if (typeof p.name === 'string' && p.name)
|
|
1715
|
+
return p.name;
|
|
1716
|
+
const firstString = Object.keys(p).map(k => p[k]).find(v => typeof v === 'string' && v.length > 0 && v.length < 60);
|
|
1717
|
+
return firstString ?? (op.targetId != null ? `#${op.targetId}` : 'new record');
|
|
1718
|
+
}
|
|
1719
|
+
statusLabel(op) {
|
|
1720
|
+
switch (op.status) {
|
|
1721
|
+
case 'failed': return 'Failed';
|
|
1722
|
+
case 'conflict': return 'Conflict';
|
|
1723
|
+
case 'stale': return 'Held';
|
|
1724
|
+
case 'syncing': return 'Sending';
|
|
1725
|
+
default: return 'Pending';
|
|
1836
1726
|
}
|
|
1837
|
-
|
|
1838
|
-
|
|
1727
|
+
}
|
|
1728
|
+
//================ Registry ================
|
|
1729
|
+
// Called from TableComponent when a config with `offline` initializes; idempotent per entity
|
|
1730
|
+
register(config) {
|
|
1731
|
+
if (!this.enabled)
|
|
1732
|
+
return null; // app-level gate: ignore per-table offline flags when the app didn't opt in
|
|
1733
|
+
const resolved = this.resolveConfig(config.offline);
|
|
1734
|
+
if (!resolved || !config.loadAction?.url)
|
|
1735
|
+
return null;
|
|
1736
|
+
const entity = config.loadAction.url.split('/')[0].split('?')[0];
|
|
1737
|
+
const existing = this.entries.get(entity);
|
|
1738
|
+
if (existing) {
|
|
1739
|
+
this.startSync();
|
|
1740
|
+
return existing;
|
|
1839
1741
|
}
|
|
1840
|
-
|
|
1742
|
+
const entry = {
|
|
1743
|
+
entity,
|
|
1744
|
+
entityName: config.entityName ?? entity,
|
|
1745
|
+
idField: (config.formConfig?.heroField ?? config.heroField ?? entity.replace(/s$/, '') + 'ID'),
|
|
1746
|
+
config: resolved,
|
|
1747
|
+
};
|
|
1748
|
+
this.entries.set(entity, entry);
|
|
1749
|
+
if (!this.registered$.value)
|
|
1750
|
+
this.registered$.next(true);
|
|
1751
|
+
this.readUrls.set(config.loadAction.url, entry);
|
|
1752
|
+
if (config.countAction?.url)
|
|
1753
|
+
this.readUrls.set(config.countAction.url, entry);
|
|
1754
|
+
if (config.formConfig?.loadAction?.url)
|
|
1755
|
+
this.readPrefixes.push({ prefix: config.formConfig.loadAction.url + '/', entry });
|
|
1756
|
+
this.registerLookups(config.formConfig, entry);
|
|
1757
|
+
this.startSync();
|
|
1758
|
+
return entry;
|
|
1841
1759
|
}
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1760
|
+
registerLookups(formConfig, entry, depth = 0) {
|
|
1761
|
+
if (!formConfig?.fields || depth > 1)
|
|
1762
|
+
return;
|
|
1763
|
+
const allow = entry.config.cacheLookups;
|
|
1764
|
+
if (allow === false)
|
|
1765
|
+
return;
|
|
1766
|
+
formConfig.fields.forEach((field) => {
|
|
1767
|
+
const url = field.loadAction?.url;
|
|
1768
|
+
if (url && (allow === true || (Array.isArray(allow) && allow.includes(url)))) {
|
|
1769
|
+
if (!this.readUrls.has(url))
|
|
1770
|
+
this.readUrls.set(url, entry);
|
|
1771
|
+
}
|
|
1772
|
+
if (field.detailsConfig?.formConfig)
|
|
1773
|
+
this.registerLookups(field.detailsConfig.formConfig, entry, depth + 1);
|
|
1851
1774
|
});
|
|
1852
1775
|
}
|
|
1853
|
-
|
|
1854
|
-
this.
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
this.
|
|
1860
|
-
|
|
1861
|
-
UpdateAutoLogin(autoLogin) {
|
|
1862
|
-
this.autoLoginSource.next(autoLogin);
|
|
1863
|
-
this.autoLogin = autoLogin;
|
|
1864
|
-
}
|
|
1865
|
-
UpdateToken(newToken) {
|
|
1866
|
-
this.tokenSource.next(newToken);
|
|
1867
|
-
}
|
|
1868
|
-
UpdateTokenExpire(newTokenExpire) {
|
|
1869
|
-
this.tokenExpireSource.next(newTokenExpire);
|
|
1870
|
-
}
|
|
1871
|
-
UpdateCurrentUser(newUser) {
|
|
1872
|
-
this.currentUserSource.next(newUser.toLocaleLowerCase());
|
|
1873
|
-
this.currentUser = newUser.toLocaleLowerCase();
|
|
1874
|
-
}
|
|
1875
|
-
UpdateIsLineManager(manager) {
|
|
1876
|
-
this.isLineManagerSource.next(manager);
|
|
1776
|
+
matchRead(url) {
|
|
1777
|
+
if (!this.hasAny)
|
|
1778
|
+
return null;
|
|
1779
|
+
const exact = this.readUrls.get(url);
|
|
1780
|
+
if (exact)
|
|
1781
|
+
return exact;
|
|
1782
|
+
const prefixed = this.readPrefixes.find(p => url.startsWith(p.prefix));
|
|
1783
|
+
return prefixed ? prefixed.entry : null;
|
|
1877
1784
|
}
|
|
1878
|
-
|
|
1879
|
-
this.
|
|
1785
|
+
matchMutation(url) {
|
|
1786
|
+
if (!this.hasAny)
|
|
1787
|
+
return null;
|
|
1788
|
+
const match = url.match(/^([^?/]+)\?action=(create|edit|delete)$/);
|
|
1789
|
+
if (!match)
|
|
1790
|
+
return null;
|
|
1791
|
+
const entry = this.entries.get(match[1]);
|
|
1792
|
+
if (!entry)
|
|
1793
|
+
return null;
|
|
1794
|
+
return entry.config.queue[match[2]] ? entry : null;
|
|
1880
1795
|
}
|
|
1881
|
-
|
|
1882
|
-
|
|
1796
|
+
// Interceptor seam: suppress the 'Connection Error' toast for URLs the offline layer handles (plus the heartbeat)
|
|
1797
|
+
suppressesUrl(absoluteUrl) {
|
|
1798
|
+
const relative = absoluteUrl.startsWith(this.httpService.apiUrl) ? absoluteUrl.substring(this.httpService.apiUrl.length) : absoluteUrl;
|
|
1799
|
+
if (relative === 'ping')
|
|
1800
|
+
return true;
|
|
1801
|
+
if (!this.hasAny)
|
|
1802
|
+
return false;
|
|
1803
|
+
return !!(this.matchRead(relative) || this.matchMutation(relative));
|
|
1883
1804
|
}
|
|
1884
|
-
|
|
1885
|
-
|
|
1805
|
+
resolveConfig(offline) {
|
|
1806
|
+
if (!offline)
|
|
1807
|
+
return null;
|
|
1808
|
+
const cfg = offline === true ? { enabled: true } : offline;
|
|
1809
|
+
if (!cfg.enabled)
|
|
1810
|
+
return null;
|
|
1811
|
+
return {
|
|
1812
|
+
enabled: true,
|
|
1813
|
+
cacheRows: cfg.cacheRows ?? 500,
|
|
1814
|
+
cacheLookups: cfg.cacheLookups ?? true,
|
|
1815
|
+
lookupMaxAgeHours: cfg.lookupMaxAgeHours ?? 72,
|
|
1816
|
+
queue: cfg.readOnly
|
|
1817
|
+
? { create: false, edit: false, delete: false }
|
|
1818
|
+
: { create: cfg.queue?.create ?? true, edit: cfg.queue?.edit ?? true, delete: cfg.queue?.delete ?? false },
|
|
1819
|
+
maxQueueAgeHours: cfg.maxQueueAgeHours ?? 72,
|
|
1820
|
+
readOnly: cfg.readOnly ?? false,
|
|
1821
|
+
};
|
|
1886
1822
|
}
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
this.
|
|
1893
|
-
|
|
1823
|
+
//================ Store (Dexie) ================
|
|
1824
|
+
// Added: wipe all locally cached records + pending outbox for the current user. Called on explicit logout
|
|
1825
|
+
// so a shared device does not retain the previous user's business data in IndexedDB after they sign out.
|
|
1826
|
+
async deleteLocalData() {
|
|
1827
|
+
try {
|
|
1828
|
+
if (this.db) {
|
|
1829
|
+
const db = this.db;
|
|
1830
|
+
this.db = null;
|
|
1831
|
+
db.close();
|
|
1832
|
+
await db.delete();
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
// Not currently open — best-effort delete by recomputing this user's db name.
|
|
1836
|
+
const tenant = this.storage.getPersistent(Constants.AUTH_TENANT_NAME) || 'default';
|
|
1837
|
+
const user = this.authService.currentUser || 'anon';
|
|
1838
|
+
const name = `tinspa_offline_${this.hash(this.httpService.apiUrl)}_${this.sanitize(tenant)}_${this.sanitize(user)}`;
|
|
1839
|
+
await Dexie.delete(name);
|
|
1894
1840
|
}
|
|
1895
|
-
|
|
1896
|
-
|
|
1841
|
+
catch (e) {
|
|
1842
|
+
console.warn('Offline data wipe failed', e);
|
|
1897
1843
|
}
|
|
1898
1844
|
}
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
this.
|
|
1909
|
-
|
|
1910
|
-
this.
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
this.UpdateToken(data.token);
|
|
1914
|
-
this.UpdateTokenExpire(data.expiration);
|
|
1915
|
-
this.signalRService.updateToken(data.token); // Changed: Keep SignalR reconnection token in sync
|
|
1916
|
-
this.UpdateRole(data.role);
|
|
1917
|
-
this.updateLoggedUserFullName(data.firstName);
|
|
1918
|
-
this.updateTenantName(data.tenantName);
|
|
1919
|
-
if (data.userName)
|
|
1920
|
-
this.UpdateCurrentUser(data.userName); // Changed: Restore username after silent refresh
|
|
1921
|
-
// Store session data in active storage
|
|
1922
|
-
this.storage.store(Constants.AUTH_TOKEN, data.token);
|
|
1923
|
-
this.storage.store(Constants.AUTH_TOKEN_EXPIRE, data.expiration);
|
|
1924
|
-
this.storage.store(Constants.AUTH_ROLES, JSON.stringify(data.role));
|
|
1925
|
-
this.storage.store(Constants.AUTH_NAME, data.firstName);
|
|
1926
|
-
this.storage.store(Constants.AUTH_TENANT_NAME, data.tenantName);
|
|
1927
|
-
if (data.userName)
|
|
1928
|
-
this.storage.store(Constants.AUTH_USER, data.userName); // Changed: Persist username so checkAuthentication finds AUTH_USER after app restart
|
|
1929
|
-
// Store refresh token persistently if provided (always in localStorage)
|
|
1930
|
-
if (data.refreshToken) {
|
|
1931
|
-
this.storage.storePersistent(Constants.AUTH_REFRESH_TOKEN, data.refreshToken);
|
|
1932
|
-
this.storage.storePersistent(Constants.AUTH_REFRESH_TOKEN_EXPIRE, data.refreshTokenExpiry);
|
|
1845
|
+
ensureOpen() {
|
|
1846
|
+
if (this.db)
|
|
1847
|
+
return this.db;
|
|
1848
|
+
const tenant = this.storage.getPersistent(Constants.AUTH_TENANT_NAME) || 'default';
|
|
1849
|
+
const user = this.authService.currentUser || 'anon';
|
|
1850
|
+
const name = `tinspa_offline_${this.hash(this.httpService.apiUrl)}_${this.sanitize(tenant)}_${this.sanitize(user)}`;
|
|
1851
|
+
const db = new Dexie(name);
|
|
1852
|
+
db.version(1).stores({ reads: '&url, entity', outbox: '++seq, opId, entity, status, clientRef', meta: '&key' });
|
|
1853
|
+
this.db = db;
|
|
1854
|
+
this.reads = db.table('reads');
|
|
1855
|
+
this.outbox = db.table('outbox');
|
|
1856
|
+
this.meta = db.table('meta');
|
|
1857
|
+
try {
|
|
1858
|
+
navigator.storage?.persist?.();
|
|
1933
1859
|
}
|
|
1934
|
-
|
|
1860
|
+
catch { }
|
|
1861
|
+
this.meta.put({ key: 'sentinel', value: Date.now() }).catch(() => { });
|
|
1862
|
+
return db;
|
|
1935
1863
|
}
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
const refreshIn = expiresAt - now - (5 * 60 * 1000); // 5 minutes before expiry
|
|
1943
|
-
if (refreshIn <= 0)
|
|
1944
|
-
return; // Token already expired or about to — reactive refresh will handle it
|
|
1945
|
-
this.refreshTimer = setTimeout(() => {
|
|
1946
|
-
this.silentRefresh().then(success => {
|
|
1947
|
-
if (!success)
|
|
1948
|
-
this.sessionExpired();
|
|
1949
|
-
});
|
|
1950
|
-
}, refreshIn);
|
|
1864
|
+
putRead(url, entity, data, maxRows) {
|
|
1865
|
+
this.ensureOpen();
|
|
1866
|
+
let stored = data;
|
|
1867
|
+
if (Array.isArray(data))
|
|
1868
|
+
stored = data.slice(0, url.includes('/list/') ? 1000 : maxRows);
|
|
1869
|
+
return this.reads.put({ url, entity, data: stored, savedAt: Date.now() }).catch(err => console.warn('Offline cache write failed', err));
|
|
1951
1870
|
}
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1871
|
+
getRead(url) {
|
|
1872
|
+
this.ensureOpen();
|
|
1873
|
+
try {
|
|
1874
|
+
return this.reads.get(url).catch(() => undefined);
|
|
1875
|
+
}
|
|
1876
|
+
catch {
|
|
1877
|
+
return Promise.resolve(undefined);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
//================ CallApi seam helpers (called by DataServiceLib) ================
|
|
1881
|
+
// Reads: network-first with write-through; on verified-offline or network failure serve the cached copy.
|
|
1882
|
+
handleOfflineRead(action, entry, networkCall) {
|
|
1883
|
+
const serveCache = () => from(this.serveCachedRead(action.url)).pipe(map(result => result
|
|
1884
|
+
? { success: true, message: 'success', data: result }
|
|
1885
|
+
: { success: false, message: 'No offline data available yet for this view', data: null }));
|
|
1886
|
+
if (!this.online)
|
|
1887
|
+
return serveCache();
|
|
1888
|
+
return networkCall().pipe(tap((response) => {
|
|
1889
|
+
if (response.success && response?.data)
|
|
1890
|
+
this.putRead(action.url, entry.entity, response.data, entry.config.cacheRows);
|
|
1891
|
+
}), catchError((err) => {
|
|
1892
|
+
if (err?.status === 0) {
|
|
1893
|
+
this.reportNetworkFailure();
|
|
1894
|
+
return serveCache();
|
|
1971
1895
|
}
|
|
1972
|
-
|
|
1896
|
+
return throwError(() => err);
|
|
1897
|
+
}));
|
|
1973
1898
|
}
|
|
1974
|
-
//
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1899
|
+
// For '/list/' lookups, append pending offline creates as selectable 'cref:' options so offline forms can reference not-yet-synced parents
|
|
1900
|
+
async serveCachedRead(url) {
|
|
1901
|
+
const saved = await this.getRead(url);
|
|
1902
|
+
let data = saved?.data ?? null;
|
|
1903
|
+
const listMatch = url.match(/^([^?/]+)\/list\//);
|
|
1904
|
+
if (listMatch) {
|
|
1905
|
+
const creates = await this.pendingCreates(listMatch[1]);
|
|
1906
|
+
if (creates.length) {
|
|
1907
|
+
const opts = creates.map(op => ({ name: `${op.payload?.name ?? 'New'} (pending)`, value: 'cref:' + op.clientRef }));
|
|
1908
|
+
data = Array.isArray(data) ? [...opts, ...data] : opts;
|
|
1909
|
+
}
|
|
1981
1910
|
}
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1911
|
+
return data;
|
|
1912
|
+
}
|
|
1913
|
+
// Persists a mutation and synthesizes a success ApiResponse so the existing dialog/refresh machinery works unchanged
|
|
1914
|
+
enqueueOffline(action, data, entry) {
|
|
1915
|
+
return from(this.enqueueMutation(action, data, entry)).pipe(map(op => ({
|
|
1916
|
+
success: true,
|
|
1917
|
+
message: 'Saved offline — will be sent when connection is restored',
|
|
1918
|
+
data: op ? { ...op.payload, _offline: { status: op.status, opId: op.opId, clientRef: op.clientRef ?? op.targetClientRef } } : data,
|
|
1919
|
+
})), catchError((err) => of({ success: false, message: 'Could not save offline: ' + (err?.message ?? 'storage error'), data: null })));
|
|
1920
|
+
}
|
|
1921
|
+
//================ Outbox writes ================
|
|
1922
|
+
async enqueueMutation(action, data, entry) {
|
|
1923
|
+
this.ensureOpen();
|
|
1924
|
+
const opAction = this.parseAction(action.url);
|
|
1925
|
+
if (!opAction)
|
|
1926
|
+
throw new Error('Unsupported offline mutation: ' + action.url);
|
|
1927
|
+
const payload = { ...data };
|
|
1928
|
+
const marker = payload._offline;
|
|
1929
|
+
delete payload._offline;
|
|
1930
|
+
const refMap = {};
|
|
1931
|
+
Object.keys(payload).forEach(k => {
|
|
1932
|
+
if (typeof payload[k] === 'string' && payload[k].startsWith('cref:')) {
|
|
1933
|
+
refMap[k] = payload[k].substring(5);
|
|
1934
|
+
payload[k] = 0;
|
|
1935
|
+
}
|
|
1936
|
+
});
|
|
1937
|
+
const userId = this.authService.currentUser || 'anon';
|
|
1938
|
+
if (opAction === 'edit' && marker?.clientRef) {
|
|
1939
|
+
const createOp = await this.outbox.where('clientRef').equals(marker.clientRef).first();
|
|
1940
|
+
if (createOp && createOp.status !== 'synced') {
|
|
1941
|
+
createOp.payload = { ...createOp.payload, ...payload, clientRef: createOp.clientRef };
|
|
1942
|
+
createOp.refMap = { ...createOp.refMap, ...refMap };
|
|
1943
|
+
await this.outbox.put(createOp);
|
|
1944
|
+
return createOp;
|
|
1989
1945
|
}
|
|
1990
|
-
catch { /* ignore parse errors */ }
|
|
1991
1946
|
}
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
if (expiry > new Date())
|
|
1998
|
-
return true;
|
|
1947
|
+
if (opAction === 'delete' && marker?.clientRef) {
|
|
1948
|
+
const createOp = await this.outbox.where('clientRef').equals(marker.clientRef).first();
|
|
1949
|
+
if (createOp && createOp.status !== 'synced') {
|
|
1950
|
+
await this.outbox.delete(createOp.seq);
|
|
1951
|
+
return null;
|
|
1999
1952
|
}
|
|
2000
|
-
catch { /* ignore parse errors */ }
|
|
2001
1953
|
}
|
|
2002
|
-
|
|
2003
|
-
|
|
1954
|
+
const targetId = opAction === 'create' ? undefined : payload[entry.idField];
|
|
1955
|
+
if (opAction === 'edit' && targetId !== undefined) {
|
|
1956
|
+
const existing = await this.outbox.where('entity').equals(entry.entity)
|
|
1957
|
+
.and(op => op.opAction === 'edit' && op.targetId === targetId && (op.status === 'pending' || op.status === 'failed')).first();
|
|
1958
|
+
if (existing) {
|
|
1959
|
+
existing.payload = payload;
|
|
1960
|
+
existing.refMap = { ...existing.refMap, ...refMap };
|
|
1961
|
+
existing.status = 'pending';
|
|
1962
|
+
await this.outbox.put(existing);
|
|
1963
|
+
return existing;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
const op = {
|
|
1967
|
+
opId: crypto.randomUUID(), entity: entry.entity, entityName: entry.entityName, url: action.url, opAction, payload, targetId,
|
|
1968
|
+
targetClientRef: marker?.clientRef, refMap: Object.keys(refMap).length ? refMap : undefined,
|
|
1969
|
+
status: 'pending', attempts: 0, maxAgeHours: entry.config.maxQueueAgeHours, userId, enqueuedAt: Date.now(),
|
|
1970
|
+
};
|
|
1971
|
+
if (opAction === 'create') {
|
|
1972
|
+
op.clientRef = crypto.randomUUID();
|
|
1973
|
+
op.payload = { ...payload, clientRef: op.clientRef };
|
|
1974
|
+
}
|
|
1975
|
+
await this.outbox.add(op);
|
|
1976
|
+
return op;
|
|
2004
1977
|
}
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
return
|
|
1978
|
+
//================ Live streams (cross-tab reactive) ================
|
|
1979
|
+
pendingOpsFor$(entity) {
|
|
1980
|
+
this.ensureOpen();
|
|
1981
|
+
return from(liveQuery(() => this.outbox.where('entity').equals(entity).and(op => op.status !== 'synced').sortBy('seq')));
|
|
2009
1982
|
}
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
if (this.hasValidSession())
|
|
2014
|
-
return true;
|
|
2015
|
-
if (this.hasRefreshToken())
|
|
2016
|
-
return await this.silentRefresh();
|
|
2017
|
-
return false;
|
|
1983
|
+
activeOps$() {
|
|
1984
|
+
this.ensureOpen();
|
|
1985
|
+
return from(liveQuery(() => this.outbox.filter(op => op.status !== 'synced').sortBy('seq')));
|
|
2018
1986
|
}
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
1987
|
+
pendingCreates(entity) {
|
|
1988
|
+
this.ensureOpen();
|
|
1989
|
+
return this.outbox.where('entity').equals(entity).and(op => op.opAction === 'create' && op.status !== 'synced').sortBy('seq');
|
|
1990
|
+
}
|
|
1991
|
+
hasPending() {
|
|
1992
|
+
this.ensureOpen();
|
|
1993
|
+
return this.outbox.where('status').equals('pending').count().then(c => c > 0);
|
|
1994
|
+
}
|
|
1995
|
+
//================ Connectivity ================
|
|
1996
|
+
// Started by the sync engine (offline table registered) OR by the toolbar indicator (to show a status light).
|
|
1997
|
+
// Uses SignalR's hub connection as the instant online signal when real-time is enabled; ping is the fallback/source of truth.
|
|
1998
|
+
ensureConnectivity() {
|
|
1999
|
+
if (!this.enabled)
|
|
2000
|
+
return; // app-level gate: no pinging/SignalR-watching when offline capability is off
|
|
2001
|
+
if (this.connectivityStarted)
|
|
2002
|
+
return;
|
|
2003
|
+
this.connectivityStarted = true;
|
|
2004
|
+
if (typeof window !== 'undefined') {
|
|
2005
|
+
window.addEventListener('offline', () => this.zone.run(() => this.setOffline()));
|
|
2006
|
+
window.addEventListener('online', () => this.zone.run(() => this.verify()));
|
|
2028
2007
|
}
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
this.storage.removePersistent(Constants.AUTH_REFRESH_TOKEN_EXPIRE);
|
|
2044
|
-
this.storage.removePersistent(Constants.AUTH_REMEMBER_ME);
|
|
2045
|
-
}
|
|
2046
|
-
resolve(false);
|
|
2047
|
-
}
|
|
2048
|
-
},
|
|
2049
|
-
error: () => {
|
|
2050
|
-
this.isRefreshing = false;
|
|
2051
|
-
// Changed: Network error (device waking up, no connectivity) — do NOT clear the refresh token.
|
|
2052
|
-
// The token may still be valid; next visibility change or 401 interceptor will retry.
|
|
2053
|
-
resolve(false);
|
|
2054
|
-
}
|
|
2055
|
-
});
|
|
2008
|
+
// SignalR hub state: connected => instantly online (+drain); disconnected => confirm via ping before declaring offline.
|
|
2009
|
+
// When real-time is disabled the hub never connects, so this stays false and ping fully drives connectivity.
|
|
2010
|
+
this.signalR.dataHubConnected$.subscribe(connected => this.zone.run(() => connected ? this.setOnline() : this.verify()));
|
|
2011
|
+
this.verify();
|
|
2012
|
+
}
|
|
2013
|
+
reportNetworkFailure() { if (this.connectivityStarted)
|
|
2014
|
+
this.setOffline(); }
|
|
2015
|
+
verify() {
|
|
2016
|
+
this.httpService.Get('ping').pipe(timeout(5000)).subscribe({
|
|
2017
|
+
next: () => this.setOnline(),
|
|
2018
|
+
error: (err) => { if (err?.status > 0)
|
|
2019
|
+
this.setOnline();
|
|
2020
|
+
else
|
|
2021
|
+
this.setOffline(); },
|
|
2056
2022
|
});
|
|
2057
2023
|
}
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
if (this.
|
|
2061
|
-
|
|
2024
|
+
setOnline() {
|
|
2025
|
+
this.stopRecoveryPolling();
|
|
2026
|
+
if (!this.online$.value) {
|
|
2027
|
+
this.online$.next(true);
|
|
2028
|
+
if (this.syncStarted)
|
|
2029
|
+
setTimeout(() => this.drain(), Math.floor(Math.random() * 5000)); // jittered so a reconnecting fleet doesn't stampede
|
|
2062
2030
|
}
|
|
2063
|
-
//
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2031
|
+
// Marry the two: whenever ping confirms we're reachable, revive SignalR if its own auto-reconnect gave up.
|
|
2032
|
+
// No-op when the hub is already connected, so this is safe to call on every online confirmation.
|
|
2033
|
+
this.signalR.reconnectIfNeeded();
|
|
2034
|
+
}
|
|
2035
|
+
setOffline() {
|
|
2036
|
+
if (this.online$.value)
|
|
2037
|
+
this.online$.next(false);
|
|
2038
|
+
this.startRecoveryPolling();
|
|
2039
|
+
}
|
|
2040
|
+
startRecoveryPolling() {
|
|
2041
|
+
if (this.recoveryTimer || !this.connectivityStarted)
|
|
2042
|
+
return;
|
|
2043
|
+
this.recoveryTimer = setInterval(() => this.verify(), 30000); // safety net; SignalR reconnect usually beats this when real-time is on
|
|
2044
|
+
}
|
|
2045
|
+
stopRecoveryPolling() {
|
|
2046
|
+
if (this.recoveryTimer) {
|
|
2047
|
+
clearInterval(this.recoveryTimer);
|
|
2048
|
+
this.recoveryTimer = null;
|
|
2067
2049
|
}
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
this.
|
|
2079
|
-
|
|
2080
|
-
this.
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
return
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
this.sessionExpired();
|
|
2099
|
-
}
|
|
2100
|
-
return success;
|
|
2101
|
-
});
|
|
2102
|
-
}
|
|
2103
|
-
this.sessionExpired();
|
|
2104
|
-
return false;
|
|
2105
|
-
});
|
|
2050
|
+
}
|
|
2051
|
+
//================ Sync engine ================
|
|
2052
|
+
startSync() {
|
|
2053
|
+
if (this.syncStarted)
|
|
2054
|
+
return;
|
|
2055
|
+
this.syncStarted = true;
|
|
2056
|
+
this.ensureConnectivity();
|
|
2057
|
+
this.resetOrphans().catch(() => { });
|
|
2058
|
+
this.periodicTimer = setInterval(() => { if (this.online)
|
|
2059
|
+
this.hasPending().then(has => { if (has)
|
|
2060
|
+
this.drain(); }); }, 60000);
|
|
2061
|
+
if (this.online)
|
|
2062
|
+
this.drain();
|
|
2063
|
+
}
|
|
2064
|
+
async drain() {
|
|
2065
|
+
if (this.draining || !this.online)
|
|
2066
|
+
return;
|
|
2067
|
+
this.draining = true;
|
|
2068
|
+
const runner = () => this.drainLoop().finally(() => { this.draining = false; });
|
|
2069
|
+
const locks = navigator.locks;
|
|
2070
|
+
if (locks?.request) {
|
|
2071
|
+
try {
|
|
2072
|
+
await locks.request('tinspa-outbox-drain', { ifAvailable: true }, async (lock) => { if (lock)
|
|
2073
|
+
await runner();
|
|
2074
|
+
else
|
|
2075
|
+
this.draining = false; });
|
|
2076
|
+
}
|
|
2077
|
+
catch {
|
|
2078
|
+
await runner();
|
|
2079
|
+
}
|
|
2106
2080
|
}
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
if (refreshToken) {
|
|
2110
|
-
// Changed: Wait for refresh to complete before deciding
|
|
2111
|
-
return this.silentRefresh().then((success) => {
|
|
2112
|
-
if (!success) {
|
|
2113
|
-
this.sessionExpired();
|
|
2114
|
-
}
|
|
2115
|
-
return success;
|
|
2116
|
-
});
|
|
2081
|
+
else {
|
|
2082
|
+
await runner();
|
|
2117
2083
|
}
|
|
2118
|
-
this.sessionExpired();
|
|
2119
|
-
return Promise.resolve(false);
|
|
2120
2084
|
}
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
//---------------- Runtime flags ----------------
|
|
2152
|
-
this.connectivityStarted = false;
|
|
2153
|
-
this.syncStarted = false;
|
|
2154
|
-
this.draining = false;
|
|
2155
|
-
this.backoffTimer = null;
|
|
2156
|
-
this.periodicTimer = null;
|
|
2157
|
-
this.recoveryTimer = null;
|
|
2085
|
+
async drainLoop() {
|
|
2086
|
+
const syncedEntities = new Set();
|
|
2087
|
+
while (true) {
|
|
2088
|
+
const op = await this.nextAutoOp();
|
|
2089
|
+
if (!op)
|
|
2090
|
+
break;
|
|
2091
|
+
op.status = 'syncing';
|
|
2092
|
+
await this.outbox.put(op); // persisted BEFORE network I/O — crash recovery reverts to pending
|
|
2093
|
+
const outcome = await this.sendOp(op);
|
|
2094
|
+
if (outcome === 'transient') {
|
|
2095
|
+
op.status = 'pending';
|
|
2096
|
+
op.attempts++;
|
|
2097
|
+
await this.outbox.put(op);
|
|
2098
|
+
this.scheduleBackoff(op.attempts);
|
|
2099
|
+
break; // strict FIFO: a transient failure halts the drain until backoff/reconnect
|
|
2100
|
+
}
|
|
2101
|
+
if (outcome === 'synced')
|
|
2102
|
+
syncedEntities.add(op.entity);
|
|
2103
|
+
if (outcome === 'failed') {
|
|
2104
|
+
this.messageService.toast(`Sync failed: ${op.entityName} ${op.opAction} — open the sync tray to review`);
|
|
2105
|
+
if (op.clientRef)
|
|
2106
|
+
await this.cascadeFailure(op.clientRef, `${op.entityName} ${op.opAction} failed`);
|
|
2107
|
+
}
|
|
2108
|
+
if (outcome === 'conflict')
|
|
2109
|
+
this.messageService.toast(`Sync conflict: ${op.entityName} changed on the server — review in the sync tray`);
|
|
2110
|
+
}
|
|
2111
|
+
if (syncedEntities.size > 0) {
|
|
2112
|
+
this.drained$.next(Array.from(syncedEntities));
|
|
2113
|
+
this.messageService.toast('Offline changes synced');
|
|
2114
|
+
}
|
|
2158
2115
|
}
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2116
|
+
async sendOp(op) {
|
|
2117
|
+
const payload = { ...op.payload };
|
|
2118
|
+
if (op.refMap && Object.keys(op.refMap).length)
|
|
2119
|
+
payload.clientRefs = JSON.stringify(op.refMap); // server resolves parent ClientRef -> real int FK
|
|
2120
|
+
let response;
|
|
2121
|
+
try {
|
|
2122
|
+
response = await firstValueFrom(this.httpService.Post(op.url, payload));
|
|
2123
|
+
}
|
|
2124
|
+
catch (err) {
|
|
2125
|
+
const status = err?.status ?? 0;
|
|
2126
|
+
if (status === 0 || status === 408 || status === 429 || status >= 500) {
|
|
2127
|
+
if (status === 0)
|
|
2128
|
+
this.reportNetworkFailure();
|
|
2129
|
+
return 'transient';
|
|
2130
|
+
}
|
|
2131
|
+
return this.finishOp(op, 'failed', err?.message ?? `HTTP ${status}`);
|
|
2132
|
+
}
|
|
2133
|
+
if (response.success)
|
|
2134
|
+
return this.finishOp(op, 'synced');
|
|
2135
|
+
const reason = response.reason ?? '';
|
|
2136
|
+
const notFound = reason === 'not_found' || response.message?.includes('not found');
|
|
2137
|
+
if (reason === 'conflict') {
|
|
2138
|
+
op.serverRecord = response.data ?? null;
|
|
2139
|
+
return this.finishOp(op, 'conflict', response.message);
|
|
2140
|
+
}
|
|
2141
|
+
if (notFound && op.opAction === 'delete')
|
|
2142
|
+
return this.finishOp(op, 'synced'); // delete of already-deleted row — outcome achieved
|
|
2143
|
+
if (notFound && op.opAction === 'edit')
|
|
2144
|
+
return this.finishOp(op, 'conflict', 'This record was deleted on the server');
|
|
2145
|
+
return this.finishOp(op, 'failed', response.message); // validation/business failure — permanent
|
|
2168
2146
|
}
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2147
|
+
async finishOp(op, status, error) {
|
|
2148
|
+
op.status = status;
|
|
2149
|
+
op.lastError = error;
|
|
2150
|
+
if (status === 'synced')
|
|
2151
|
+
op.syncedAt = Date.now();
|
|
2152
|
+
await this.outbox.put(op);
|
|
2153
|
+
return status;
|
|
2175
2154
|
}
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2155
|
+
async nextAutoOp() {
|
|
2156
|
+
this.ensureOpen();
|
|
2157
|
+
const ops = await this.outbox.orderBy('seq').filter(op => op.status === 'pending').toArray();
|
|
2158
|
+
for (const op of ops) {
|
|
2159
|
+
if (Date.now() - op.enqueuedAt > op.maxAgeHours * 3600 * 1000) {
|
|
2160
|
+
op.status = 'stale';
|
|
2161
|
+
op.lastError = 'Held — too old to send automatically';
|
|
2162
|
+
await this.outbox.put(op);
|
|
2163
|
+
continue;
|
|
2164
|
+
}
|
|
2165
|
+
return op;
|
|
2183
2166
|
}
|
|
2167
|
+
return undefined;
|
|
2184
2168
|
}
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2169
|
+
async resetOrphans() {
|
|
2170
|
+
this.ensureOpen();
|
|
2171
|
+
const orphans = await this.outbox.where('status').equals('syncing').toArray();
|
|
2172
|
+
for (const op of orphans) {
|
|
2173
|
+
op.status = 'pending';
|
|
2174
|
+
await this.outbox.put(op);
|
|
2175
|
+
} // safe: opId/ClientRef idempotency makes resend collide, not duplicate
|
|
2176
|
+
const cutoff = Date.now() - 24 * 3600 * 1000;
|
|
2177
|
+
await this.outbox.where('status').equals('synced').and(op => (op.syncedAt ?? 0) < cutoff).delete();
|
|
2178
|
+
}
|
|
2179
|
+
async cascadeFailure(clientRef, reason) {
|
|
2180
|
+
this.ensureOpen();
|
|
2181
|
+
const dependents = await this.outbox.filter(op => op.status === 'pending' && !!op.refMap && Object.values(op.refMap).includes(clientRef)).toArray();
|
|
2182
|
+
for (const op of dependents) {
|
|
2183
|
+
op.status = 'failed';
|
|
2184
|
+
op.lastError = 'Blocked: ' + reason;
|
|
2185
|
+
await this.outbox.put(op);
|
|
2198
2186
|
}
|
|
2199
|
-
const entry = {
|
|
2200
|
-
entity,
|
|
2201
|
-
entityName: config.entityName ?? entity,
|
|
2202
|
-
idField: (config.formConfig?.heroField ?? config.heroField ?? entity.replace(/s$/, '') + 'ID'),
|
|
2203
|
-
config: resolved,
|
|
2204
|
-
};
|
|
2205
|
-
this.entries.set(entity, entry);
|
|
2206
|
-
if (!this.registered$.value)
|
|
2207
|
-
this.registered$.next(true);
|
|
2208
|
-
this.readUrls.set(config.loadAction.url, entry);
|
|
2209
|
-
if (config.countAction?.url)
|
|
2210
|
-
this.readUrls.set(config.countAction.url, entry);
|
|
2211
|
-
if (config.formConfig?.loadAction?.url)
|
|
2212
|
-
this.readPrefixes.push({ prefix: config.formConfig.loadAction.url + '/', entry });
|
|
2213
|
-
this.registerLookups(config.formConfig, entry);
|
|
2214
|
-
this.startSync();
|
|
2215
|
-
return entry;
|
|
2216
2187
|
}
|
|
2217
|
-
|
|
2218
|
-
if (
|
|
2188
|
+
scheduleBackoff(attempts) {
|
|
2189
|
+
if (this.backoffTimer)
|
|
2190
|
+
clearTimeout(this.backoffTimer);
|
|
2191
|
+
const cap = Math.min(5000 * Math.pow(2, attempts), 300000); // exponential 5s·2^n capped 5min, full jitter
|
|
2192
|
+
this.backoffTimer = setTimeout(() => this.drain(), Math.floor(Math.random() * cap));
|
|
2193
|
+
}
|
|
2194
|
+
//================ Sync-tray actions ================
|
|
2195
|
+
async retryOp(opId) {
|
|
2196
|
+
const op = await this.getOp(opId);
|
|
2197
|
+
if (!op)
|
|
2219
2198
|
return;
|
|
2220
|
-
|
|
2221
|
-
|
|
2199
|
+
op.status = 'pending';
|
|
2200
|
+
op.attempts = 0;
|
|
2201
|
+
op.enqueuedAt = Date.now();
|
|
2202
|
+
await this.outbox.put(op);
|
|
2203
|
+
this.drain();
|
|
2204
|
+
}
|
|
2205
|
+
discardOp(opId) { this.ensureOpen(); return this.outbox.where('opId').equals(opId).delete(); }
|
|
2206
|
+
// keepMine re-arms the op with the fresh server RowVersion (explicit, informed overwrite); keepServer discards
|
|
2207
|
+
async resolveConflict(opId, choice) {
|
|
2208
|
+
const op = await this.getOp(opId);
|
|
2209
|
+
if (!op)
|
|
2222
2210
|
return;
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
}
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2211
|
+
if (choice === 'keepServer') {
|
|
2212
|
+
await this.discardOp(opId);
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
if (op.serverRecord?.rowVersion)
|
|
2216
|
+
op.payload = { ...op.payload, rowVersion: op.serverRecord.rowVersion };
|
|
2217
|
+
else
|
|
2218
|
+
delete op.payload.rowVersion;
|
|
2219
|
+
op.status = 'pending';
|
|
2220
|
+
op.attempts = 0;
|
|
2221
|
+
op.enqueuedAt = Date.now();
|
|
2222
|
+
await this.outbox.put(op);
|
|
2223
|
+
this.drain();
|
|
2232
2224
|
}
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2225
|
+
getOp(opId) { this.ensureOpen(); return this.outbox.where('opId').equals(opId).first(); }
|
|
2226
|
+
//================ helpers ================
|
|
2227
|
+
parseAction(url) {
|
|
2228
|
+
if (url.includes('?action=create'))
|
|
2229
|
+
return 'create';
|
|
2230
|
+
if (url.includes('?action=edit'))
|
|
2231
|
+
return 'edit';
|
|
2232
|
+
if (url.includes('?action=delete'))
|
|
2233
|
+
return 'delete';
|
|
2234
|
+
return null;
|
|
2241
2235
|
}
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2236
|
+
hash(v) { let h = 0; for (let i = 0; i < v.length; i++)
|
|
2237
|
+
h = ((h << 5) - h + v.charCodeAt(i)) | 0; return Math.abs(h).toString(36); }
|
|
2238
|
+
sanitize(v) { return v.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); }
|
|
2239
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OfflineService, deps: [{ token: HttpService }, { token: AuthService }, { token: StorageService }, { token: MessageService }, { token: SignalRService }, { token: i0.NgZone }, { token: TIN_SPA_RUNTIME_CONFIG, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
2240
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OfflineService, providedIn: 'root' }); }
|
|
2241
|
+
}
|
|
2242
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OfflineService, decorators: [{
|
|
2243
|
+
type: Injectable,
|
|
2244
|
+
args: [{ providedIn: 'root' }]
|
|
2245
|
+
}], ctorParameters: () => [{ type: HttpService }, { type: AuthService }, { type: StorageService }, { type: MessageService }, { type: SignalRService }, { type: i0.NgZone }, { type: undefined, decorators: [{
|
|
2246
|
+
type: Optional
|
|
2247
|
+
}, {
|
|
2248
|
+
type: Inject,
|
|
2249
|
+
args: [TIN_SPA_RUNTIME_CONFIG]
|
|
2250
|
+
}] }] });
|
|
2251
|
+
|
|
2252
|
+
// import { Core } from 'tin-core';
|
|
2253
|
+
class LogService {
|
|
2254
|
+
constructor() {
|
|
2255
|
+
this.level = LogLevel.All;
|
|
2256
|
+
this.logToConsole = true;
|
|
2257
|
+
this.logToApi = false;
|
|
2252
2258
|
}
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2259
|
+
debug(msg, ...optionalParams) {
|
|
2260
|
+
this.writeToLog(msg, LogLevel.Debug, optionalParams);
|
|
2261
|
+
}
|
|
2262
|
+
info(msg, ...optionalParams) {
|
|
2263
|
+
this.writeToLog(msg, LogLevel.Info, optionalParams);
|
|
2264
|
+
}
|
|
2265
|
+
warn(msg, ...optionalParams) {
|
|
2266
|
+
this.writeToLog(msg, LogLevel.Warn, optionalParams);
|
|
2261
2267
|
}
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
return null;
|
|
2265
|
-
const cfg = offline === true ? { enabled: true } : offline;
|
|
2266
|
-
if (!cfg.enabled)
|
|
2267
|
-
return null;
|
|
2268
|
-
return {
|
|
2269
|
-
enabled: true,
|
|
2270
|
-
cacheRows: cfg.cacheRows ?? 500,
|
|
2271
|
-
cacheLookups: cfg.cacheLookups ?? true,
|
|
2272
|
-
lookupMaxAgeHours: cfg.lookupMaxAgeHours ?? 72,
|
|
2273
|
-
queue: cfg.readOnly
|
|
2274
|
-
? { create: false, edit: false, delete: false }
|
|
2275
|
-
: { create: cfg.queue?.create ?? true, edit: cfg.queue?.edit ?? true, delete: cfg.queue?.delete ?? false },
|
|
2276
|
-
maxQueueAgeHours: cfg.maxQueueAgeHours ?? 72,
|
|
2277
|
-
readOnly: cfg.readOnly ?? false,
|
|
2278
|
-
};
|
|
2268
|
+
error(msg, ...optionalParams) {
|
|
2269
|
+
this.writeToLog(msg, LogLevel.Error, optionalParams);
|
|
2279
2270
|
}
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
if (this.db)
|
|
2283
|
-
return this.db;
|
|
2284
|
-
const tenant = this.storage.getPersistent(Constants.AUTH_TENANT_NAME) || 'default';
|
|
2285
|
-
const user = this.authService.currentUser || 'anon';
|
|
2286
|
-
const name = `tinspa_offline_${this.hash(this.httpService.apiUrl)}_${this.sanitize(tenant)}_${this.sanitize(user)}`;
|
|
2287
|
-
const db = new Dexie(name);
|
|
2288
|
-
db.version(1).stores({ reads: '&url, entity', outbox: '++seq, opId, entity, status, clientRef', meta: '&key' });
|
|
2289
|
-
this.db = db;
|
|
2290
|
-
this.reads = db.table('reads');
|
|
2291
|
-
this.outbox = db.table('outbox');
|
|
2292
|
-
this.meta = db.table('meta');
|
|
2293
|
-
try {
|
|
2294
|
-
navigator.storage?.persist?.();
|
|
2295
|
-
}
|
|
2296
|
-
catch { }
|
|
2297
|
-
this.meta.put({ key: 'sentinel', value: Date.now() }).catch(() => { });
|
|
2298
|
-
return db;
|
|
2271
|
+
fatal(msg, ...optionalParams) {
|
|
2272
|
+
this.writeToLog(msg, LogLevel.Fatal, optionalParams);
|
|
2299
2273
|
}
|
|
2300
|
-
|
|
2301
|
-
this.
|
|
2302
|
-
let stored = data;
|
|
2303
|
-
if (Array.isArray(data))
|
|
2304
|
-
stored = data.slice(0, url.includes('/list/') ? 1000 : maxRows);
|
|
2305
|
-
return this.reads.put({ url, entity, data: stored, savedAt: Date.now() }).catch(err => console.warn('Offline cache write failed', err));
|
|
2274
|
+
log(msg, ...optionalParams) {
|
|
2275
|
+
this.writeToLog(msg, LogLevel.All, optionalParams);
|
|
2306
2276
|
}
|
|
2307
|
-
|
|
2308
|
-
this.
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2277
|
+
writeToLog(msg, level, params) {
|
|
2278
|
+
if (this.shouldLog(level)) {
|
|
2279
|
+
// Build log string
|
|
2280
|
+
let value = Core.getFormatedDate(Date(), false).replace("T", " ");
|
|
2281
|
+
// value += " - Type: " + LogLevel[this.level];
|
|
2282
|
+
value += " - Message: " + msg;
|
|
2283
|
+
if (params.length) {
|
|
2284
|
+
value += " - Extra Info: " + this.formatParams(params);
|
|
2285
|
+
}
|
|
2286
|
+
// Log the value
|
|
2287
|
+
if (this.logToConsole) {
|
|
2288
|
+
console.log(value);
|
|
2289
|
+
}
|
|
2290
|
+
if (this.logToApi) {
|
|
2291
|
+
//Call API post
|
|
2292
|
+
}
|
|
2314
2293
|
}
|
|
2315
2294
|
}
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
return networkCall().pipe(tap((response) => {
|
|
2325
|
-
if (response.success && response?.data)
|
|
2326
|
-
this.putRead(action.url, entry.entity, response.data, entry.config.cacheRows);
|
|
2327
|
-
}), catchError((err) => {
|
|
2328
|
-
if (err?.status === 0) {
|
|
2329
|
-
this.reportNetworkFailure();
|
|
2330
|
-
return serveCache();
|
|
2295
|
+
formatParams(params) {
|
|
2296
|
+
let ret = params.join(",");
|
|
2297
|
+
// Is there at least one object in the array?
|
|
2298
|
+
if (params.some((p) => typeof p == "object")) {
|
|
2299
|
+
ret = "";
|
|
2300
|
+
// Build comma-delimited string
|
|
2301
|
+
for (let item of params) {
|
|
2302
|
+
ret += JSON.stringify(item) + ",";
|
|
2331
2303
|
}
|
|
2332
|
-
|
|
2333
|
-
|
|
2304
|
+
}
|
|
2305
|
+
return ret;
|
|
2334
2306
|
}
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
if (listMatch) {
|
|
2341
|
-
const creates = await this.pendingCreates(listMatch[1]);
|
|
2342
|
-
if (creates.length) {
|
|
2343
|
-
const opts = creates.map(op => ({ name: `${op.payload?.name ?? 'New'} (pending)`, value: 'cref:' + op.clientRef }));
|
|
2344
|
-
data = Array.isArray(data) ? [...opts, ...data] : opts;
|
|
2345
|
-
}
|
|
2307
|
+
shouldLog(level) {
|
|
2308
|
+
let ret = false;
|
|
2309
|
+
if ((level >= this.level && level !== LogLevel.Off) ||
|
|
2310
|
+
this.level === LogLevel.All) {
|
|
2311
|
+
ret = true;
|
|
2346
2312
|
}
|
|
2347
|
-
return
|
|
2313
|
+
return ret;
|
|
2348
2314
|
}
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2315
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: LogService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
2316
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: LogService, providedIn: "root" }); }
|
|
2317
|
+
}
|
|
2318
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: LogService, decorators: [{
|
|
2319
|
+
type: Injectable,
|
|
2320
|
+
args: [{
|
|
2321
|
+
providedIn: "root",
|
|
2322
|
+
}]
|
|
2323
|
+
}], ctorParameters: () => [] });
|
|
2324
|
+
|
|
2325
|
+
class AuthService {
|
|
2326
|
+
constructor(_route, socialService, storage, router, httpService, messageService, logService, signalRService, injector) {
|
|
2327
|
+
this._route = _route;
|
|
2328
|
+
this.socialService = socialService;
|
|
2329
|
+
this.storage = storage;
|
|
2330
|
+
this.router = router;
|
|
2331
|
+
this.httpService = httpService;
|
|
2332
|
+
this.messageService = messageService;
|
|
2333
|
+
this.logService = logService;
|
|
2334
|
+
this.signalRService = signalRService;
|
|
2335
|
+
this.injector = injector;
|
|
2336
|
+
this.googleAuthInitialized = false;
|
|
2337
|
+
this.isRefreshing = false; // Changed: Guard to prevent concurrent refresh calls
|
|
2338
|
+
this.refreshTimer = null; // Changed: Proactive token refresh timer
|
|
2339
|
+
this.socialUserSource = new BehaviorSubject(null);
|
|
2340
|
+
this.socialUserObserv = this.socialUserSource.asObservable();
|
|
2341
|
+
//Logged in
|
|
2342
|
+
this.loggedin = false;
|
|
2343
|
+
this.loggedinSource = new BehaviorSubject(this.loggedin);
|
|
2344
|
+
this.loggedinObserv = this.loggedinSource.asObservable();
|
|
2345
|
+
//autoLogin
|
|
2346
|
+
this.autoLogin = false;
|
|
2347
|
+
this.autoLoginSource = new BehaviorSubject(this.autoLogin);
|
|
2348
|
+
this.autoLoginObserv = this.autoLoginSource.asObservable();
|
|
2349
|
+
//Token
|
|
2350
|
+
this.token = "";
|
|
2351
|
+
this.tokenSource = new BehaviorSubject(this.token);
|
|
2352
|
+
this.tokenObserv = this.tokenSource.asObservable();
|
|
2353
|
+
//TokenExpire
|
|
2354
|
+
this.tokenExpire = "";
|
|
2355
|
+
this.tokenExpireSource = new BehaviorSubject(this.tokenExpire);
|
|
2356
|
+
this.tokenExpireObserv = this.tokenExpireSource.asObservable();
|
|
2357
|
+
//Current User
|
|
2358
|
+
this.currentUser = "tnyagato";
|
|
2359
|
+
this.currentUserSource = new BehaviorSubject(this.currentUser);
|
|
2360
|
+
this.currentUserObserv = this.currentUserSource.asObservable();
|
|
2361
|
+
//Line Manager
|
|
2362
|
+
this.islineManager = false;
|
|
2363
|
+
this.isLineManagerSource = new BehaviorSubject(this.islineManager);
|
|
2364
|
+
this.isLineManagerObserv = this.isLineManagerSource.asObservable();
|
|
2365
|
+
//Roles
|
|
2366
|
+
this.currentRole = new Role;
|
|
2367
|
+
this.currentRoleSource = new BehaviorSubject(this.currentRole);
|
|
2368
|
+
this.myRoleObserv = this.currentRoleSource.asObservable();
|
|
2369
|
+
//Logged in FirstName
|
|
2370
|
+
this.loggedUserSource = new BehaviorSubject("tnyagato");
|
|
2371
|
+
this.loggedUserFullName = this.loggedUserSource.asObservable();
|
|
2372
|
+
//TenantName
|
|
2373
|
+
this.tenantNameSource = new BehaviorSubject("Tenant Name");
|
|
2374
|
+
this.tenantNameObserv = this.tenantNameSource.asObservable();
|
|
2375
|
+
this.initializeGoogleAuth();
|
|
2376
|
+
this.initVisibilityRefresh(); // Changed: Refresh token when tab becomes visible after being hidden
|
|
2356
2377
|
}
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
if (typeof payload[k] === 'string' && payload[k].startsWith('cref:')) {
|
|
2369
|
-
refMap[k] = payload[k].substring(5);
|
|
2370
|
-
payload[k] = 0;
|
|
2378
|
+
initializeGoogleAuth() {
|
|
2379
|
+
if (this.googleAuthInitialized) {
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
this.googleAuthInitialized = true;
|
|
2383
|
+
this.socialService.authState.subscribe({
|
|
2384
|
+
next: (socialUser) => {
|
|
2385
|
+
this.socialUserSource.next(socialUser);
|
|
2386
|
+
},
|
|
2387
|
+
error: (error) => {
|
|
2388
|
+
console.debug('Google Sign-In initialization error (expected if not using Google auth):', error?.message);
|
|
2371
2389
|
}
|
|
2372
2390
|
});
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2391
|
+
}
|
|
2392
|
+
// Changed: Enhanced clearSession with optional server-side revocation
|
|
2393
|
+
clearSession(revokeOnServer = false) {
|
|
2394
|
+
if (revokeOnServer) {
|
|
2395
|
+
// Changed: Revoke only this device's refresh token — other devices keep their sessions
|
|
2396
|
+
const refreshToken = this.storage.getPersistent(Constants.AUTH_REFRESH_TOKEN);
|
|
2397
|
+
if (refreshToken) {
|
|
2398
|
+
this.httpService.Post('User/revoke', { refreshToken }).subscribe({
|
|
2399
|
+
error: () => { } // Silently ignore errors during logout
|
|
2400
|
+
});
|
|
2381
2401
|
}
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
return null;
|
|
2402
|
+
// Added: on explicit logout, wipe the offline IndexedDB cache so a shared device retains no
|
|
2403
|
+
// prior-user records. Resolved lazily to avoid a circular dependency with OfflineService.
|
|
2404
|
+
// Done before storage is cleared so the cache DB name can still be resolved if needed.
|
|
2405
|
+
try {
|
|
2406
|
+
this.injector.get(OfflineService).deleteLocalData();
|
|
2388
2407
|
}
|
|
2408
|
+
catch { /* offline layer not in use */ }
|
|
2389
2409
|
}
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2410
|
+
if (this.refreshTimer) {
|
|
2411
|
+
clearTimeout(this.refreshTimer);
|
|
2412
|
+
this.refreshTimer = null;
|
|
2413
|
+
} // Changed: Cancel proactive refresh timer
|
|
2414
|
+
this.signalRService.stopConnection(); // Changed: Stop consolidated SignalR connection before clearing session
|
|
2415
|
+
this.Updateloggedin(false);
|
|
2416
|
+
this.UpdateAutoLogin(false);
|
|
2417
|
+
this.UpdateRole(new Role());
|
|
2418
|
+
this.UpdateToken(''); // Changed: Clear in-memory token
|
|
2419
|
+
this.UpdateTokenExpire(''); // Changed: Clear in-memory token expiry
|
|
2420
|
+
if (revokeOnServer) {
|
|
2421
|
+
this.storage.clearAll(); // Changed: Full logout clears everything including refresh token
|
|
2401
2422
|
}
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
targetClientRef: marker?.clientRef, refMap: Object.keys(refMap).length ? refMap : undefined,
|
|
2405
|
-
status: 'pending', attempts: 0, maxAgeHours: entry.config.maxQueueAgeHours, userId, enqueuedAt: Date.now(),
|
|
2406
|
-
};
|
|
2407
|
-
if (opAction === 'create') {
|
|
2408
|
-
op.clientRef = crypto.randomUUID();
|
|
2409
|
-
op.payload = { ...payload, clientRef: op.clientRef };
|
|
2423
|
+
else {
|
|
2424
|
+
this.storage.clearSessionData(); // Changed: Session expired — preserve refresh token for "remember me"
|
|
2410
2425
|
}
|
|
2411
|
-
|
|
2412
|
-
return op;
|
|
2426
|
+
this.socialService.signOut();
|
|
2413
2427
|
}
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2428
|
+
sessionExpired() {
|
|
2429
|
+
this.clearSession(false); // Changed: Don't revoke on server, token just expired
|
|
2430
|
+
let url = new URL(window.location.href);
|
|
2431
|
+
let path = url.hash.replace("#/", "");
|
|
2432
|
+
this.router.navigate(["login"], {
|
|
2433
|
+
relativeTo: this._route,
|
|
2434
|
+
queryParams: { redirectTo: path },
|
|
2435
|
+
queryParamsHandling: 'merge',
|
|
2436
|
+
skipLocationChange: false
|
|
2437
|
+
});
|
|
2418
2438
|
}
|
|
2419
|
-
|
|
2420
|
-
this.
|
|
2421
|
-
|
|
2439
|
+
logoff() {
|
|
2440
|
+
this.clearSession(true); // Changed: Revoke refresh tokens on explicit logout
|
|
2441
|
+
this.router.navigate(["login"]);
|
|
2422
2442
|
}
|
|
2423
|
-
|
|
2424
|
-
this.
|
|
2425
|
-
|
|
2443
|
+
Updateloggedin(loggedin) {
|
|
2444
|
+
this.loggedinSource.next(loggedin);
|
|
2445
|
+
this.loggedin = loggedin;
|
|
2426
2446
|
}
|
|
2427
|
-
|
|
2428
|
-
this.
|
|
2429
|
-
|
|
2447
|
+
UpdateAutoLogin(autoLogin) {
|
|
2448
|
+
this.autoLoginSource.next(autoLogin);
|
|
2449
|
+
this.autoLogin = autoLogin;
|
|
2430
2450
|
}
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
// Uses SignalR's hub connection as the instant online signal when real-time is enabled; ping is the fallback/source of truth.
|
|
2434
|
-
ensureConnectivity() {
|
|
2435
|
-
if (!this.enabled)
|
|
2436
|
-
return; // app-level gate: no pinging/SignalR-watching when offline capability is off
|
|
2437
|
-
if (this.connectivityStarted)
|
|
2438
|
-
return;
|
|
2439
|
-
this.connectivityStarted = true;
|
|
2440
|
-
if (typeof window !== 'undefined') {
|
|
2441
|
-
window.addEventListener('offline', () => this.zone.run(() => this.setOffline()));
|
|
2442
|
-
window.addEventListener('online', () => this.zone.run(() => this.verify()));
|
|
2443
|
-
}
|
|
2444
|
-
// SignalR hub state: connected => instantly online (+drain); disconnected => confirm via ping before declaring offline.
|
|
2445
|
-
// When real-time is disabled the hub never connects, so this stays false and ping fully drives connectivity.
|
|
2446
|
-
this.signalR.dataHubConnected$.subscribe(connected => this.zone.run(() => connected ? this.setOnline() : this.verify()));
|
|
2447
|
-
this.verify();
|
|
2451
|
+
UpdateToken(newToken) {
|
|
2452
|
+
this.tokenSource.next(newToken);
|
|
2448
2453
|
}
|
|
2449
|
-
|
|
2450
|
-
this.
|
|
2451
|
-
verify() {
|
|
2452
|
-
this.httpService.Get('ping').pipe(timeout(5000)).subscribe({
|
|
2453
|
-
next: () => this.setOnline(),
|
|
2454
|
-
error: (err) => { if (err?.status > 0)
|
|
2455
|
-
this.setOnline();
|
|
2456
|
-
else
|
|
2457
|
-
this.setOffline(); },
|
|
2458
|
-
});
|
|
2454
|
+
UpdateTokenExpire(newTokenExpire) {
|
|
2455
|
+
this.tokenExpireSource.next(newTokenExpire);
|
|
2459
2456
|
}
|
|
2460
|
-
|
|
2461
|
-
this.
|
|
2462
|
-
|
|
2463
|
-
this.online$.next(true);
|
|
2464
|
-
if (this.syncStarted)
|
|
2465
|
-
setTimeout(() => this.drain(), Math.floor(Math.random() * 5000)); // jittered so a reconnecting fleet doesn't stampede
|
|
2466
|
-
}
|
|
2467
|
-
// Marry the two: whenever ping confirms we're reachable, revive SignalR if its own auto-reconnect gave up.
|
|
2468
|
-
// No-op when the hub is already connected, so this is safe to call on every online confirmation.
|
|
2469
|
-
this.signalR.reconnectIfNeeded();
|
|
2457
|
+
UpdateCurrentUser(newUser) {
|
|
2458
|
+
this.currentUserSource.next(newUser.toLocaleLowerCase());
|
|
2459
|
+
this.currentUser = newUser.toLocaleLowerCase();
|
|
2470
2460
|
}
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
this.online$.next(false);
|
|
2474
|
-
this.startRecoveryPolling();
|
|
2461
|
+
UpdateIsLineManager(manager) {
|
|
2462
|
+
this.isLineManagerSource.next(manager);
|
|
2475
2463
|
}
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
return;
|
|
2479
|
-
this.recoveryTimer = setInterval(() => this.verify(), 30000); // safety net; SignalR reconnect usually beats this when real-time is on
|
|
2464
|
+
UpdateRole(role) {
|
|
2465
|
+
this.currentRoleSource.next(role);
|
|
2480
2466
|
}
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
clearInterval(this.recoveryTimer);
|
|
2484
|
-
this.recoveryTimer = null;
|
|
2485
|
-
}
|
|
2467
|
+
updateLoggedUserFullName(userName) {
|
|
2468
|
+
this.loggedUserSource.next(userName);
|
|
2486
2469
|
}
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
if (this.syncStarted)
|
|
2490
|
-
return;
|
|
2491
|
-
this.syncStarted = true;
|
|
2492
|
-
this.ensureConnectivity();
|
|
2493
|
-
this.resetOrphans().catch(() => { });
|
|
2494
|
-
this.periodicTimer = setInterval(() => { if (this.online)
|
|
2495
|
-
this.hasPending().then(has => { if (has)
|
|
2496
|
-
this.drain(); }); }, 60000);
|
|
2497
|
-
if (this.online)
|
|
2498
|
-
this.drain();
|
|
2470
|
+
updateTenantName(tenantName) {
|
|
2471
|
+
this.tenantNameSource.next(tenantName);
|
|
2499
2472
|
}
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
try {
|
|
2508
|
-
await locks.request('tinspa-outbox-drain', { ifAvailable: true }, async (lock) => { if (lock)
|
|
2509
|
-
await runner();
|
|
2510
|
-
else
|
|
2511
|
-
this.draining = false; });
|
|
2512
|
-
}
|
|
2513
|
-
catch {
|
|
2514
|
-
await runner();
|
|
2515
|
-
}
|
|
2473
|
+
//Authorisation
|
|
2474
|
+
isAuthorised(capName) {
|
|
2475
|
+
if (!this.currentRoleSource.value[capName]) {
|
|
2476
|
+
// this.logService.info(`authorise: ${this.currentRoleSource.value.roleName} not authorised for ${capName}`);
|
|
2477
|
+
this.router.navigate(["home"]);
|
|
2478
|
+
this.messageService.toast(`Unauthorised: ${capName}`);
|
|
2479
|
+
return false;
|
|
2516
2480
|
}
|
|
2517
2481
|
else {
|
|
2518
|
-
|
|
2482
|
+
return true;
|
|
2519
2483
|
}
|
|
2520
2484
|
}
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
break;
|
|
2527
|
-
op.status = 'syncing';
|
|
2528
|
-
await this.outbox.put(op); // persisted BEFORE network I/O — crash recovery reverts to pending
|
|
2529
|
-
const outcome = await this.sendOp(op);
|
|
2530
|
-
if (outcome === 'transient') {
|
|
2531
|
-
op.status = 'pending';
|
|
2532
|
-
op.attempts++;
|
|
2533
|
-
await this.outbox.put(op);
|
|
2534
|
-
this.scheduleBackoff(op.attempts);
|
|
2535
|
-
break; // strict FIFO: a transient failure halts the drain until backoff/reconnect
|
|
2536
|
-
}
|
|
2537
|
-
if (outcome === 'synced')
|
|
2538
|
-
syncedEntities.add(op.entity);
|
|
2539
|
-
if (outcome === 'failed') {
|
|
2540
|
-
this.messageService.toast(`Sync failed: ${op.entityName} ${op.opAction} — open the sync tray to review`);
|
|
2541
|
-
if (op.clientRef)
|
|
2542
|
-
await this.cascadeFailure(op.clientRef, `${op.entityName} ${op.opAction} failed`);
|
|
2543
|
-
}
|
|
2544
|
-
if (outcome === 'conflict')
|
|
2545
|
-
this.messageService.toast(`Sync conflict: ${op.entityName} changed on the server — review in the sync tray`);
|
|
2546
|
-
}
|
|
2547
|
-
if (syncedEntities.size > 0) {
|
|
2548
|
-
this.drained$.next(Array.from(syncedEntities));
|
|
2549
|
-
this.messageService.toast('Offline changes synced');
|
|
2550
|
-
}
|
|
2485
|
+
// Changed: Pure validation — no side effects. Callers handle refresh/redirect.
|
|
2486
|
+
isValidToken() {
|
|
2487
|
+
let tokenExpire = new Date(this.tokenExpireSource.value);
|
|
2488
|
+
let nowDate = new Date();
|
|
2489
|
+
return tokenExpire > nowDate;
|
|
2551
2490
|
}
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2491
|
+
// Changed: Centralized handler for storing all auth data from login/refresh response
|
|
2492
|
+
applyLoginResponse(data, rememberMe) {
|
|
2493
|
+
// Set storage mode: sessionStorage when not remembered, localStorage when remembered
|
|
2494
|
+
this.storage.setSessionMode(!rememberMe);
|
|
2495
|
+
// Store remember-me flag persistently (survives browser close)
|
|
2496
|
+
this.storage.storePersistent(Constants.AUTH_REMEMBER_ME, rememberMe);
|
|
2497
|
+
// Update in-memory state
|
|
2498
|
+
this.Updateloggedin(true);
|
|
2499
|
+
this.UpdateToken(data.token);
|
|
2500
|
+
this.UpdateTokenExpire(data.expiration);
|
|
2501
|
+
this.signalRService.updateToken(data.token); // Changed: Keep SignalR reconnection token in sync
|
|
2502
|
+
this.UpdateRole(data.role);
|
|
2503
|
+
this.updateLoggedUserFullName(data.firstName);
|
|
2504
|
+
this.updateTenantName(data.tenantName);
|
|
2505
|
+
if (data.userName)
|
|
2506
|
+
this.UpdateCurrentUser(data.userName); // Changed: Restore username after silent refresh
|
|
2507
|
+
// Store session data in active storage
|
|
2508
|
+
this.storage.store(Constants.AUTH_TOKEN, data.token);
|
|
2509
|
+
this.storage.store(Constants.AUTH_TOKEN_EXPIRE, data.expiration);
|
|
2510
|
+
this.storage.store(Constants.AUTH_ROLES, JSON.stringify(data.role));
|
|
2511
|
+
this.storage.store(Constants.AUTH_NAME, data.firstName);
|
|
2512
|
+
this.storage.store(Constants.AUTH_TENANT_NAME, data.tenantName);
|
|
2513
|
+
if (data.userName)
|
|
2514
|
+
this.storage.store(Constants.AUTH_USER, data.userName); // Changed: Persist username so checkAuthentication finds AUTH_USER after app restart
|
|
2515
|
+
// Store refresh token persistently if provided (always in localStorage)
|
|
2516
|
+
if (data.refreshToken) {
|
|
2517
|
+
this.storage.storePersistent(Constants.AUTH_REFRESH_TOKEN, data.refreshToken);
|
|
2518
|
+
this.storage.storePersistent(Constants.AUTH_REFRESH_TOKEN_EXPIRE, data.refreshTokenExpiry);
|
|
2576
2519
|
}
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2520
|
+
this.scheduleTokenRefresh(data.expiration); // Changed: Schedule proactive refresh before token expires
|
|
2521
|
+
}
|
|
2522
|
+
// Changed: Schedule a silent refresh 5 minutes before the access token expires
|
|
2523
|
+
scheduleTokenRefresh(expiration) {
|
|
2524
|
+
if (this.refreshTimer)
|
|
2525
|
+
clearTimeout(this.refreshTimer); // Clear any existing timer
|
|
2526
|
+
const expiresAt = new Date(expiration).getTime();
|
|
2527
|
+
const now = Date.now();
|
|
2528
|
+
const refreshIn = expiresAt - now - (5 * 60 * 1000); // 5 minutes before expiry
|
|
2529
|
+
if (refreshIn <= 0)
|
|
2530
|
+
return; // Token already expired or about to — reactive refresh will handle it
|
|
2531
|
+
this.refreshTimer = setTimeout(() => {
|
|
2532
|
+
this.silentRefresh().then(success => {
|
|
2533
|
+
if (!success)
|
|
2534
|
+
this.sessionExpired();
|
|
2535
|
+
});
|
|
2536
|
+
}, refreshIn);
|
|
2582
2537
|
}
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2538
|
+
// Changed: When tab becomes visible, check if token needs immediate refresh
|
|
2539
|
+
initVisibilityRefresh() {
|
|
2540
|
+
document.addEventListener('visibilitychange', () => {
|
|
2541
|
+
if (document.visibilityState !== 'visible')
|
|
2542
|
+
return;
|
|
2543
|
+
if (!this.loggedin)
|
|
2544
|
+
return;
|
|
2545
|
+
const expire = this.tokenExpireSource.value;
|
|
2546
|
+
if (!expire)
|
|
2547
|
+
return;
|
|
2548
|
+
const expiresAt = new Date(expire).getTime();
|
|
2549
|
+
const now = Date.now();
|
|
2550
|
+
const minutesLeft = (expiresAt - now) / 60000;
|
|
2551
|
+
// If token expires in less than 10 minutes or is already expired, refresh immediately
|
|
2552
|
+
if (minutesLeft < 10 && this.hasRefreshToken()) {
|
|
2553
|
+
this.silentRefresh().then(success => {
|
|
2554
|
+
if (!success)
|
|
2555
|
+
this.sessionExpired();
|
|
2556
|
+
});
|
|
2557
|
+
}
|
|
2558
|
+
});
|
|
2590
2559
|
}
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2560
|
+
// Changed: Non-destructive check — only returns true if token is genuinely valid (not expired)
|
|
2561
|
+
hasValidSession() {
|
|
2562
|
+
// Check in-memory state first
|
|
2563
|
+
if (this.loggedin && this.tokenExpireSource.value) {
|
|
2564
|
+
const tokenExpire = new Date(this.tokenExpireSource.value);
|
|
2565
|
+
if (tokenExpire > new Date())
|
|
2566
|
+
return true;
|
|
2567
|
+
}
|
|
2568
|
+
// Changed: Check localStorage for non-expired token (not just existence)
|
|
2569
|
+
const localExpiry = localStorage.getItem(Constants.AUTH_TOKEN_EXPIRE);
|
|
2570
|
+
if (localExpiry) {
|
|
2571
|
+
try {
|
|
2572
|
+
const expiry = new Date(JSON.parse(unescape(atob(localExpiry))));
|
|
2573
|
+
if (expiry > new Date())
|
|
2574
|
+
return true;
|
|
2600
2575
|
}
|
|
2601
|
-
|
|
2576
|
+
catch { /* ignore parse errors */ }
|
|
2602
2577
|
}
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
const cutoff = Date.now() - 24 * 3600 * 1000;
|
|
2613
|
-
await this.outbox.where('status').equals('synced').and(op => (op.syncedAt ?? 0) < cutoff).delete();
|
|
2614
|
-
}
|
|
2615
|
-
async cascadeFailure(clientRef, reason) {
|
|
2616
|
-
this.ensureOpen();
|
|
2617
|
-
const dependents = await this.outbox.filter(op => op.status === 'pending' && !!op.refMap && Object.values(op.refMap).includes(clientRef)).toArray();
|
|
2618
|
-
for (const op of dependents) {
|
|
2619
|
-
op.status = 'failed';
|
|
2620
|
-
op.lastError = 'Blocked: ' + reason;
|
|
2621
|
-
await this.outbox.put(op);
|
|
2578
|
+
// Changed: Check sessionStorage for non-expired token
|
|
2579
|
+
const sessionExpiry = sessionStorage.getItem(Constants.AUTH_TOKEN_EXPIRE);
|
|
2580
|
+
if (sessionExpiry) {
|
|
2581
|
+
try {
|
|
2582
|
+
const expiry = new Date(JSON.parse(unescape(atob(sessionExpiry))));
|
|
2583
|
+
if (expiry > new Date())
|
|
2584
|
+
return true;
|
|
2585
|
+
}
|
|
2586
|
+
catch { /* ignore parse errors */ }
|
|
2622
2587
|
}
|
|
2588
|
+
// Changed: Refresh token alone does NOT count as valid session — caller must handle refresh separately
|
|
2589
|
+
return false;
|
|
2623
2590
|
}
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
this.backoffTimer = setTimeout(() => this.drain(), Math.floor(Math.random() * cap));
|
|
2591
|
+
// Changed: Check if a refresh token exists (for login page to attempt silent refresh)
|
|
2592
|
+
hasRefreshToken() {
|
|
2593
|
+
const refreshToken = this.storage.getPersistent(Constants.AUTH_REFRESH_TOKEN);
|
|
2594
|
+
return refreshToken !== null;
|
|
2629
2595
|
}
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
if (
|
|
2634
|
-
return;
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
await this.outbox.put(op);
|
|
2639
|
-
this.drain();
|
|
2596
|
+
// Shared entry point for any page that needs to auto-redirect authenticated users
|
|
2597
|
+
// Returns true if session is active or was successfully restored via silent refresh
|
|
2598
|
+
async tryRestoreSession() {
|
|
2599
|
+
if (this.hasValidSession())
|
|
2600
|
+
return true;
|
|
2601
|
+
if (this.hasRefreshToken())
|
|
2602
|
+
return await this.silentRefresh();
|
|
2603
|
+
return false;
|
|
2640
2604
|
}
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
if (
|
|
2648
|
-
|
|
2649
|
-
return;
|
|
2605
|
+
// Changed: Silently refresh the JWT using the stored refresh token
|
|
2606
|
+
silentRefresh() {
|
|
2607
|
+
if (this.isRefreshing)
|
|
2608
|
+
return Promise.resolve(false); // Prevent concurrent refreshes
|
|
2609
|
+
this.isRefreshing = true;
|
|
2610
|
+
const refreshToken = this.storage.getPersistent(Constants.AUTH_REFRESH_TOKEN);
|
|
2611
|
+
if (!refreshToken) {
|
|
2612
|
+
this.isRefreshing = false;
|
|
2613
|
+
return Promise.resolve(false);
|
|
2650
2614
|
}
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2615
|
+
return new Promise((resolve) => {
|
|
2616
|
+
this.httpService.Post('User/refresh', { refreshToken }).subscribe({
|
|
2617
|
+
next: (response) => {
|
|
2618
|
+
this.isRefreshing = false;
|
|
2619
|
+
if (response.success) {
|
|
2620
|
+
const rememberMe = this.storage.getPersistent(Constants.AUTH_REMEMBER_ME) ?? true;
|
|
2621
|
+
this.applyLoginResponse(response.data, rememberMe);
|
|
2622
|
+
resolve(true);
|
|
2623
|
+
}
|
|
2624
|
+
else {
|
|
2625
|
+
// Changed: Server explicitly rejected the token (revoked/expired) — safe to clear
|
|
2626
|
+
const currentToken = this.storage.getPersistent(Constants.AUTH_REFRESH_TOKEN);
|
|
2627
|
+
if (currentToken === refreshToken) {
|
|
2628
|
+
this.storage.removePersistent(Constants.AUTH_REFRESH_TOKEN);
|
|
2629
|
+
this.storage.removePersistent(Constants.AUTH_REFRESH_TOKEN_EXPIRE);
|
|
2630
|
+
this.storage.removePersistent(Constants.AUTH_REMEMBER_ME);
|
|
2631
|
+
}
|
|
2632
|
+
resolve(false);
|
|
2633
|
+
}
|
|
2634
|
+
},
|
|
2635
|
+
error: () => {
|
|
2636
|
+
this.isRefreshing = false;
|
|
2637
|
+
// Changed: Network error (device waking up, no connectivity) — do NOT clear the refresh token.
|
|
2638
|
+
// The token may still be valid; next visibility change or 401 interceptor will retry.
|
|
2639
|
+
resolve(false);
|
|
2640
|
+
}
|
|
2641
|
+
});
|
|
2642
|
+
});
|
|
2660
2643
|
}
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
if (
|
|
2669
|
-
|
|
2670
|
-
|
|
2644
|
+
// Changed: Returns Promise<boolean> to properly await refresh instead of optimistically returning true
|
|
2645
|
+
checkAuthentication() {
|
|
2646
|
+
if (this.loggedin && this.isValidToken()) {
|
|
2647
|
+
return Promise.resolve(true);
|
|
2648
|
+
}
|
|
2649
|
+
// Changed: Restore storage mode from persistent remember-me flag
|
|
2650
|
+
const rememberMe = this.storage.getPersistent(Constants.AUTH_REMEMBER_ME);
|
|
2651
|
+
if (rememberMe !== null) {
|
|
2652
|
+
this.storage.setSessionMode(!rememberMe);
|
|
2653
|
+
}
|
|
2654
|
+
if (this.storage.hasKey(Constants.AUTH_USER)) {
|
|
2655
|
+
this.Updateloggedin(true);
|
|
2656
|
+
this.storage.get(Constants.AUTH_USER).then((resp) => {
|
|
2657
|
+
this.UpdateCurrentUser(resp);
|
|
2658
|
+
});
|
|
2659
|
+
this.storage.get(Constants.AUTH_NAME).then((resp) => {
|
|
2660
|
+
this.updateLoggedUserFullName(resp);
|
|
2661
|
+
});
|
|
2662
|
+
this.storage.get(Constants.AUTH_ROLES).then((resp) => {
|
|
2663
|
+
let ret = JSON.parse(resp);
|
|
2664
|
+
this.UpdateRole(ret);
|
|
2665
|
+
});
|
|
2666
|
+
this.storage.get(Constants.AUTH_TENANT_NAME).then((resp) => {
|
|
2667
|
+
this.updateTenantName(resp);
|
|
2668
|
+
});
|
|
2669
|
+
// Changed: Load token and check validity — if expired, await silentRefresh before allowing navigation
|
|
2670
|
+
return this.storage.get(Constants.AUTH_TOKEN).then((token) => {
|
|
2671
|
+
this.UpdateToken(token);
|
|
2672
|
+
return this.storage.get(Constants.AUTH_TOKEN_EXPIRE);
|
|
2673
|
+
}).then((expire) => {
|
|
2674
|
+
this.UpdateTokenExpire(expire);
|
|
2675
|
+
const tokenExpire = new Date(expire);
|
|
2676
|
+
if (tokenExpire > new Date()) {
|
|
2677
|
+
return true; // Token still valid
|
|
2678
|
+
}
|
|
2679
|
+
// Token expired — attempt silent refresh and wait for result
|
|
2680
|
+
const refreshToken = this.storage.getPersistent(Constants.AUTH_REFRESH_TOKEN);
|
|
2681
|
+
if (refreshToken) {
|
|
2682
|
+
return this.silentRefresh().then((success) => {
|
|
2683
|
+
if (!success) {
|
|
2684
|
+
this.sessionExpired();
|
|
2685
|
+
}
|
|
2686
|
+
return success;
|
|
2687
|
+
});
|
|
2688
|
+
}
|
|
2689
|
+
this.sessionExpired();
|
|
2690
|
+
return false;
|
|
2691
|
+
});
|
|
2692
|
+
}
|
|
2693
|
+
// Changed: Check for persistent refresh token when no session data exists
|
|
2694
|
+
const refreshToken = this.storage.getPersistent(Constants.AUTH_REFRESH_TOKEN);
|
|
2695
|
+
if (refreshToken) {
|
|
2696
|
+
// Changed: Wait for refresh to complete before deciding
|
|
2697
|
+
return this.silentRefresh().then((success) => {
|
|
2698
|
+
if (!success) {
|
|
2699
|
+
this.sessionExpired();
|
|
2700
|
+
}
|
|
2701
|
+
return success;
|
|
2702
|
+
});
|
|
2703
|
+
}
|
|
2704
|
+
this.sessionExpired();
|
|
2705
|
+
return Promise.resolve(false);
|
|
2671
2706
|
}
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
sanitize(v) { return v.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); }
|
|
2675
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OfflineService, deps: [{ token: HttpService }, { token: AuthService }, { token: StorageService }, { token: MessageService }, { token: SignalRService }, { token: i0.NgZone }, { token: TIN_SPA_RUNTIME_CONFIG, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
2676
|
-
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: OfflineService, providedIn: 'root' }); }
|
|
2707
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AuthService, deps: [{ token: i1$2.ActivatedRoute }, { token: i2$2.SocialAuthService }, { token: StorageService }, { token: i1$2.Router }, { token: HttpService }, { token: MessageService }, { token: LogService }, { token: SignalRService }, { token: i0.Injector }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
2708
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AuthService, providedIn: 'root' }); }
|
|
2677
2709
|
}
|
|
2678
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type:
|
|
2710
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AuthService, decorators: [{
|
|
2679
2711
|
type: Injectable,
|
|
2680
|
-
args: [{
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
type: Inject,
|
|
2685
|
-
args: [TIN_SPA_RUNTIME_CONFIG]
|
|
2686
|
-
}] }] });
|
|
2712
|
+
args: [{
|
|
2713
|
+
providedIn: 'root'
|
|
2714
|
+
}]
|
|
2715
|
+
}], ctorParameters: () => [{ type: i1$2.ActivatedRoute }, { type: i2$2.SocialAuthService }, { type: StorageService }, { type: i1$2.Router }, { type: HttpService }, { type: MessageService }, { type: LogService }, { type: SignalRService }, { type: i0.Injector }] });
|
|
2687
2716
|
|
|
2688
2717
|
class DataServiceLib {
|
|
2689
2718
|
constructor(httpService, router, route, authService, offline) {
|
|
@@ -4430,6 +4459,10 @@ class DialogService {
|
|
|
4430
4459
|
iframe.style.height = '0';
|
|
4431
4460
|
iframe.style.border = 'none';
|
|
4432
4461
|
iframe.style.visibility = 'hidden';
|
|
4462
|
+
// Changed: sandbox the print frame WITHOUT allow-scripts so any injected <script> / event handlers
|
|
4463
|
+
// in the print content cannot execute. allow-same-origin lets us write content and call print();
|
|
4464
|
+
// allow-modals permits the browser print dialog.
|
|
4465
|
+
iframe.setAttribute('sandbox', 'allow-same-origin allow-modals');
|
|
4433
4466
|
document.body.appendChild(iframe);
|
|
4434
4467
|
if (iframe.contentWindow) {
|
|
4435
4468
|
iframe.contentWindow.document.open();
|
|
@@ -13361,6 +13394,7 @@ class HtmlComponent {
|
|
|
13361
13394
|
this.value = '';
|
|
13362
13395
|
this.maxHeight = '500px'; // Default maximum height
|
|
13363
13396
|
this.display = ''; // For consistency with other components
|
|
13397
|
+
this.sanitizedHtml = '';
|
|
13364
13398
|
}
|
|
13365
13399
|
ngOnInit() {
|
|
13366
13400
|
this.sanitizeHtml();
|
|
@@ -13369,7 +13403,10 @@ class HtmlComponent {
|
|
|
13369
13403
|
this.sanitizeHtml();
|
|
13370
13404
|
}
|
|
13371
13405
|
sanitizeHtml() {
|
|
13372
|
-
|
|
13406
|
+
// Changed: sanitize instead of bypassing Angular's protection. This strips <script>, event handlers
|
|
13407
|
+
// (onerror/onclick, etc.) and other XSS vectors while keeping safe formatting markup, preventing
|
|
13408
|
+
// stored XSS from any user-influenced html-type field.
|
|
13409
|
+
this.sanitizedHtml = this.sanitizer.sanitize(SecurityContext.HTML, this.value ?? '') ?? '';
|
|
13373
13410
|
}
|
|
13374
13411
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: HtmlComponent, deps: [{ token: i1$5.DomSanitizer }], target: i0.ɵɵFactoryTarget.Component }); }
|
|
13375
13412
|
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: HtmlComponent, isStandalone: false, selector: "spa-html", inputs: { value: "value", maxHeight: "maxHeight", display: "display" }, usesOnChanges: true, ngImport: i0, template: "<div *ngIf=\"display\" class=\"html-label\">{{display}}</div>\r\n<div class=\"html-container\" [ngStyle]=\"{'max-height': maxHeight}\">\r\n <div class=\"html-content\" [innerHTML]=\"sanitizedHtml\"></div>\r\n</div>", styles: [".html-container{width:100%;overflow-y:auto;border:1px solid #e0e0e0;border-radius:4px;padding:8px;background-color:#fafafa}.html-content{width:100%}.html-label{font-weight:500;margin-bottom:5px}\n"], dependencies: [{ kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }] }); }
|