@nauticana/sail 1.0.2 → 1.0.7

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,9 +316,13 @@ 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
- sail ships a shared `BillingService` and nine 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).
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).
319
326
 
320
327
  ### Service
321
328
 
@@ -356,10 +363,24 @@ export class PricingPage {
356
363
  </sail-plan-selector>
357
364
  ```
358
365
 
359
- **Checkout button** — calls `createCheckout()` and redirects to the provider-hosted URL. `PublicPlan.priceId` lets you wire the picker directly to checkout without a local mapping table:
366
+ **Offer / price picker (v1.0.5)** — a plan no longer has a single price. keel v1.0.5 sells per-plan **offers** in the `subscription_plan_price` table (billing cycle × commitment term, each with its own `provider_price_id`), surfaced as `PublicPlan.prices: PlanPrice[]`. The customer picks an offer **at checkout**; its `priceId` is what flows to `<sail-checkout-button>`:
367
+
368
+ ```html
369
+ <sail-price-selector
370
+ [prices]="selectedPlan().prices ?? []"
371
+ [selected]="offer()?.priceId"
372
+ (selectionChange)="offer.set($event)">
373
+ </sail-price-selector>
374
+ ```
375
+
376
+ `<sail-price-selector>` is presentational (a `radiogroup` of offers), formats each amount with `Intl` from the offer's currency (so JPY/BHD render with the right number of decimals), and maps PERIOD_TYPE codes to copy via `[cycleLabels]`. It emits the full `PlanPrice` so you keep the chosen terms, not just the id.
377
+
378
+ > **Migration.** The flat `PublicPlan.priceId` is deprecated (keel v1.0.5 dropped `subscription_plan.provider_price_id`) — read the id from the selected offer (`offer().priceId`). **keel v1.0.7 also removed `PublicPlan.monthlyCost`/`annualCost`** from both `/public/plans` and the billing catalog, so any plan-card code reading them now gets `undefined`. Derive a display price from the offers with `fromPriceLabel(plan.prices)` (used by `<sail-plan-selector>`), or render the per-offer amounts via `<sail-price-selector>`. These fields are removed from sail's `PublicPlan` type so the compiler flags any remaining references.
379
+
380
+ **Checkout button** — calls `createCheckout()` and redirects to the provider-hosted URL. Feed it the chosen offer's `priceId`:
360
381
  ```html
361
382
  <sail-checkout-button
362
- [priceId]="selectedPlan().priceId!"
383
+ [priceId]="offer()!.priceId!"
363
384
  [mode]="'subscription'"
364
385
  [successUrl]="'https://app.example.com/billing/done'"
365
386
  [cancelUrl]="'https://app.example.com/billing'"
@@ -521,8 +542,9 @@ A complete screen is the pieces above assembled over `BillingService` — no met
521
542
  <sail-trial-banner [trialEnd]="sub()?.trialEnd"></sail-trial-banner>
522
543
  <sail-status-chip [value]="sub()?.status"></sail-status-chip>
523
544
  <sail-plan-selector [plans]="plans()" [selected]="sub()?.planId" (selectionChange)="onPlanPicked($event)"></sail-plan-selector>
545
+ <sail-price-selector [prices]="selectedPlan()?.prices ?? []" [selected]="offer()?.priceId" (selectionChange)="offer.set($event)"></sail-price-selector>
524
546
  <sail-seat-selector [seats]="sub()?.seats ?? 1" (seatsChange)="seats.set($event)"></sail-seat-selector>
525
- <sail-checkout-button [priceId]="priceFor(selected())" [quantity]="seats()" [successUrl]="okUrl" [cancelUrl]="backUrl"></sail-checkout-button>
547
+ <sail-checkout-button [priceId]="offer()!.priceId!" [quantity]="seats()" [successUrl]="okUrl" [cancelUrl]="backUrl"></sail-checkout-button>
526
548
  <sail-usage-meter [meters]="usage()"></sail-usage-meter>
527
549
  <sail-payment-methods></sail-payment-methods>
528
550
  <sail-portal-button></sail-portal-button>
@@ -906,7 +928,7 @@ npm update @nauticana/sail
906
928
  Or to install a specific version:
907
929
 
908
930
  ```bash
909
- npm install @nauticana/sail@0.9.0
931
+ npm install @nauticana/sail@1.0.7
910
932
  ```
911
933
 
912
934
  ## Migrating to v0.5.0
@@ -1497,6 +1519,21 @@ No data migration needed. Rows written under the old plaintext-fingerprint schem
1497
1519
 
1498
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.
1499
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
+
1500
1537
  ## Modernization items
1501
1538
 
1502
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.2",
3
+ "version": "1.0.7",
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
  }
