@nauticana/sail 1.1.16 → 1.1.18

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
 
@@ -240,17 +240,41 @@ keel REST list responses are paginated:
240
240
  { "items": [...], "limit": 100, "offset": 0, "total": 12345 }
241
241
  ```
242
242
 
243
- `BackendService.list<T>()` continues to return `Observable<T[]>` — sail unwraps `items` for you. To access the metadata, use `listPaginated<T>()`:
243
+ `BackendService.list<T>()` continues to return `Observable<T[]>` — sail unwraps `items` for you. Both list methods accept a typed `PageRequest` after the filter map. To access the metadata, use `listPaginated<T>()`:
244
244
 
245
245
  ```typescript
246
- this.backend.listPaginated<MyRow>('orders', { _limit: '50', _offset: '0' })
246
+ this.backend.listPaginated<MyRow>('orders', { Status: 'open' }, {
247
+ limit: 50,
248
+ offset: 0,
249
+ orderBy: 'created_at DESC',
250
+ })
247
251
  .subscribe((page) => {
248
252
  this.rows.set(page.items);
249
253
  this.total.set(page.total); // total rows matching the filter
250
254
  });
251
255
  ```
252
256
 
253
- Default page size is 100, capped at 1000 server-side. Pass `_limit` / `_offset` in the filter map to control paging.
257
+ Default page size is 100, capped at 1000 server-side. Sail maps `limit`, `offset`, and `orderBy` to keel's `_limit`, `_offset`, and `_order` query controls. Existing callers that put those string controls in the filter map remain compatible; explicit `PageRequest` values take precedence.
258
+
259
+ ## Backend-driven constant captions
260
+
261
+ `LabelService` loads one keel `constant_value` domain from the backend-provided `ApplicationData.ConstantCache` and caches its value-to-caption map. The domain is the backend `constant_id` (for example `ride_type`), not a locale: keel's constant catalogue does not contain a language column. It does not call generic `constant_value` CRUD, which is normally restricted to administrative roles.
262
+
263
+ ```typescript
264
+ this.labels.loadLabels('ride_type').subscribe(() => {
265
+ this.rideTypeCaption.set(this.labels.getLabel('ride_type', ride.type));
266
+ });
267
+ ```
268
+
269
+ Concurrent loads share one application-data subscription, completed domains stay cached, and application-data failures can be retried. Missing values fall back to the raw key.
270
+
271
+ ## Status-filtered API errors
272
+
273
+ Pass an optional status allowlist when backend detail is safe to show only for selected responses. Other HTTP statuses return the caller's generic fallback:
274
+
275
+ ```typescript
276
+ errorDetail(error, 'Could not save.', [400, 409]);
277
+ ```
254
278
 
255
279
  ## Configuration reference
256
280
 
@@ -304,6 +328,9 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
304
328
  | `POST /public/otp/resend` | Re-issue OTP for an existing `otpToken` |
305
329
  | `POST /public/2fa/verify` | Login-time TOTP verification (uses `loginToken`) |
306
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=`) |
307
334
  | `GET /api/config/appdata` | Metadata (menus, permissions, table definitions) |
308
335
  | `POST/GET/DELETE /api/{version}/{table}/list\|get\|post\|delete` | CRUD operations (paginated `list`) |
309
336
  | `POST /api/user/2fa/setup` | Generate TOTP secret, QR URI, and backup codes — **requires re-auth** |
@@ -316,6 +343,7 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
316
343
  | `POST /api/push/register` | Register an FCM / APNs token |
317
344
  | `POST /api/push/revoke` | Revoke an FCM / APNs token |
318
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()`) |
319
347
  | `GET /public/plans` | Subscription plan catalog (unauthenticated) |
320
348
  | `GET /api/billing/subscription` | Active subscription for the partner |
321
349
  | `POST /api/billing/subscription/cancel` | Cancel auto-renew |
@@ -358,7 +386,15 @@ If "trust this device" appears to do nothing (user is 2FA-prompted on every logi
358
386
 
359
387
  ### Session token storage
360
388
 
361
- 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.
362
398
 
363
399
  ## Billing
364
400
 
@@ -1163,10 +1199,10 @@ Wherever you stored the OTP `sessionId`, swap it for `otpToken: string`.
1163
1199
 
1164
1200
  `BackendService.list<T>()` returns `Observable<T[]>` (unchanged). It now expects keel to return paginated `{items, limit, offset, total}` and has dropped the legacy "bare array" fallback. If you have an in-house wrapper that calls keel `list` endpoints directly, expect the wrapper shape.
1165
1201
 
1166
- For pagination metadata, use `listPaginated<T>()`:
1202
+ For pagination metadata, use `listPaginated<T>()` with a typed `PageRequest`:
1167
1203
 
1168
1204
  ```typescript
