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.
@@ -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 AuthService {
1748
- constructor(_route, socialService, storage, router, httpService, messageService, logService, signalRService) {
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.logService = logService;
1756
- this.signalRService = signalRService;
1757
- this.googleAuthInitialized = false;
1758
- this.isRefreshing = false; // Changed: Guard to prevent concurrent refresh calls
1759
- this.refreshTimer = null; // Changed: Proactive token refresh timer
1760
- this.socialUserSource = new BehaviorSubject(null);
1761
- this.socialUserObserv = this.socialUserSource.asObservable();
1762
- //Logged in
1763
- this.loggedin = false;
1764
- this.loggedinSource = new BehaviorSubject(this.loggedin);
1765
- this.loggedinObserv = this.loggedinSource.asObservable();
1766
- //autoLogin
1767
- this.autoLogin = false;
1768
- this.autoLoginSource = new BehaviorSubject(this.autoLogin);
1769
- this.autoLoginObserv = this.autoLoginSource.asObservable();
1770
- //Token
1771
- this.token = "";
1772
- this.tokenSource = new BehaviorSubject(this.token);
1773
- this.tokenObserv = this.tokenSource.asObservable();
1774
- //TokenExpire
1775
- this.tokenExpire = "";
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
- initializeGoogleAuth() {
1800
- if (this.googleAuthInitialized) {
1801
- return;
1802
- }
1803
- this.googleAuthInitialized = true;
1804
- this.socialService.authState.subscribe({
1805
- next: (socialUser) => {
1806
- this.socialUserSource.next(socialUser);
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
- // Changed: Enhanced clearSession with optional server-side revocation
1814
- clearSession(revokeOnServer = false) {
1815
- if (revokeOnServer) {
1816
- // Changed: Revoke only this device's refresh token — other devices keep their sessions
1817
- const refreshToken = this.storage.getPersistent(Constants.AUTH_REFRESH_TOKEN);
1818
- if (refreshToken) {
1819
- this.httpService.Post('User/revoke', { refreshToken }).subscribe({
1820
- error: () => { } // Silently ignore errors during logout
1821
- });
1822
- }
1823
- }
1824
- if (this.refreshTimer) {
1825
- clearTimeout(this.refreshTimer);
1826
- this.refreshTimer = null;
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
- else {
1838
- this.storage.clearSessionData(); // Changed: Session expired — preserve refresh token for "remember me"
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
- this.socialService.signOut();
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
- sessionExpired() {
1843
- this.clearSession(false); // Changed: Don't revoke on server, token just expired
1844
- let url = new URL(window.location.href);
1845
- let path = url.hash.replace("#/", "");
1846
- this.router.navigate(["login"], {
1847
- relativeTo: this._route,
1848
- queryParams: { redirectTo: path },
1849
- queryParamsHandling: 'merge',
1850
- skipLocationChange: false
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
- logoff() {
1854
- this.clearSession(true); // Changed: Revoke refresh tokens on explicit logout
1855
- this.router.navigate(["login"]);
1856
- }
1857
- Updateloggedin(loggedin) {
1858
- this.loggedinSource.next(loggedin);
1859
- this.loggedin = loggedin;
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
- UpdateRole(role) {
1879
- this.currentRoleSource.next(role);
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
- updateLoggedUserFullName(userName) {
1882
- this.loggedUserSource.next(userName);
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
- updateTenantName(tenantName) {
1885
- this.tenantNameSource.next(tenantName);
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
- //Authorisation
1888
- isAuthorised(capName) {
1889
- if (!this.currentRoleSource.value[capName]) {
1890
- // this.logService.info(`authorise: ${this.currentRoleSource.value.roleName} not authorised for ${capName}`);
1891
- this.router.navigate(["home"]);
1892
- this.messageService.toast(`Unauthorised: ${capName}`);
1893
- return false;
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
- else {
1896
- return true;
1841
+ catch (e) {
1842
+ console.warn('Offline data wipe failed', e);
1897
1843
  }
1898
1844
  }
1899
- // Changed: Pure validation — no side effects. Callers handle refresh/redirect.
1900
- isValidToken() {
1901
- let tokenExpire = new Date(this.tokenExpireSource.value);
1902
- let nowDate = new Date();
1903
- return tokenExpire > nowDate;
1904
- }
1905
- // Changed: Centralized handler for storing all auth data from login/refresh response
1906
- applyLoginResponse(data, rememberMe) {
1907
- // Set storage mode: sessionStorage when not remembered, localStorage when remembered
1908
- this.storage.setSessionMode(!rememberMe);
1909
- // Store remember-me flag persistently (survives browser close)
1910
- this.storage.storePersistent(Constants.AUTH_REMEMBER_ME, rememberMe);
1911
- // Update in-memory state
1912
- this.Updateloggedin(true);
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
- this.scheduleTokenRefresh(data.expiration); // Changed: Schedule proactive refresh before token expires
1860
+ catch { }
1861
+ this.meta.put({ key: 'sentinel', value: Date.now() }).catch(() => { });
1862
+ return db;
1935
1863
  }
1936
- // Changed: Schedule a silent refresh 5 minutes before the access token expires
1937
- scheduleTokenRefresh(expiration) {
1938
- if (this.refreshTimer)
1939
- clearTimeout(this.refreshTimer); // Clear any existing timer
1940
- const expiresAt = new Date(expiration).getTime();
1941
- const now = Date.now();
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
- // Changed: When tab becomes visible, check if token needs immediate refresh
1953
- initVisibilityRefresh() {
1954
- document.addEventListener('visibilitychange', () => {
1955
- if (document.visibilityState !== 'visible')
1956
- return;
1957
- if (!this.loggedin)
1958
- return;
1959
- const expire = this.tokenExpireSource.value;
1960
- if (!expire)
1961
- return;
1962
- const expiresAt = new Date(expire).getTime();
1963
- const now = Date.now();
1964
- const minutesLeft = (expiresAt - now) / 60000;
1965
- // If token expires in less than 10 minutes or is already expired, refresh immediately
1966
- if (minutesLeft < 10 && this.hasRefreshToken()) {
1967
- this.silentRefresh().then(success => {
1968
- if (!success)
1969
- this.sessionExpired();
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
- // Changed: Non-destructive check only returns true if token is genuinely valid (not expired)
1975
- hasValidSession() {
1976
- // Check in-memory state first
1977
- if (this.loggedin && this.tokenExpireSource.value) {
1978
- const tokenExpire = new Date(this.tokenExpireSource.value);
1979
- if (tokenExpire > new Date())
1980
- return true;
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
- // Changed: Check localStorage for non-expired token (not just existence)
1983
- const localExpiry = localStorage.getItem(Constants.AUTH_TOKEN_EXPIRE);
1984
- if (localExpiry) {
1985
- try {
1986
- const expiry = new Date(JSON.parse(unescape(atob(localExpiry))));
1987
- if (expiry > new Date())
1988
- return true;
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
- // Changed: Check sessionStorage for non-expired token
1993
- const sessionExpiry = sessionStorage.getItem(Constants.AUTH_TOKEN_EXPIRE);
1994
- if (sessionExpiry) {
1995
- try {
1996
- const expiry = new Date(JSON.parse(unescape(atob(sessionExpiry))));
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
- // Changed: Refresh token alone does NOT count as valid session — caller must handle refresh separately
2003
- return false;
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
- // Changed: Check if a refresh token exists (for login page to attempt silent refresh)
2006
- hasRefreshToken() {
2007
- const refreshToken = this.storage.getPersistent(Constants.AUTH_REFRESH_TOKEN);
2008
- return refreshToken !== null;
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
- // Shared entry point for any page that needs to auto-redirect authenticated users
2011
- // Returns true if session is active or was successfully restored via silent refresh
2012
- async tryRestoreSession() {
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
- // Changed: Silently refresh the JWT using the stored refresh token
2020
- silentRefresh() {
2021
- if (this.isRefreshing)
2022
- return Promise.resolve(false); // Prevent concurrent refreshes
2023
- this.isRefreshing = true;
2024
- const refreshToken = this.storage.getPersistent(Constants.AUTH_REFRESH_TOKEN);
2025
- if (!refreshToken) {
2026
- this.isRefreshing = false;
2027
- return Promise.resolve(false);
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
- return new Promise((resolve) => {
2030
- this.httpService.Post('User/refresh', { refreshToken }).subscribe({
2031
- next: (response) => {
2032
- this.isRefreshing = false;
2033
- if (response.success) {
2034
- const rememberMe = this.storage.getPersistent(Constants.AUTH_REMEMBER_ME) ?? true;
2035
- this.applyLoginResponse(response.data, rememberMe);
2036
- resolve(true);
2037
- }
2038
- else {
2039
- // Changed: Server explicitly rejected the token (revoked/expired) safe to clear
2040
- const currentToken = this.storage.getPersistent(Constants.AUTH_REFRESH_TOKEN);
2041
- if (currentToken === refreshToken) {
2042
- this.storage.removePersistent(Constants.AUTH_REFRESH_TOKEN);
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
- // Changed: Returns Promise<boolean> to properly await refresh instead of optimistically returning true
2059
- checkAuthentication() {
2060
- if (this.loggedin && this.isValidToken()) {
2061
- return Promise.resolve(true);
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
- // Changed: Restore storage mode from persistent remember-me flag
2064
- const rememberMe = this.storage.getPersistent(Constants.AUTH_REMEMBER_ME);
2065
- if (rememberMe !== null) {
2066
- this.storage.setSessionMode(!rememberMe);
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
- if (this.storage.hasKey(Constants.AUTH_USER)) {
2069
- this.Updateloggedin(true);
2070
- this.storage.get(Constants.AUTH_USER).then((resp) => {
2071
- this.UpdateCurrentUser(resp);
2072
- });
2073
- this.storage.get(Constants.AUTH_NAME).then((resp) => {
2074
- this.updateLoggedUserFullName(resp);
2075
- });
2076
- this.storage.get(Constants.AUTH_ROLES).then((resp) => {
2077
- let ret = JSON.parse(resp);
2078
- this.UpdateRole(ret);
2079
- });
2080
- this.storage.get(Constants.AUTH_TENANT_NAME).then((resp) => {
2081
- this.updateTenantName(resp);
2082
- });
2083
- // Changed: Load token and check validity — if expired, await silentRefresh before allowing navigation
2084
- return this.storage.get(Constants.AUTH_TOKEN).then((token) => {
2085
- this.UpdateToken(token);
2086
- return this.storage.get(Constants.AUTH_TOKEN_EXPIRE);
2087
- }).then((expire) => {
2088
- this.UpdateTokenExpire(expire);
2089
- const tokenExpire = new Date(expire);
2090
- if (tokenExpire > new Date()) {
2091
- return true; // Token still valid
2092
- }
2093
- // Token expired — attempt silent refresh and wait for result
2094
- const refreshToken = this.storage.getPersistent(Constants.AUTH_REFRESH_TOKEN);
2095
- if (refreshToken) {
2096
- return this.silentRefresh().then((success) => {
2097
- if (!success) {
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
- // Changed: Check for persistent refresh token when no session data exists
2108
- const refreshToken = this.storage.getPersistent(Constants.AUTH_REFRESH_TOKEN);
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
- 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 }], target: i0.ɵɵFactoryTarget.Injectable }); }
2122
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AuthService, providedIn: 'root' }); }
2123
- }
2124
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AuthService, decorators: [{
2125
- type: Injectable,
2126
- args: [{
2127
- providedIn: 'root'
2128
- }]
2129
- }], ctorParameters: () => [{ type: i1$2.ActivatedRoute }, { type: i2$2.SocialAuthService }, { type: StorageService }, { type: i1$2.Router }, { type: HttpService }, { type: MessageService }, { type: LogService }, { type: SignalRService }] });
2130
-
2131
- class OfflineService {
2132
- constructor(httpService, authService, storage, messageService, signalR, zone, runtimeConfig) {
2133
- this.httpService = httpService;
2134
- this.authService = authService;
2135
- this.storage = storage;
2136
- this.messageService = messageService;
2137
- this.signalR = signalR;
2138
- this.zone = zone;
2139
- this.runtimeConfig = runtimeConfig;
2140
- //================ Connectivity (public) ================
2141
- this.online$ = new BehaviorSubject(typeof navigator === 'undefined' ? true : navigator.onLine);
2142
- // Entities whose ops synced in the last drain — tables listen to refetch (push-then-pull)
2143
- this.drained$ = new Subject();
2144
- //---------------- Dexie ----------------
2145
- this.db = null;
2146
- //---------------- Registry ----------------
2147
- this.entries = new Map();
2148
- this.readUrls = new Map();
2149
- this.readPrefixes = [];
2150
- this.registered$ = new BehaviorSubject(false);
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
- /** App-level gate: offline capability only runs when the app opts in via provideTinSpaRuntime({ offline: true }).
2160
- * When off, register()/ensureConnectivity() no-op, so a consumer that never uses offline runs none of this. */
2161
- get enabled() { return !!this.runtimeConfig?.offline; }
2162
- get hasAny() { return this.entries.size > 0; }
2163
- get online() { return this.online$.value; }
2164
- //================ Sync page view ================
2165
- // Live, display-ready outbox rows (summary + status label projected on) for the sync page's spa-table.
2166
- outboxView$() {
2167
- return this.activeOps$().pipe(map(ops => ops.map(op => ({ ...op, summary: this.summarize(op), statusLabel: this.statusLabel(op) }))));
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
- summarize(op) {
2170
- const p = op.payload ?? {};
2171
- if (typeof p.name === 'string' && p.name)
2172
- return p.name;
2173
- const firstString = Object.keys(p).map(k => p[k]).find(v => typeof v === 'string' && v.length > 0 && v.length < 60);
2174
- return firstString ?? (op.targetId != null ? `#${op.targetId}` : 'new record');
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
- statusLabel(op) {
2177
- switch (op.status) {
2178
- case 'failed': return 'Failed';
2179
- case 'conflict': return 'Conflict';
2180
- case 'stale': return 'Held';
2181
- case 'syncing': return 'Sending';
2182
- default: return 'Pending';
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
- //================ Registry ================
2186
- // Called from TableComponent when a config with `offline` initializes; idempotent per entity
2187
- register(config) {
2188
- if (!this.enabled)
2189
- return null; // app-level gate: ignore per-table offline flags when the app didn't opt in
2190
- const resolved = this.resolveConfig(config.offline);
2191
- if (!resolved || !config.loadAction?.url)
2192
- return null;
2193
- const entity = config.loadAction.url.split('/')[0].split('?')[0];
2194
- const existing = this.entries.get(entity);
2195
- if (existing) {
2196
- this.startSync();
2197
- return existing;
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
- registerLookups(formConfig, entry, depth = 0) {
2218
- if (!formConfig?.fields || depth > 1)
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
- const allow = entry.config.cacheLookups;
2221
- if (allow === false)
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
- formConfig.fields.forEach((field) => {
2224
- const url = field.loadAction?.url;
2225
- if (url && (allow === true || (Array.isArray(allow) && allow.includes(url)))) {
2226
- if (!this.readUrls.has(url))
2227
- this.readUrls.set(url, entry);
2228
- }
2229
- if (field.detailsConfig?.formConfig)
2230
- this.registerLookups(field.detailsConfig.formConfig, entry, depth + 1);
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
- matchRead(url) {
2234
- if (!this.hasAny)
2235
- return null;
2236
- const exact = this.readUrls.get(url);
2237
- if (exact)
2238
- return exact;
2239
- const prefixed = this.readPrefixes.find(p => url.startsWith(p.prefix));
2240
- return prefixed ? prefixed.entry : null;
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
- matchMutation(url) {
2243
- if (!this.hasAny)
2244
- return null;
2245
- const match = url.match(/^([^?/]+)\?action=(create|edit|delete)$/);
2246
- if (!match)
2247
- return null;
2248
- const entry = this.entries.get(match[1]);
2249
- if (!entry)
2250
- return null;
2251
- return entry.config.queue[match[2]] ? entry : null;
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
- // Interceptor seam: suppress the 'Connection Error' toast for URLs the offline layer handles (plus the heartbeat)
2254
- suppressesUrl(absoluteUrl) {
2255
- const relative = absoluteUrl.startsWith(this.httpService.apiUrl) ? absoluteUrl.substring(this.httpService.apiUrl.length) : absoluteUrl;
2256
- if (relative === 'ping')
2257
- return true;
2258
- if (!this.hasAny)
2259
- return false;
2260
- return !!(this.matchRead(relative) || this.matchMutation(relative));
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
- resolveConfig(offline) {
2263
- if (!offline)
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
- //================ Store (Dexie) ================
2281
- ensureOpen() {
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
- putRead(url, entity, data, maxRows) {
2301
- this.ensureOpen();
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
- getRead(url) {
2308
- this.ensureOpen();
2309
- try {
2310
- return this.reads.get(url).catch(() => undefined);
2311
- }
2312
- catch {
2313
- return Promise.resolve(undefined);
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
- //================ CallApi seam helpers (called by DataServiceLib) ================
2317
- // Reads: network-first with write-through; on verified-offline or network failure serve the cached copy.
2318
- handleOfflineRead(action, entry, networkCall) {
2319
- const serveCache = () => from(this.serveCachedRead(action.url)).pipe(map(result => result
2320
- ? { success: true, message: 'success', data: result }
2321
- : { success: false, message: 'No offline data available yet for this view', data: null }));
2322
- if (!this.online)
2323
- return serveCache();
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
- return throwError(() => err);
2333
- }));
2304
+ }
2305
+ return ret;
2334
2306
  }
2335
- // For '/list/' lookups, append pending offline creates as selectable 'cref:' options so offline forms can reference not-yet-synced parents
2336
- async serveCachedRead(url) {
2337
- const saved = await this.getRead(url);
2338
- let data = saved?.data ?? null;
2339
- const listMatch = url.match(/^([^?/]+)\/list\//);
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 data;
2313
+ return ret;
2348
2314
  }
2349
- // Persists a mutation and synthesizes a success ApiResponse so the existing dialog/refresh machinery works unchanged
2350
- enqueueOffline(action, data, entry) {
2351
- return from(this.enqueueMutation(action, data, entry)).pipe(map(op => ({
2352
- success: true,
2353
- message: 'Saved offline — will be sent when connection is restored',
2354
- data: op ? { ...op.payload, _offline: { status: op.status, opId: op.opId, clientRef: op.clientRef ?? op.targetClientRef } } : data,
2355
- })), catchError((err) => of({ success: false, message: 'Could not save offline: ' + (err?.message ?? 'storage error'), data: null })));
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
- //================ Outbox writes ================
2358
- async enqueueMutation(action, data, entry) {
2359
- this.ensureOpen();
2360
- const opAction = this.parseAction(action.url);
2361
- if (!opAction)
2362
- throw new Error('Unsupported offline mutation: ' + action.url);
2363
- const payload = { ...data };
2364
- const marker = payload._offline;
2365
- delete payload._offline;
2366
- const refMap = {};
2367
- Object.keys(payload).forEach(k => {
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
- const userId = this.authService.currentUser || 'anon';
2374
- if (opAction === 'edit' && marker?.clientRef) {
2375
- const createOp = await this.outbox.where('clientRef').equals(marker.clientRef).first();
2376
- if (createOp && createOp.status !== 'synced') {
2377
- createOp.payload = { ...createOp.payload, ...payload, clientRef: createOp.clientRef };
2378
- createOp.refMap = { ...createOp.refMap, ...refMap };
2379
- await this.outbox.put(createOp);
2380
- return createOp;
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
- if (opAction === 'delete' && marker?.clientRef) {
2384
- const createOp = await this.outbox.where('clientRef').equals(marker.clientRef).first();
2385
- if (createOp && createOp.status !== 'synced') {
2386
- await this.outbox.delete(createOp.seq);
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
- const targetId = opAction === 'create' ? undefined : payload[entry.idField];
2391
- if (opAction === 'edit' && targetId !== undefined) {
2392
- const existing = await this.outbox.where('entity').equals(entry.entity)
2393
- .and(op => op.opAction === 'edit' && op.targetId === targetId && (op.status === 'pending' || op.status === 'failed')).first();
2394
- if (existing) {
2395
- existing.payload = payload;
2396
- existing.refMap = { ...existing.refMap, ...refMap };
2397
- existing.status = 'pending';
2398
- await this.outbox.put(existing);
2399
- return existing;
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
- const op = {
2403
- opId: crypto.randomUUID(), entity: entry.entity, entityName: entry.entityName, url: action.url, opAction, payload, targetId,
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
- await this.outbox.add(op);
2412
- return op;
2426
+ this.socialService.signOut();
2413
2427
  }
2414
- //================ Live streams (cross-tab reactive) ================
2415
- pendingOpsFor$(entity) {
2416
- this.ensureOpen();
2417
- return from(liveQuery(() => this.outbox.where('entity').equals(entity).and(op => op.status !== 'synced').sortBy('seq')));
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
- activeOps$() {
2420
- this.ensureOpen();
2421
- return from(liveQuery(() => this.outbox.filter(op => op.status !== 'synced').sortBy('seq')));
2439
+ logoff() {
2440
+ this.clearSession(true); // Changed: Revoke refresh tokens on explicit logout
2441
+ this.router.navigate(["login"]);
2422
2442
  }
2423
- pendingCreates(entity) {
2424
- this.ensureOpen();
2425
- return this.outbox.where('entity').equals(entity).and(op => op.opAction === 'create' && op.status !== 'synced').sortBy('seq');
2443
+ Updateloggedin(loggedin) {
2444
+ this.loggedinSource.next(loggedin);
2445
+ this.loggedin = loggedin;
2426
2446
  }
2427
- hasPending() {
2428
- this.ensureOpen();
2429
- return this.outbox.where('status').equals('pending').count().then(c => c > 0);
2447
+ UpdateAutoLogin(autoLogin) {
2448
+ this.autoLoginSource.next(autoLogin);
2449
+ this.autoLogin = autoLogin;
2430
2450
  }
2431
- //================ Connectivity ================
2432
- // Started by the sync engine (offline table registered) OR by the toolbar indicator (to show a status light).
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
- reportNetworkFailure() { if (this.connectivityStarted)
2450
- this.setOffline(); }
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
- setOnline() {
2461
- this.stopRecoveryPolling();
2462
- if (!this.online$.value) {
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
- setOffline() {
2472
- if (this.online$.value)
2473
- this.online$.next(false);
2474
- this.startRecoveryPolling();
2461
+ UpdateIsLineManager(manager) {
2462
+ this.isLineManagerSource.next(manager);
2475
2463
  }
2476
- startRecoveryPolling() {
2477
- if (this.recoveryTimer || !this.connectivityStarted)
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
- stopRecoveryPolling() {
2482
- if (this.recoveryTimer) {
2483
- clearInterval(this.recoveryTimer);
2484
- this.recoveryTimer = null;
2485
- }
2467
+ updateLoggedUserFullName(userName) {
2468
+ this.loggedUserSource.next(userName);
2486
2469
  }
2487
- //================ Sync engine ================
2488
- startSync() {
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
- async drain() {
2501
- if (this.draining || !this.online)
2502
- return;
2503
- this.draining = true;
2504
- const runner = () => this.drainLoop().finally(() => { this.draining = false; });
2505
- const locks = navigator.locks;
2506
- if (locks?.request) {
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
- await runner();
2482
+ return true;
2519
2483
  }
2520
2484
  }
2521
- async drainLoop() {
2522
- const syncedEntities = new Set();
2523
- while (true) {
2524
- const op = await this.nextAutoOp();
2525
- if (!op)
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
- async sendOp(op) {
2553
- const payload = { ...op.payload };
2554
- if (op.refMap && Object.keys(op.refMap).length)
2555
- payload.clientRefs = JSON.stringify(op.refMap); // server resolves parent ClientRef -> real int FK
2556
- let response;
2557
- try {
2558
- response = await firstValueFrom(this.httpService.Post(op.url, payload));
2559
- }
2560
- catch (err) {
2561
- const status = err?.status ?? 0;
2562
- if (status === 0 || status === 408 || status === 429 || status >= 500) {
2563
- if (status === 0)
2564
- this.reportNetworkFailure();
2565
- return 'transient';
2566
- }
2567
- return this.finishOp(op, 'failed', err?.message ?? `HTTP ${status}`);
2568
- }
2569
- if (response.success)
2570
- return this.finishOp(op, 'synced');
2571
- const reason = response.reason ?? '';
2572
- const notFound = reason === 'not_found' || response.message?.includes('not found');
2573
- if (reason === 'conflict') {
2574
- op.serverRecord = response.data ?? null;
2575
- return this.finishOp(op, 'conflict', response.message);
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
- if (notFound && op.opAction === 'delete')
2578
- return this.finishOp(op, 'synced'); // delete of already-deleted row — outcome achieved
2579
- if (notFound && op.opAction === 'edit')
2580
- return this.finishOp(op, 'conflict', 'This record was deleted on the server');
2581
- return this.finishOp(op, 'failed', response.message); // validation/business failure — permanent
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
- async finishOp(op, status, error) {
2584
- op.status = status;
2585
- op.lastError = error;
2586
- if (status === 'synced')
2587
- op.syncedAt = Date.now();
2588
- await this.outbox.put(op);
2589
- return status;
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
- async nextAutoOp() {
2592
- this.ensureOpen();
2593
- const ops = await this.outbox.orderBy('seq').filter(op => op.status === 'pending').toArray();
2594
- for (const op of ops) {
2595
- if (Date.now() - op.enqueuedAt > op.maxAgeHours * 3600 * 1000) {
2596
- op.status = 'stale';
2597
- op.lastError = 'Held — too old to send automatically';
2598
- await this.outbox.put(op);
2599
- continue;
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
- return op;
2576
+ catch { /* ignore parse errors */ }
2602
2577
  }
2603
- return undefined;
2604
- }
2605
- async resetOrphans() {
2606
- this.ensureOpen();
2607
- const orphans = await this.outbox.where('status').equals('syncing').toArray();
2608
- for (const op of orphans) {
2609
- op.status = 'pending';
2610
- await this.outbox.put(op);
2611
- } // safe: opId/ClientRef idempotency makes resend collide, not duplicate
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
- scheduleBackoff(attempts) {
2625
- if (this.backoffTimer)
2626
- clearTimeout(this.backoffTimer);
2627
- const cap = Math.min(5000 * Math.pow(2, attempts), 300000); // exponential 5s·2^n capped 5min, full jitter
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
- //================ Sync-tray actions ================
2631
- async retryOp(opId) {
2632
- const op = await this.getOp(opId);
2633
- if (!op)
2634
- return;
2635
- op.status = 'pending';
2636
- op.attempts = 0;
2637
- op.enqueuedAt = Date.now();
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
- discardOp(opId) { this.ensureOpen(); return this.outbox.where('opId').equals(opId).delete(); }
2642
- // keepMine re-arms the op with the fresh server RowVersion (explicit, informed overwrite); keepServer discards
2643
- async resolveConflict(opId, choice) {
2644
- const op = await this.getOp(opId);
2645
- if (!op)
2646
- return;
2647
- if (choice === 'keepServer') {
2648
- await this.discardOp(opId);
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
- if (op.serverRecord?.rowVersion)
2652
- op.payload = { ...op.payload, rowVersion: op.serverRecord.rowVersion };
2653
- else
2654
- delete op.payload.rowVersion;
2655
- op.status = 'pending';
2656
- op.attempts = 0;
2657
- op.enqueuedAt = Date.now();
2658
- await this.outbox.put(op);
2659
- this.drain();
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
- getOp(opId) { this.ensureOpen(); return this.outbox.where('opId').equals(opId).first(); }
2662
- //================ helpers ================
2663
- parseAction(url) {
2664
- if (url.includes('?action=create'))
2665
- return 'create';
2666
- if (url.includes('?action=edit'))
2667
- return 'edit';
2668
- if (url.includes('?action=delete'))
2669
- return 'delete';
2670
- return null;
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
- hash(v) { let h = 0; for (let i = 0; i < v.length; i++)
2673
- h = ((h << 5) - h + v.charCodeAt(i)) | 0; return Math.abs(h).toString(36); }
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: OfflineService, decorators: [{
2710
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: AuthService, decorators: [{
2679
2711
  type: Injectable,
2680
- args: [{ providedIn: 'root' }]
2681
- }], ctorParameters: () => [{ type: HttpService }, { type: AuthService }, { type: StorageService }, { type: MessageService }, { type: SignalRService }, { type: i0.NgZone }, { type: undefined, decorators: [{
2682
- type: Optional
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
- this.sanitizedHtml = this.sanitizer.bypassSecurityTrustHtml(this.value);
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"] }] }); }