@nauticana/sail 1.1.27 → 1.1.29

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.29 adopts keel v1.2.94 (signup: confirmation signs in, partner setup, sign-in hand-off code), 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
@@ -345,6 +346,10 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
345
346
  | `POST /public/otp/resend` | Re-issue OTP for an existing `otpToken` |
346
347
  | `POST /public/2fa/verify` | Login-time TOTP verification (uses `loginToken`) |
347
348
  | `POST /public/2fa/backup-verify` | Login-time backup-code verification |
349
+ | `POST /public/register` | Start a signup from a `PartnerRegistration`; emails the confirmation code |
350
+ | `POST /public/register/confirm` | Confirm `?email=&code=`; answers session tokens and the partner result (`confirmRegister`) |
351
+ | `POST /public/register/exchange` | Trade a single-use hand-off `{code}` for a session (`exchangeHandoff`) |
352
+ | `POST /api/register/partner` | Create the signed-in user's partner from a `PartnerSetup` (`createPartner`) |
348
353
  | `POST /public/token/refresh` | Rotate `{refreshToken}` → new `{token, refreshToken}`; 401 ends the session |
349
354
  | `POST /public/logout` | Revoke `{refreshToken}` (called by `BaseAuthService.logout()`) |
350
355
  | `GET /public/ws` | keel realtime hub handshake (`RealtimeService`; JWT via `?token=`) |
