@nauticana/sail 1.1.17 → 1.1.19

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,7 +2,7 @@
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. The current line is **sail v1.1.x ↔ keel v1.2.x**; the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). 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`.
5
+ > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). 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
  **Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows. `passwordPolicyValidator` + `BaseAuthService.ensurePasswordPolicy()` — validate passwords against keel's policy (via `SailGuiConfig.passwordPolicyUrl`); see Configuration reference.
8
8
 
@@ -328,6 +328,9 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
328
328
  | `POST /public/otp/resend` | Re-issue OTP for an existing `otpToken` |
329
329
  | `POST /public/2fa/verify` | Login-time TOTP verification (uses `loginToken`) |
330
330
  | `POST /public/2fa/backup-verify` | Login-time backup-code verification |
331
+ | `POST /public/token/refresh` | Rotate `{refreshToken}` → new `{token, refreshToken}`; 401 ends the session |
332
+ | `POST /public/logout` | Revoke `{refreshToken}` (called by `BaseAuthService.logout()`) |
333
+ | `GET /public/ws` | keel realtime hub handshake (`RealtimeService`; JWT via `?token=`) |
331
334
  | `GET /api/config/appdata` | Metadata (menus, permissions, table definitions) |
332
335
  | `POST/GET/DELETE /api/{version}/{table}/list\|get\|post\|delete` | CRUD operations (paginated `list`) |
333
336
  | `POST /api/user/2fa/setup` | Generate TOTP secret, QR URI, and backup codes — **requires re-auth** |
@@ -340,6 +343,7 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
340
343
  | `POST /api/push/register` | Register an FCM / APNs token |
341
344
  | `POST /api/push/revoke` | Revoke an FCM / APNs token |
342
345
  | `POST /api/billing/checkout` | Create provider-hosted checkout session — **JWT-gated, allowlist-validated** |
346
+ | `POST /api/billing/setup-intent` | Provider setup intent for on-device card capture (`BillingService.createSetupIntent()`) |
343
347
  | `GET /public/plans` | Subscription plan catalog (unauthenticated) |
344
348
  | `GET /api/billing/subscription` | Active subscription for the partner |
345
349
  | `POST /api/billing/subscription/cancel` | Cancel auto-renew |
@@ -382,7 +386,15 @@ If "trust this device" appears to do nothing (user is 2FA-prompted on every logi
382
386
 
383
387
  ### Session token storage
384
388
 
385
- 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.
389
+ The session JWT, the refresh token, 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 — refresh rotation makes short lifetimes free for the user.
390
+
391
+ ### Refresh-token rotation
392
+
393
+ Every keel login response (keel v1.2.57+) carries `refreshToken` beside `token`; `completeLogin` stores both. When an authenticated call answers 401, `authInterceptor` calls `BaseAuthService.refreshSession()` — one shared `POST /public/token/refresh` per burst — stores the rotated pair and replays the request with the new JWT. A 401 from the refresh itself (revoked, expired, already rotated) clears the session and lands on `login?sessionExpired=true`; any other refresh failure keeps the session and lets the 401 count toward the circuit breaker. `logout()` posts the refresh token to `/public/logout` before clearing local state. `acceptToken(token, refreshToken?)` accepts the pair for handoff flows. No wiring beyond registering `authInterceptor`.
394
+
395
+ ### Realtime
396
+
397
+ `RealtimeService` (root-provided) is the client for keel's `realtime.Hub`. `connect()` opens `RestURL.realtimeURL` (`/public/ws`) on the configured backend host with the stored JWT, reconnects with backoff, and re-subscribes every channel after a reconnect. `channel<T>(name)` returns the payloads keel broadcasts on that channel; `userFrames<T>()` returns payloads addressed to the user; `send(frame)` forwards an application frame to the hub's `OnMessage`. A subscription keel's `CanSubscribe` rejects is logged and exposed on `lastError`. Channel names and payload shapes are app-owned.
386
398
 
387
399
  ## Billing
388
400
 
@@ -864,8 +876,9 @@ For backward compatibility, the older OAuth-code flow `BaseAuthService.loginWith
864
876
  Horizontal building blocks for an entitlement-aware dashboard. All are presentational (signals in, events out) and ship no CSS — the app styles the documented class hooks and supplies the metric data, chart, and action logic.
865
877
 
866
878
  - **`DashboardShellComponent`** (`sail-dashboard-shell`) — responsive card-grid layout. `[heading]` + a `[dashboardActions]` projection slot for the header; project cards into the default slot. Styles: `.dashboard-shell` / `.dashboard-header` / `.dashboard-grid`.
867
- - **`DataCardComponent`** (`sail-data-card`) — card chrome for one visualization with `[state]` ∈ `loading | ready | empty | error | locked`. The chart is supplied as an `<ng-template>` and instantiated **only** in `ready`, so during loading/error/locked it never exists (no hidden canvas). `description` (required) is the screen-reader text alternative; `[tableHeaders]`/`[tableRows]` render a visually-hidden (`.sr-only`) data table. Emits `upgrade` / `retry`.
879
+ - **`DataCardComponent`** (`sail-data-card`) — card chrome for one visualization with `[state]` ∈ `loading | ready | empty | error | locked | no-source`. The chart is supplied as an `<ng-template>` and instantiated **only** in `ready`, so during loading/error/locked/no-source it never exists (no hidden canvas). `description` (required) is the screen-reader text alternative; `[tableHeaders]`/`[tableRows]` render a visually-hidden (`.sr-only`) data table. Emits `upgrade`, `connect`, and `retry`.
868
880
  - **`LockedOverlayComponent`** (`sail-locked-overlay`) — entitlement gate. Wraps protected content given as an `<ng-template>`; when `[locked]` the template is never instantiated, so **no data exists behind the gate** (DOM, canvas, or network) — the strict free-tier rule. Emits `upgrade`.
881
+ - **`NoSourceOverlayComponent`** (`sail-no-source-overlay`) — source-readiness gate. Takes `[sourceName]` and emits `connect`; while `[unavailable]`, its protected `<ng-template>` is never instantiated. Use this for included features whose provider is not connected or whose collector is unavailable—never show an upgrade prompt for this state.
869
882
  - **`ActionCenterComponent`** (`sail-action-center`) — prioritized next-best-action list from `[actions]: ActionItem[]` (sorted by `priority`, done items show a check). Emits `act`.
870
883
  - **`EntitySelectorComponent`** (`sail-entity-selector`) — scope dropdown over `[entities]: EntityOption[]`; resolves `[selected]` or the first active entity and emits `selectionChange`.
871
884
  - **`VerificationFlowComponent`** (`sail-verification-flow`) — self-service request → enter-code → verified UI (a 2FA sibling), backend-agnostic: emits `requestVerification` / `confirmCode`, driven by `[step]` + `[errorMessage]` the host feeds back.
