@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 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. Use **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**. 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`.
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
- | **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere), `BillingService`, `BackendService`, `LabelService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
19
- | **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `BaseAsync` |
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.5.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 v0.5)
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.9.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",
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) => this.setError(err, fallbackError),
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
- isTimeStamp(fieldName: string): boolean {
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
- return col.DataType === 'timestamp';
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 fieldName.endsWith('Time') || fieldName.endsWith('time');
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, 16);
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
- if (this.isTimeStamp(key)) {
317
- record[key] = this.formatTimeStamp(record[key]);
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 TIMESTAMP — normalize empty to null.
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
- if (this.isTimeStamp(key)) {
336
- record[key] = this.restoreTimeStamp(record[key]);
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
- /** Stripe price ID (or keel-internal plan id resolved server-side). */
26
- readonly priceId = input.required<string>();
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: this.mode(),
46
- priceId: this.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
- <div class="auth-checkbox">
106
- <mat-checkbox formControlName="consentLocation">
107
- I consent to geocode my business address for location services
108
- </mat-checkbox>
109
- </div>
110
-
111
- @if (geocodeStatus() === 'loading') {
112
- <div class="geocode-status geocode-loading" role="status">Verifying address...</div>
113
- } @else if (geocodeStatus() === 'success') {
114
- <div class="geocode-status geocode-success" role="status">Address verified.</div>
115
- } @else if (geocodeStatus() === 'error') {
116
- <div class="geocode-status geocode-error" role="alert">{{ geocodeError() }}</div>
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
- `Sign-in with ${provider} failed.`,
106
+ fallback,
107
+ (err) => this.loginError.emit(this.toError(err, fallback)),
106
108
  );
107
- // Also surface errors to the host via output.
108
- // (BaseAsync.run already captures errorMessage; the emit is additional.)
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.clearMessages();
67
- this.auth.verify2FALogin(
68
- code!,
69
- trustDevice ?? false,
70
- this.getDeviceName(),
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
- const reportId = this.apiName().split('/').pop() ?? '';
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
- const reportId = this.apiName().split('/').pop() ?? '';
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(api)) {
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
  }
@@ -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 { HttpErrorResponse, HttpInterceptorFn, HttpResponse } from '@angular/common/http';
2
- import { catchError, map, throwError } from 'rxjs';
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.includes('/api/')) {
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). Uses `/public/2fa/verify`
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
- // withCredentials so the browser stores the `keel_td` cookie keel sets on
179
- // a `trustDevice:true` verify — that cookie is what skips 2FA next login.
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
- error: (err) => {
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.navigate(['/dashboard']);
623
+ this.router.navigateByUrl(this.postLoginTarget());
619
624
  },
620
625
  error: (err) => {
621
626
  console.error('initRoutes failed:', err);
@@ -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
@@ -11,6 +11,10 @@
11
11
  "emitDecoratorMetadata": true,
12
12
  "noEmit": true
13
13
  },
14
+ "angularCompilerOptions": {
15
+ "strictTemplates": true,
16
+ "strictInjectionParameters": true
17
+ },
14
18
  "include": ["src/**/*.ts"],
15
19
  "exclude": ["node_modules"]
16
20
  }