@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 +50 -13
- package/package.json +7 -1
- package/src/component/abstract/base_async.ts +7 -2
- package/src/component/billing/checkout_button.ts +14 -4
- package/src/component/billing/plan_selector.html +5 -4
- package/src/component/billing/plan_selector.ts +8 -0
- package/src/component/billing/price_selector.html +17 -0
- package/src/component/billing/price_selector.ts +66 -0
- package/src/component/login/register_component.html +16 -14
- package/src/component/login/register_component.ts +13 -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/index.ts +2 -0
- package/src/model/appdata.ts +31 -6
- 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/src/util/money.ts +29 -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,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
|
|
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
|
-
**
|
|
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]="
|
|
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]="
|
|
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.
|
|
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.
|
|
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) =>
|
|
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
|
-
/**
|
|
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(),
|
|
@@ -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
|
-
|
|
6
|
-
<
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
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>
|
|
@@ -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
|
-
|
|
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/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';
|
package/src/model/appdata.ts
CHANGED
|
@@ -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
|
-
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
// `<sail-checkout-button
|
|
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 {
|
|
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
|
+
}
|
|
@@ -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