@nauticana/sail 1.1.8 → 1.1.10

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.
Files changed (79) hide show
  1. package/README.md +36 -3
  2. package/package.json +5 -1
  3. package/src/component/abstract/base_async.ts +9 -11
  4. package/src/component/abstract/base_form.ts +6 -5
  5. package/src/component/abstract/base_table.ts +131 -58
  6. package/src/component/abstract/base_view.ts +43 -27
  7. package/src/component/account/profile_editor.ts +2 -2
  8. package/src/component/billing/payment_methods.ts +1 -2
  9. package/src/component/billing/price_selector.html +2 -1
  10. package/src/component/billing/price_selector.ts +28 -13
  11. package/src/component/billing/trial_banner.ts +7 -1
  12. package/src/component/billing/usage_meter.ts +2 -2
  13. package/src/component/dashboard/data_card.html +1 -1
  14. package/src/component/dashboard/entity_selector.ts +21 -23
  15. package/src/component/form/form_field.html +6 -5
  16. package/src/component/form/form_field.ts +39 -21
  17. package/src/component/form/form_record.html +3 -3
  18. package/src/component/form/form_record.ts +23 -8
  19. package/src/component/form/form_table.html +2 -2
  20. package/src/component/form/form_table.ts +6 -5
  21. package/src/component/login/account_deletion.ts +6 -6
  22. package/src/component/login/chpass_component.html +4 -4
  23. package/src/component/login/chpass_component.ts +0 -1
  24. package/src/component/login/confirm_chpass_component.html +4 -4
  25. package/src/component/login/confirm_chpass_component.ts +1 -4
  26. package/src/component/login/consent_gate.html +6 -6
  27. package/src/component/login/consent_gate.ts +17 -24
  28. package/src/component/login/login_component.html +7 -4
  29. package/src/component/login/login_component.ts +9 -5
  30. package/src/component/login/otp_input.html +13 -5
  31. package/src/component/login/otp_input.ts +34 -27
  32. package/src/component/login/register_component.html +31 -14
  33. package/src/component/login/register_component.ts +80 -89
  34. package/src/component/login/social_login.html +2 -2
  35. package/src/component/login/social_login.ts +20 -13
  36. package/src/component/navigation/navigation.html +2 -2
  37. package/src/component/navigation/navigation.ts +2 -0
  38. package/src/component/oauth_connection/oauth_connection_panel.html +68 -0
  39. package/src/component/oauth_connection/oauth_connection_panel.ts +252 -0
  40. package/src/component/payment/user_payment_methods.html +2 -2
  41. package/src/component/payment/user_payment_methods.ts +12 -23
  42. package/src/component/payout/bank_info_form.html +4 -4
  43. package/src/component/payout/bank_info_form.ts +22 -26
  44. package/src/component/payout/provider_onboarding.html +5 -5
  45. package/src/component/payout/provider_onboarding.ts +28 -48
  46. package/src/component/security/trusted_devices.html +1 -1
  47. package/src/component/security/trusted_devices.ts +0 -1
  48. package/src/component/security/twofactor_setup.html +8 -4
  49. package/src/component/security/twofactor_setup.ts +21 -3
  50. package/src/component/security/twofactor_verify.html +2 -1
  51. package/src/component/security/twofactor_verify.ts +2 -8
  52. package/src/component/table/table_detail.html +15 -14
  53. package/src/component/table/table_detail.ts +62 -41
  54. package/src/component/table/table_edit.html +15 -11
  55. package/src/component/table/table_edit.ts +94 -67
  56. package/src/component/table/table_list.html +45 -12
  57. package/src/component/table/table_list.ts +98 -26
  58. package/src/component/table/table_lookup.html +11 -8
  59. package/src/component/table/table_lookup.ts +45 -37
  60. package/src/component/table/table_report.html +2 -2
  61. package/src/component/table/table_report.ts +37 -31
  62. package/src/component/table/table_search.html +4 -4
  63. package/src/component/table/table_search.ts +36 -45
  64. package/src/config.ts +1 -1
  65. package/src/index.ts +4 -0
  66. package/src/model/appdata.ts +5 -9
  67. package/src/model/common.ts +27 -27
  68. package/src/model/decorator.ts +25 -21
  69. package/src/model/oauth_connection.ts +50 -0
  70. package/src/service/api-response.interceptor.ts +4 -1
  71. package/src/service/auth.interceptor.ts +27 -7
  72. package/src/service/auth.service.ts +188 -134
  73. package/src/service/label.service.ts +6 -5
  74. package/src/service/oauth_connection.service.ts +56 -0
  75. package/src/service/rest_service.ts +57 -13
  76. package/src/service/rest_url.ts +23 -7
  77. package/src/service/script_loader.ts +12 -2
  78. package/src/util/errors.ts +20 -0
  79. package/src/util/money.ts +19 -7