@@ -511,7 +516,9 @@ metadata: {
511
516
 
512
517
  ### Registration → checkout flow
513
518
 
514
- The registration confirmation (`ConfirmRegisterComponent`) already handles the payment redirect. When the backend returns `paymentRequired: true`, the user is sent to `resp.paymentUrl` (Stripe Checkout) automatically. No extra wiring needed — just select a paid plan during registration.
519
+ `BaseAuthService.confirmRegister(email, code)` signs the user in from the tokens keel answers (keel v1.2.94), so no separate login follows. `ConfirmRegisterComponent` then sends the user to `resp.paymentUrl` when the backend returns `paymentRequired: true` with one; otherwise the post-login navigation applies.
520
+
521
+ A two-step signup registers the account alone (a `PartnerRegistration` without partner fields), then calls `createPartner(setup)` once signed in. It posts keel's `PartnerSetup` — application fields ride in `extra` — rotates the refresh token so the session carries the new partner, reloads appdata and routes, and emits `PartnerCreated`. When `paymentRequired` comes without a `paymentUrl`, the application's billing page opens the checkout. `exchangeHandoff(code)` completes a sign-in that arrived by redirect with keel's single-use code, including the 2FA step.
515
522
 
516
523
  ### `BaseAsync`
517
524
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.27",
3
+ "version": "1.1.29",
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": {
@@ -1,7 +1,7 @@
1
1
  import { ChangeDetectionStrategy, Component, inject, OnInit, ViewEncapsulation } from '@angular/core';
2
2
  import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
3
3
  import { BaseAuthService } from '../../service/auth.service';
4
- import { ActivatedRoute, Router, RouterLink } from '@angular/router';
4
+ import { ActivatedRoute, RouterLink } from '@angular/router';
5
5
  import { MatButtonModule } from '@angular/material/button';
6
6
  import { MatFormFieldModule } from '@angular/material/form-field';
7
7
  import { MatInputModule } from '@angular/material/input';
@@ -24,7 +24,6 @@ import { BaseAsync } from '../abstract/base_async';
24
24
  export class ConfirmRegisterComponent extends BaseAsync implements OnInit {
25
25
  private auth = inject(BaseAuthService);
26
26
  private fb = inject(FormBuilder);
27
- private router = inject(Router);
28
27
  private route = inject(ActivatedRoute);
29
28
  protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
30
29
 
@@ -49,15 +48,11 @@ export class ConfirmRegisterComponent extends BaseAsync implements OnInit {
49
48
  this.auth.confirmRegister(email!, code!),
50
49
  (resp) => {
51
50
  this.successMessage.set('Registration confirmed.');
51
+ // confirmRegister signed the user in and owns navigation; only a paid
52
+ // plan's checkout leaves the SPA (window.location: it is cross-origin).
52
53
  if (resp?.paymentRequired && resp.paymentUrl) {
53
- // Paid plan: go straight to payment. Subscription is 'P' until paid.
54
- // Using window.location (not router) so absolute / cross-origin
55
- // payment URLs also work.
56
54
  window.location.href = resp.paymentUrl;
57
- return;
58
55
  }
59
- // Free plan: land on login.
60
- this.router.navigate(['/login/local']);
61
56
  },
62
57
  'Confirmation failed. Please try again.',
63
58
  );
@@ -159,22 +159,22 @@ export class RegisterComponent implements OnInit {
159
159
  this.registerError.set('');
160
160
  const formValue = this.registerForm.getRawValue();
161
161
  const partnerReg: PartnerRegistration = {
162
- FirstName: formValue.FirstName!,
163
- LastName: formValue.LastName!,
164
- UserName: formValue.UserName!,
165
- Email: formValue.Email!,
166
- Password: formValue.Password!,
167
- PartnerCaption: formValue.PartnerCaption!,
168
- Address: formValue.Address!,
169
- City: formValue.City!,
170
- State: formValue.State!,
171
- Zipcode: formValue.Zipcode!,
172
- Country: formValue.Country!,
173
- Phone: formValue.Phone!,
174
- DomainURL: formValue.DomainURL!,
175
- Latitude: formValue.Latitude,
176
- Longitude: formValue.Longitude,
177
- PlanID: formValue.PlanID!,
162
+ firstName: formValue.FirstName!,
163
+ lastName: formValue.LastName!,
164
+ userName: formValue.UserName!,
165
+ email: formValue.Email!,
166
+ password: formValue.Password!,
167
+ partnerCaption: formValue.PartnerCaption!,
168
+ address: formValue.Address!,
169
+ city: formValue.City!,
170
+ state: formValue.State!,
171
+ zipcode: formValue.Zipcode!,
172
+ country: formValue.Country!,
173
+ phone: formValue.Phone!,
174
+ domainUrl: formValue.DomainURL!,
175
+ latitude: formValue.Latitude ?? undefined,
176
+ longitude: formValue.Longitude ?? undefined,
177
+ planId: formValue.PlanID!,
178
178
  };
179
179
  this.auth.register(partnerReg).subscribe({
180
180
  // Pass the email along so the confirm page pre-fills it.
@@ -198,24 +198,46 @@ export interface TrustedDevice {
198
198
  created_at: string;
199
199
  }
200
200
 
201
- export interface PartnerRegistration {
202
- FirstName: string;
203
- LastName: string;
204
- UserName: string;
205
- Email: string;
206
- Password: string;
207
- PartnerCaption: string;
208
- Address: string;
209
- City: string;
210
- State: string;
211
- Zipcode: string;
212
- Country: string;
213
- Phone: string;
214
- // null when geocoding is disabled or the address wasn't verified.
215
- Latitude: number | null;
216
- Longitude: number | null;
217
- DomainURL: string;
218
- PlanID: string;
201
+ // Mirrors keel `user.AccountRegistration`.
202
+ export interface AccountRegistration {
203
+ firstName: string;
204
+ lastName: string;
205
+ userName: string;
206
+ email: string;
207
+ password: string;
208
+ }
209
+
210
+ // Mirrors keel `user.PartnerSetup`. `extra` carries the application's own fields.
211
+ export interface PartnerSetup<TExtra = unknown> {
212
+ partnerCaption: string;
213
+ address: string;
214
+ city: string;
215
+ state: string;
216
+ zipcode: string;
217
+ country: string;
218
+ phone: string;
219
+ latitude?: number;
220
+ longitude?: number;
221
+ domainUrl: string;
222
+ planId: string;
223
+ // Chosen offer (PERIOD_TYPE codes); none selects the plan's cheapest.
224
+ billingCycle?: string;
225
+ termType?: string;
226
+ termCount?: number;
227
+ extra?: TExtra;
228
+ }
229
+
230
+ // Mirrors keel `user.PartnerRegistration`. Without partner fields only the
231
+ // account is created and the partner follows through `createPartner`.
232
+ export type PartnerRegistration<TExtra = unknown> = AccountRegistration & Partial<PartnerSetup<TExtra>>;
233
+
234
+ // Mirrors keel `user.PartnerCreated`. `paymentRequired` without a `paymentUrl`
235
+ // means the application's billing page opens the checkout.
236
+ export interface PartnerCreated {
237
+ partnerId: number;
238
+ planId: string;
239
+ paymentRequired: boolean;
240
+ paymentUrl?: string;
219
241
  }
220
242
 
221
243
  // One purchasable offer for a plan — a (billing cycle × commitment term) row of
@@ -261,15 +283,13 @@ export interface PublicPlan {
261
283
  trialDays?: number;
262
284
  }
263
285
 
264
- // ConfirmRegisterResponse — shape returned by POST /public/confirm/register.
265
- // When paymentRequired is true the UI should redirect the user to paymentUrl
266
- // before letting them log in.
267
- export interface ConfirmRegisterResponse {
268
- status: string;
269
- partnerId: number;
270
- planId: string;
271
- paymentRequired: boolean;
272
- paymentUrl?: string;
286
+ // ConfirmRegisterResponse — shape returned by POST /public/register/confirm:
287
+ // the new session's tokens, plus the partner result when the registration had one.
288
+ export interface ConfirmRegisterResponse extends TokenPair {
289
+ status: string;
290
+ planId?: string;
291
+ paymentRequired?: boolean;
292
+ paymentUrl?: string;
273
293
  }
274
294
 
275
295
  // ── Billing / Payment types ──
@@ -2,7 +2,7 @@ import { Injectable, WritableSignal, inject, signal } from '@angular/core';
2
2
  import { HttpErrorResponse, HttpParams } from '@angular/common/http';
3
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, TokenPair, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
5
+ import { ApplicationData, ConfirmRegisterResponse, DictionaryPath, LoginResponse2FA, PartnerCreated, PartnerRegistration, PartnerSetup, RestReport, TableDefinition, TokenPair, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
6
6
  import {
7
7
  LoginResponseSocial,
8
8
  OtpRequest, OtpResendRequest, OtpResponse, OtpVerifyRequest, OtpVerifyResponse,
@@ -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.';
@@ -90,6 +90,8 @@ export abstract class BaseAuthService extends BaseRestService {
90
90
  protected get registerUrl() { return this.url(RestURL.registerURL); }
91
91
  protected get chpassUrl() { return this.url(RestURL.chpassURL); }
92
92
  protected get confirmRegisterUrl() { return this.url(RestURL.confirmRegisterURL); }
93
+ protected get registerExchangeUrl() { return this.url(RestURL.registerExchangeURL); }
94
+ protected get registerPartnerUrl() { return this.url(RestURL.registerPartnerURL); }
93
95
  protected get confirmChpassUrl() { return this.url(RestURL.confirmChpassURL); }
94
96
  protected get loginGoogleUrl() { return this.url(RestURL.loginGoogleURL); }
95
97
  protected get twoFactorSetupUrl() { return this.url(RestURL.twoFactorSetupURL); }
@@ -219,25 +221,37 @@ export abstract class BaseAuthService extends BaseRestService {
219
221
  this.isLoggedIn.set(true);
220
222
  this.sessionHandoffError.set(null);
221
223
  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
- }
224
+ if (this.startHandoff()) return;
228
225
  this.loadAppData();
229
226
  this.initRoutes();
230
227
  }
231
228
 
229
+ /** Hands the session to the OAuth authorization server when the page carries an
230
+ * allowed `?return=` and oauthServerUrl is set; false when there is nothing to hand off. */
231
+ private startHandoff(): boolean {
232
+ const ret = this.validatedReturnUrl();
233
+ const oauthServer = this.oauthServerBase();
234
+ if (!ret || !oauthServer) return false;
235
+ this.handOffSession(oauthServer, ret);
236
+ return true;
237
+ }
238
+
232
239
  /** Exchange the bearer session for the authorization server's own cookie session
233
240
  * (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 } }>(
241
+ * A 401 rotates the refresh token once and retries. Any failure stays on the
242
+ * login page; the browser is never sent elsewhere. */
243
+ private handOffSession(oauthServer: string, ret: string): void {
244
+ const mint = (token: string) => this.http.post<{ data?: { redirect?: unknown } }>(
237
245
  oauthServer + '/oauth/session/handoff',
238
246
  { return: ret },
239
247
  { headers: { Authorization: `Bearer ${token}` } },
240
- ).pipe(take(1)).subscribe({
248
+ );
249
+ mint(this.token ?? '').pipe(
250
+ catchError((err: HttpErrorResponse) => err.status === 401 && localStorage.getItem('refreshToken')
251
+ ? this.refreshSession().pipe(mergeMap(mint))
252
+ : throwError(() => err)),
253
+ take(1),
254
+ ).subscribe({
241
255
  next: (res) => {
242
256
  const redirect = res?.data?.redirect;
243
257
  if (typeof redirect !== 'string' || !sameOrigin(redirect, oauthServer)) {
@@ -247,9 +261,9 @@ export abstract class BaseAuthService extends BaseRestService {
247
261
  }
248
262
  window.location.assign(redirect);
249
263
  },
250
- error: (err: HttpErrorResponse) => {
264
+ error: (err: unknown) => {
251
265
  console.error('Session handoff failed:', err);
252
- this.sessionHandoffError.set(handoffErrorMessage(err.status));
266
+ this.sessionHandoffError.set(handoffErrorMessage((err as { status?: number } | null)?.status));
253
267
  },
254
268
  });
255
269
  }
@@ -437,9 +451,32 @@ export abstract class BaseAuthService extends BaseRestService {
437
451
  return this.http.post<{ message: string }>(this.profilePhoneConfirmUrl, { value, code });
438
452
  }
439
453
 
440
- confirmRegister(email: string, code: string) {
454
+ /** Confirm a registration; success completes the login from the tokens keel
455
+ * answers. A registration without partner fields leaves `partnerId` 0. */
456
+ confirmRegister(email: string, code: string): Observable<ConfirmRegisterResponse> {
441
457
  const params = new HttpParams().set('email', email).set('code', code);
442
- return this.http.post<ConfirmRegisterResponse>(this.confirmRegisterUrl, null, { params });
458
+ return this.http.post<ConfirmRegisterResponse>(this.confirmRegisterUrl, null, { params }).pipe(
459
+ tap((res) => this.completeLogin(res.token, res.refreshToken)),
460
+ );
461
+ }
462
+
463
+ /** Create the partner of a signed-in user who has none, then rotate the
464
+ * refresh token so the session carries the partner. An error after keel
465
+ * created the partner means only the rotation failed; do not post again. */
466
+ createPartner(setup: PartnerSetup): Observable<PartnerCreated> {
467
+ return this.http.post<PartnerCreated>(this.registerPartnerUrl, setup).pipe(
468
+ mergeMap((created) => this.refreshSession().pipe(
469
+ tap((token) => this.completeLogin(token, localStorage.getItem('refreshToken') ?? undefined)),
470
+ map(() => created),
471
+ )),
472
+ );
473
+ }
474
+
475
+ /** Trade keel's single-use hand-off code for a session; same contract as login(). */
476
+ exchangeHandoff(code: string): Observable<LoginResponse2FA> {
477
+ return this.http.post<LoginResponse2FA>(this.registerExchangeUrl, { code }, { withCredentials: true }).pipe(
478
+ tap((res) => this.handleLoginResponse(res)),
479
+ );
443
480
  }
444
481
 
445
482
  confirmChpass(username: string, code: string, new_password: string) {
@@ -448,11 +485,12 @@ export abstract class BaseAuthService extends BaseRestService {
448
485
 
449
486
  loadStoredSession() {
450
487
  this.token = localStorage.getItem('jwt');
451
- if (this.token) {
452
- this.isLoggedIn.set(true);
453
- this.loadAppData();
454
- this.initRoutes();
455
- }
488
+ if (!this.token) return;
489
+ this.isLoggedIn.set(true);
490
+ // A signed-in user sent to the login page by the authorization server is handed off without signing in again.
491
+ if (window.location.pathname.startsWith('/login') && this.startHandoff()) return;
492
+ this.loadAppData();
493
+ this.initRoutes();
456
494
  }
457
495
 
458
496
  /** Revoke the refresh token server-side (best effort: the local session is
@@ -472,7 +510,7 @@ export abstract class BaseAuthService extends BaseRestService {
472
510
  }
473
511
 
474
512
  register(reg: PartnerRegistration) {
475
- return this.http.post<PartnerRegistration>(this.registerUrl, reg);
513
+ return this.http.post<{ status: string }>(this.registerUrl, reg);
476
514
  }
477
515
 
478
516
  // ── Phone / email OTP ──
@@ -9,7 +9,9 @@ export const RestURL = {
9
9
  loginGoogleURL: '/public/login/google',
10
10
  registerURL: '/public/register',
11
11
  chpassURL: '/public/login/chpass',
12
- confirmRegisterURL: '/public/confirm/register',
12
+ confirmRegisterURL: '/public/register/confirm',
13
+ registerExchangeURL: '/public/register/exchange',
14
+ registerPartnerURL: '/api/register/partner',
13
15
  plansURL: '/public/plans',
14
16
  confirmChpassURL: '/public/confirm/password',
15
17
  twoFactorSetupURL: '/api/user/2fa/setup',