@nauticana/sail 1.1.9 → 1.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/package.json +5 -1
- package/src/component/abstract/base_async.ts +9 -11
- package/src/component/abstract/base_form.ts +6 -5
- package/src/component/abstract/base_table.ts +131 -58
- package/src/component/abstract/base_view.ts +43 -27
- package/src/component/account/profile_editor.ts +2 -2
- package/src/component/billing/payment_methods.ts +1 -2
- package/src/component/billing/price_selector.html +2 -1
- package/src/component/billing/price_selector.ts +28 -13
- package/src/component/billing/trial_banner.ts +7 -1
- package/src/component/billing/usage_meter.ts +2 -2
- package/src/component/dashboard/data_card.html +1 -1
- package/src/component/dashboard/entity_selector.ts +21 -23
- package/src/component/form/form_field.html +6 -5
- package/src/component/form/form_field.ts +39 -21
- package/src/component/form/form_record.html +3 -3
- package/src/component/form/form_record.ts +23 -8
- package/src/component/form/form_table.html +2 -2
- package/src/component/form/form_table.ts +6 -5
- package/src/component/login/account_deletion.ts +6 -6
- package/src/component/login/chpass_component.html +4 -4
- package/src/component/login/chpass_component.ts +0 -1
- package/src/component/login/confirm_chpass_component.html +4 -4
- package/src/component/login/confirm_chpass_component.ts +1 -4
- package/src/component/login/consent_gate.html +6 -6
- package/src/component/login/consent_gate.ts +17 -24
- package/src/component/login/login_component.html +7 -4
- package/src/component/login/login_component.ts +9 -5
- package/src/component/login/otp_input.html +13 -5
- package/src/component/login/otp_input.ts +34 -27
- package/src/component/login/register_component.html +31 -14
- package/src/component/login/register_component.ts +80 -89
- package/src/component/login/sms_consent.html +11 -0
- package/src/component/login/sms_consent.ts +38 -0
- package/src/component/login/social_login.html +2 -2
- package/src/component/login/social_login.ts +20 -13
- package/src/component/navigation/navigation.html +2 -2
- package/src/component/navigation/navigation.ts +2 -0
- package/src/component/oauth_connection/oauth_connection_panel.ts +24 -3
- package/src/component/payment/user_payment_methods.html +2 -2
- package/src/component/payment/user_payment_methods.ts +12 -23
- package/src/component/payout/bank_info_form.html +4 -4
- package/src/component/payout/bank_info_form.ts +22 -26
- package/src/component/payout/provider_onboarding.html +5 -5
- package/src/component/payout/provider_onboarding.ts +28 -48
- package/src/component/security/trusted_devices.html +1 -1
- package/src/component/security/trusted_devices.ts +0 -1
- package/src/component/security/twofactor_setup.html +8 -4
- package/src/component/security/twofactor_setup.ts +21 -3
- package/src/component/security/twofactor_verify.html +2 -1
- package/src/component/security/twofactor_verify.ts +2 -8
- package/src/component/table/table_detail.html +15 -14
- package/src/component/table/table_detail.ts +62 -41
- package/src/component/table/table_edit.html +15 -11
- package/src/component/table/table_edit.ts +94 -67
- package/src/component/table/table_list.html +45 -12
- package/src/component/table/table_list.ts +98 -26
- package/src/component/table/table_lookup.html +11 -8
- package/src/component/table/table_lookup.ts +45 -37
- package/src/component/table/table_report.html +2 -2
- package/src/component/table/table_report.ts +37 -31
- package/src/component/table/table_search.html +4 -4
- package/src/component/table/table_search.ts +36 -45
- package/src/config.ts +5 -1
- package/src/index.ts +2 -0
- package/src/model/appdata.ts +5 -9
- package/src/model/common.ts +27 -27
- package/src/model/decorator.ts +25 -21
- package/src/service/api-response.interceptor.ts +4 -1
- package/src/service/auth.interceptor.ts +27 -7
- package/src/service/auth.service.ts +188 -134
- package/src/service/label.service.ts +6 -5
- package/src/service/oauth_connection.service.ts +2 -2
- package/src/service/rest_service.ts +57 -13
- package/src/service/rest_url.ts +23 -7
- package/src/service/script_loader.ts +12 -2
- package/src/util/errors.ts +20 -0
- 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,
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
protected
|
|
34
|
-
protected
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
protected
|
|
49
|
-
protected
|
|
50
|
-
protected
|
|
51
|
-
protected
|
|
52
|
-
protected
|
|
53
|
-
protected
|
|
54
|
-
protected
|
|
55
|
-
protected
|
|
56
|
-
protected
|
|
57
|
-
protected
|
|
58
|
-
protected
|
|
59
|
-
protected
|
|
60
|
-
protected
|
|
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.
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
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.
|
|
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`
|
|
340
|
-
*
|
|
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
|
|
398
|
-
//
|
|
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(() =>
|
|
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
|
|
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
|
-
|
|
457
|
-
|
|
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 ??
|
|
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)
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
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`
|
|
547
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
// -
|
|
5
|
-
// -
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
+
loadLabels(_langcode: string) {
|
|
16
17
|
// TODO: fetch from backend and populate this.labels
|
|
17
18
|
}
|
|
18
19
|
}
|
|
@@ -20,7 +20,7 @@ export class OAuthConnectionService extends BaseRestService {
|
|
|
20
20
|
/** Start an OAuth connect flow; resolves to the provider consent URL to redirect to. */
|
|
21
21
|
startOAuth(provider: string, extra: Record<string, string> = {}): Observable<string> {
|
|
22
22
|
const qs = new URLSearchParams(extra).toString();
|
|
23
|
-
const path = `/api/oauth/${provider}/authorize${qs ? '?' + qs : ''}`;
|
|
23
|
+
const path = `/api/oauth/${encodeURIComponent(provider)}/authorize${qs ? '?' + qs : ''}`;
|
|
24
24
|
return this.http.get<{ url: string }>(this.url(path)).pipe(map((r) => r.url));
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -28,7 +28,7 @@ export class OAuthConnectionService extends BaseRestService {
|
|
|
28
28
|
* status). entityId selects which one (0 = tenant-wide). */
|
|
29
29
|
testConnection(provider: string, entityId = 0): Observable<{ status: string; message?: string }> {
|
|
30
30
|
return this.http.post<{ status: string; message?: string }>(
|
|
31
|
-
this.url(`/api/oauth/${provider}/test?entity_id=${entityId}`),
|
|
31
|
+
this.url(`/api/oauth/${encodeURIComponent(provider)}/test?entity_id=${entityId}`),
|
|
32
32
|
{},
|
|
33
33
|
);
|
|
34
34
|
}
|