@@ -878,6 +891,36 @@ Horizontal building blocks for an entitlement-aware dashboard. All are presentat
878
891
 
879
892
  Models: `DataCardState`, `ActionItem`, `EntityOption`, `VerificationStep`.
880
893
 
894
+ Analytics clients that need this distinction can call the protected
895
+ `analyticWithStatus<T>()` on `BaseRestService`. It returns
896
+ `{ rows, source: { status, id } }`, populated from `X-Data-Source-Status` and
897
+ `X-Data-Source`; the existing `analytic<T>()` remains `Observable<T[]>`.
898
+
899
+ When the app and its API are on different origins the browser hides both headers
900
+ unless the backend sends
901
+ `Access-Control-Expose-Headers: X-Data-Source-Status, X-Data-Source`; without it
902
+ `source.status` is `null` and a missing source is indistinguishable from an
903
+ empty result.
904
+
905
+ ## Grouped review actions and diffs (v1.1.19)
906
+
907
+ `GroupedBulkActionComponent` (`sail-grouped-bulk-action`) groups eligible rows
908
+ with the consumer's `[groupBy]` function and renders an app-chosen bounded
909
+ `[sampleSize]` through a projected `<ng-template>`. The app supplies all domain
910
+ copy and an `[execute]` function returning `{ attempted, succeeded }`. When
911
+ `succeeded < attempted`, the component visibly renders the app's
912
+ `[partialSuccessMessage]`; it never reports a partial operation as complete.
913
+ The outcome message renders above the groups (`role="alert"`,
914
+ `.grouped-bulk-action__message--partial` / `--error`) and stays until the next
915
+ action, so it survives the row refresh that removes its group. Group headings
916
+ are `role="heading"` at `[headingLevel]` (default 3).
917
+
918
+ `BeforeAfterDiffComponent` (`sail-before-after-diff`) renders a
919
+ whitespace-preserving word-level LCS diff. `[maxTokens]` defaults to 2000; above
920
+ the cap it skips the quadratic comparison and shows the whole new value as an
921
+ addition. Additions and deletions are `<ins class="diff-add">` /
922
+ `<del class="diff-del">`, unchanged text `<span class="diff-same">`. The pure `wordDiff()` helper is also exported for custom layouts.
923
+
881
924
  ## Provider connections (v1.1.9)
882
925
 
883
926
  `ConnectionPanel` is the drop-in UI for keel's `oauth/connect` layer — connect a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.17",
3
+ "version": "1.1.19",
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
6
  "scripts": {
@@ -33,5 +33,12 @@
33
33
  <button matButton="elevated" type="button" class="primary" (click)="upgrade.emit()">{{ ctaLabel() }}</button>
34
34
  </div>
35
35
  }
36
+ @case ('no-source') {
37
+ <div class="data-card-no-source" role="group" [attr.aria-label]="(heading() || 'Insight') + ' — source unavailable'">
38
+ <p class="no-source-name">{{ sourceName() }}</p>
39
+ <p class="no-source-message">{{ noSourceMessage() }}</p>
40
+ <button matButton="elevated" type="button" class="primary" (click)="connect.emit()">{{ connectLabel() }}</button>
41
+ </div>
42
+ }
36
43
  }
37
44
  </section>
