@nauticana/sail 1.0.3 → 1.0.8
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 +31 -9
- package/package.json +7 -1
- package/src/component/abstract/base_async.ts +7 -2
- package/src/component/abstract/base_table.ts +33 -12
- package/src/component/billing/checkout_button.ts +14 -4
- package/src/component/form/form_field.ts +1 -0
- package/src/component/login/register_component.html +15 -13
- package/src/component/login/register_component.ts +7 -3
- package/src/component/login/social_login.ts +9 -3
- package/src/component/security/twofactor_verify.ts +8 -5
- package/src/component/table/table_lookup.ts +2 -1
- package/src/component/table/table_report.ts +11 -5
- package/src/model/appdata.ts +1 -1
- package/src/service/api-response.interceptor.ts +10 -12
- package/src/service/auth.interceptor.ts +2 -1
- package/src/service/auth.service.ts +23 -18
- package/src/service/rest_url.ts +21 -0
- package/tsconfig.json +4 -0
package/README.md
CHANGED
|
@@ -2,24 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
A shared Angular component library for building CRUD-based admin frontends. Provides table management, form handling, navigation, authentication, two-factor authentication, and trusted device management — all driven by metadata from a [keel](https://github.com/nauticana/keel) Go backend.
|
|
4
4
|
|
|
5
|
-
> **Compatibility:** sail and keel are versioned in lock-step.
|
|
5
|
+
> **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.0.x ↔ keel v1.0.x**. Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
|
|
6
6
|
|
|
7
7
|
## What it provides
|
|
8
8
|
|
|
9
9
|
| Category | Exports |
|
|
10
10
|
|----------|---------|
|
|
11
|
-
| **Table components** | `TableList`, `TableSearch`, `TableEdit`, `TableDetail`, `TableLookup` |
|
|
11
|
+
| **Table components** | `TableList`, `TableSearch`, `TableEdit`, `TableDetail`, `TableLookup`, `TableReport` |
|
|
12
12
|
| **Form components** | `DynamicField`, `RecordForm`, `TableForm` |
|
|
13
13
|
| **Navigation** | `Navigation` (sidenav + toolbar with menu, responsive) |
|
|
14
14
|
| **Login** | `LoginComponent`, `RegisterComponent`, `ChpassComponent`, `ConfirmRegisterComponent`, `ConfirmChpassComponent` |
|
|
15
15
|
| **Security** | `TwoFactorSetupComponent`, `TwoFactorVerifyComponent`, `TrustedDevicesComponent`, `AccountDeletionComponent` |
|
|
16
16
|
| **Auth** | `ConsentGateComponent`, `OtpInputComponent`, `SocialLoginComponent` |
|
|
17
|
-
| **Billing** | `PlanSelectorComponent`, `CheckoutButtonComponent`, `PaymentMethodsComponent` |
|
|
18
|
-
| **
|
|
19
|
-
| **
|
|
17
|
+
| **Billing** | `PlanSelectorComponent`, `PriceSelectorComponent`, `CheckoutButtonComponent`, `PaymentMethodsComponent`, `PortalButtonComponent`, `UsageMeterComponent`, `StatusChipComponent`, `TrialBannerComponent`, `SeatSelectorComponent`, `DunningBannerComponent` |
|
|
18
|
+
| **Payout** | `PayoutProviderOnboardingComponent`, `PayoutBankInfoFormComponent` |
|
|
19
|
+
| **Payments / SCA** | `UserPaymentMethodsComponent`, `ScaConfirmComponent`, `ScaConfirmer` (port), `SCA_CONFIRMER` (token) |
|
|
20
|
+
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere), `BillingService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
21
|
+
| **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `BaseAsync`, `BaseRestService` |
|
|
20
22
|
| **Config** | `SAIL_GUI_CONFIG`, `SailGuiConfig`, `configureRestUrls()` |
|
|
21
|
-
| **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `TrustedDevice`, `PublicPlan`, `PaymentMethod`, `Subscription`, `Invoice`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, 2FA types, etc. |
|
|
23
|
+
| **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, 2FA types, etc. |
|
|
22
24
|
| **Decorators** | `@IsString()`, `@IsNumeric()` (class-validator based) |
|
|
25
|
+
| **Utils** | `titleCase()`, `fromPriceLabel()` |
|
|
23
26
|
|
|
24
27
|
## Quick start
|
|
25
28
|
|
|
@@ -35,7 +38,7 @@ This adds the following to your `package.json`:
|
|
|
35
38
|
|
|
36
39
|
```json
|
|
37
40
|
"dependencies": {
|
|
38
|
-
"@nauticana/sail": "^0.
|
|
41
|
+
"@nauticana/sail": "^1.0.0",
|
|
39
42
|
"class-validator": "^0.15.1"
|
|
40
43
|
}
|
|
41
44
|
```
|
|
@@ -249,7 +252,7 @@ interface SailGuiConfig {
|
|
|
249
252
|
}
|
|
250
253
|
```
|
|
251
254
|
|
|
252
|
-
## Backend endpoints (keel
|
|
255
|
+
## Backend endpoints (keel v1.0)
|
|
253
256
|
|
|
254
257
|
| Endpoint | Purpose |
|
|
255
258
|
|----------|---------|
|
|
@@ -313,6 +316,10 @@ For the browser to store and resend that cookie, the requests must be credential
|
|
|
313
316
|
|
|
314
317
|
If "trust this device" appears to do nothing (user is 2FA-prompted on every login despite checking the box), the cause is almost always a missing `AllowCredentials` / wildcard-origin on the backend — open devtools and confirm the `keel_td` cookie is actually set after a successful verify.
|
|
315
318
|
|
|
319
|
+
### Session token storage
|
|
320
|
+
|
|
321
|
+
The session JWT and the short-lived `loginToken` are kept in `localStorage` (the trusted-device secret is the only HttpOnly cookie). This is a deliberate trade-off for a token-based SPA, but it means any XSS in the consuming app can read the session token. Harden accordingly: ship a strict `Content-Security-Policy`, keep third-party script surface minimal, and prefer short JWT lifetimes on the keel side.
|
|
322
|
+
|
|
316
323
|
## Billing
|
|
317
324
|
|
|
318
325
|
sail ships a shared `BillingService` and ten billing components backed by keel's payment endpoints (see [keel/SHARED_PAYMENT.md](https://github.com/nauticana/keel/blob/main/SHARED_PAYMENT.md) for the provider contract — Stripe by default, pluggable).
|
|
@@ -921,7 +928,7 @@ npm update @nauticana/sail
|
|
|
921
928
|
Or to install a specific version:
|
|
922
929
|
|
|
923
930
|
```bash
|
|
924
|
-
npm install @nauticana/sail@0.
|
|
931
|
+
npm install @nauticana/sail@1.0.7
|
|
925
932
|
```
|
|
926
933
|
|
|
927
934
|
## Migrating to v0.5.0
|
|
@@ -1512,6 +1519,21 @@ No data migration needed. Rows written under the old plaintext-fingerprint schem
|
|
|
1512
1519
|
|
|
1513
1520
|
v0.9.0 targets **keel v0.9**. The full backend contract is documented in [keel/README.md → Trusted-device model (v0.9) and Migration from v0.8 trusted-device API](https://github.com/nauticana/keel/blob/main/README.md). Verify your backend sets `AllowCredentials` (cross-origin only) before rolling out.
|
|
1514
1521
|
|
|
1522
|
+
## Migrating to v1.0.7 — interceptor scoping, 2FA-verify return type
|
|
1523
|
+
|
|
1524
|
+
Additive for the bundled components; the notes below apply only if you call the auth API directly or relied on the interceptors mutating non-keel traffic.
|
|
1525
|
+
|
|
1526
|
+
- **Interceptors are now scoped to the keel host.** `authInterceptor` only attaches the JWT to the configured backend's `/api/` URLs (set via `configureRestUrls(host)`), and `apiResponseInterceptor` only unwraps / passes through responses from keel URLs. Requests to other origins are left untouched — the JWT no longer leaks to a third-party URL that merely contains `/api/`. If you routed non-keel calls through these interceptors expecting them to mutate the payload, handle that in your own interceptor.
|
|
1527
|
+
- **`apiResponseInterceptor` preserves the original error shape.** It no longer flattens `HttpErrorResponse.error` to a string, so `err.error.detail` / `err.error.message` (keel's RFC 7807 ProblemDetail) reach your components intact — matching what `BaseAsync` already reads.
|
|
1528
|
+
- **`verify2FALogin()` now returns `Observable<TwoFactorVerifyResponse>`** instead of subscribing internally and returning `void` — mirroring `verifyBackupCode()`, so a component can show loading / invalid-code state. **You must subscribe** for the request to fire. The bundled `TwoFactorVerifyComponent` is already updated; only direct callers need to change:
|
|
1529
|
+
|
|
1530
|
+
```diff
|
|
1531
|
+
- this.auth.verify2FALogin(code, trustDevice, deviceName);
|
|
1532
|
+
+ this.auth.verify2FALogin(code, trustDevice, deviceName).subscribe();
|
|
1533
|
+
```
|
|
1534
|
+
|
|
1535
|
+
- **`<sail-checkout-button>` `priceId` is now optional**, so setup-mode (save-card) works as documented — omit `[priceId]` for `[mode]="'setup'"`. Subscription / payment modes without a price now surface an inline error instead of a 400.
|
|
1536
|
+
|
|
1515
1537
|
## Modernization items
|
|
1516
1538
|
|
|
1517
1539
|
When you extend sail in your own app, apply the same patterns sail itself follows. Downstream projects that copied templates or scaffolding from earlier sail versions will benefit from the same sweep. This list is a checklist — none are sail-specific.
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"typecheck": "tsc --noEmit",
|
|
8
|
+
"check:templates": "ngc -p tsconfig.json",
|
|
9
|
+
"check": "npm run typecheck && npm run check:templates"
|
|
10
|
+
},
|
|
6
11
|
"files": [
|
|
7
12
|
"src/",
|
|
8
13
|
"tsconfig.json"
|
|
@@ -47,6 +52,7 @@
|
|
|
47
52
|
"@angular/cdk": "^21.2.6",
|
|
48
53
|
"@angular/common": "^21.2.8",
|
|
49
54
|
"@angular/compiler": "^21.2.8",
|
|
55
|
+
"@angular/compiler-cli": "^21.2.8",
|
|
50
56
|
"@angular/core": "^21.2.8",
|
|
51
57
|
"@angular/forms": "^21.2.8",
|
|
52
58
|
"@angular/material": "^21.2.6",
|
|
@@ -35,12 +35,14 @@ export abstract class BaseAsync {
|
|
|
35
35
|
/**
|
|
36
36
|
* Subscribe to an Observable with shared loading/error semantics.
|
|
37
37
|
* Clears messages, sets loading, invokes onSuccess on next, and routes
|
|
38
|
-
* errors through setError with the given fallback message.
|
|
38
|
+
* errors through setError with the given fallback message. Pass `onError`
|
|
39
|
+
* when the component must also react to failures (e.g. emit an output).
|
|
39
40
|
*/
|
|
40
41
|
protected run<T>(
|
|
41
42
|
obs: Observable<T>,
|
|
42
43
|
onSuccess: (value: T) => void,
|
|
43
44
|
fallbackError = 'Operation failed. Please try again.',
|
|
45
|
+
onError?: (err: unknown) => void,
|
|
44
46
|
): void {
|
|
45
47
|
this.clearMessages();
|
|
46
48
|
this.loading.set(true);
|
|
@@ -49,7 +51,10 @@ export abstract class BaseAsync {
|
|
|
49
51
|
this.loading.set(false);
|
|
50
52
|
onSuccess(value);
|
|
51
53
|
},
|
|
52
|
-
error: (err) =>
|
|
54
|
+
error: (err) => {
|
|
55
|
+
this.setError(err, fallbackError);
|
|
56
|
+
onError?.(err);
|
|
57
|
+
},
|
|
53
58
|
});
|
|
54
59
|
}
|
|
55
60
|
}
|
|
@@ -267,12 +267,26 @@ export abstract class BaseTable {
|
|
|
267
267
|
return String(rawValue);
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
|
|
270
|
+
/**
|
|
271
|
+
* Temporal input kind for a field, or null if not temporal. Drives both the
|
|
272
|
+
* load-format and save-restore so TIME/DATE columns aren't given the
|
|
273
|
+
* datetime-local (seconds + 'Z') treatment a Postgres TIME/DATE rejects.
|
|
274
|
+
*/
|
|
275
|
+
temporalKind(fieldName: string): 'time' | 'date' | 'datetime-local' | null {
|
|
271
276
|
const col = this.getColumn(fieldName)
|
|
272
|
-
if (col)
|
|
273
|
-
|
|
277
|
+
if (col?.InputType === 'time') return 'time'
|
|
278
|
+
if (col?.InputType === 'date') return 'date'
|
|
279
|
+
if (col?.InputType === 'datetime-local') return 'datetime-local'
|
|
280
|
+
// Fallback for column metadata predating keel's time/date split.
|
|
281
|
+
if (col ? col.DataType === 'timestamp'
|
|
282
|
+
: (fieldName.endsWith('Time') || fieldName.endsWith('time'))) {
|
|
283
|
+
return 'datetime-local'
|
|
274
284
|
}
|
|
275
|
-
return
|
|
285
|
+
return null
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
isTimeStamp(fieldName: string): boolean {
|
|
289
|
+
return this.temporalKind(fieldName) !== null
|
|
276
290
|
}
|
|
277
291
|
|
|
278
292
|
isNumber(fieldName: string): boolean {
|
|
@@ -303,9 +317,11 @@ export abstract class BaseTable {
|
|
|
303
317
|
return Array.isArray(value);
|
|
304
318
|
}
|
|
305
319
|
|
|
306
|
-
formatTimeStamp(val: string): string {
|
|
320
|
+
formatTimeStamp(val: string, kind: 'time' | 'date' | 'datetime-local' = 'datetime-local'): string {
|
|
307
321
|
if (val && typeof val === 'string') {
|
|
308
|
-
return val.slice(0,
|
|
322
|
+
if (kind === 'time') return val.slice(0, 5); // HH:MM
|
|
323
|
+
if (kind === 'date') return val.slice(0, 10); // YYYY-MM-DD
|
|
324
|
+
return val.slice(0, 16); // YYYY-MM-DDTHH:MM
|
|
309
325
|
}
|
|
310
326
|
return val;
|
|
311
327
|
}
|
|
@@ -313,16 +329,20 @@ export abstract class BaseTable {
|
|
|
313
329
|
formatRecordTimeStamp(record: any) {
|
|
314
330
|
if (record) {
|
|
315
331
|
for (const key of Object.keys(record)) {
|
|
316
|
-
|
|
317
|
-
|
|
332
|
+
const kind = this.temporalKind(key);
|
|
333
|
+
if (kind) {
|
|
334
|
+
record[key] = this.formatTimeStamp(record[key], kind);
|
|
318
335
|
}
|
|
319
336
|
}
|
|
320
337
|
}
|
|
321
338
|
}
|
|
322
339
|
|
|
323
|
-
restoreTimeStamp(val: any): any {
|
|
324
|
-
// PostgreSQL can't cast '' to
|
|
340
|
+
restoreTimeStamp(val: any, kind: 'time' | 'date' | 'datetime-local' = 'datetime-local'): any {
|
|
341
|
+
// PostgreSQL can't cast '' to a temporal type — normalize empty to null.
|
|
325
342
|
if (val === '' || val === undefined || val === null) return null;
|
|
343
|
+
// TIME/DATE values are sent verbatim ('08:45', '2026-06-15'); only
|
|
344
|
+
// datetime-local needs the seconds + UTC marker the picker omits.
|
|
345
|
+
if (kind === 'time' || kind === 'date') return val;
|
|
326
346
|
if (typeof val === 'string' && !val.endsWith('Z')) {
|
|
327
347
|
return val + ':00Z';
|
|
328
348
|
}
|
|
@@ -332,8 +352,9 @@ export abstract class BaseTable {
|
|
|
332
352
|
restoreRecordTimeStamp(record: any) {
|
|
333
353
|
if (record) {
|
|
334
354
|
for (const key of Object.keys(record)) {
|
|
335
|
-
|
|
336
|
-
|
|
355
|
+
const kind = this.temporalKind(key);
|
|
356
|
+
if (kind) {
|
|
357
|
+
record[key] = this.restoreTimeStamp(record[key], kind);
|
|
337
358
|
}
|
|
338
359
|
}
|
|
339
360
|
}
|
|
@@ -22,8 +22,12 @@ import { CheckoutMode } from '../../model/appdata';
|
|
|
22
22
|
export class CheckoutButtonComponent extends BaseAsync {
|
|
23
23
|
private readonly billing = inject(BillingService);
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Stripe price ID (or keel-internal plan id resolved server-side).
|
|
27
|
+
* Required for 'subscription' / 'payment'; omit for 'setup' (save-card),
|
|
28
|
+
* where keel rejects a non-empty priceId with 400.
|
|
29
|
+
*/
|
|
30
|
+
readonly priceId = input<string>();
|
|
27
31
|
/** Checkout mode — 'subscription' (default), 'payment' (one-off), or 'setup' (capture method). */
|
|
28
32
|
readonly mode = input<CheckoutMode>('subscription');
|
|
29
33
|
/** Quantity for the priced item. */
|
|
@@ -40,10 +44,16 @@ export class CheckoutButtonComponent extends BaseAsync {
|
|
|
40
44
|
readonly label = input('Continue to payment');
|
|
41
45
|
|
|
42
46
|
startCheckout(): void {
|
|
47
|
+
const mode = this.mode();
|
|
48
|
+
const priceId = this.priceId();
|
|
49
|
+
if (mode !== 'setup' && !priceId) {
|
|
50
|
+
this.errorMessage.set('Select a plan before continuing.');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
43
53
|
this.run(
|
|
44
54
|
this.billing.createCheckout({
|
|
45
|
-
mode
|
|
46
|
-
priceId:
|
|
55
|
+
mode,
|
|
56
|
+
priceId: mode === 'setup' ? undefined : priceId,
|
|
47
57
|
quantity: this.quantity(),
|
|
48
58
|
email: this.email(),
|
|
49
59
|
successUrl: this.successUrl(),
|
|
@@ -72,6 +72,7 @@ export class DynamicField extends BaseTable {
|
|
|
72
72
|
get isDateLike(): boolean {
|
|
73
73
|
if (!this.col) return false;
|
|
74
74
|
return this.col.InputType === 'date'
|
|
75
|
+
|| this.col.InputType === 'time'
|
|
75
76
|
|| this.col.InputType === 'datetime'
|
|
76
77
|
|| this.col.InputType === 'datetime-local';
|
|
77
78
|
}
|
|
@@ -102,18 +102,20 @@
|
|
|
102
102
|
</mat-form-field>
|
|
103
103
|
</div>
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
105
|
+
@if (geocodeEnabled) {
|
|
106
|
+
<div class="auth-checkbox">
|
|
107
|
+
<mat-checkbox formControlName="consentLocation">
|
|
108
|
+
I consent to geocode my business address for location services
|
|
109
|
+
</mat-checkbox>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
@if (geocodeStatus() === 'loading') {
|
|
113
|
+
<div class="geocode-status geocode-loading" role="status">Verifying address...</div>
|
|
114
|
+
} @else if (geocodeStatus() === 'success') {
|
|
115
|
+
<div class="geocode-status geocode-success" role="status">Address verified.</div>
|
|
116
|
+
} @else if (geocodeStatus() === 'error') {
|
|
117
|
+
<div class="geocode-status geocode-error" role="alert">{{ geocodeError() }}</div>
|
|
118
|
+
}
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
@if (registerError()) {
|
|
@@ -121,7 +123,7 @@
|
|
|
121
123
|
}
|
|
122
124
|
|
|
123
125
|
<button matButton="elevated" class="primary" type="submit"
|
|
124
|
-
[disabled]="!registerForm.valid || geocodeStatus() !== 'success'">
|
|
126
|
+
[disabled]="!registerForm.valid || (geocodeEnabled && geocodeStatus() !== 'success')">
|
|
125
127
|
Create Account
|
|
126
128
|
</button>
|
|
127
129
|
</form>
|
|
@@ -38,6 +38,10 @@ export class RegisterComponent implements OnInit {
|
|
|
38
38
|
private router = inject(Router);
|
|
39
39
|
protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
40
40
|
|
|
41
|
+
/** Geocoding is only active when a Google Maps key is configured; without
|
|
42
|
+
* one the address-consent + verify step is skipped so registration still works. */
|
|
43
|
+
protected readonly geocodeEnabled = !!this.guiConfig.googleMapsApiKey;
|
|
44
|
+
|
|
41
45
|
readonly registerError = signal('');
|
|
42
46
|
readonly plans = signal<PublicPlan[]>([]);
|
|
43
47
|
|
|
@@ -58,7 +62,7 @@ export class RegisterComponent implements OnInit {
|
|
|
58
62
|
PlanID: ['FREE', Validators.required],
|
|
59
63
|
Latitude: [null as number | null],
|
|
60
64
|
Longitude: [null as number | null],
|
|
61
|
-
consentLocation: [false, Validators.requiredTrue],
|
|
65
|
+
consentLocation: [false, this.geocodeEnabled ? Validators.requiredTrue : []],
|
|
62
66
|
});
|
|
63
67
|
|
|
64
68
|
readonly geocodeStatus = signal<'idle' | 'loading' | 'success' | 'error'>('idle');
|
|
@@ -92,7 +96,7 @@ export class RegisterComponent implements OnInit {
|
|
|
92
96
|
constructor() {
|
|
93
97
|
effect(() => {
|
|
94
98
|
const consent = this.consentLocation();
|
|
95
|
-
if (consent) {
|
|
99
|
+
if (this.geocodeEnabled && consent) {
|
|
96
100
|
this.tryGeocode();
|
|
97
101
|
} else {
|
|
98
102
|
this.registerForm.patchValue({ Latitude: null, Longitude: null });
|
|
@@ -107,7 +111,7 @@ export class RegisterComponent implements OnInit {
|
|
|
107
111
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
108
112
|
.subscribe(() => {
|
|
109
113
|
this.registerForm.patchValue({ Latitude: null, Longitude: null });
|
|
110
|
-
if (this.consentLocation()) {
|
|
114
|
+
if (this.geocodeEnabled && this.consentLocation()) {
|
|
111
115
|
this.tryGeocode();
|
|
112
116
|
}
|
|
113
117
|
});
|
|
@@ -99,13 +99,19 @@ export class SocialLoginComponent extends BaseAsync implements OnInit {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
private onIdToken(provider: SocialProvider, idToken: string) {
|
|
102
|
+
const fallback = `Sign-in with ${provider} failed.`;
|
|
102
103
|
this.run(
|
|
103
104
|
this.auth.loginSocial(provider, idToken, this.buildSignupConsent()),
|
|
104
105
|
(resp) => this.loginSuccess.emit(resp),
|
|
105
|
-
|
|
106
|
+
fallback,
|
|
107
|
+
(err) => this.loginError.emit(this.toError(err, fallback)),
|
|
106
108
|
);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private toError(err: unknown, fallback: string): Error {
|
|
112
|
+
if (err instanceof Error) return err;
|
|
113
|
+
const e = err as { error?: { detail?: string; message?: string } };
|
|
114
|
+
return new Error(e?.error?.detail ?? e?.error?.message ?? fallback);
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
private buildSignupConsent(): SignupConsent | undefined {
|
|
@@ -63,11 +63,14 @@ export class TwoFactorVerifyComponent extends BaseAsync {
|
|
|
63
63
|
'Backup code verification failed.',
|
|
64
64
|
);
|
|
65
65
|
} else {
|
|
66
|
-
this.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
this.run(
|
|
67
|
+
this.auth.verify2FALogin(code!, trustDevice ?? false, this.getDeviceName()),
|
|
68
|
+
(res) => {
|
|
69
|
+
if (!res.valid || !res.token) {
|
|
70
|
+
this.errorMessage.set('Invalid authentication code.');
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
'Verification failed. Please try again.',
|
|
71
74
|
);
|
|
72
75
|
}
|
|
73
76
|
}
|
|
@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit,
|
|
|
2
2
|
import { MatButtonModule } from "@angular/material/button";
|
|
3
3
|
import { MAT_DIALOG_DATA, MatDialogActions, MatDialogClose, MatDialogContent, MatDialogRef, MatDialogTitle } from "@angular/material/dialog";
|
|
4
4
|
import { MatIconModule } from "@angular/material/icon";
|
|
5
|
+
import { take } from "rxjs";
|
|
5
6
|
import { RecordForm } from "../form/form_record";
|
|
6
7
|
import { BaseView } from "../abstract/base_view";
|
|
7
8
|
|
|
@@ -41,7 +42,7 @@ export class TableLookup extends BaseView implements OnInit {
|
|
|
41
42
|
private resolveApi() {
|
|
42
43
|
const tableName = this.tableName();
|
|
43
44
|
if (!this.apiName() && tableName) {
|
|
44
|
-
this.cacheService.getAppData().subscribe((data) => {
|
|
45
|
+
this.cacheService.getAppData().pipe(take(1)).subscribe((data) => {
|
|
45
46
|
if (data.Apis) {
|
|
46
47
|
for (const api in data.Apis) {
|
|
47
48
|
const entry = data.Apis[api];
|
|
@@ -42,14 +42,21 @@ export class TableReport extends BaseRestService {
|
|
|
42
42
|
*/
|
|
43
43
|
readonly paramValues = signal<Record<string, string>>({});
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Report id — the last path segment of apiName (e.g. 'scorecard' for
|
|
47
|
+
* 'analytic/scorecard'). This is the key for metadata lookup AND the value
|
|
48
|
+
* REPORT/ACCESS grants are scoped to; the full path is only used for the
|
|
49
|
+
* fetch URL.
|
|
50
|
+
*/
|
|
51
|
+
readonly reportId = computed(() => this.apiName().split('/').pop() ?? '');
|
|
52
|
+
|
|
45
53
|
/**
|
|
46
54
|
* Report header text — long-form description from rest_report_header.
|
|
47
55
|
* Falls back to the title-cased table name only if the lookup misses
|
|
48
56
|
* (e.g. a brand-new report whose row hasn't been seeded yet).
|
|
49
57
|
*/
|
|
50
58
|
readonly reportTitle = computed(() => {
|
|
51
|
-
|
|
52
|
-
return this.auth.getReport(reportId)?.Description || titleCase(this.tableName());
|
|
59
|
+
return this.auth.getReport(this.reportId())?.Description || titleCase(this.tableName());
|
|
53
60
|
});
|
|
54
61
|
|
|
55
62
|
/**
|
|
@@ -60,8 +67,7 @@ export class TableReport extends BaseRestService {
|
|
|
60
67
|
* reports.
|
|
61
68
|
*/
|
|
62
69
|
readonly params = computed<ReportParam[]>(() => {
|
|
63
|
-
|
|
64
|
-
return this.auth.getReport(reportId)?.Params ?? [];
|
|
70
|
+
return this.auth.getReport(this.reportId())?.Params ?? [];
|
|
65
71
|
});
|
|
66
72
|
|
|
67
73
|
constructor() {
|
|
@@ -70,7 +76,7 @@ export class TableReport extends BaseRestService {
|
|
|
70
76
|
const api = this.apiName();
|
|
71
77
|
if (!api) return;
|
|
72
78
|
|
|
73
|
-
if (!this.auth.canReport(
|
|
79
|
+
if (!this.auth.canReport(this.reportId())) {
|
|
74
80
|
this.errorMessage.set('You do not have permission to view this report.');
|
|
75
81
|
return;
|
|
76
82
|
}
|
package/src/model/appdata.ts
CHANGED
|
@@ -40,7 +40,7 @@ export interface TableColumn {
|
|
|
40
40
|
PascalName: string;
|
|
41
41
|
Caption: string;
|
|
42
42
|
DataType: string;
|
|
43
|
-
InputType: 'text' | 'password' | 'number' | 'date' | 'datetime' | 'datetime-local' | 'textarea' | 'select' | 'checkbox';
|
|
43
|
+
InputType: 'text' | 'password' | 'number' | 'date' | 'time' | 'datetime' | 'datetime-local' | 'textarea' | 'select' | 'checkbox';
|
|
44
44
|
Size: number;
|
|
45
45
|
Scale: number;
|
|
46
46
|
Step: string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
|
|
2
|
+
import { map } from 'rxjs';
|
|
3
|
+
import { isKeelUrl } from './rest_url';
|
|
3
4
|
|
|
4
5
|
// Detect a final response event by structural shape rather than
|
|
5
6
|
// `event instanceof HttpResponse`. Bundling can produce two distinct
|
|
@@ -21,7 +22,14 @@ function isFinalResponse(e: unknown): e is HttpResponse<unknown> {
|
|
|
21
22
|
&& typeof r.status === 'number';
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
// Scoped to keel URLs so third-party responses (and their errors) pass through
|
|
26
|
+
// untouched. Unwraps keel's `{ data }` envelope; errors are left intact so
|
|
27
|
+
// consumers can read the original `HttpErrorResponse.error` (keel's RFC 7807
|
|
28
|
+
// ProblemDetail, with `detail` / `title`).
|
|
24
29
|
export const apiResponseInterceptor: HttpInterceptorFn = (req, next) => {
|
|
30
|
+
if (!isKeelUrl(req.url)) {
|
|
31
|
+
return next(req);
|
|
32
|
+
}
|
|
25
33
|
return next(req).pipe(
|
|
26
34
|
map(event => {
|
|
27
35
|
if (isFinalResponse(event)) {
|
|
@@ -32,15 +40,5 @@ export const apiResponseInterceptor: HttpInterceptorFn = (req, next) => {
|
|
|
32
40
|
}
|
|
33
41
|
return event;
|
|
34
42
|
}),
|
|
35
|
-
catchError((error: HttpErrorResponse) => {
|
|
36
|
-
const detail = error.error?.detail || error.error?.title || error.message;
|
|
37
|
-
return throwError(() => new HttpErrorResponse({
|
|
38
|
-
error: detail,
|
|
39
|
-
headers: error.headers,
|
|
40
|
-
status: error.status,
|
|
41
|
-
statusText: error.statusText,
|
|
42
|
-
url: error.url ?? undefined,
|
|
43
|
-
}));
|
|
44
|
-
}),
|
|
45
43
|
);
|
|
46
44
|
};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { HttpInterceptorFn } from '@angular/common/http';
|
|
2
|
+
import { isKeelApiUrl } from './rest_url';
|
|
2
3
|
|
|
3
4
|
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
|
4
5
|
const token = localStorage.getItem('jwt');
|
|
5
|
-
if (token && req.url
|
|
6
|
+
if (token && isKeelApiUrl(req.url)) {
|
|
6
7
|
req = req.clone({
|
|
7
8
|
setHeaders: { Authorization: `Bearer ${token}` },
|
|
8
9
|
});
|
|
@@ -161,33 +161,27 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
|
-
* Verify a TOTP code during the LOGIN flow (public endpoint
|
|
164
|
+
* Verify a TOTP code during the LOGIN flow (public endpoint, `/public/2fa/verify`).
|
|
165
|
+
* Returns the response so callers can drive loading / invalid-code UI; on a
|
|
166
|
+
* valid code the login is completed (token stored, app data + routes loaded).
|
|
167
|
+
* withCredentials so the browser stores the `keel_td` cookie keel sets on a
|
|
168
|
+
* `trustDevice:true` verify — that cookie is what skips 2FA next login.
|
|
165
169
|
*/
|
|
166
|
-
verify2FALogin(code: string, trustDevice = false, deviceName?: string) {
|
|
167
|
-
const loginToken = localStorage.getItem('loginToken');
|
|
168
|
-
if (!loginToken) {
|
|
169
|
-
console.error('No login token found');
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
170
|
+
verify2FALogin(code: string, trustDevice = false, deviceName?: string): Observable<TwoFactorVerifyResponse> {
|
|
172
171
|
const request: TwoFactorVerifyRequest = {
|
|
173
172
|
code,
|
|
174
|
-
loginToken,
|
|
173
|
+
loginToken: localStorage.getItem('loginToken') ?? '',
|
|
175
174
|
trustDevice,
|
|
176
175
|
deviceName,
|
|
177
176
|
};
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
this.http.post<TwoFactorVerifyResponse>(this.twoFactorLoginVerifyUrl, request, { withCredentials: true }).subscribe({
|
|
181
|
-
next: (res) => {
|
|
177
|
+
return this.http.post<TwoFactorVerifyResponse>(this.twoFactorLoginVerifyUrl, request, { withCredentials: true }).pipe(
|
|
178
|
+
tap((res) => {
|
|
182
179
|
if (res.valid && res.token) {
|
|
183
180
|
localStorage.removeItem('loginToken');
|
|
184
181
|
this.completeLogin(res.token);
|
|
185
182
|
}
|
|
186
|
-
},
|
|
187
|
-
|
|
188
|
-
console.error('2FA verification failed:', err);
|
|
189
|
-
},
|
|
190
|
-
});
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
191
185
|
}
|
|
192
186
|
|
|
193
187
|
/** Verify a single-use backup code during the LOGIN flow (public endpoint). */
|
|
@@ -526,6 +520,17 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
526
520
|
return undefined;
|
|
527
521
|
}
|
|
528
522
|
|
|
523
|
+
/**
|
|
524
|
+
* Where to land after routes are (re)built. A fresh login starts from the
|
|
525
|
+
* root or a `/login` route → go to the dashboard. A refresh / bookmark of an
|
|
526
|
+
* authenticated route → preserve it so deep links survive route restoration.
|
|
527
|
+
*/
|
|
528
|
+
private postLoginTarget(): string {
|
|
529
|
+
const path = window.location.pathname;
|
|
530
|
+
const preserve = !!path && path !== '/' && !path.startsWith('/login');
|
|
531
|
+
return preserve ? path + window.location.search : '/dashboard';
|
|
532
|
+
}
|
|
533
|
+
|
|
529
534
|
async initRoutes() {
|
|
530
535
|
// Lazy imports to break circular dependency:
|
|
531
536
|
// auth.service → TableSearch/TableList → BaseTable → auth.service
|
|
@@ -615,7 +620,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
615
620
|
|
|
616
621
|
newRoutes.push({ path: '**', redirectTo: 'dashboard' });
|
|
617
622
|
this.router.resetConfig(newRoutes);
|
|
618
|
-
this.router.
|
|
623
|
+
this.router.navigateByUrl(this.postLoginTarget());
|
|
619
624
|
},
|
|
620
625
|
error: (err) => {
|
|
621
626
|
console.error('initRoutes failed:', err);
|
package/src/service/rest_url.ts
CHANGED
|
@@ -54,3 +54,24 @@ export function configureRestUrls(httpHost: string, overrides?: Partial<typeof R
|
|
|
54
54
|
Object.assign(RestURL, overrides);
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* True when `url` targets the configured keel backend's authenticated `/api/`
|
|
60
|
+
* surface. Used to scope the JWT interceptor so the token is never attached to
|
|
61
|
+
* a third-party origin that merely happens to contain `/api/` in its path.
|
|
62
|
+
*/
|
|
63
|
+
export function isKeelApiUrl(url: string): boolean {
|
|
64
|
+
const host = RestURL.httpHost;
|
|
65
|
+
return host ? url.startsWith(host + '/api/') : url.startsWith('/api/');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* True when `url` targets the keel backend at all (`/api/` or `/public/`).
|
|
70
|
+
* Used to scope the response interceptor's envelope-unwrapping so third-party
|
|
71
|
+
* responses pass through untouched.
|
|
72
|
+
*/
|
|
73
|
+
export function isKeelUrl(url: string): boolean {
|
|
74
|
+
const host = RestURL.httpHost;
|
|
75
|
+
if (host) return url.startsWith(host + '/');
|
|
76
|
+
return url.startsWith('/api/') || url.startsWith('/public/');
|
|
77
|
+
}
|
package/tsconfig.json
CHANGED