@@ -1,7 +1,7 @@
1
1
  import { Injectable, WritableSignal, inject, signal } from '@angular/core';
2
- import { HttpParams } from '@angular/common/http';
3
- import { map, take, tap } from 'rxjs/operators';
4
- import { ApplicationData, AuthSummary, ConfirmRegisterResponse, DictionaryPath, LoginResponse2FA, PartnerRegistration, RestReport, TableDefinition, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
2
+ import { HttpErrorResponse, HttpParams } from '@angular/common/http';
3
+ import { filter, map, mergeMap, take, tap } from 'rxjs/operators';
4
+ import { ApplicationData, ConfirmRegisterResponse, DictionaryPath, LoginResponse2FA, PartnerRegistration, RestReport, TableDefinition, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
5
5
  import {
6
6
  LoginResponseSocial,
7
7
  OtpRequest, OtpResendRequest, OtpResponse, OtpVerifyRequest, OtpVerifyResponse,
@@ -11,7 +11,7 @@ import {
11
11
  UserProfile,
12
12
  } from '../model/auth';
13
13
  import { RestURL } from './rest_url';
14
- import { Observable, ReplaySubject, catchError, of } from 'rxjs';
14
+ import { BehaviorSubject, Observable, ReplaySubject, catchError, defer, merge, of, throwError } from 'rxjs';
15
15
  import { ApplicationMenu, ConstantValue } from '../model/common';
16
16
  import { CanActivateFn, CanDeactivateFn, Router, Routes } from '@angular/router';
17
17
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../config';
@@ -22,46 +22,81 @@ import { resetAuthCircuit } from './auth.interceptor';
22
22
  const byCaption = (a: ConstantValue, b: ConstantValue) =>
23
23
  (a.Caption ?? '').localeCompare(b.Caption ?? '', undefined, { numeric: true, sensitivity: 'base' });
24
24
 
25
+ /** One grant in the permission index — pattern compiled once at appdata load, not per check. */
26
+ interface CompiledPermission {
27
+ Obj: string;
28
+ Act: string;
29
+ Val: string;
30
+ Rx: RegExp;
31
+ }
32
+
33
+ /** Permission row as keel emits it; ObjectName/Low are the legacy field names. */
34
+ interface PermissionRow {
35
+ AuthorizationObjectId?: string;
36
+ ObjectName?: string;
37
+ Action?: string;
38
+ LowLimit?: string;
39
+ Low?: string;
40
+ }
41
+
25
42
  @Injectable()
26
43
  export abstract class BaseAuthService extends BaseRestService {
27
44
  protected readonly router = inject(Router);
28
45
  protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
29
- protected cache!: ApplicationData;
30
- private readonly appData$ = new ReplaySubject<ApplicationData>(1);
31
- private readonly authIndex = new Map<string, AuthSummary[]>();
32
-
33
- protected readonly appdataUrl = this.url(RestURL.appdataURL);
34
- protected readonly loginUrl = this.url(RestURL.loginURL);
35
- protected readonly registerUrl = this.url(RestURL.registerURL);
36
- protected readonly chpassUrl = this.url(RestURL.chpassURL);
37
- protected readonly confirmRegisterUrl = this.url(RestURL.confirmRegisterURL);
38
- protected readonly confirmChpassUrl = this.url(RestURL.confirmChpassURL);
39
- protected readonly loginGoogleUrl = this.url(RestURL.loginGoogleURL);
40
- protected readonly twoFactorSetupUrl = this.url(RestURL.twoFactorSetupURL);
41
- protected readonly twoFactorVerifyUrl = this.url(RestURL.twoFactorVerifyURL);
42
- protected readonly twoFactorDisableUrl = this.url(RestURL.twoFactorDisableURL);
43
- protected readonly twoFactorLoginVerifyUrl = this.url(RestURL.twoFactorLoginVerifyURL);
44
- protected readonly twoFactorBackupVerifyUrl = this.url(RestURL.twoFactorBackupVerifyURL);
45
- protected readonly trustedDeviceListUrl = this.url(RestURL.trustedDeviceListURL);
46
- protected readonly trustedDeviceRegisterUrl = this.url(RestURL.trustedDeviceRegisterURL);
47
- protected readonly trustedDeviceRevokeUrl = this.url(RestURL.trustedDeviceRevokeURL);
48
- protected readonly otpSendUrl = this.url(RestURL.otpSendURL);
49
- protected readonly otpVerifyUrl = this.url(RestURL.otpVerifyURL);
50
- protected readonly otpResendUrl = this.url(RestURL.otpResendURL);
51
- protected readonly loginSocialUrl = this.url(RestURL.loginSocialURL);
52
- protected readonly logoutEverywhereUrl = this.url(RestURL.logoutEverywhereURL);
53
- protected readonly deleteAccountUrl = this.url(RestURL.deleteAccountURL);
54
- protected readonly pushRegisterUrl = this.url(RestURL.pushRegisterURL);
55
- protected readonly pushRevokeUrl = this.url(RestURL.pushRevokeURL);
56
- protected readonly profileUrl = this.url(RestURL.profileURL);
57
- protected readonly profileEmailUrl = this.url(RestURL.profileEmailURL);
58
- protected readonly profileEmailConfirmUrl = this.url(RestURL.profileEmailConfirmURL);
59
- protected readonly profilePhoneUrl = this.url(RestURL.profilePhoneURL);
60
- protected readonly profilePhoneConfirmUrl = this.url(RestURL.profilePhoneConfirmURL);
46
+
47
+ // Reactive cache: computed()s that read metadata through `cache` (via
48
+ // getTableDefinition etc.) recompute automatically when appdata loads/clears.
49
+ private readonly appDataCache = signal<ApplicationData | undefined>(undefined);
50
+ protected get cache(): ApplicationData | undefined { return this.appDataCache(); }
51
+ protected set cache(value: ApplicationData | undefined) { this.appDataCache.set(value); }
52
+
53
+ // Stable stream identity: subscribers survive logout (they see menus clear via
54
+ // the null) and a session generation guards in-flight responses (see loadAppData).
55
+ private readonly appData$ = new BehaviorSubject<ApplicationData | null>(null);
56
+ private appDataFail$ = new ReplaySubject<unknown>(1);
57
+ private sessionGeneration = 0;
58
+ private readonly authIndex = new Map<string, CompiledPermission[]>();
59
+ /** The route config active before the first initRoutes() — restored on logout
60
+ * so the login screen doesn't keep the previous user's route tree. */
61
+ private preLoginRoutes: Routes | null = null;
62
+
63
+ // Lazy getters, not field initializers: fields would snapshot during super(),
64
+ // before a subclass constructor can call configureRestUrls().
65
+ protected get appdataUrl() { return this.url(RestURL.appdataURL); }
66
+ protected get loginUrl() { return this.url(RestURL.loginURL); }
67
+ protected get registerUrl() { return this.url(RestURL.registerURL); }
68
+ protected get chpassUrl() { return this.url(RestURL.chpassURL); }
69
+ protected get confirmRegisterUrl() { return this.url(RestURL.confirmRegisterURL); }
70
+ protected get confirmChpassUrl() { return this.url(RestURL.confirmChpassURL); }
71
+ protected get loginGoogleUrl() { return this.url(RestURL.loginGoogleURL); }
72
+ protected get twoFactorSetupUrl() { return this.url(RestURL.twoFactorSetupURL); }
73
+ protected get twoFactorVerifyUrl() { return this.url(RestURL.twoFactorVerifyURL); }
74
+ protected get twoFactorDisableUrl() { return this.url(RestURL.twoFactorDisableURL); }
75
+ protected get twoFactorLoginVerifyUrl() { return this.url(RestURL.twoFactorLoginVerifyURL); }
76
+ protected get twoFactorBackupVerifyUrl() { return this.url(RestURL.twoFactorBackupVerifyURL); }
77
+ protected get trustedDeviceListUrl() { return this.url(RestURL.trustedDeviceListURL); }
78
+ protected get trustedDeviceRegisterUrl() { return this.url(RestURL.trustedDeviceRegisterURL); }
79
+ protected get trustedDeviceRevokeUrl() { return this.url(RestURL.trustedDeviceRevokeURL); }
80
+ protected get otpSendUrl() { return this.url(RestURL.otpSendURL); }
81
+ protected get otpVerifyUrl() { return this.url(RestURL.otpVerifyURL); }
82
+ protected get otpResendUrl() { return this.url(RestURL.otpResendURL); }
83
+ protected get loginSocialUrl() { return this.url(RestURL.loginSocialURL); }
84
+ protected get logoutEverywhereUrl() { return this.url(RestURL.logoutEverywhereURL); }
85
+ protected get deleteAccountUrl() { return this.url(RestURL.deleteAccountURL); }
86
+ protected get pushRegisterUrl() { return this.url(RestURL.pushRegisterURL); }
87
+ protected get pushRevokeUrl() { return this.url(RestURL.pushRevokeURL); }
88
+ protected get profileUrl() { return this.url(RestURL.profileURL); }
89
+ protected get profileEmailUrl() { return this.url(RestURL.profileEmailURL); }
90
+ protected get profileEmailConfirmUrl() { return this.url(RestURL.profileEmailConfirmURL); }
91
+ protected get profilePhoneUrl() { return this.url(RestURL.profilePhoneURL); }
92
+ protected get profilePhoneConfirmUrl() { return this.url(RestURL.profilePhoneConfirmURL); }
61
93
 
62
94
  token: string | null = null;
63
95
  readonly isLoggedIn = signal(false);
64
96
 
97
+ /** Last appdata load failure (null when none). */
98
+ readonly appDataError = signal<unknown>(null);
99
+
65
100
  /** Set when the OAuth session-cookie handoff fails; the UI shows it and lets the
66
101
  * user retry instead of being redirected into a cookieless login loop. */
67
102
  readonly sessionHandoffError = signal<string | null>(null);
@@ -103,12 +138,22 @@ export abstract class BaseAuthService extends BaseRestService {
103
138
  }
104
139
 
105
140
  loadAppData() {
106
- this.http.get<ApplicationData>(this.appdataUrl).pipe(
107
- tap((data) => {
141
+ const generation = this.sessionGeneration;
142
+ this.appDataError.set(null);
143
+ this.http.get<ApplicationData>(this.appdataUrl).subscribe({
144
+ next: (data) => {
145
+ if (generation !== this.sessionGeneration) return; // response outlived its session
108
146
  this.cache = data;
109
147
  this.buildAuthIndex();
110
- }),
111
- ).subscribe((data: ApplicationData) => this.appData$.next(data));
148
+ this.appData$.next(data);
149
+ },
150
+ error: (err) => {
151
+ if (generation !== this.sessionGeneration) return;
152
+ console.error('loadAppData failed:', err);
153
+ this.appDataError.set(err);
154
+ this.appDataFail$.next(err);
155
+ },
156
+ });
112
157
  }
113
158
 
114
159
  /** Adopt an externally-minted JWT (registration/SSO handoff flows) and run the
@@ -163,39 +208,32 @@ export abstract class BaseAuthService extends BaseRestService {
163
208
  return '';
164
209
  }
165
210
 
166
- login(username: string, password: string) {
167
- // withCredentials so the browser sends the server-set `keel_td` cookie
168
- // (keel v0.9 trusted-device secret). When it maps to a trusted row, keel
169
- // skips 2FA. The old client-supplied `deviceFingerprint` field is gone.
170
- this.http.post<LoginResponse2FA>(this.loginUrl, { username, password }, { withCredentials: true }).subscribe({
171
- next: (res) => {
172
- if (res.twoFactorRequired && res.loginToken) {
173
- localStorage.setItem('loginToken', res.loginToken);
174
- this.router.navigate(['/login/2fa']);
175
- } else {
176
- this.completeLogin(res.token);
177
- }
178
- },
179
- error: (err) => {
180
- console.error('Login failed:', err);
181
- },
182
- });
211
+ /** Route a login response: into the 2FA step when required, else complete the login. */
212
+ private handleLoginResponse(res: LoginResponse2FA): void {
213
+ if (res.twoFactorRequired && res.loginToken) {
214
+ localStorage.setItem('loginToken', res.loginToken);
215
+ this.router.navigate(['/login/2fa']);
216
+ } else {
217
+ this.completeLogin(res.token);
218
+ }
183
219
  }
184
220
 
185
- loginWithGoogle(code: string, redirectUri?: string) {
186
- this.http.post<LoginResponse2FA>(this.loginGoogleUrl, { code, redirectUri }, { withCredentials: true }).subscribe({
187
- next: (res) => {
188
- if (res.twoFactorRequired && res.loginToken) {
189
- localStorage.setItem('loginToken', res.loginToken);
190
- this.router.navigate(['/login/2fa']);
191
- } else {
192
- this.completeLogin(res.token);
193
- }
194
- },
195
- error: (err) => {
196
- console.error('Google login failed:', err);
197
- },
198
- });
221
+ /**
222
+ * POST credentials; success completes the login (or routes to 2FA). Callers
223
+ * subscribe and handle the error channel to surface failures in the UI.
224
+ * withCredentials sends keel's `keel_td` trusted-device cookie (skips 2FA).
225
+ */
226
+ login(username: string, password: string): Observable<LoginResponse2FA> {
227
+ return this.http.post<LoginResponse2FA>(this.loginUrl, { username, password }, { withCredentials: true }).pipe(
228
+ tap((res) => this.handleLoginResponse(res)),
229
+ );
230
+ }
231
+
232
+ /** OAuth-code Google login; same contract as login(). */
233
+ loginWithGoogle(code: string, redirectUri?: string): Observable<LoginResponse2FA> {
234
+ return this.http.post<LoginResponse2FA>(this.loginGoogleUrl, { code, redirectUri }, { withCredentials: true }).pipe(
235
+ tap((res) => this.handleLoginResponse(res)),
236
+ );
199
237
  }
200
238
 
201
239
  // ── 2FA Methods ──
@@ -239,10 +277,17 @@ export abstract class BaseAuthService extends BaseRestService {
239
277
  );
240
278
  }
241
279
 
242
- /** Verify a single-use backup code during the LOGIN flow (public endpoint). */
243
- verifyBackupCode(code: string) {
280
+ /** Verify a login-flow backup code; a valid code completes the login (same contract as verify2FALogin). */
281
+ verifyBackupCode(code: string): Observable<TwoFactorVerifyResponse> {
244
282
  const loginToken = localStorage.getItem('loginToken');
245
- return this.http.post<TwoFactorVerifyResponse>(this.twoFactorBackupVerifyUrl, { code, loginToken }, { withCredentials: true });
283
+ return this.http.post<TwoFactorVerifyResponse>(this.twoFactorBackupVerifyUrl, { code, loginToken }, { withCredentials: true }).pipe(
284
+ tap((res) => {
285
+ if (res.valid && res.token) {
286
+ localStorage.removeItem('loginToken');
287
+ this.completeLogin(res.token);
288
+ }
289
+ }),
290
+ );
246
291
  }
247
292
 
248
293
  /**
@@ -318,11 +363,7 @@ export abstract class BaseAuthService extends BaseRestService {
318
363
  }
319
364
 
320
365
  logout() {
321
- this.token = null;
322
- this.isLoggedIn.set(false);
323
- localStorage.removeItem('jwt');
324
- localStorage.removeItem('menu');
325
- this.clearRole();
366
+ this.clearSession();
326
367
  this.router.navigate(['/login/local'], { queryParams: { loggedOut: 'true' } });
327
368
  }
328
369
 
@@ -336,9 +377,8 @@ export abstract class BaseAuthService extends BaseRestService {
336
377
  // keel endpoints directly.
337
378
 
338
379
  /**
339
- * Send an OTP to a phone or email contact. `contactType` is a frontend-only
340
- * field used by callers to drive UI navigation; it is stripped before
341
- * posting because keel rejects unknown JSON fields with 400.
380
+ * Send an OTP to a phone or email contact. `contactType` (keel v0.5.11+)
381
+ * selects the SMS-vs-email dispatch channel and is posted as-is.
342
382
  *
343
383
  * The response is `{ otpToken }` — an opaque server-issued token (32 random
344
384
  * bytes, base64-URL) bound to the user_id in keel's cache for ~5 minutes.
@@ -348,11 +388,6 @@ export abstract class BaseAuthService extends BaseRestService {
348
388
  * numbers by comparing 200 vs 404.
349
389
  */
350
390
  sendOtp(req: OtpRequest): Observable<OtpResponse> {
351
- // Send the request as-is — `contactType` is now a real backend
352
- // field (keel v0.5.11+) that selects the SMS-vs-email dispatch
353
- // channel. The pre-v0.5.11 strip-before-post workaround was
354
- // removed when email-OTP support landed in keel/handler/
355
- // otp_handler.go.
356
391
  return this.http.post<OtpResponse>(this.otpSendUrl, req);
357
392
  }
358
393
 
@@ -394,12 +429,14 @@ export abstract class BaseAuthService extends BaseRestService {
394
429
  }
395
430
 
396
431
  // Single-use nonce for the social id_token flow (replay protection): GET on
397
- // the same social-login route. Empty when the backend hasn't enabled it —
398
- // callers then sign in without one.
432
+ // the same social-login route. Empty only when the backend explicitly hasn't
433
+ // enabled it (404/405); transient failures propagate so replay protection is
434
+ // never silently dropped.
399
435
  getSocialNonce(): Observable<string> {
400
436
  return this.http.get<{ nonce: string }>(this.loginSocialUrl).pipe(
401
437
  map((res) => res?.nonce ?? ''),
402
- catchError(() => of('')),
438
+ catchError((err: HttpErrorResponse) =>
439
+ (err.status === 404 || err.status === 405) ? of('') : throwError(() => err)),
403
440
  );
404
441
  }
405
442
 
@@ -443,57 +480,73 @@ export abstract class BaseAuthService extends BaseRestService {
443
480
  return this.http.post<void>(this.pushRevokeUrl, { token });
444
481
  }
445
482
 
446
- /** Clear in-memory + localStorage session without triggering a navigation. */
483
+ /** Clear the session without navigating — including cached appdata, grants,
484
+ * and the route tree, so nothing leaks past logout. */
447
485
  protected clearSession() {
448
486
  this.token = null;
449
487
  this.isLoggedIn.set(false);
450
488
  localStorage.removeItem('jwt');
451
489
  localStorage.removeItem('menu');
490
+ localStorage.removeItem('loginToken');
491
+ this.clearRole();
492
+ this.cache = undefined;
493
+ this.authIndex.clear();
494
+ this.appDataError.set(null);
495
+ this.sessionGeneration++; // in-flight loads from this session are ignored
496
+ this.appData$.next(null); // live subscribers see menus clear; identity stays stable
497
+ this.appDataFail$.complete();
498
+ this.appDataFail$ = new ReplaySubject<unknown>(1);
499
+ if (this.preLoginRoutes) {
500
+ this.router.resetConfig(this.preLoginRoutes);
501
+ }
502
+ }
503
+
504
+ /** Called by the auth interceptor when the 401 circuit is open — the stored session is dead. */
505
+ sessionExpired(): void {
506
+ if (!this.isLoggedIn()) return;
507
+ this.clearSession();
508
+ this.router.navigate(['/login/local'], { queryParams: { sessionExpired: 'true' } });
452
509
  }
453
510
 
454
511
  private buildAuthIndex() {
455
512
  this.authIndex.clear();
456
- for (const auth of this.cache.Permissions) {
457
- const obj = auth.AuthorizationObjectId ?? (auth as any).ObjectName;
513
+ if (!this.cache?.Permissions) return;
514
+ for (const auth of this.cache.Permissions as PermissionRow[]) {
515
+ const obj = auth.AuthorizationObjectId ?? auth.ObjectName;
458
516
  const act = auth.Action;
459
- const low = auth.LowLimit ?? (auth as any).Low;
517
+ const low = auth.LowLimit ?? auth.Low;
460
518
  if (!obj || !act || !low) continue;
461
519
  const key = obj + '|' + act;
462
- if (!this.authIndex.has(key)) {
463
- this.authIndex.set(key, []);
464
- }
465
520
  const reg = low
466
521
  .replace(/[.+^${}()|[\]\\*?]/g, '\\$&')
467
522
  .replace(/\\\*/g, '.*')
468
523
  .replace(/\\\?/g, '.');
469
- this.authIndex.get(key)!.push({
470
- Obj: obj,
471
- Act: act,
472
- Val: low,
473
- Reg: reg,
474
- } as AuthSummary);
524
+ const list = this.authIndex.get(key) ?? [];
525
+ list.push({ Obj: obj, Act: act, Val: low, Rx: new RegExp(`^${reg}$`) });
526
+ this.authIndex.set(key, list);
475
527
  }
476
528
  }
477
529
 
478
530
  private checkPermission(authorityObjectId: string, activity: string, value: string): boolean {
479
- if (!this.cache || !this.cache.Permissions) {
480
- return false;
481
- }
482
- const key = authorityObjectId + '|' + activity;
483
- const auths = this.authIndex.get(key);
484
- if (!auths) {
531
+ if (!this.cache) {
485
532
  return false;
486
533
  }
487
- for (const auth of auths) {
488
- if (new RegExp(`^${auth.Reg}$`).test(value)) {
489
- return true;
490
- }
491
- }
492
- return false;
493
- }
494
-
495
- getAppData() {
496
- return this.appData$.asObservable();
534
+ const auths = this.authIndex.get(authorityObjectId + '|' + activity);
535
+ return !!auths && auths.some((auth) => auth.Rx.test(value));
536
+ }
537
+
538
+ /** Appdata stream. Errors on load failure while nothing is cached yet, so
539
+ * waiters fail loudly instead of hanging; defer binds the failure channel
540
+ * to the current session's subject. */
541
+ getAppData(): Observable<ApplicationData> {
542
+ return defer(() => {
543
+ const failures = this.appDataFail$.pipe(
544
+ filter(() => !this.cache),
545
+ mergeMap((err) => throwError(() => err)),
546
+ );
547
+ const data = this.appData$.pipe(filter((d): d is ApplicationData => d !== null));
548
+ return merge(data, failures);
549
+ });
497
550
  }
498
551
 
499
552
  getDomainValues(domainName: string): ConstantValue[] | undefined {
@@ -524,8 +577,10 @@ export abstract class BaseAuthService extends BaseRestService {
524
577
  } as unknown as ConstantValue)).sort(byCaption);
525
578
  }
526
579
 
580
+ /** Menu stream for navigation UIs; never errors (failures surface via
581
+ * appDataError) and emits [] on logout so menus clear. */
527
582
  getMenus(): Observable<ApplicationMenu[]> {
528
- return this.appData$.pipe(map((data: ApplicationData) => data?.MainMenu ?? []));
583
+ return this.appData$.pipe(map((data) => data?.MainMenu ?? []));
529
584
  }
530
585
 
531
586
  getTableDefinition(tableName: string): TableDefinition | undefined {
@@ -543,14 +598,11 @@ export abstract class BaseAuthService extends BaseRestService {
543
598
  }
544
599
 
545
600
  /**
546
- * keel emits Reports as `map[string]*RestReport` which serializes to a
547
- * JSON object keyed by report id, not an array. The previous cast to
548
- * `RestReport[]` was a type lie that produced an empty iteration in
549
- * practice. Object.values is the correct unwrap.
601
+ * keel emits Reports as `map[string]*RestReport` — a JSON object keyed by
602
+ * report id, not an array. Object.values is the correct unwrap.
550
603
  */
551
604
  getReports(): RestReport[] {
552
- const reports = this.cache?.['Reports'] as Record<string, RestReport> | undefined;
553
- return reports ? Object.values(reports) : [];
605
+ return this.cache?.Reports ? Object.values(this.cache.Reports) : [];
554
606
  }
555
607
 
556
608
  /**
@@ -558,8 +610,7 @@ export abstract class BaseAuthService extends BaseRestService {
558
610
  * Returns undefined if Reports cache is missing or the id isn't registered.
559
611
  */
560
612
  getReport(id: string): RestReport | undefined {
561
- const reports = this.cache?.['Reports'] as Record<string, RestReport> | undefined;
562
- return reports?.[id];
613
+ return this.cache?.Reports?.[id];
563
614
  }
564
615
 
565
616
  canRead(tableName: string) { return this.checkPermission('TABLE', 'SELECT', tableName); }
@@ -580,16 +631,16 @@ export abstract class BaseAuthService extends BaseRestService {
580
631
 
581
632
  readonly canActivate: CanActivateFn = (route) => {
582
633
  return this.getAppData().pipe(
634
+ take(1),
583
635
  map(() => {
584
636
  const path = route.routeConfig?.path;
585
637
  if (!path) return true;
586
- const allowed = this.canAccess(path.split('/')[0]);
587
- if (!allowed) {
588
- console.warn(`[canActivate] access denied for: ${path}`);
589
- this.router.navigate(['/dashboard']);
590
- }
591
- return allowed;
638
+ if (this.canAccess(path.split('/')[0])) return true;
639
+ console.warn(`[canActivate] access denied for: ${path}`);
640
+ return this.router.parseUrl('/dashboard');
592
641
  }),
642
+ // appdata failed to load — the session is unusable; land on login.
643
+ catchError(() => of(this.router.parseUrl('/login/local'))),
593
644
  );
594
645
  };
595
646
 
@@ -728,6 +779,9 @@ export abstract class BaseAuthService extends BaseRestService {
728
779
  }
729
780
 
730
781
  newRoutes.push({ path: '**', redirectTo: 'dashboard' });
782
+ if (!this.preLoginRoutes) {
783
+ this.preLoginRoutes = this.router.config;
784
+ }
731
785
  this.router.resetConfig(newRoutes);
732
786
  this.router.navigateByUrl(this.postLoginTarget());
733
787
  },
@@ -1,18 +1,19 @@
1
1
  import { Injectable } from "@angular/core";
2
2
 
3
3
  // TODO: Implement translated captions service
4
- // - LoadLabels(langcode) should fetch labels from the backend
5
- // - GetLabel(langcode, key) should return the translated caption for a given key
4
+ // - loadLabels(langcode) should fetch labels from the backend
5
+ // - getLabel(langcode, key) should return the translated caption for a given key
6
6
  // - BaseTable.colCaption() and related methods should use this service for i18n
7
7
  @Injectable({ providedIn: 'root' })
8
8
  export class LabelService {
9
9
  protected labels: Map<string, Map<string, string>> = new Map();
10
10
 
11
- GetLabel(langcode: string, key: string): string {
12
- return this.labels.get(langcode)?.get(key) ?? '';
11
+ /** Falls back to the key itself so a missing translation is visible, not a blank caption. */
12
+ getLabel(langcode: string, key: string): string {
13
+ return this.labels.get(langcode)?.get(key) ?? key;
13
14
  }
14
15
 
15
- LoadLabels(_langcode: string) {
16
+ loadLabels(_langcode: string) {
16
17
  // TODO: fetch from backend and populate this.labels
17
18
  }
18
19
  }
@@ -0,0 +1,56 @@
1
+ import { Injectable, inject } from '@angular/core';
2
+ import { Observable } from 'rxjs';
3
+ import { map } from 'rxjs/operators';
4
+ import { BaseRestService } from './base_rest.service';
5
+ import { BackendService } from './rest_service';
6
+ import { PartnerCredential } from '../model/oauth_connection';
7
+
8
+ /**
9
+ * OAuthConnectionService — frontend client for keel oauth/connect.
10
+ *
11
+ * OAuth flow endpoints live under /api/oauth/*; the stored connection rows are
12
+ * read/removed through the generic REST `partner_credential` API. entityId (0 =
13
+ * tenant-wide, >0 = a specific business) scopes per-entity connections.
14
+ */
15
+ @Injectable({ providedIn: 'root' })
16
+ export class OAuthConnectionService extends BaseRestService {
17
+ private readonly backend = inject(BackendService);
18
+ private readonly crudApi = 'partner_credential';
19
+
20
+ /** Start an OAuth connect flow; resolves to the provider consent URL to redirect to. */
21
+ startOAuth(provider: string, extra: Record<string, string> = {}): Observable<string> {
22
+ const qs = new URLSearchParams(extra).toString();
23
+ const path = `/api/oauth/${encodeURIComponent(provider)}/authorize${qs ? '?' + qs : ''}`;
24
+ return this.http.get<{ url: string }>(this.url(path)).pipe(map((r) => r.url));
25
+ }
26
+
27
+ /** Re-validate a stored connection (POST — it makes an outbound call and updates
28
+ * status). entityId selects which one (0 = tenant-wide). */
29
+ testConnection(provider: string, entityId = 0): Observable<{ status: string; message?: string }> {
30
+ return this.http.post<{ status: string; message?: string }>(
31
+ this.url(`/api/oauth/${encodeURIComponent(provider)}/test?entity_id=${entityId}`),
32
+ {},
33
+ );
34
+ }
35
+
36
+ /** The partner's stored connections for one entity scope (0 = tenant-wide).
37
+ * Filters by entity so the panel never shows another business's connections. */
38
+ list(entityId = 0): Observable<PartnerCredential[]> {
39
+ return this.backend.list<PartnerCredential>(this.crudApi, { EntityId: String(entityId) });
40
+ }
41
+
42
+ /** Remove a stored connection by id. */
43
+ disconnect(id: number): Observable<{ message: string }> {
44
+ return this.backend.delete(this.crudApi, { Id: String(id) });
45
+ }
46
+
47
+ /** Save an API-key connection (sealed server-side by the connect handler). */
48
+ saveApiKey(provider: string, credRef: string, apiEndpoint = '', entityId = 0): Observable<{ status: string }> {
49
+ return this.http.post<{ status: string }>(this.url('/api/oauth/apikey'), {
50
+ provider,
51
+ cred_ref: credRef,
52
+ api_endpoint: apiEndpoint,
53
+ entity_id: entityId,
54
+ });
55
+ }
56
+ }