1169
- backend.listPaginated<MyRow>('orders', { _limit: '50', _offset: '0' })
1205
+ backend.listPaginated<MyRow>('orders', undefined, { limit: 50, offset: 0 })
1170
1206
  .subscribe(page => {
1171
1207
  this.rows.set(page.items);
1172
1208
  this.total.set(page.total);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.16",
3
+ "version": "1.1.18",
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": {
@@ -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
  *
@@ -103,12 +103,11 @@ export class TableList extends AbstractEditableView implements OnInit {
103
103
  }
104
104
 
105
105
  private loadPage(): Observable<unknown> {
106
- const filter = {
107
- ...this.searchTerms(),
108
- _limit: String(this.pageSize()),
109
- _offset: String(this.pageIndex() * this.pageSize()),
110
- };
111
- return this.backendService.listPaginated<Record<string, unknown>>(this.apiName(), filter).pipe(
106
+ return this.backendService.listPaginated<Record<string, unknown>>(
107
+ this.apiName(),
108
+ this.searchTerms(),
109
+ { limit: this.pageSize(), offset: this.pageIndex() * this.pageSize() },
110
+ ).pipe(
112
111
  tap((page) => {
113
112
  this.records.set(page.items ?? []);
114
113
  this.total.set(page.total ?? page.items?.length ?? 0);
package/src/index.ts CHANGED
@@ -72,6 +72,7 @@ export * from './service/oauth_connection.service';
72
72
  export * from './service/user_payment_method.service';
73
73
  export * from './service/script_loader';
74
74
  export * from './service/auth.interceptor';
75
+ export * from './service/realtime.service';
75
76
  export * from './service/api-response.interceptor';
76
77
  export * from './service/rest_service';
77
78
  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
  }
@@ -330,6 +349,13 @@ export interface PaginatedList<T> {
330
349
  total: number;
331
350
  }
332
351
 
352
+ /** Typed controls for keel REST list pagination and ordering. */
353
+ export interface PageRequest {
354
+ limit?: number;
355
+ offset?: number;
356
+ orderBy?: string;
357
+ }
358
+
333
359
  export interface Subscription {
334
360
  planId: string;
335
361
  status: string; // 'A' active, 'P' pending-payment, 'C' cancelled, 'T' trialing, 'X' past-due — caption via SUBSCRIPTION_STATUS dict
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,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);
@@ -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
@@ -1,19 +1,44 @@
1
- import { Injectable } from "@angular/core";
1
+ import { inject, Injectable } from "@angular/core";
2
+ import { Observable, map, of, shareReplay, take, tap } from "rxjs";
3
+ import { BaseAuthService } from "./auth.service";
2
4
 
3
- // TODO: Implement translated captions service
4
- // - loadLabels(langcode) should fetch labels from the backend
5
- // - getLabel(langcode, key) should return the translated caption for a given key
6
- // - BaseTable.colCaption() and related methods should use this service for i18n
7
5
  @Injectable({ providedIn: 'root' })
8
6
  export class LabelService {
7
+ private readonly auth = inject(BaseAuthService);
9
8
  protected labels: Map<string, Map<string, string>> = new Map();
9
+ private readonly pendingLoads = new Map<string, Observable<void>>();
10
10
 
11
- /** Falls back to the key itself so a missing translation is visible, not a blank caption. */
12
- getLabel(langcode: string, key: string): string {
13
- return this.labels.get(langcode)?.get(key) ?? key;
11
+ /** Return a caption from a constant domain, falling back visibly to its raw value. */
12
+ getLabel(constantId: string, key: string): string {
13
+ return this.labels.get(constantId)?.get(key) ?? key;
14
14
  }
15
15
 
16
- loadLabels(_langcode: string) {
17
- // TODO: fetch from backend and populate this.labels
16
+ /**
17
+ * Load one backend-provided `constant_value` domain from ApplicationData.
18
+ * Completed and concurrent requests are cached; a failed application-data
19
+ * load is evicted so a later call can retry.
20
+ */
21
+ loadLabels(constantId: string): Observable<void> {
22
+ const domain = constantId.trim();
23
+ if (!domain) throw new Error('sail: a constant id is required to load labels.');
24
+ if (this.labels.has(domain)) return of(undefined);
25
+
26
+ const pending = this.pendingLoads.get(domain);
27
+ if (pending) return pending;
28
+
29
+ const request = this.auth.getAppData().pipe(
30
+ take(1),
31
+ tap({
32
+ next: (data) => this.labels.set(
33
+ domain,
34
+ new Map(Object.entries(data.ConstantCache?.[domain] ?? {})),
35
+ ),
36
+ error: () => this.pendingLoads.delete(domain),
37
+ }),
38
+ map(() => undefined),
39
+ shareReplay({ bufferSize: 1, refCount: false }),
40
+ );
41
+ this.pendingLoads.set(domain, request);
42
+ return request;
18
43
  }
19
44
  }
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  import { HttpErrorResponse, HttpParams } from "@angular/common/http";
2
2
  import { inject, Injectable } from "@angular/core";
3
3
  import { catchError, map, Observable, switchMap, take, throwError } from "rxjs";
4
- import { ApplicationData, PaginatedList } from "../model/appdata";
4
+ import { ApplicationData, PageRequest, PaginatedList } from "../model/appdata";
5
5
  import { RestURL } from "./rest_url";
6
6
  import { BaseAuthService } from "./auth.service";
7
7
  import { BaseRestService } from "./base_rest.service";
@@ -83,15 +83,19 @@ export class BackendService extends BaseRestService {
83
83
  return this.http.request<T>(method, this.apiUrl(path), { body }).pipe(catchError((err) => this.handleError(err)));
84
84
  }
85
85
 
86
- /** Build query params from a flat string-string map; skips undefined/null values. */
87
- private toParams(filter?: {[key: string]: string}): HttpParams {
86
+ /** Build query params from filters plus keel's underscore-prefixed paging aliases. */
87
+ private toParams(filter?: {[key: string]: string}, page?: PageRequest): HttpParams {
88
88
  let params = new HttpParams();
89
- if (!filter) return params;
90
- for (const key in filter) {
91
- if (Object.prototype.hasOwnProperty.call(filter, key) && filter[key] != null) {
92
- params = params.set(key, filter[key]);
89
+ if (filter) {
90
+ for (const key in filter) {
91
+ if (Object.prototype.hasOwnProperty.call(filter, key) && filter[key] != null) {
92
+ params = params.set(key, filter[key]);
93
+ }
93
94
  }
94
95
  }
96
+ if (page?.limit != null) params = params.set('_limit', page.limit);
97
+ if (page?.offset != null) params = params.set('_offset', page.offset);
98
+ if (page?.orderBy) params = params.set('_order', page.orderBy);
95
99
  return params;
96
100
  }
97
101
 
@@ -100,16 +104,16 @@ export class BackendService extends BaseRestService {
100
104
  return this.auth.getAppData().pipe(take(1));
101
105
  }
102
106
 
103
- /** Unwraps `{items, limit, offset, total}` to the items array. Pass `_limit` / `_offset` in `filter` to control paging (defaults: 100 / 0). */
104
- list<T>(apiName: string, filter?: {[key: string]: string}) : Observable<T[]> {
105
- return this.listPaginated<T>(apiName, filter).pipe(map((page) => page.items));
107
+ /** Unwraps `{items, limit, offset, total}` to the items array. */
108
+ list<T>(apiName: string, filter?: {[key: string]: string}, page?: PageRequest) : Observable<T[]> {
109
+ return this.listPaginated<T>(apiName, filter, page).pipe(map((result) => result.items));
106
110
  }
107
111
 
108
- listPaginated<T>(apiName: string, filter?: {[key: string]: string}) : Observable<PaginatedList<T>> {
112
+ listPaginated<T>(apiName: string, filter?: {[key: string]: string}, page?: PageRequest) : Observable<PaginatedList<T>> {
109
113
  return this.appDataReady().pipe(
110
114
  switchMap(() => this.http.get<PaginatedList<T>>(
111
115
  this.crudUrl(apiName, 'list'),
112
- {params: this.toParams(filter)},
116
+ {params: this.toParams(filter, page)},
113
117
  )),
114
118
  catchError((err) => this.handleError(err)),
115
119
  );
@@ -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',
@@ -39,9 +39,15 @@ export function isTransportFailure(err: unknown): boolean {
39
39
  * `detail` did not come from the API: either nothing reached it (status 0) or
40
40
  * something in front of it replied with a body we cannot read. Both used to
41
41
  * collapse into the caller's fallback, which reads as a backend rejection and
42
- * hides the real fault; they are now reported distinctly.
42
+ * hides the real fault; they are now reported distinctly. When `statuses` is
43
+ * supplied, HTTP statuses outside that allowlist return `fallback` without
44
+ * exposing their response text.
43
45
  */
44
- export function errorDetail(err: unknown, fallback: string): string {
46
+ export function errorDetail(err: unknown, fallback: string, statuses?: readonly number[]): string {
47
+ const status = statusOf(err);
48
+ if (statuses && status !== undefined && !statuses.includes(status)) {
49
+ return fallback;
50
+ }
45
51
  if (isTransportFailure(err)) {
46
52
  return 'Cannot reach the server — the network or a CORS rule blocked the response.';
47
53
  }
@@ -53,7 +59,6 @@ export function errorDetail(err: unknown, fallback: string): string {
53
59
  if (err instanceof Error && err.message) {
54
60
  return err.message;
55
61
  }
56
- const status = statusOf(err);
57
62
  return status ? `${fallback} (HTTP ${status})` : fallback;
58
63
  }
59
64