@@ -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(),
@@ -2,10 +2,11 @@
2
2
  @for (plan of plans(); track plan.id) {
3
3
  <div class="plan-card" [class.plan-selected]="plan.id === selected()">
4
4
  <h3 class="plan-caption">{{ plan.caption }}</h3>
5
- <div class="plan-price">
6
- <span class="plan-amount">{{ plan.monthlyCost }}</span>
7
- <span class="plan-period">/ month</span>
8
- </div>
5
+ @if (priceLabels()[plan.id]; as priceLabel) {
6
+ <div class="plan-price">
7
+ <span class="plan-amount">{{ priceLabel }}</span>
8
+ </div>
9
+ }
9
10
  @if (featuresFor(plan.id).length > 0) {
10
11
  <ul class="plan-features">
11
12
  @for (feature of featuresFor(plan.id); track feature) {
@@ -1,6 +1,7 @@
1
1
  import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input, output } from '@angular/core';
2
2
  import { MatButtonModule } from '@angular/material/button';
3
3
  import { PublicPlan } from '../../model/appdata';
4
+ import { fromPriceLabel } from '../../util/money';
4
5
 
5
6
  /**
6
7
  * Plan picker. Pure presentational component — stateless, no service calls.
@@ -42,6 +43,13 @@ export class PlanSelectorComponent {
42
43
  return out;
43
44
  });
44
45
 
46
+ /** planId → indicative "from" price derived from the plan's offers ('' = none). */
47
+ readonly priceLabels = computed<Record<string, string>>(() => {
48
+ const out: Record<string, string> = {};
49
+ for (const plan of this.plans()) out[plan.id] = fromPriceLabel(plan.prices);
50
+ return out;
51
+ });
52
+
45
53
  onSelect(planId: string): void {
46
54
  this.selectionChange.emit(planId);
47
55
  }
@@ -0,0 +1,17 @@
1
+ <div class="price-selector" role="radiogroup" aria-label="Billing options">
2
+ @for (offer of offers(); track offer.key) {
3
+ <button class="price-offer" type="button"
4
+ role="radio"
5
+ [class.price-selected]="offer.key === selected()"
6
+ [attr.aria-checked]="offer.key === selected()"
7
+ (click)="onSelect(offer)">
8
+ <span class="price-amount">{{ offer.amountText }}</span>
9
+ <span class="price-cycle">/ {{ offer.cycleText }}</span>
10
+ @if (offer.termText) {
11
+ <span class="price-term">{{ offer.termText }}</span>
12
+ }
13
+ </button>
14
+ } @empty {
15
+ <p class="empty-state">No billing options available.</p>
16
+ }
17
+ </div>
@@ -0,0 +1,66 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input, output } from '@angular/core';
2
+ import { PlanPrice } from '../../model/appdata';
3
+ import { formatCurrency } from '../../util/money';
4
+
5
+ /** Per-offer view model for accessible, currency-formatted rendering. */
6
+ interface OfferRow {
7
+ key: string; // stable id for tracking / selection (priceId or composite)
8
+ price: PlanPrice;
9
+ amountText: string; // currency-formatted major amount
10
+ cycleText: string; // "Monthly" / "Annual" / …
11
+ termText: string; // commitment note, '' when the term is a single cycle
12
+ }
13
+
14
+ /**
15
+ * Offer picker for a plan's `subscription_plan_price` rows (keel v1.0.5). A plan
16
+ * sells several offers (billing cycle × commitment term); the customer picks one
17
+ * at checkout, then its `priceId` feeds `<sail-checkout-button [priceId]>`.
18
+ *
19
+ * Pure presentational — stateless. Amounts are formatted with `Intl` from each
20
+ * offer's currency, so non-2-decimal currencies (JPY 0, BHD 3) render correctly.
21
+ * Period codes map to copy via `[cycleLabels]` (override/extend the defaults).
22
+ */
23
+ @Component({
24
+ selector: 'sail-price-selector',
25
+ templateUrl: './price_selector.html',
26
+ changeDetection: ChangeDetectionStrategy.OnPush,
27
+ encapsulation: ViewEncapsulation.None,
28
+ })
29
+ export class PriceSelectorComponent {
30
+ private static readonly DEFAULT_CYCLE: Record<string, string> = {
31
+ D: 'Daily',
32
+ W: 'Weekly',
33
+ M: 'Monthly',
34
+ Q: 'Quarterly',
35
+ A: 'Annual',
36
+ };
37
+
38
+ readonly prices = input<PlanPrice[]>([]);
39
+ /** Selected offer key — a `priceId`, or the composite key when priceId is empty. */
40
+ readonly selected = input<string | undefined>(undefined);
41
+ /** PERIOD_TYPE code → label; overrides/extends the built-in defaults. */
42
+ readonly cycleLabels = input<{[periodCode: string]: string}>({});
43
+ readonly selectionChange = output<PlanPrice>();
44
+
45
+ readonly offers = computed<OfferRow[]>(() => {
46
+ const labels = this.cycleLabels();
47
+ const cycleLabel = (code: string) =>
48
+ labels[code] ?? PriceSelectorComponent.DEFAULT_CYCLE[code] ?? code;
49
+ return this.prices().map((p) => {
50
+ const singleCycle = p.termCount === 1 && p.termType === p.billingCycle;
51
+ return {
52
+ key: p.priceId || `${p.billingCycle}-${p.termType}-${p.termCount}`,
53
+ price: p,
54
+ amountText: formatCurrency(p.amount, p.currency),
55
+ cycleText: cycleLabel(p.billingCycle),
56
+ termText: singleCycle
57
+ ? ''
58
+ : `${p.termCount} ${cycleLabel(p.termType)}${p.termCount > 1 ? 's' : ''} commitment`,
59
+ };
60
+ });
61
+ });
62
+
63
+ onSelect(offer: OfferRow): void {
64
+ this.selectionChange.emit(offer.price);
65
+ }
66
+ }
@@ -46,7 +46,7 @@
46
46
  <mat-select formControlName="PlanID" required>
47
47
  @for (p of plans(); track p.id) {
48
48
  <mat-option [value]="p.id">
49
- {{ p.caption }}@if (p.monthlyCost > 0) { — \${{ p.monthlyCost }}/mo }
49
+ {{ p.caption }}@if (priceHint(p); as hint) { — {{ hint }} }
50
50
  </mat-option>
51
51
  }
52
52
  </mat-select>
@@ -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>
@@ -13,6 +13,7 @@ import { firstValueFrom, map, merge } from 'rxjs';
13
13
  import { HttpClient } from '@angular/common/http';
14
14
  import { PartnerRegistration, PublicPlan } from '../../model/appdata';
15
15
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
16
+ import { fromPriceLabel } from '../../util/money';
16
17
 
17
18
  @Component({
18
19
  selector: 'sail-register',
@@ -37,6 +38,10 @@ export class RegisterComponent implements OnInit {
37
38
  private router = inject(Router);
38
39
  protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
39
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
+
40
45
  readonly registerError = signal('');
41
46
  readonly plans = signal<PublicPlan[]>([]);
42
47
 
@@ -57,7 +62,7 @@ export class RegisterComponent implements OnInit {
57
62
  PlanID: ['FREE', Validators.required],
58
63
  Latitude: [null as number | null],
59
64
  Longitude: [null as number | null],
60
- consentLocation: [false, Validators.requiredTrue],
65
+ consentLocation: [false, this.geocodeEnabled ? Validators.requiredTrue : []],
61
66
  });
62
67
 
63
68
  readonly geocodeStatus = signal<'idle' | 'loading' | 'success' | 'error'>('idle');
@@ -76,6 +81,11 @@ export class RegisterComponent implements OnInit {
76
81
  return this.guiConfig.googleMapsApiKey ?? '';
77
82
  }
78
83
 
84
+ /** Indicative "from" price for a plan option, derived from its offers ('' = free/none). */
85
+ protected priceHint(plan: PublicPlan): string {
86
+ return fromPriceLabel(plan.prices);
87
+ }
88
+
79
89
  ngOnInit() {
80
90
  this.billing.listPlans().subscribe({
81
91
  next: (list) => this.plans.set(list ?? []),
@@ -86,7 +96,7 @@ export class RegisterComponent implements OnInit {
86
96
  constructor() {
87
97
  effect(() => {
88
98
  const consent = this.consentLocation();
89
- if (consent) {
99
+ if (this.geocodeEnabled && consent) {
90
100
  this.tryGeocode();
91
101
  } else {
92
102
  this.registerForm.patchValue({ Latitude: null, Longitude: null });
@@ -101,7 +111,7 @@ export class RegisterComponent implements OnInit {
101
111
  .pipe(takeUntilDestroyed(this.destroyRef))
102
112
  .subscribe(() => {
103
113
  this.registerForm.patchValue({ Latitude: null, Longitude: null });
104
- if (this.consentLocation()) {
114
+ if (this.geocodeEnabled && this.consentLocation()) {
105
115
  this.tryGeocode();
106
116
  }
107
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
  }
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export * from './component/security/twofactor_setup';
30
30
  export * from './component/security/twofactor_verify';
31
31
  export * from './component/security/trusted_devices';
32
32
  export * from './component/billing/plan_selector';
33
+ export * from './component/billing/price_selector';
33
34
  export * from './component/billing/checkout_button';
34
35
  export * from './component/billing/payment_methods';
35
36
  export * from './component/billing/portal_button';
@@ -55,3 +56,4 @@ export * from './service/rest_url';
55
56
  export * from './service/label.service';
56
57
  export * from './service/base_rest.service';
57
58
  export * from './util/text';
59
+ export * from './util/money';
@@ -201,17 +201,39 @@ export interface PartnerRegistration {
201
201
  PlanID: string;
202
202
  }
203
203
 
204
+ // One purchasable offer for a plan — a (billing cycle × commitment term) row of
205
+ // keel's `subscription_plan_price` table (v1.0.5). A plan sells several; the
206
+ // customer picks one at checkout via `<sail-price-selector>`. Mirrors keel
207
+ // `billing.PlanPrice`.
208
+ export interface PlanPrice {
209
+ billingCycle: string; // PERIOD_TYPE code (D/W/M/A) — how often charged
210
+ termCount: number; // commitment length, in termType units
211
+ termType: string; // PERIOD_TYPE code — commitment/pricing unit
212
+ amountMinor: number; // price per termType unit, in minor units (cents)
213
+ amount: number; // same amount in major units (currency-exponent applied)
214
+ currency: string; // ISO 4217
215
+ // Provider price id (Stripe Price). Empty for free/unpriced offers; pass to
216
+ // `<sail-checkout-button [priceId]>` / `createCheckout({ priceId })`.
217
+ priceId?: string;
218
+ }
219
+
204
220
  // PublicPlan — shape returned by GET /public/plans. Used on the registration
205
221
  // page to render the plan picker and to drive `<sail-checkout-button>` flows.
206
222
  export interface PublicPlan {
207
223
  id: string;
208
224
  caption: string;
209
- monthlyCost: number;
210
- annualCost: number;
211
- // Stripe price ID (or provider-equivalent). Optional because keel only
212
- // populates it when a plan is wired to a billing provider; FREE plans and
213
- // not-yet-priced tiers leave it empty. Pass straight to
214
- // `<sail-checkout-button [priceId]>` / `BillingService.createCheckout({ priceId })`.
225
+ // NOTE: keel v1.0.7 REMOVED the flat `monthlyCost`/`annualCost` from the plan
226
+ // JSON (both `/public/plans` and the billing catalog). Price lives per-offer
227
+ // in `prices[]`; derive a display price with `fromPriceLabel(plan.prices)`.
228
+ // Purchasable offers (keel v1.0.5 `subscription_plan_price`, nested under the
229
+ // plan). The source of truth for checkout — feed a chosen offer's `priceId`
230
+ // to `<sail-checkout-button>`. Returned by `/public/plans` as of keel v1.0.6
231
+ // (so `<sail-price-selector>` works on the registration page too); absent on
232
+ // pre-v1.0.6 backends.
233
+ prices?: PlanPrice[];
234
+ // @deprecated keel v1.0.5 moved per-offer prices into `prices[]`
235
+ // (`subscription_plan.provider_price_id` was dropped). Use
236
+ // `prices?.[…].priceId`. Kept optional for pre-v1.0.5 backends.
215
237
  priceId?: string;
216
238
  // keel `subscription_plan.activation_mode` (SUBSCRIPTION_ACTIVATION_MODE
217
239
  // dictionary, single char — e.g. 'P' paid, 'F' free, 'T' trial). Drives the
@@ -320,6 +342,9 @@ export interface Subscription {
320
342
  monthlyCost: number;
321
343
  currency: string;
322
344
  autoRenew: boolean;
345
+ // PERIOD_TYPE code (D/W/M/A) the sub is charged on, copied from the chosen
346
+ // offer (keel `billing_cycle`); absent on pre-v1.0.5 backends.
347
+ billingCycle?: string;
323
348
  // ISO timestamp the trial ends (keel `trial_end`); empty/absent when not
324
349
  // trialing. Drives `<sail-trial-banner>`.
325
350
  trialEnd?: 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
+ }
@@ -0,0 +1,29 @@
1
+ import { PlanPrice } from '../model/appdata';
2
+
3
+ /**
4
+ * Format a major-unit amount in its currency, honouring the currency exponent
5
+ * (JPY 0 decimals, BHD 3, …) via `Intl`. Falls back to "<amount> <currency>"
6
+ * for an unknown currency code rather than throwing.
7
+ */
8
+ export function formatCurrency(amount: number, currency: string): string {
9
+ try {
10
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount);
11
+ } catch {
12
+ return `${amount} ${currency}`;
13
+ }
14
+ }
15
+
16
+ /**
17
+ * "From" price teaser for a plan card / dropdown — the lowest priced offer,
18
+ * currency-formatted, prefixed "from " when the plan sells more than one priced
19
+ * offer. Returns '' when the plan has no priced offers (free/trial), so callers
20
+ * can omit the price line entirely. The exact offer is chosen later via
21
+ * `<sail-price-selector>`.
22
+ */
23
+ export function fromPriceLabel(prices: PlanPrice[] | undefined): string {
24
+ const priced = (prices ?? []).filter((p) => p.amount > 0);
25
+ if (priced.length === 0) return '';
26
+ const lowest = priced.reduce((a, b) => (b.amount < a.amount ? b : a));
27
+ const formatted = formatCurrency(lowest.amount, lowest.currency);
28
+ return priced.length > 1 ? `from ${formatted}` : formatted;
29
+ }
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
  }