@nauticana/sail 1.1.27 → 1.1.28

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**; 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`.
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.28 adopts keel v1.2.93 (absolute `return` for the OAuth hand-off), 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
 
@@ -315,11 +315,12 @@ interface SailGuiConfig {
315
315
 
316
316
  ### Password-policy validation
317
317
 
318
- **OAuth sign-in hand-off (v1.1.27, keel v1.2.89).** When the login page is opened with
319
- `?return=<authorize URL>` and both `oauthServerUrl` and `allowedReturnHosts` are set,
320
- `completeLogin` posts `{"return": <raw value>}` with the bearer JWT to
318
+ **OAuth sign-in hand-off (v1.1.28, keel v1.2.93).** When the login page is opened with
319
+ `?return=<absolute authorize URL>` and both `oauthServerUrl` and `allowedReturnHosts` are set,
320
+ sail posts `{"return": <raw value>}` with the bearer JWT to
321
321
  `{oauthServerUrl}/oauth/session/handoff` and then leaves the SPA with a full navigation to the
322
- `redirect` keel answers. keel sets the authorization server's own HttpOnly cookie while
322
+ `redirect` keel answers. This runs after a sign-in, and from `loadStoredSession()` when a user
323
+ who is already signed in opens a `/login` route; a 401 rotates the refresh token once and retries. keel sets the authorization server's own HttpOnly cookie while
323
324
  redeeming that URL; sail does not opt into cross-origin cookies or send a CSRF token. A
324
325
  `return` whose host is not in `allowedReturnHosts`, or a `redirect` outside `oauthServerUrl`'s
325
326
  origin, is not followed. Failures set `BaseAuthService.sessionHandoffError` (rendered by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.27",
3
+ "version": "1.1.28",
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
  "exports": {
@@ -25,7 +25,7 @@ export class RefreshSupersededError extends Error {
25
25
  constructor() { super('session changed during refresh'); this.name = 'RefreshSupersededError'; }
26
26
  }
27
27
 
28
- function handoffErrorMessage(status: number): string {
28
+ function handoffErrorMessage(status: number | undefined): string {
29
29
  if (status === 400) return 'This sign-in link is not valid. Start again from the application you were connecting.';
30
30
  if (status === 401) return 'Your session was not accepted. Please sign in again.';
31
31
  return 'Could not complete sign-in. Please try again.';
@@ -219,25 +219,37 @@ export abstract class BaseAuthService extends BaseRestService {
219
219
  this.isLoggedIn.set(true);
220
220
  this.sessionHandoffError.set(null);
221
221
  resetAuthCircuit(); // a new session must not inherit a prior session's open circuit
222
- const ret = this.validatedReturnUrl();
223
- const oauthServer = this.oauthServerBase();
224
- if (ret && oauthServer) {
225
- this.handOffSession(oauthServer, ret, token);
226
- return;
227
- }
222
+ if (this.startHandoff()) return;
228
223
  this.loadAppData();
229
224
  this.initRoutes();
230
225
  }
231
226
 
227
+ /** Hands the session to the OAuth authorization server when the page carries an
228
+ * allowed `?return=` and oauthServerUrl is set; false when there is nothing to hand off. */
229
+ private startHandoff(): boolean {
230
+ const ret = this.validatedReturnUrl();
231
+ const oauthServer = this.oauthServerBase();
232
+ if (!ret || !oauthServer) return false;
233
+ this.handOffSession(oauthServer, ret);
234
+ return true;
235
+ }
236
+
232
237
  /** Exchange the bearer session for the authorization server's own cookie session
233
238
  * (keel POST /oauth/session/handoff), then leave the SPA for the URL it returns.
234
- * Any failure stays on the login page; the browser is never sent elsewhere. */
235
- private handOffSession(oauthServer: string, ret: string, token: string): void {
236
- this.http.post<{ data?: { redirect?: unknown } }>(
239
+ * A 401 rotates the refresh token once and retries. Any failure stays on the
240
+ * login page; the browser is never sent elsewhere. */
241
+ private handOffSession(oauthServer: string, ret: string): void {
242
+ const mint = (token: string) => this.http.post<{ data?: { redirect?: unknown } }>(
237
243
  oauthServer + '/oauth/session/handoff',
238
244
  { return: ret },
239
245
  { headers: { Authorization: `Bearer ${token}` } },
240
- ).pipe(take(1)).subscribe({
246
+ );
247
+ mint(this.token ?? '').pipe(
248
+ catchError((err: HttpErrorResponse) => err.status === 401 && localStorage.getItem('refreshToken')
249
+ ? this.refreshSession().pipe(mergeMap(mint))
250
+ : throwError(() => err)),
251
+ take(1),
252
+ ).subscribe({
241
253
  next: (res) => {
242
254
  const redirect = res?.data?.redirect;
243
255
  if (typeof redirect !== 'string' || !sameOrigin(redirect, oauthServer)) {
@@ -247,9 +259,9 @@ export abstract class BaseAuthService extends BaseRestService {
247
259
  }
248
260
  window.location.assign(redirect);
249
261
  },
250
- error: (err: HttpErrorResponse) => {
262
+ error: (err: unknown) => {
251
263
  console.error('Session handoff failed:', err);
252
- this.sessionHandoffError.set(handoffErrorMessage(err.status));
264
+ this.sessionHandoffError.set(handoffErrorMessage((err as { status?: number } | null)?.status));
253
265
  },
254
266
  });
255
267
  }
@@ -448,11 +460,12 @@ export abstract class BaseAuthService extends BaseRestService {
448
460
 
449
461
  loadStoredSession() {
450
462
  this.token = localStorage.getItem('jwt');
451
- if (this.token) {
452
- this.isLoggedIn.set(true);
453
- this.loadAppData();
454
- this.initRoutes();
455
- }
463
+ if (!this.token) return;
464
+ this.isLoggedIn.set(true);
465
+ // A signed-in user sent to the login page by the authorization server is handed off without signing in again.
466
+ if (window.location.pathname.startsWith('/login') && this.startHandoff()) return;
467
+ this.loadAppData();
468
+ this.initRoutes();
456
469
  }
457
470
 
458
471
  /** Revoke the refresh token server-side (best effort: the local session is