@@ -30,10 +30,14 @@ export class DataCardComponent {
30
30
  readonly errorMessage = input<string>('Could not load this data.');
31
31
  readonly emptyMessage = input<string>('No data yet.');
32
32
  readonly lockedMessage = input<string>('Upgrade to unlock this insight.');
33
+ readonly sourceName = input<string>('data source');
34
+ readonly noSourceMessage = input<string>('Connect the required data source to see this insight.');
33
35
  readonly ctaLabel = input<string>('Upgrade');
36
+ readonly connectLabel = input<string>('Connect');
34
37
  readonly tableHeaders = input<string[]>([]);
35
38
  readonly tableRows = input<(string | number)[][]>([]);
36
39
  readonly upgrade = output<void>();
40
+ readonly connect = output<void>();
37
41
  readonly retry = output<void>();
38
42
 
39
43
  /** The chart, provided as a single <ng-template>; instantiated only when ready. */
@@ -0,0 +1,9 @@
1
+ @if (unavailable()) {
2
+ <div class="no-source-overlay" role="group" [attr.aria-label]="sourceName() + ' — source unavailable'">
3
+ <p class="no-source-name">{{ sourceName() }}</p>
4
+ <p class="no-source-message">{{ message() }}</p>
5
+ <button matButton="elevated" type="button" class="primary" (click)="connect.emit()">{{ ctaLabel() }}</button>
6
+ </div>
7
+ } @else if (content(); as tpl) {
8
+ <ng-container [ngTemplateOutlet]="tpl"></ng-container>
9
+ }
@@ -0,0 +1,21 @@
1
+ import { NgTemplateOutlet } from '@angular/common';
2
+ import { ChangeDetectionStrategy, Component, TemplateRef, ViewEncapsulation, contentChild, input, output } from '@angular/core';
3
+ import { MatButtonModule } from '@angular/material/button';
4
+
5
+ /** Source-readiness sibling of LockedOverlayComponent: the template is never instantiated while unavailable. */
6
+ @Component({
7
+ selector: 'sail-no-source-overlay',
8
+ templateUrl: './no_source_overlay.html',
9
+ changeDetection: ChangeDetectionStrategy.OnPush,
10
+ encapsulation: ViewEncapsulation.None,
11
+ imports: [NgTemplateOutlet, MatButtonModule],
12
+ })
13
+ export class NoSourceOverlayComponent {
14
+ readonly unavailable = input.required<boolean>();
15
+ readonly sourceName = input.required<string>();
16
+ readonly message = input<string>('Connect this data source to continue.');
17
+ readonly ctaLabel = input<string>('Connect');
18
+ readonly connect = output<void>();
19
+
20
+ readonly content = contentChild(TemplateRef);
21
+ }
@@ -13,8 +13,8 @@ import { UserPaymentMethod } from '../../model/appdata';
13
13
  * + delete actions. Inherits loading/error state from BaseAsync.
14
14
  *
15
15
  * Customise the heading and "add" CTA via inputs; emit (addClicked) so
16
- * the consumer routes to its own SetupIntent flow (sail does not bundle
17
- * a SetupIntent UI today — providers vary too much).
16
+ * the consumer routes to its own SetupIntent flow: BillingService.createSetupIntent()
17
+ * returns the client secret; the provider payment sheet stays app-owned.
18
18
  *
19
19
  * Ships no CSS — the consuming app styles the classes globally.
20
20
  *
@@ -0,0 +1 @@
1
+ <pre class="before-after-diff">@for (token of tokens(); track $index) {@switch (token.kind) {@case ('add') {<ins class="diff-add">{{ token.text }}</ins>}@case ('del') {<del class="diff-del">{{ token.text }}</del>}@default {<span class="diff-same">{{ token.text }}</span>}}}</pre>
@@ -0,0 +1,54 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input } from '@angular/core';
2
+
3
+ export type DiffTokenKind = 'same' | 'add' | 'del';
4
+
5
+ export interface DiffToken {
6
+ text: string;
7
+ kind: DiffTokenKind;
8
+ }
9
+
10
+ /** Whitespace-preserving word-level LCS diff with a bounded work limit. */
11
+ export function wordDiff(before: string, after: string, maxTokens = 2000): DiffToken[] {
12
+ const left = before.split(/(\s+)/).filter((token) => token.length > 0);
13
+ const right = after.split(/(\s+)/).filter((token) => token.length > 0);
14
+ if (left.length > maxTokens || right.length > maxTokens) return [{ text: after, kind: 'add' }];
15
+
16
+ const matrix = Array.from({ length: left.length + 1 }, () => new Uint32Array(right.length + 1));
17
+ for (let i = left.length - 1; i >= 0; i--) {
18
+ for (let j = right.length - 1; j >= 0; j--) {
19
+ matrix[i][j] = left[i] === right[j]
20
+ ? matrix[i + 1][j + 1] + 1
21
+ : Math.max(matrix[i + 1][j], matrix[i][j + 1]);
22
+ }
23
+ }
24
+
25
+ const tokens: DiffToken[] = [];
26
+ let i = 0;
27
+ let j = 0;
28
+ while (i < left.length && j < right.length) {
29
+ if (left[i] === right[j]) {
30
+ tokens.push({ text: left[i++], kind: 'same' });
31
+ j++;
32
+ } else if (matrix[i + 1][j] >= matrix[i][j + 1]) {
33
+ tokens.push({ text: left[i++], kind: 'del' });
34
+ } else {
35
+ tokens.push({ text: right[j++], kind: 'add' });
36
+ }
37
+ }
38
+ while (i < left.length) tokens.push({ text: left[i++], kind: 'del' });
39
+ while (j < right.length) tokens.push({ text: right[j++], kind: 'add' });
40
+ return tokens;
41
+ }
42
+
43
+ @Component({
44
+ selector: 'sail-before-after-diff',
45
+ templateUrl: './before_after_diff.html',
46
+ changeDetection: ChangeDetectionStrategy.OnPush,
47
+ encapsulation: ViewEncapsulation.None,
48
+ })
49
+ export class BeforeAfterDiffComponent {
50
+ readonly before = input.required<string>();
51
+ readonly after = input.required<string>();
52
+ readonly maxTokens = input<number>(2000);
53
+ readonly tokens = computed(() => wordDiff(this.before(), this.after(), this.maxTokens()));
54
+ }
@@ -0,0 +1,36 @@
1
+ @if (message(); as outcome) {
2
+ <p class="grouped-bulk-action__message"
3
+ [class.grouped-bulk-action__message--partial]="outcome.kind === 'partial'"
4
+ [class.grouped-bulk-action__message--error]="outcome.kind === 'error'"
5
+ role="alert">{{ outcome.text }}</p>
6
+ }
7
+
8
+ @for (group of groups(); track group.key) {
9
+ <section class="grouped-bulk-action" [attr.data-group-key]="group.key">
10
+ <header class="grouped-bulk-action__header">
11
+ <div class="grouped-bulk-action__heading" role="heading" [attr.aria-level]="headingLevel()">{{ heading()(group) }}</div>
12
+ <button matButton="filled" type="button"
13
+ [disabled]="busyKey() !== null"
14
+ (click)="run(group)">
15
+ {{ actionLabel()(group, busyKey() === group.key) }}
16
+ </button>
17
+ </header>
18
+
19
+ @if (description()(group); as text) {
20
+ <p class="grouped-bulk-action__description">{{ text }}</p>
21
+ }
22
+
23
+ @if (sampleTemplate(); as template) {
24
+ <div class="grouped-bulk-action__sample">
25
+ @for (row of group.sample; track $index) {
26
+ <ng-container [ngTemplateOutlet]="template"
27
+ [ngTemplateOutletContext]="{ $implicit: row, group: group }"></ng-container>
28
+ }
29
+ </div>
30
+ }
31
+
32
+ @if (group.count > group.sample.length) {
33
+ <p class="grouped-bulk-action__remainder">{{ remainderLabel()(group.count - group.sample.length, group) }}</p>
34
+ }
35
+ </section>
36
+ }
@@ -0,0 +1,120 @@
1
+ import { NgTemplateOutlet } from '@angular/common';
2
+ import { ChangeDetectionStrategy, Component, DestroyRef, TemplateRef, ViewEncapsulation, computed, contentChild, inject, input, output, signal } from '@angular/core';
3
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
4
+ import { MatButtonModule } from '@angular/material/button';
5
+ import { finalize, Observable } from 'rxjs';
6
+
7
+ export type BulkGroupKey = string | number;
8
+
9
+ export interface BulkActionGroup<T> {
10
+ key: BulkGroupKey;
11
+ rows: readonly T[];
12
+ sample: readonly T[];
13
+ count: number;
14
+ }
15
+
16
+ /** Generic outcome used to detect and report partial completion. */
17
+ export interface BulkActionResult {
18
+ attempted: number;
19
+ succeeded: number;
20
+ }
21
+
22
+ export interface BulkActionCompletion<T> {
23
+ group: BulkActionGroup<T>;
24
+ result: BulkActionResult;
25
+ }
26
+
27
+ interface OutcomeMessage {
28
+ text: string;
29
+ kind: 'partial' | 'error';
30
+ }
31
+
32
+ /** The projected template receives each sample row as `$implicit` and its group as `group`. */
33
+ @Component({
34
+ selector: 'sail-grouped-bulk-action',
35
+ templateUrl: './grouped_bulk_action.html',
36
+ changeDetection: ChangeDetectionStrategy.OnPush,
37
+ encapsulation: ViewEncapsulation.None,
38
+ imports: [NgTemplateOutlet, MatButtonModule],
39
+ })
40
+ export class GroupedBulkActionComponent<T> {
41
+ readonly rows = input.required<readonly T[]>();
42
+ readonly groupBy = input.required<(row: T) => BulkGroupKey | null | undefined>();
43
+ readonly eligible = input<(row: T) => boolean>(() => true);
44
+ readonly sampleSize = input.required<number>();
45
+ readonly minimumGroupSize = input<number>(2);
46
+ readonly execute = input.required<(group: BulkActionGroup<T>) => Observable<BulkActionResult>>();
47
+
48
+ readonly heading = input.required<(group: BulkActionGroup<T>) => string>();
49
+ readonly headingLevel = input<number>(3);
50
+ readonly description = input<(group: BulkActionGroup<T>) => string>(() => '');
51
+ readonly actionLabel = input.required<(group: BulkActionGroup<T>, busy: boolean) => string>();
52
+ readonly remainderLabel = input.required<(remaining: number, group: BulkActionGroup<T>) => string>();
53
+ readonly partialSuccessMessage = input.required<(result: BulkActionResult, group: BulkActionGroup<T>) => string>();
54
+ readonly failureMessage = input.required<(error: unknown, group: BulkActionGroup<T>) => string>();
55
+ readonly sortGroups = input<(left: BulkActionGroup<T>, right: BulkActionGroup<T>) => number>(() => 0);
56
+
57
+ readonly finished = output<BulkActionCompletion<T>>();
58
+ readonly failed = output<{ group: BulkActionGroup<T>; error: unknown }>();
59
+
60
+ readonly busyKey = signal<BulkGroupKey | null>(null);
61
+ /** Held outside the groups: a refresh after the action usually removes the group it concerns. */
62
+ readonly message = signal<OutcomeMessage | null>(null);
63
+ readonly sampleTemplate = contentChild<TemplateRef<unknown>>(TemplateRef);
64
+ readonly groups = computed(() => this.buildGroups());
65
+
66
+ private readonly destroyRef = inject(DestroyRef);
67
+
68
+ run(group: BulkActionGroup<T>): void {
69
+ if (this.busyKey() !== null) return;
70
+ this.busyKey.set(group.key);
71
+ this.message.set(null);
72
+ let request: Observable<BulkActionResult>;
73
+ try {
74
+ request = this.execute()(group);
75
+ } catch (error) {
76
+ this.handleFailure(group, error);
77
+ return;
78
+ }
79
+ request.pipe(
80
+ takeUntilDestroyed(this.destroyRef),
81
+ finalize(() => this.busyKey.set(null)),
82
+ ).subscribe({
83
+ next: (result) => {
84
+ if (result.succeeded < result.attempted) {
85
+ this.message.set({ text: this.partialSuccessMessage()(result, group), kind: 'partial' });
86
+ }
87
+ this.finished.emit({ group, result });
88
+ },
89
+ error: (error: unknown) => this.handleFailure(group, error),
90
+ });
91
+ }
92
+
93
+ private buildGroups(): BulkActionGroup<T>[] {
94
+ const grouped = new Map<BulkGroupKey, T[]>();
95
+ for (const row of this.rows()) {
96
+ if (!this.eligible()(row)) continue;
97
+ const key = this.groupBy()(row);
98
+ if (key === null || key === undefined || key === '') continue;
99
+ const groupRows = grouped.get(key) ?? [];
100
+ groupRows.push(row);
101
+ grouped.set(key, groupRows);
102
+ }
103
+ const sampleSize = Math.max(0, this.sampleSize());
104
+ return [...grouped.entries()]
105
+ .filter(([, groupRows]) => groupRows.length >= this.minimumGroupSize())
106
+ .map(([key, groupRows]) => ({
107
+ key,
108
+ rows: groupRows,
109
+ sample: groupRows.slice(0, sampleSize),
110
+ count: groupRows.length,
111
+ }))
112
+ .sort(this.sortGroups());
113
+ }
114
+
115
+ private handleFailure(group: BulkActionGroup<T>, error: unknown): void {
116
+ this.busyKey.set(null);
117
+ this.message.set({ text: this.failureMessage()(error, group), kind: 'error' });
118
+ this.failed.emit({ group, error });
119
+ }
120
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export * from './component/abstract/base_table';
10
10
  export * from './component/dashboard/dashboard_shell';
11
11
  export * from './component/dashboard/data_card';
12
12
  export * from './component/dashboard/locked_overlay';
13
+ export * from './component/dashboard/no_source_overlay';
13
14
  export * from './component/dashboard/action_center';
14
15
  export * from './component/dashboard/entity_selector';
15
16
  export * from './component/dashboard/verification_flow';
@@ -26,6 +27,8 @@ export * from './component/table/table_edit';
26
27
  export * from './component/table/table_detail';
27
28
  export * from './component/table/table_lookup';
28
29
  export * from './component/table/table_report';
30
+ export * from './component/table/grouped_bulk_action';
31
+ export * from './component/table/before_after_diff';
29
32
  export * from './component/navigation/navigation';
30
33
  export * from './component/login/login_component';
31
34
  export * from './component/login/register_component';
@@ -72,6 +75,7 @@ export * from './service/oauth_connection.service';
72
75
  export * from './service/user_payment_method.service';
73
76
  export * from './service/script_loader';
74
77
  export * from './service/auth.interceptor';
78
+ export * from './service/realtime.service';
75
79
  export * from './service/api-response.interceptor';
76
80
  export * from './service/rest_service';
77
81
  export * from './service/rest_url';
@@ -140,8 +140,18 @@ export interface RestReport {
140
140
 
141
141
  // 2FA / Trusted device types
142
142
 
143
+ /** POST /public/token/refresh response; every login response carries the same pair. */
144
+ export interface TokenPair {
145
+ token: string;
146
+ refreshToken: string;
147
+ userId?: number;
148
+ partnerId?: number;
149
+ }
150
+
143
151
  export interface LoginResponse2FA {
144
152
  token: string;
153
+ /** keel v1.2.57+: rotated at /public/token/refresh when the JWT expires. */
154
+ refreshToken?: string;
145
155
  twoFactorRequired: boolean;
146
156
  loginToken?: string;
147
157
  userId?: number;
@@ -168,6 +178,7 @@ export interface TwoFactorVerifyRequest {
168
178
  export interface TwoFactorVerifyResponse {
169
179
  valid: boolean;
170
180
  token?: string;
181
+ refreshToken?: string;
171
182
  }
172
183
 
173
184
  export interface TrustedDevice {
@@ -286,6 +297,14 @@ export interface CheckoutRequest {
286
297
  metadata?: { [key: string]: string };
287
298
  }
288
299
 
300
+ /** POST /api/billing/setup-intent — open the provider's payment sheet with these. */
301
+ export interface SetupIntentResponse {
302
+ setupIntentId: string;
303
+ clientSecret: string;
304
+ customerId: string;
305
+ ephemeralKey: string;
306
+ }
307
+
289
308
  export interface CheckoutResponse {
290
309
  checkoutUrl: string;
291
310
  }
package/src/model/auth.ts CHANGED
@@ -93,6 +93,7 @@ export interface OtpVerifyRequest {
93
93
 
94
94
  export interface OtpVerifyResponse {
95
95
  token: string;
96
+ refreshToken?: string;
96
97
  userId?: number;
97
98
  partnerId?: number;
98
99
  }
@@ -137,6 +138,7 @@ export interface SocialLoginRequest {
137
138
 
138
139
  export interface LoginResponseSocial {
139
140
  token: string;
141
+ refreshToken?: string;
140
142
  userId?: number;
141
143
  partnerId?: number;
142
144
  isNewUser: boolean;
@@ -1,5 +1,5 @@
1
1
  /** Lifecycle state of a data/chart card (sail-data-card). */
2
- export type DataCardState = 'loading' | 'ready' | 'empty' | 'error' | 'locked';
2
+ export type DataCardState = 'loading' | 'ready' | 'empty' | 'error' | 'locked' | 'no-source';
3
3
 
4
4
  /** A prioritized, completable next-best-action (sail-action-center, FR-008). */
5
5
  export interface ActionItem {
@@ -1,9 +1,13 @@
1
- import { HttpErrorResponse, HttpEventType, HttpInterceptorFn } from '@angular/common/http';
1
+ import { HttpErrorResponse, HttpEvent, HttpEventType, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
2
2
  import { inject, Injector } from '@angular/core';
3
- import { catchError, tap, throwError } from 'rxjs';
3
+ import { Observable, catchError, switchMap, tap, throwError } from 'rxjs';
4
4
  import { isKeelApiUrl } from './rest_url';
5
- import { BaseAuthService } from './auth.service';
5
+ import { BaseAuthService, RefreshSupersededError } from './auth.service';
6
6
 
7
+ // A 401 on an authed call first tries one refresh-token rotation (keel
8
+ // /public/token/refresh) and replays the request with the new JWT; only when
9
+ // no refresh token is stored or keel rejects it does the 401 count below.
10
+ //
7
11
  // Auth-loop breaker: repeated 401s on token-bearing API calls (a stale session) can
8
12
  // drive the app to hammer the API/CDN. After AUTH_FAIL_THRESHOLD within
9
13
  // AUTH_FAIL_WINDOW_MS the circuit opens for AUTH_CIRCUIT_COOLDOWN_MS — authed
@@ -25,7 +29,8 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
25
29
  // → interceptors) and so an open circuit clears the dead session even when it
26
30
  // was opened by a previous page load.
27
31
  const injector = inject(Injector);
28
- const expireSession = () => injector.get(BaseAuthService, null, { optional: true })?.sessionExpired();
32
+ const auth = () => injector.get(BaseAuthService, null, { optional: true });
33
+ const expireSession = () => auth()?.sessionExpired();
29
34
 
30
35
  const token = localStorage.getItem('jwt');
31
36
  const authedApi = !!token && isKeelApiUrl(req.url);
@@ -46,6 +51,11 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
46
51
  }));
47
52
  }
48
53
 
54
+ const countFailure = (err: HttpErrorResponse) => {
55
+ if (recordAuthFailure()) expireSession();
56
+ return throwError(() => err);
57
+ };
58
+
49
59
  return next(req).pipe(
50
60
  tap((event) => {
51
61
  if (authedApi && event.type === HttpEventType.Response) {
@@ -53,14 +63,33 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
53
63
  }
54
64
  }),
55
65
  catchError((err: HttpErrorResponse) => {
56
- if (authedApi && err.status === 401 && recordAuthFailure()) {
57
- expireSession();
58
- }
59
- return throwError(() => err);
66
+ if (!authedApi || err.status !== 401) return throwError(() => err);
67
+ const service = auth();
68
+ if (!service || !localStorage.getItem('refreshToken')) return countFailure(err);
69
+ return service.refreshSession().pipe(
70
+ catchError((refreshErr: unknown) => {
71
+ if (refreshErr instanceof RefreshSupersededError) {
72
+ return throwError(() => err); // another session took over; it is not ours to expire
73
+ }
74
+ if (refreshErr instanceof HttpErrorResponse && refreshErr.status !== 401) {
75
+ return countFailure(err); // transient refresh failure: keep the session, count the 401
76
+ }
77
+ expireSession(); // keel rejected the refresh token: the session is dead
78
+ return throwError(() => err);
79
+ }),
80
+ switchMap((token) => replay(req, next, token)), // replay errors surface as-is
81
+ );
60
82
  }),
61
83
  );
62
84
  };
63
85
 
86
+ /** Re-send the request once with the rotated JWT; a second 401 is a real denial. */
87
+ function replay(req: HttpRequest<unknown>, next: HttpHandlerFn, token: string): Observable<HttpEvent<unknown>> {
88
+ return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })).pipe(
89
+ tap((event) => { if (event.type === HttpEventType.Response) clearAuthFailures(); }),
90
+ );
91
+ }
92
+
64
93
  function authCircuitOpen(): boolean {
65
94
  return Date.now() < Number(sessionStorage.getItem(OPEN_KEY) ?? 0);
66
95
  }
@@ -1,8 +1,8 @@
1
1
  import { Injectable, WritableSignal, inject, signal } from '@angular/core';
2
2
  import { HttpErrorResponse, HttpParams } from '@angular/common/http';
3
- import { filter, map, mergeMap, take, tap } from 'rxjs/operators';
3
+ import { filter, finalize, map, mergeMap, shareReplay, take, tap } from 'rxjs/operators';
4
4
  import { PasswordRules } from '../util/password_policy';
5
- import { ApplicationData, ConfirmRegisterResponse, DictionaryPath, LoginResponse2FA, PartnerRegistration, RestReport, TableDefinition, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
5
+ import { ApplicationData, ConfirmRegisterResponse, DictionaryPath, LoginResponse2FA, PartnerRegistration, RestReport, TableDefinition, TokenPair, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
6
6
  import {
7
7
  LoginResponseSocial,
8
8
  OtpRequest, OtpResendRequest, OtpResponse, OtpVerifyRequest, OtpVerifyResponse,
@@ -12,13 +12,19 @@ import {
12
12
  UserProfile,
13
13
  } from '../model/auth';
14
14
  import { RestURL } from './rest_url';
15
- import { BehaviorSubject, Observable, ReplaySubject, catchError, defer, merge, of, throwError } from 'rxjs';
15
+ import { BehaviorSubject, EMPTY, Observable, ReplaySubject, catchError, defer, merge, of, throwError } from 'rxjs';
16
16
  import { ApplicationMenu, ConstantValue } from '../model/common';
17
17
  import { CanActivateFn, CanDeactivateFn, Router, Routes } from '@angular/router';
18
18
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../config';
19
19
  import { BaseRestService } from './base_rest.service';
20
20
  import { resetAuthCircuit } from './auth.interceptor';
21
21
 
22
+ /** Thrown by refreshSession() when the session changed while the rotation was in
23
+ * flight; the result was discarded and the current session is untouched. */
24
+ export class RefreshSupersededError extends Error {
25
+ constructor() { super('session changed during refresh'); this.name = 'RefreshSupersededError'; }
26
+ }
27
+
22
28
  /** Sort dropdown options by their visible label so long lookup lists are scannable. */
23
29
  const byCaption = (a: ConstantValue, b: ConstantValue) =>
24
30
  (a.Caption ?? '').localeCompare(b.Caption ?? '', undefined, { numeric: true, sensitivity: 'base' });
@@ -65,6 +71,8 @@ export abstract class BaseAuthService extends BaseRestService {
65
71
  // before a subclass constructor can call configureRestUrls().
66
72
  protected get appdataUrl() { return this.url(RestURL.appdataURL); }
67
73
  protected get loginUrl() { return this.url(RestURL.loginURL); }
74
+ protected get tokenRefreshUrl() { return this.url(RestURL.tokenRefreshURL); }
75
+ protected get logoutUrl() { return this.url(RestURL.logoutURL); }
68
76
  protected get registerUrl() { return this.url(RestURL.registerURL); }
69
77
  protected get chpassUrl() { return this.url(RestURL.chpassURL); }
70
78
  protected get confirmRegisterUrl() { return this.url(RestURL.confirmRegisterURL); }
@@ -94,6 +102,8 @@ export abstract class BaseAuthService extends BaseRestService {
94
102
 
95
103
  token: string | null = null;
96
104
  readonly isLoggedIn = signal(false);
105
+ /** In-flight rotation shared by every caller so one expired JWT triggers one refresh. */
106
+ private refreshInFlight: Observable<string> | null = null;
97
107
 
98
108
  /** keel password policy for pre-submit validation; undefined until loaded
99
109
  * (or when guiConfig.passwordPolicyUrl is unset). Fed via ensurePasswordPolicy(). */
@@ -176,14 +186,23 @@ export abstract class BaseAuthService extends BaseRestService {
176
186
  /** Adopt an externally-minted JWT (registration/SSO handoff flows) and run the
177
187
  * full post-login sequence — store under the canonical key, load appdata, init
178
188
  * routes. Apps must use this instead of writing localStorage directly. */
179
- acceptToken(token: string) {
180
- this.completeLogin(token);
189
+ acceptToken(token: string, refreshToken?: string) {
190
+ this.completeLogin(token, refreshToken);
181
191
  }
182
192
 
183
- private completeLogin(token: string) {
193
+ /** A pair with no refresh token also drops the stored one: it may belong to a different account. */
194
+ private storeTokens(token: string, refreshToken?: string) {
184
195
  this.token = token;
185
- this.isLoggedIn.set(true);
186
196
  localStorage.setItem('jwt', token);
197
+ if (refreshToken) localStorage.setItem('refreshToken', refreshToken);
198
+ else localStorage.removeItem('refreshToken');
199
+ }
200
+
201
+ private completeLogin(token: string, refreshToken?: string) {
202
+ this.refreshInFlight = null; // a rotation started under the previous session must not land
203
+ this.sessionGeneration++;
204
+ this.storeTokens(token, refreshToken);
205
+ this.isLoggedIn.set(true);
187
206
  resetAuthCircuit(); // a new session must not inherit a prior session's open circuit
188
207
  const ret = this.validatedReturnUrl();
189
208
  if (ret && this.guiConfig.sessionCookieUrl) {
@@ -231,10 +250,34 @@ export abstract class BaseAuthService extends BaseRestService {
231
250
  localStorage.setItem('loginToken', res.loginToken);
232
251
  this.router.navigate(['/login/2fa']);
233
252
  } else {
234
- this.completeLogin(res.token);
253
+ this.completeLogin(res.token, res.refreshToken);
235
254
  }
236
255
  }
237
256
 
257
+ /**
258
+ * Rotate the stored refresh token at keel's /public/token/refresh and emit the
259
+ * new JWT. Concurrent callers share one request. Errors when no refresh token
260
+ * is stored or keel rejects it (401: revoked, expired, or already rotated) —
261
+ * the caller decides whether that ends the session (the auth interceptor does).
262
+ */
263
+ refreshSession(): Observable<string> {
264
+ if (this.refreshInFlight) return this.refreshInFlight;
265
+ const refreshToken = localStorage.getItem('refreshToken');
266
+ if (!refreshToken) return throwError(() => new Error('no refresh token stored'));
267
+ const generation = this.sessionGeneration;
268
+ const inFlight = this.http.post<TokenPair>(this.tokenRefreshUrl, { refreshToken }).pipe(
269
+ map((res) => {
270
+ if (generation !== this.sessionGeneration) throw new RefreshSupersededError();
271
+ this.storeTokens(res.token, res.refreshToken);
272
+ return res.token;
273
+ }),
274
+ finalize(() => { if (this.refreshInFlight === inFlight) this.refreshInFlight = null; }),
275
+ shareReplay(1),
276
+ );
277
+ this.refreshInFlight = inFlight;
278
+ return inFlight;
279
+ }
280
+
238
281
  /**
239
282
  * POST credentials; success completes the login (or routes to 2FA). Callers
240
283
  * subscribe and handle the error channel to surface failures in the UI.
@@ -288,7 +331,7 @@ export abstract class BaseAuthService extends BaseRestService {
288
331
  tap((res) => {
289
332
  if (res.valid && res.token) {
290
333
  localStorage.removeItem('loginToken');
291
- this.completeLogin(res.token);
334
+ this.completeLogin(res.token, res.refreshToken);
292
335
  }
293
336
  }),
294
337
  );
@@ -301,7 +344,7 @@ export abstract class BaseAuthService extends BaseRestService {
301
344
  tap((res) => {
302
345
  if (res.valid && res.token) {
303
346
  localStorage.removeItem('loginToken');
304
- this.completeLogin(res.token);
347
+ this.completeLogin(res.token, res.refreshToken);
305
348
  }
306
349
  }),
307
350
  );
@@ -379,11 +422,22 @@ export abstract class BaseAuthService extends BaseRestService {
379
422
  }
380
423
  }
381
424
 
425
+ /** Revoke the refresh token server-side (best effort: the local session is
426
+ * cleared regardless), then land on the login screen. */
382
427
  logout() {
428
+ this.revokeRefreshToken().subscribe();
383
429
  this.clearSession();
384
430
  this.router.navigate(['/login/local'], { queryParams: { loggedOut: 'true' } });
385
431
  }
386
432
 
433
+ private revokeRefreshToken(): Observable<unknown> {
434
+ const refreshToken = localStorage.getItem('refreshToken');
435
+ if (!refreshToken) return EMPTY;
436
+ return this.http.post(this.logoutUrl, { refreshToken }).pipe(
437
+ catchError((err) => { console.warn('refresh token revocation failed:', err); return EMPTY; }),
438
+ );
439
+ }
440
+
387
441
  register(reg: PartnerRegistration) {
388
442
  return this.http.post<PartnerRegistration>(this.registerUrl, reg);
389
443
  }
@@ -423,7 +477,7 @@ export abstract class BaseAuthService extends BaseRestService {
423
477
  return this.http.post<OtpVerifyResponse>(this.otpVerifyUrl, req).pipe(
424
478
  tap((res) => {
425
479
  if (res?.token) {
426
- this.completeLogin(res.token);
480
+ this.completeLogin(res.token, res.refreshToken);
427
481
  if (role) this.setRole(role);
428
482
  }
429
483
  }),
@@ -441,7 +495,7 @@ export abstract class BaseAuthService extends BaseRestService {
441
495
  ): Observable<LoginResponseSocial> {
442
496
  const payload: SocialLoginRequest = { provider, token: idToken, ...consent };
443
497
  return this.http.post<LoginResponseSocial>(this.loginSocialUrl, payload).pipe(
444
- tap((res) => { if (res?.token) this.completeLogin(res.token); }),
498
+ tap((res) => { if (res?.token) this.completeLogin(res.token, res.refreshToken); }),
445
499
  );
446
500
  }
447
501
 
@@ -503,13 +557,15 @@ export abstract class BaseAuthService extends BaseRestService {
503
557
  this.token = null;
504
558
  this.isLoggedIn.set(false);
505
559
  localStorage.removeItem('jwt');
560
+ localStorage.removeItem('refreshToken');
506
561
  localStorage.removeItem('menu');
507
562
  localStorage.removeItem('loginToken');
508
563
  this.clearRole();
509
564
  this.cache = undefined;
510
565
  this.authIndex.clear();
511
566
  this.appDataError.set(null);
512
- this.sessionGeneration++; // in-flight loads from this session are ignored
567
+ this.refreshInFlight = null;
568
+ this.sessionGeneration++; // in-flight loads and refreshes from this session are ignored
513
569
  this.appData$.next(null); // live subscribers see menus clear; identity stays stable
514
570
  this.appDataFail$.complete();
515
571
  this.appDataFail$ = new ReplaySubject<unknown>(1);
@@ -1,6 +1,6 @@
1
1
  import { HttpClient, HttpParams } from '@angular/common/http';
2
2
  import { inject } from '@angular/core';
3
- import { Observable } from 'rxjs';
3
+ import { map, Observable } from 'rxjs';
4
4
  import { RestURL } from './rest_url';
5
5
 
6
6
  /**
@@ -22,10 +22,45 @@ export abstract class BaseRestService {
22
22
  /** GET an analytic endpoint (`{host}{api_prefix}analytic/<endpoint>`), returning
23
23
  * its rows. Optional flat string params become the query string. */
24
24
  protected analytic<T>(endpoint: string, params?: Record<string, string>): Observable<T[]> {
25
- let p = new HttpParams();
26
- for (const k in params) {
27
- if (Object.prototype.hasOwnProperty.call(params, k)) p = p.set(k, params[k]);
25
+ return this.http.get<T[]>(this.analyticUrl(endpoint), { params: this.analyticParams(params) });
26
+ }
27
+
28
+ /** `analytic()` plus the `X-Data-Source-Status` / `X-Data-Source` response headers, so an
29
+ * empty result can be told apart from a missing source. `analytic()` stays array-valued. */
30
+ protected analyticWithStatus<T>(endpoint: string, params?: Record<string, string>): Observable<AnalyticResult<T>> {
31
+ return this.http.get<T[]>(this.analyticUrl(endpoint), {
32
+ params: this.analyticParams(params),
33
+ observe: 'response',
34
+ }).pipe(
35
+ map((response) => ({
36
+ rows: response.body ?? [],
37
+ source: {
38
+ status: response.headers.get('X-Data-Source-Status'),
39
+ id: response.headers.get('X-Data-Source'),
40
+ },
41
+ })),
42
+ );
43
+ }
44
+
45
+ private analyticUrl(endpoint: string): string {
46
+ return this.url(RestURL.api_prefix + 'analytic/' + endpoint);
47
+ }
48
+
49
+ private analyticParams(params?: Record<string, string>): HttpParams {
50
+ let result = new HttpParams();
51
+ for (const key in params) {
52
+ if (Object.prototype.hasOwnProperty.call(params, key)) result = result.set(key, params[key]);
28
53
  }
29
- return this.http.get<T[]>(this.url(RestURL.api_prefix + 'analytic/' + endpoint), { params: p });
54
+ return result;
30
55
  }
31
56
  }
57
+
58
+ export interface AnalyticResult<T> {
59
+ rows: T[];
60
+ source: AnalyticSourceStatus;
61
+ }
62
+
63
+ export interface AnalyticSourceStatus {
64
+ status: string | null;
65
+ id: string | null;
66
+ }
@@ -9,6 +9,7 @@ import {
9
9
  PaymentMethod,
10
10
  PortalResponse,
11
11
  PublicPlan,
12
+ SetupIntentResponse,
12
13
  Subscription,
13
14
  UsageMeter,
14
15
  } from '../model/appdata';
@@ -73,6 +74,15 @@ export class BillingService extends BaseRestService {
73
74
  return this.http.get<PaymentMethod[]>(this.url(RestURL.paymentMethodsURL));
74
75
  }
75
76
 
77
+ /**
78
+ * Create a provider setup intent for on-device card capture (keel v1.2.57
79
+ * AbstractPaymentHandler.CreateSetupIntent). Identity comes from the JWT; the
80
+ * response feeds the provider's payment sheet. Persist customerId on first use.
81
+ */
82
+ createSetupIntent(): Observable<SetupIntentResponse> {
83
+ return this.http.post<SetupIntentResponse>(this.url(RestURL.setupIntentURL), {});
84
+ }
85
+
76
86
  /**
77
87
  * Create a provider customer-portal session; caller redirects to the URL so
78
88
  * the partner can manage their payment method / subscription on the
@@ -0,0 +1,142 @@
1
+ import { DestroyRef, Injectable, inject, signal } from '@angular/core';
2
+ import { Observable, Subject, filter, map } from 'rxjs';
3
+ import { RestURL } from './rest_url';
4
+
5
+ /** Frame published on a channel by keel's realtime.Hub (Broadcast / PublishChannel). */
6
+ export interface RealtimeChannelFrame<T = unknown> {
7
+ channel: string;
8
+ data: T;
9
+ }
10
+
11
+ /** Control frames of the keel realtime wire protocol. */
12
+ interface ControlFrame {
13
+ op: 'subscribed' | 'unsubscribed' | 'error';
14
+ channel?: string;
15
+ reason?: string;
16
+ }
17
+
18
+ const RECONNECT_MIN_MS = 1_000;
19
+ const RECONNECT_MAX_MS = 30_000;
20
+
21
+ /**
22
+ * RealtimeService — client for keel's realtime.Hub (`GET /public/ws`).
23
+ *
24
+ * Owns the socket lifecycle: JWT handshake via `?token=`, reconnect with
25
+ * backoff, and re-subscription of every channel after a reconnect. Channel
26
+ * names and payload schemas are the app's; sail only carries the frames.
27
+ * Relay delivery is fire-and-forget on the server, so keep a REST read path for
28
+ * state a client may have missed while disconnected.
29
+ */
30
+ @Injectable({ providedIn: 'root' })
31
+ export class RealtimeService {
32
+ private readonly destroyRef = inject(DestroyRef);
33
+ private socket: WebSocket | null = null;
34
+ private wanted = false;
35
+ private reconnectDelay = RECONNECT_MIN_MS;
36
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
37
+ private readonly channels = new Set<string>();
38
+ private readonly frames$ = new Subject<unknown>();
39
+
40
+ readonly connected = signal(false);
41
+ /** Last subscription rejected by keel's CanSubscribe, as `{channel, reason}`. */
42
+ readonly lastError = signal<{ channel?: string; reason?: string } | null>(null);
43
+
44
+ constructor() {
45
+ this.destroyRef.onDestroy(() => this.disconnect());
46
+ }
47
+
48
+ /** Open the socket with the stored JWT. Reconnects on drop until disconnect(). */
49
+ connect(): void {
50
+ this.wanted = true;
51
+ if (this.socket) return;
52
+ const token = localStorage.getItem('jwt');
53
+ if (!token) throw new Error('RealtimeService.connect: no session token stored');
54
+ const ws = new WebSocket(`${socketUrl(RestURL.realtimeURL)}?token=${encodeURIComponent(token)}`);
55
+ this.socket = ws;
56
+ ws.onopen = () => {
57
+ if (this.socket !== ws) return;
58
+ this.reconnectDelay = RECONNECT_MIN_MS;
59
+ this.connected.set(true);
60
+ for (const channel of this.channels) this.sendFrame({ op: 'subscribe', channel });
61
+ };
62
+ ws.onmessage = (ev: MessageEvent<string>) => { if (this.socket === ws) this.onFrame(ev.data); };
63
+ ws.onclose = () => {
64
+ if (this.socket !== ws) return; // superseded by disconnect()/connect(); ignore its close
65
+ this.socket = null;
66
+ this.connected.set(false);
67
+ if (this.wanted) this.scheduleReconnect();
68
+ };
69
+ ws.onerror = () => ws.close();
70
+ }
71
+
72
+ disconnect(): void {
73
+ this.wanted = false;
74
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
75
+ this.socket?.close();
76
+ this.socket = null;
77
+ this.connected.set(false);
78
+ }
79
+
80
+ /** Frames published on `channel`; subscribes on first use and survives reconnects. */
81
+ channel<T = unknown>(channel: string): Observable<T> {
82
+ if (!this.channels.has(channel)) {
83
+ this.channels.add(channel);
84
+ if (this.connected()) this.sendFrame({ op: 'subscribe', channel });
85
+ }
86
+ return this.frames$.pipe(
87
+ filter((f): f is RealtimeChannelFrame<T> => isChannelFrame(f) && f.channel === channel),
88
+ map((f) => f.data),
89
+ );
90
+ }
91
+
92
+ unsubscribe(channel: string): void {
93
+ if (this.channels.delete(channel) && this.connected()) this.sendFrame({ op: 'unsubscribe', channel });
94
+ }
95
+
96
+ /** Payloads keel delivers to this user directly (SendToUser / PublishUser). */
97
+ userFrames<T = unknown>(): Observable<T> {
98
+ return this.frames$.pipe(filter((f): f is T => !isChannelFrame(f) && !isControlFrame(f)));
99
+ }
100
+
101
+ /** Send an application frame; keel passes anything but subscribe/unsubscribe to OnMessage. */
102
+ send(frame: object): void {
103
+ if (!this.connected()) throw new Error('RealtimeService.send: socket not connected');
104
+ this.sendFrame(frame);
105
+ }
106
+
107
+ private sendFrame(frame: object): void {
108
+ this.socket?.send(JSON.stringify(frame));
109
+ }
110
+
111
+ private onFrame(raw: string): void {
112
+ let frame: unknown;
113
+ try { frame = JSON.parse(raw); } catch { console.error('realtime: non-JSON frame dropped'); return; }
114
+ if (isControlFrame(frame) && frame.op === 'error') {
115
+ this.lastError.set({ channel: frame.channel, reason: frame.reason });
116
+ console.error(`realtime: subscribe ${frame.channel} rejected: ${frame.reason}`);
117
+ }
118
+ this.frames$.next(frame);
119
+ }
120
+
121
+ private scheduleReconnect(): void {
122
+ this.reconnectTimer = setTimeout(() => {
123
+ this.reconnectTimer = null;
124
+ if (this.wanted) this.connect();
125
+ }, this.reconnectDelay);
126
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
127
+ }
128
+ }
129
+
130
+ function isChannelFrame(f: unknown): f is RealtimeChannelFrame {
131
+ return !!f && typeof f === 'object' && 'channel' in f && 'data' in f && !('op' in f);
132
+ }
133
+
134
+ function isControlFrame(f: unknown): f is ControlFrame {
135
+ return !!f && typeof f === 'object' && 'op' in f;
136
+ }
137
+
138
+ /** ws(s):// form of the configured backend host plus `path`; same-origin when no host is set. */
139
+ function socketUrl(path: string): string {
140
+ const base = RestURL.httpHost || window.location.origin;
141
+ return base.replace(/^http/, 'ws') + path;
142
+ }
@@ -3,6 +3,9 @@ export const RestURL = {
3
3
  api_prefix: '/api/',
4
4
  appdataURL: '/api/config/appdata',
5
5
  loginURL: '/public/login/local',
6
+ tokenRefreshURL: '/public/token/refresh', // rotates {refreshToken} → new {token, refreshToken}
7
+ logoutURL: '/public/logout', // revokes {refreshToken}
8
+ realtimeURL: '/public/ws', // keel realtime.Hub handshake; JWT via ?token=
6
9
  loginGoogleURL: '/public/login/google',
7
10
  registerURL: '/public/register',
8
11
  chpassURL: '/public/login/chpass',
@@ -25,6 +28,7 @@ export const RestURL = {
25
28
  invoicesURL: '/api/billing/invoices',
26
29
  paymentMethodsURL: '/api/billing/payment-methods',
27
30
  portalURL: '/api/billing/portal',
31
+ setupIntentURL: '/api/billing/setup-intent',
28
32
  usageURL: '/api/billing/usage',
29
33
  // OTP / social / push / account-lifecycle endpoints
30
34
  otpSendURL: '/public/otp/send',