@nauticana/sail 0.5.0
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/LICENSE +201 -0
- package/README.md +741 -0
- package/package.json +58 -0
- package/src/component/abstract/base_async.ts +49 -0
- package/src/component/abstract/base_form.ts +11 -0
- package/src/component/abstract/base_table.ts +320 -0
- package/src/component/abstract/base_view.ts +93 -0
- package/src/component/billing/checkout_button.html +10 -0
- package/src/component/billing/checkout_button.ts +57 -0
- package/src/component/billing/payment_methods.html +27 -0
- package/src/component/billing/payment_methods.ts +36 -0
- package/src/component/billing/plan_selector.html +24 -0
- package/src/component/billing/plan_selector.ts +29 -0
- package/src/component/form/form_field.html +54 -0
- package/src/component/form/form_field.ts +156 -0
- package/src/component/form/form_record.html +14 -0
- package/src/component/form/form_record.ts +56 -0
- package/src/component/form/form_table.html +19 -0
- package/src/component/form/form_table.ts +58 -0
- package/src/component/login/account_deletion.html +42 -0
- package/src/component/login/account_deletion.ts +79 -0
- package/src/component/login/chpass_component.html +58 -0
- package/src/component/login/chpass_component.ts +96 -0
- package/src/component/login/confirm_chpass_component.html +47 -0
- package/src/component/login/confirm_chpass_component.ts +70 -0
- package/src/component/login/confirm_register_component.html +37 -0
- package/src/component/login/confirm_register_component.ts +71 -0
- package/src/component/login/consent_gate.html +30 -0
- package/src/component/login/consent_gate.ts +109 -0
- package/src/component/login/login_component.html +43 -0
- package/src/component/login/login_component.ts +73 -0
- package/src/component/login/otp_input.html +38 -0
- package/src/component/login/otp_input.ts +104 -0
- package/src/component/login/register_component.html +132 -0
- package/src/component/login/register_component.ts +184 -0
- package/src/component/login/social_login.html +13 -0
- package/src/component/login/social_login.ts +120 -0
- package/src/component/navigation/navigation.html +49 -0
- package/src/component/navigation/navigation.ts +69 -0
- package/src/component/security/trusted_devices.html +76 -0
- package/src/component/security/trusted_devices.ts +92 -0
- package/src/component/security/twofactor_setup.html +75 -0
- package/src/component/security/twofactor_setup.ts +106 -0
- package/src/component/security/twofactor_verify.html +45 -0
- package/src/component/security/twofactor_verify.ts +90 -0
- package/src/component/table/table_detail.html +53 -0
- package/src/component/table/table_detail.ts +133 -0
- package/src/component/table/table_edit.html +58 -0
- package/src/component/table/table_edit.ts +163 -0
- package/src/component/table/table_list.html +41 -0
- package/src/component/table/table_list.ts +100 -0
- package/src/component/table/table_lookup.html +35 -0
- package/src/component/table/table_lookup.ts +88 -0
- package/src/component/table/table_search.html +17 -0
- package/src/component/table/table_search.ts +80 -0
- package/src/config.ts +67 -0
- package/src/index.ts +41 -0
- package/src/model/appdata.ts +266 -0
- package/src/model/auth.ts +134 -0
- package/src/model/common.ts +115 -0
- package/src/model/decorator.ts +74 -0
- package/src/service/api-response.interceptor.ts +26 -0
- package/src/service/auth.interceptor.ts +11 -0
- package/src/service/auth.service.ts +576 -0
- package/src/service/billing.service.ts +65 -0
- package/src/service/label.service.ts +18 -0
- package/src/service/rest_service.ts +94 -0
- package/src/service/rest_url.ts +42 -0
- package/src/service/script_loader.ts +31 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { Injectable, WritableSignal, inject, signal } from '@angular/core';
|
|
2
|
+
import { HttpClient, 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';
|
|
5
|
+
import {
|
|
6
|
+
LoginResponseSocial,
|
|
7
|
+
OtpRequest, OtpResendRequest, OtpResponse, OtpVerifyRequest, OtpVerifyResponse,
|
|
8
|
+
PushPlatform, PushRegisterRequest,
|
|
9
|
+
ReauthCredentials,
|
|
10
|
+
SignupConsent, SocialLoginRequest, SocialProvider,
|
|
11
|
+
} from '../model/auth';
|
|
12
|
+
import { RestURL } from './rest_url';
|
|
13
|
+
import { Observable, ReplaySubject } from 'rxjs';
|
|
14
|
+
import { ApplicationMenu, ConstantValue } from '../model/common';
|
|
15
|
+
import { CanActivateFn, Router, Routes } from '@angular/router';
|
|
16
|
+
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../config';
|
|
17
|
+
|
|
18
|
+
@Injectable()
|
|
19
|
+
export abstract class BaseAuthService {
|
|
20
|
+
private readonly http = inject(HttpClient);
|
|
21
|
+
protected readonly router = inject(Router);
|
|
22
|
+
protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
23
|
+
protected cache!: ApplicationData;
|
|
24
|
+
private readonly appData$ = new ReplaySubject<ApplicationData>(1);
|
|
25
|
+
private readonly authIndex = new Map<string, AuthSummary[]>();
|
|
26
|
+
|
|
27
|
+
protected get appdataUrl() { return RestURL.httpHost + RestURL.appdataURL; }
|
|
28
|
+
protected get loginUrl() { return RestURL.httpHost + RestURL.loginURL; }
|
|
29
|
+
protected get registerUrl() { return RestURL.httpHost + RestURL.registerURL; }
|
|
30
|
+
protected get chpassUrl() { return RestURL.httpHost + RestURL.chpassURL; }
|
|
31
|
+
protected get confirmRegisterUrl() { return RestURL.httpHost + RestURL.confirmRegisterURL; }
|
|
32
|
+
protected get confirmChpassUrl() { return RestURL.httpHost + RestURL.confirmChpassURL; }
|
|
33
|
+
protected get loginGoogleUrl() { return RestURL.httpHost + RestURL.loginGoogleURL; }
|
|
34
|
+
protected get twoFactorSetupUrl() { return RestURL.httpHost + RestURL.twoFactorSetupURL; }
|
|
35
|
+
protected get twoFactorVerifyUrl() { return RestURL.httpHost + RestURL.twoFactorVerifyURL; }
|
|
36
|
+
protected get twoFactorDisableUrl() { return RestURL.httpHost + RestURL.twoFactorDisableURL; }
|
|
37
|
+
protected get twoFactorLoginVerifyUrl() { return RestURL.httpHost + RestURL.twoFactorLoginVerifyURL; }
|
|
38
|
+
protected get twoFactorBackupVerifyUrl() { return RestURL.httpHost + RestURL.twoFactorBackupVerifyURL; }
|
|
39
|
+
protected get trustedDeviceListUrl() { return RestURL.httpHost + RestURL.trustedDeviceListURL; }
|
|
40
|
+
protected get trustedDeviceRegisterUrl() { return RestURL.httpHost + RestURL.trustedDeviceRegisterURL; }
|
|
41
|
+
protected get trustedDeviceRevokeUrl() { return RestURL.httpHost + RestURL.trustedDeviceRevokeURL; }
|
|
42
|
+
protected get otpSendUrl() { return RestURL.httpHost + RestURL.otpSendURL; }
|
|
43
|
+
protected get otpVerifyUrl() { return RestURL.httpHost + RestURL.otpVerifyURL; }
|
|
44
|
+
protected get otpResendUrl() { return RestURL.httpHost + RestURL.otpResendURL; }
|
|
45
|
+
protected get loginSocialUrl() { return RestURL.httpHost + RestURL.loginSocialURL; }
|
|
46
|
+
protected get logoutEverywhereUrl() { return RestURL.httpHost + RestURL.logoutEverywhereURL; }
|
|
47
|
+
protected get deleteAccountUrl() { return RestURL.httpHost + RestURL.deleteAccountURL; }
|
|
48
|
+
protected get pushRegisterUrl() { return RestURL.httpHost + RestURL.pushRegisterURL; }
|
|
49
|
+
protected get pushRevokeUrl() { return RestURL.httpHost + RestURL.pushRevokeURL; }
|
|
50
|
+
|
|
51
|
+
token: string | null = null;
|
|
52
|
+
readonly isLoggedIn = signal(false);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Multi-role app support. Consumers that distinguish rider/driver/admin
|
|
56
|
+
* (or any equivalent split) call setRole() after login, then
|
|
57
|
+
* navigateToRoleHome({...}) to route to the per-role landing page. The
|
|
58
|
+
* value is persisted in localStorage so the role survives a refresh.
|
|
59
|
+
*
|
|
60
|
+
* Single-role consumers (default) leave currentRole untouched; the
|
|
61
|
+
* signal stays null and these helpers are never called.
|
|
62
|
+
*/
|
|
63
|
+
readonly currentRole: WritableSignal<string | null> = signal(localStorage.getItem('userRole'));
|
|
64
|
+
|
|
65
|
+
/** Persist role to localStorage and update the signal. */
|
|
66
|
+
setRole(role: string): void {
|
|
67
|
+
if (!role) return;
|
|
68
|
+
localStorage.setItem('userRole', role);
|
|
69
|
+
this.currentRole.set(role);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Drop the persisted role. Called by logout(); also exposed for explicit "clear role" UX. */
|
|
73
|
+
clearRole(): void {
|
|
74
|
+
localStorage.removeItem('userRole');
|
|
75
|
+
this.currentRole.set(null);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Navigate to the home route for the currently-set role. roleHomeMap
|
|
80
|
+
* maps role names → URL paths (e.g. `{ rider: '/rider/home', driver:
|
|
81
|
+
* '/driver/home', admin: '/admin' }`). Falls back to '/' when no role
|
|
82
|
+
* is set or when the map has no entry for the current role.
|
|
83
|
+
*/
|
|
84
|
+
navigateToRoleHome(roleHomeMap: Record<string, string>): void {
|
|
85
|
+
const role = this.currentRole();
|
|
86
|
+
const target = role ? roleHomeMap[role] : null;
|
|
87
|
+
this.router.navigateByUrl(target ?? '/');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
loadAppData() {
|
|
91
|
+
this.http.get<ApplicationData>(this.appdataUrl).pipe(
|
|
92
|
+
tap((data) => {
|
|
93
|
+
this.cache = data;
|
|
94
|
+
this.buildAuthIndex();
|
|
95
|
+
}),
|
|
96
|
+
).subscribe((data: ApplicationData) => this.appData$.next(data));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private completeLogin(token: string) {
|
|
100
|
+
this.token = token;
|
|
101
|
+
this.isLoggedIn.set(true);
|
|
102
|
+
localStorage.setItem('jwt', token);
|
|
103
|
+
this.loadAppData();
|
|
104
|
+
this.initRoutes();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
login(username: string, password: string, deviceFingerprint?: string) {
|
|
108
|
+
this.http.post<LoginResponse2FA>(this.loginUrl, { username, password, deviceFingerprint }).subscribe({
|
|
109
|
+
next: (res) => {
|
|
110
|
+
if (res.twoFactorRequired && res.loginToken) {
|
|
111
|
+
localStorage.setItem('loginToken', res.loginToken);
|
|
112
|
+
this.router.navigate(['/login/2fa']);
|
|
113
|
+
} else {
|
|
114
|
+
this.completeLogin(res.token);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
error: (err) => {
|
|
118
|
+
console.error('Login failed:', err);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
loginWithGoogle(code: string, redirectUri?: string) {
|
|
124
|
+
this.http.post<LoginResponse2FA>(this.loginGoogleUrl, { code, redirectUri }).subscribe({
|
|
125
|
+
next: (res) => {
|
|
126
|
+
if (res.twoFactorRequired && res.loginToken) {
|
|
127
|
+
localStorage.setItem('loginToken', res.loginToken);
|
|
128
|
+
this.router.navigate(['/login/2fa']);
|
|
129
|
+
} else {
|
|
130
|
+
this.completeLogin(res.token);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
error: (err) => {
|
|
134
|
+
console.error('Google login failed:', err);
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── 2FA Methods ──
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Generate a fresh TOTP seed + 10 backup codes. Requires recent
|
|
143
|
+
* re-authentication — pass either current `password` or current
|
|
144
|
+
* `twoFactorCode` (one of them is mandatory). Server-side rotation
|
|
145
|
+
* revokes all active refresh tokens.
|
|
146
|
+
*/
|
|
147
|
+
setup2FA(reauth: ReauthCredentials) {
|
|
148
|
+
return this.http.post<TwoFactorSetupResponse>(this.twoFactorSetupUrl, reauth);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Confirm a freshly-set TOTP seed by verifying a code (authenticated). */
|
|
152
|
+
verify2FA(request: TwoFactorVerifyRequest) {
|
|
153
|
+
return this.http.post<TwoFactorVerifyResponse>(this.twoFactorVerifyUrl, request);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Verify a TOTP code during the LOGIN flow (public endpoint). Uses `/public/2fa/verify`
|
|
158
|
+
*/
|
|
159
|
+
verify2FALogin(code: string, trustDevice = false, deviceFingerprint?: string, deviceName?: string) {
|
|
160
|
+
const loginToken = localStorage.getItem('loginToken');
|
|
161
|
+
if (!loginToken) {
|
|
162
|
+
console.error('No login token found');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const request: TwoFactorVerifyRequest = {
|
|
166
|
+
code,
|
|
167
|
+
loginToken,
|
|
168
|
+
trustDevice,
|
|
169
|
+
deviceFingerprint,
|
|
170
|
+
deviceName,
|
|
171
|
+
};
|
|
172
|
+
this.http.post<TwoFactorVerifyResponse>(this.twoFactorLoginVerifyUrl, request).subscribe({
|
|
173
|
+
next: (res) => {
|
|
174
|
+
if (res.valid && res.token) {
|
|
175
|
+
localStorage.removeItem('loginToken');
|
|
176
|
+
this.completeLogin(res.token);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
error: (err) => {
|
|
180
|
+
console.error('2FA verification failed:', err);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Verify a single-use backup code during the LOGIN flow (public endpoint). */
|
|
186
|
+
verifyBackupCode(code: string) {
|
|
187
|
+
const loginToken = localStorage.getItem('loginToken');
|
|
188
|
+
return this.http.post<TwoFactorVerifyResponse>(this.twoFactorBackupVerifyUrl, { code, loginToken });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Disable 2FA. Requires BOTH current password AND current TOTP `code`.
|
|
193
|
+
* Server-side revokes all active refresh tokens.
|
|
194
|
+
*/
|
|
195
|
+
disable2FA(password: string, code: string) {
|
|
196
|
+
return this.http.post<{ message: string }>(this.twoFactorDisableUrl, { password, code });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Trusted Device Methods ──
|
|
200
|
+
|
|
201
|
+
getTrustedDevices() {
|
|
202
|
+
return this.http.get<TrustedDevice[]>(this.trustedDeviceListUrl);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
revokeTrustedDevice(deviceId: number) {
|
|
206
|
+
return this.http.post<{ message: string }>(this.trustedDeviceRevokeUrl, { deviceId });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
chpass(username: string, new_password: string, old_password: string) {
|
|
210
|
+
return this.http.post<{ message: string }>(this.chpassUrl, { username, old_password, new_password });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
forgotPassword(username: string) {
|
|
214
|
+
return this.http.post<{ message: string }>(this.chpassUrl, { username });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
confirmRegister(email: string, code: string) {
|
|
218
|
+
const params = new HttpParams().set('email', email).set('code', code);
|
|
219
|
+
return this.http.post<ConfirmRegisterResponse>(this.confirmRegisterUrl, null, { params });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
confirmChpass(username: string, code: string, new_password: string) {
|
|
223
|
+
return this.http.post<{ message: string }>(this.confirmChpassUrl, { username, code, new_password });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
loadStoredSession() {
|
|
227
|
+
this.token = localStorage.getItem('jwt');
|
|
228
|
+
if (this.token) {
|
|
229
|
+
this.isLoggedIn.set(true);
|
|
230
|
+
this.loadAppData();
|
|
231
|
+
this.initRoutes();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
logout() {
|
|
236
|
+
this.token = null;
|
|
237
|
+
this.isLoggedIn.set(false);
|
|
238
|
+
localStorage.removeItem('jwt');
|
|
239
|
+
localStorage.removeItem('menu');
|
|
240
|
+
this.clearRole();
|
|
241
|
+
this.router.navigate(['/login/local'], { queryParams: { loggedOut: 'true' } });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
register(reg: PartnerRegistration) {
|
|
245
|
+
return this.http.post<PartnerRegistration>(this.registerUrl, reg);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── Phone / email OTP ──
|
|
249
|
+
// Override in subclasses to add project-specific behaviour (e.g. routing the
|
|
250
|
+
// JWT into rider vs driver session state). Default implementations call the
|
|
251
|
+
// keel endpoints directly.
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Send an OTP to a phone or email contact. `contactType` is a frontend-only
|
|
255
|
+
* field used by callers to drive UI navigation; it is stripped before
|
|
256
|
+
* posting because keel rejects unknown JSON fields with 400.
|
|
257
|
+
*
|
|
258
|
+
* The response is `{ otpToken }` — an opaque server-issued token (32 random
|
|
259
|
+
* bytes, base64-URL) bound to the user_id in keel's cache for ~5 minutes.
|
|
260
|
+
* Echo it back to verifyOtp / resendOtp. On unknown contacts the login
|
|
261
|
+
* fall-through still returns 200 with a token (no SMS dispatched), so the
|
|
262
|
+
* response shape is identical and an attacker cannot enumerate registered
|
|
263
|
+
* numbers by comparing 200 vs 404.
|
|
264
|
+
*/
|
|
265
|
+
sendOtp(req: OtpRequest): Observable<OtpResponse> {
|
|
266
|
+
const { contactType: _contactType, ...payload } = req;
|
|
267
|
+
return this.http.post<OtpResponse>(this.otpSendUrl, payload);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
resendOtp(otpToken: string, purpose?: OtpRequest['purpose']): Observable<{ status: string }> {
|
|
271
|
+
const req: OtpResendRequest = { otpToken, purpose };
|
|
272
|
+
return this.http.post<{ status: string }>(this.otpResendUrl, req);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Verify an OTP and complete login. The optional `role` argument is
|
|
277
|
+
* persisted on success so multi-role consumers (rider/driver/admin)
|
|
278
|
+
* can route to the right landing screen after the JWT lands. When
|
|
279
|
+
* omitted (single-role apps), the role stays unchanged.
|
|
280
|
+
*/
|
|
281
|
+
verifyOtp(req: OtpVerifyRequest, role?: string): Observable<OtpVerifyResponse> {
|
|
282
|
+
return this.http.post<OtpVerifyResponse>(this.otpVerifyUrl, req).pipe(
|
|
283
|
+
tap((res) => {
|
|
284
|
+
if (res?.token) {
|
|
285
|
+
this.completeLogin(res.token);
|
|
286
|
+
if (role) this.setRole(role);
|
|
287
|
+
}
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Social login (ID token flow) ──
|
|
293
|
+
// Distinct from the older loginWithGoogle(code) OAuth-code flow, which
|
|
294
|
+
// remains for backward compatibility. Prefer loginSocial for new code.
|
|
295
|
+
|
|
296
|
+
loginSocial(
|
|
297
|
+
provider: SocialProvider,
|
|
298
|
+
idToken: string,
|
|
299
|
+
consent?: SignupConsent,
|
|
300
|
+
): Observable<LoginResponseSocial> {
|
|
301
|
+
const payload: SocialLoginRequest = { provider, token: idToken, ...consent };
|
|
302
|
+
return this.http.post<LoginResponseSocial>(this.loginSocialUrl, payload).pipe(
|
|
303
|
+
tap((res) => { if (res?.token) this.completeLogin(res.token); }),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Account lifecycle ──
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Soft-delete the caller's account. Requires recent re-authentication —
|
|
311
|
+
* pass current `password` and/or `twoFactorCode`. Server anonymizes
|
|
312
|
+
* user_account, revokes all tokens / trusted devices / social links.
|
|
313
|
+
* Clears local session on success.
|
|
314
|
+
*/
|
|
315
|
+
deleteAccount(reauth: ReauthCredentials, reason?: string): Observable<void> {
|
|
316
|
+
const body = { ...reauth, reason };
|
|
317
|
+
return this.http.request<void>('DELETE', this.deleteAccountUrl, { body }).pipe(
|
|
318
|
+
tap(() => this.clearSession()),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Revoke every active refresh token for this user (sign out everywhere).
|
|
324
|
+
* Requires recent re-authentication — pass current `password` and/or
|
|
325
|
+
* `twoFactorCode`.
|
|
326
|
+
*/
|
|
327
|
+
logoutEverywhere(reauth: ReauthCredentials): Observable<void> {
|
|
328
|
+
return this.http.post<void>(this.logoutEverywhereUrl, reauth);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── Push token registration ──
|
|
332
|
+
|
|
333
|
+
registerPushToken(
|
|
334
|
+
platform: PushPlatform,
|
|
335
|
+
token: string,
|
|
336
|
+
appVersion?: string,
|
|
337
|
+
deviceModel?: string,
|
|
338
|
+
): Observable<void> {
|
|
339
|
+
const req: PushRegisterRequest = { platform, token, appVersion, deviceModel };
|
|
340
|
+
return this.http.post<void>(this.pushRegisterUrl, req);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
revokePushToken(token: string): Observable<void> {
|
|
344
|
+
return this.http.post<void>(this.pushRevokeUrl, { token });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Clear in-memory + localStorage session without triggering a navigation. */
|
|
348
|
+
protected clearSession() {
|
|
349
|
+
this.token = null;
|
|
350
|
+
this.isLoggedIn.set(false);
|
|
351
|
+
localStorage.removeItem('jwt');
|
|
352
|
+
localStorage.removeItem('menu');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private buildAuthIndex() {
|
|
356
|
+
this.authIndex.clear();
|
|
357
|
+
for (const auth of this.cache.Permissions) {
|
|
358
|
+
const obj = auth.AuthorizationObjectId ?? (auth as any).ObjectName;
|
|
359
|
+
const act = auth.Action;
|
|
360
|
+
const low = auth.LowLimit ?? (auth as any).Low;
|
|
361
|
+
if (!obj || !act || !low) continue;
|
|
362
|
+
const key = obj + '|' + act;
|
|
363
|
+
if (!this.authIndex.has(key)) {
|
|
364
|
+
this.authIndex.set(key, []);
|
|
365
|
+
}
|
|
366
|
+
const reg = low
|
|
367
|
+
.replace(/[.+^${}()|[\]\\*?]/g, '\\$&')
|
|
368
|
+
.replace(/\\\*/g, '.*')
|
|
369
|
+
.replace(/\\\?/g, '.');
|
|
370
|
+
this.authIndex.get(key)!.push({
|
|
371
|
+
Obj: obj,
|
|
372
|
+
Act: act,
|
|
373
|
+
Val: low,
|
|
374
|
+
Reg: reg,
|
|
375
|
+
} as AuthSummary);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private checkPermission(authorityObjectId: string, activity: string, value: string): boolean {
|
|
380
|
+
if (!this.cache || !this.cache.Permissions) {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
const key = authorityObjectId + '|' + activity;
|
|
384
|
+
const auths = this.authIndex.get(key);
|
|
385
|
+
if (!auths) {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
for (const auth of auths) {
|
|
389
|
+
if (new RegExp(`^${auth.Reg}$`).test(value)) {
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
getAppData() {
|
|
397
|
+
return this.appData$.asObservable();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
getDomainValues(domainName: string): ConstantValue[] | undefined {
|
|
401
|
+
if (!this.cache || !this.cache.ConstantCache || !this.cache.ConstantCache[domainName]) {
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
const opField = this.guiConfig.opField;
|
|
405
|
+
const domainMap = this.cache.ConstantCache[domainName];
|
|
406
|
+
return Object.keys(domainMap).map((key) => ({
|
|
407
|
+
ConstantId: domainName,
|
|
408
|
+
Value: key,
|
|
409
|
+
Caption: domainMap[key],
|
|
410
|
+
[opField]: 'S',
|
|
411
|
+
} as unknown as ConstantValue));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
getTableValues(tableName: string): ConstantValue[] | undefined {
|
|
415
|
+
if (!this.cache || !this.cache.TableCache || !this.cache.TableCache[tableName]) {
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
const opField = this.guiConfig.opField;
|
|
419
|
+
const tableMap = this.cache.TableCache[tableName];
|
|
420
|
+
return Object.keys(tableMap).map((key) => ({
|
|
421
|
+
ConstantId: tableName,
|
|
422
|
+
Value: key,
|
|
423
|
+
Caption: tableMap[key],
|
|
424
|
+
[opField]: 'S',
|
|
425
|
+
} as unknown as ConstantValue));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
getMenus(): Observable<ApplicationMenu[]> {
|
|
429
|
+
return this.appData$.pipe(map((data: ApplicationData) => data?.MainMenu ?? []));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
getTableDefinition(tableName: string): TableDefinition | undefined {
|
|
433
|
+
if (!this.cache || !this.cache.TableDefinitions) {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
return this.cache.TableDefinitions[tableName];
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
getApiDictionary(dictionaryId: string): DictionaryPath | undefined {
|
|
440
|
+
if (!this.cache || !this.cache.Apis) {
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
443
|
+
return this.cache.Apis[dictionaryId];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
getReports(): RestReport[] {
|
|
447
|
+
return (this.cache?.['Reports'] as RestReport[]) ?? [];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
canRead(tableName: string) { return this.checkPermission('TABLE', 'SELECT', tableName); }
|
|
451
|
+
canCreate(tableName: string) { return this.checkPermission('TABLE', 'INSERT', tableName); }
|
|
452
|
+
canUpdate(tableName: string) { return this.checkPermission('TABLE', 'UPDATE', tableName); }
|
|
453
|
+
canDelete(tableName: string) { return this.checkPermission('TABLE', 'DELETE', tableName); }
|
|
454
|
+
canAccess(pageName: string) { return this.checkPermission('PAGE', 'ACCESS', pageName); }
|
|
455
|
+
canReport(reportName: string) { return this.checkPermission('REPORT', 'ACCESS', reportName); }
|
|
456
|
+
|
|
457
|
+
readonly canActivate: CanActivateFn = (route) => {
|
|
458
|
+
return this.getAppData().pipe(
|
|
459
|
+
map(() => {
|
|
460
|
+
const path = route.routeConfig?.path;
|
|
461
|
+
if (!path) return true;
|
|
462
|
+
const allowed = this.canAccess(path.split('/')[0]);
|
|
463
|
+
if (!allowed) {
|
|
464
|
+
console.warn(`[canActivate] access denied for: ${path}`);
|
|
465
|
+
this.router.navigate(['/dashboard']);
|
|
466
|
+
}
|
|
467
|
+
return allowed;
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
/** Override in subclasses to add project-specific routes */
|
|
473
|
+
protected buildExtraRoutes(data: ApplicationData): Routes {
|
|
474
|
+
if (this.guiConfig.extraRoutes) {
|
|
475
|
+
return this.guiConfig.extraRoutes(data);
|
|
476
|
+
}
|
|
477
|
+
return [];
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private getRouteOverride(restUri: string): import('@angular/core').Type<unknown> | undefined {
|
|
481
|
+
const overrides = this.guiConfig.menuItemRouteOverrides;
|
|
482
|
+
if (!overrides) return undefined;
|
|
483
|
+
if (overrides[restUri]) return overrides[restUri];
|
|
484
|
+
for (const pattern of Object.keys(overrides)) {
|
|
485
|
+
if (pattern.endsWith('/*') && restUri.startsWith(pattern.slice(0, -1))) {
|
|
486
|
+
return overrides[pattern];
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async initRoutes() {
|
|
493
|
+
// Lazy imports to break circular dependency:
|
|
494
|
+
// auth.service → TableSearch/TableList → BaseTable → auth.service
|
|
495
|
+
const { TableSearch } = await import('../component/table/table_search');
|
|
496
|
+
const { TableList } = await import('../component/table/table_list');
|
|
497
|
+
|
|
498
|
+
this.getAppData().pipe(take(1)).subscribe({
|
|
499
|
+
next: (data: ApplicationData) => {
|
|
500
|
+
const menus = data.MainMenu ?? [];
|
|
501
|
+
const apis = data.Apis;
|
|
502
|
+
|
|
503
|
+
const publicRoutes = this.guiConfig.publicRoutes ?? [];
|
|
504
|
+
const dashboardComponent = this.guiConfig.dashboardComponent;
|
|
505
|
+
const extraRoutes = this.buildExtraRoutes(data);
|
|
506
|
+
|
|
507
|
+
const newRoutes: Routes = [
|
|
508
|
+
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
|
509
|
+
...publicRoutes,
|
|
510
|
+
...extraRoutes,
|
|
511
|
+
];
|
|
512
|
+
|
|
513
|
+
if (dashboardComponent) {
|
|
514
|
+
newRoutes.push({ path: 'dashboard', component: dashboardComponent });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
for (const menu of menus) {
|
|
518
|
+
if (!menu.Id || !menu.ApplicationMenuItems) continue;
|
|
519
|
+
const menuPath = menu.Id.toLowerCase();
|
|
520
|
+
const children: Routes = [];
|
|
521
|
+
|
|
522
|
+
for (const page of menu.ApplicationMenuItems) {
|
|
523
|
+
if (!page.ItemId) continue;
|
|
524
|
+
const restUri = page.RestUri ?? '';
|
|
525
|
+
const api = apis?.[restUri];
|
|
526
|
+
const apiName = api?.Version ? api.Version + '/' + restUri : restUri;
|
|
527
|
+
const tableName = api?.Table?.TableName ?? restUri;
|
|
528
|
+
|
|
529
|
+
const overrideComponent = this.getRouteOverride(restUri);
|
|
530
|
+
if (overrideComponent) {
|
|
531
|
+
children.push({
|
|
532
|
+
path: page.ItemId,
|
|
533
|
+
component: overrideComponent,
|
|
534
|
+
canActivate: [this.canActivate],
|
|
535
|
+
data: { tableName, apiName },
|
|
536
|
+
});
|
|
537
|
+
} else if (page.FilterOnList) {
|
|
538
|
+
children.push({
|
|
539
|
+
path: page.ItemId,
|
|
540
|
+
component: TableSearch,
|
|
541
|
+
canActivate: [this.canActivate],
|
|
542
|
+
data: {
|
|
543
|
+
tableName,
|
|
544
|
+
apiName,
|
|
545
|
+
targetRoute: menuPath + '/' + page.ItemId + '/list',
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
children.push({
|
|
549
|
+
path: page.ItemId + '/list',
|
|
550
|
+
component: TableList,
|
|
551
|
+
canActivate: [this.canActivate],
|
|
552
|
+
data: { tableName, apiName },
|
|
553
|
+
});
|
|
554
|
+
} else {
|
|
555
|
+
children.push({
|
|
556
|
+
path: page.ItemId,
|
|
557
|
+
component: TableList,
|
|
558
|
+
canActivate: [this.canActivate],
|
|
559
|
+
data: { tableName, apiName },
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
newRoutes.push({ path: menuPath, children });
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
newRoutes.push({ path: '**', redirectTo: 'dashboard' });
|
|
568
|
+
this.router.resetConfig(newRoutes);
|
|
569
|
+
this.router.navigate(['/dashboard']);
|
|
570
|
+
},
|
|
571
|
+
error: (err) => {
|
|
572
|
+
console.error('initRoutes failed:', err);
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Injectable, inject } from '@angular/core';
|
|
2
|
+
import { HttpClient } from '@angular/common/http';
|
|
3
|
+
import { Observable } from 'rxjs';
|
|
4
|
+
import { RestURL } from './rest_url';
|
|
5
|
+
import {
|
|
6
|
+
CheckoutRequest,
|
|
7
|
+
CheckoutResponse,
|
|
8
|
+
Invoice,
|
|
9
|
+
PaymentMethod,
|
|
10
|
+
PublicPlan,
|
|
11
|
+
Subscription,
|
|
12
|
+
} from '../model/appdata';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* BillingService — shared billing API client.
|
|
16
|
+
*
|
|
17
|
+
* Owns all plan/subscription/payment-method/invoice calls. Consumer apps
|
|
18
|
+
* inject this service rather than duplicating HTTP wiring. See
|
|
19
|
+
* keel/SHARED_PAYMENT.md for the backend contract.
|
|
20
|
+
*/
|
|
21
|
+
@Injectable({ providedIn: 'root' })
|
|
22
|
+
export class BillingService {
|
|
23
|
+
private readonly http = inject(HttpClient);
|
|
24
|
+
|
|
25
|
+
private url(path: string): string {
|
|
26
|
+
return RestURL.httpHost + path;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Public catalog — safe to call before login. */
|
|
30
|
+
listPlans(): Observable<PublicPlan[]> {
|
|
31
|
+
return this.http.get<PublicPlan[]>(this.url(RestURL.plansURL));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create a provider-hosted checkout session; caller redirects to the URL.
|
|
36
|
+
*
|
|
37
|
+
* The endpoint is JWT-gated and validates `priceId`, `successUrl`,
|
|
38
|
+
* `cancelUrl` against server-side allowlists. Configure
|
|
39
|
+
* `AllowedRedirectHosts` and `AllowedPriceIDs` on the keel deployment
|
|
40
|
+
* or every checkout will be rejected with 400.
|
|
41
|
+
*/
|
|
42
|
+
createCheckout(req: CheckoutRequest): Observable<CheckoutResponse> {
|
|
43
|
+
return this.http.post<CheckoutResponse>(this.url(RestURL.checkoutURL), req);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Current partner's active subscription. */
|
|
47
|
+
getSubscription(): Observable<Subscription> {
|
|
48
|
+
return this.http.get<Subscription>(this.url(RestURL.subscriptionURL));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Cancel auto-renew on the current subscription. */
|
|
52
|
+
cancelSubscription(): Observable<void> {
|
|
53
|
+
return this.http.post<void>(this.url(RestURL.cancelSubURL), {});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Partner's invoice history. */
|
|
57
|
+
listInvoices(): Observable<Invoice[]> {
|
|
58
|
+
return this.http.get<Invoice[]>(this.url(RestURL.invoicesURL));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Payment methods on file for the partner. */
|
|
62
|
+
listPaymentMethods(): Observable<PaymentMethod[]> {
|
|
63
|
+
return this.http.get<PaymentMethod[]>(this.url(RestURL.paymentMethodsURL));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Injectable } from "@angular/core";
|
|
2
|
+
|
|
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
|
|
6
|
+
// - BaseTable.colCaption() and related methods should use this service for i18n
|
|
7
|
+
@Injectable({ providedIn: 'root' })
|
|
8
|
+
export class LabelService {
|
|
9
|
+
protected labels: Map<string, Map<string, string>> = new Map();
|
|
10
|
+
|
|
11
|
+
GetLabel(langcode: string, key: string): string {
|
|
12
|
+
return this.labels.get(langcode)?.get(key) ?? '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
LoadLabels(_langcode: string) {
|
|
16
|
+
// TODO: fetch from backend and populate this.labels
|
|
17
|
+
}
|
|
18
|
+
}
|