@nauticana/sail 1.1.26 → 1.1.27

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
@@ -308,11 +308,24 @@ interface SailGuiConfig {
308
308
  defaultPolicyLanguage?: string; // ISO 639-1 fallback language
309
309
  accountDeletedRoute?: string; // Route after account deletion (default '/login/local')
310
310
  passwordPolicyUrl?: string; // Public route serving keel's password policy (see below)
311
+ oauthServerUrl?: string; // Base URL of keel's OAuth authorization server (see below)
312
+ allowedReturnHosts?: string[]; // Hosts a post-login ?return= may name
311
313
  }
312
314
  ```
313
315
 
314
316
  ### Password-policy validation
315
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
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
323
+ redeeming that URL; sail does not opt into cross-origin cookies or send a CSRF token. A
324
+ `return` whose host is not in `allowedReturnHosts`, or a `redirect` outside `oauthServerUrl`'s
325
+ origin, is not followed. Failures set `BaseAuthService.sessionHandoffError` (rendered by
326
+ `<sail-login>`) and never redirect: 400 means keel rejected the return URL, 401 means the user
327
+ signs in again to retry. The authorization server must allow the SPA origin by CORS.
328
+
316
329
  Set `passwordPolicyUrl` to the app's public route for keel's `PublicHandler.GetPasswordPolicy`
317
330
  (e.g. `'/public/v1/auth/password/policy'`). `BaseAuthService.ensurePasswordPolicy()` fetches it
318
331
  once into the `passwordRules` signal, and `passwordPolicyValidator(() => auth.passwordRules())`
@@ -434,6 +447,11 @@ export class PricingPage {
434
447
  | `createPortalSession()` | `POST /api/billing/portal` | `Observable<PortalResponse>` |
435
448
  | `listUsage()` | `GET /api/billing/usage` | `Observable<UsageMeter[]>` |
436
449
 
450
+ `cancelSubscription()`, `changePlan()` and `createPortalSession()` answer 403 (keel v1.2.89)
451
+ unless the user holds `PARTNER_PLAN_SUBSCRIPTION` `CANCEL` / `CHANGE` / `PORTAL`, seeded for
452
+ `PARTNER_ADMIN`. Test a failure with `isPermissionDenied(err)` and report it as not permitted;
453
+ `<sail-portal-button>` does.
454
+
437
455
  ### Components
438
456
 
439
457
  **Plan picker** — pure presentational, no API calls:
@@ -774,6 +792,15 @@ currency exponent reported by `Intl.NumberFormat`, so zero- and three-decimal
774
792
  currencies render correctly; earnings stay separated by currency rather than
775
793
  being summed into a misleading total.
776
794
 
795
+ **Delegation roles (v1.1.27, keel v1.2.89).** `AgencyDelegation.roles` (always present) and
796
+ `AgencyClient.roles` carry `AgencyRoleGrant { role, expiresAt? }`; no `expiresAt` is
797
+ open-ended, and a delegation without roles grants no access.
798
+ `AgencyService.setDelegationRoles(roles, clientPartnerId = 0)` replaces the whole set — an
799
+ empty list removes access without revoking the delegation. Role codes and captions come from
800
+ the `agency_delegation_role` constant catalogue (`LabelService.options('agency_delegation_role')`);
801
+ do not hardcode them. keel answers 422 for an unknown, duplicate or blank role, more than 32
802
+ roles, or an expiry not in the future, and 403 without `AGENCY_DELEGATION` / `SET_ROLE`.
803
+
777
804
  The agency URL defaults use `/api/v1/agency/*`. Apps mounting Keel elsewhere
778
805
  must override the `RestURL.agency*URL` values with `configureRestUrls()`.
779
806
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.26",
3
+ "version": "1.1.27",
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": {
@@ -2,12 +2,14 @@ import { ChangeDetectionStrategy, Component, ViewEncapsulation, inject, input }
2
2
  import { MatButtonModule } from '@angular/material/button';
3
3
  import { BaseAsync } from '../abstract/base_async';
4
4
  import { BillingService } from '../../service/billing.service';
5
+ import { isPermissionDenied } from '../../util/errors';
5
6
 
6
7
  /**
7
8
  * Button that opens a provider customer-portal session and redirects the
8
9
  * browser to the returned URL — the partner manages their payment method /
9
10
  * subscription on the provider's hosted page. Mirrors CheckoutButtonComponent;
10
- * uses BaseAsync for loading/error state.
11
+ * uses BaseAsync for loading/error state. A 403 (no PARTNER_PLAN_SUBSCRIPTION /
12
+ * PORTAL grant) reads as not permitted rather than as a provider failure.
11
13
  */
12
14
  @Component({
13
15
  selector: 'sail-portal-button',
@@ -26,6 +28,9 @@ export class PortalButtonComponent extends BaseAsync {
26
28
  this.billing.createPortalSession(),
27
29
  (resp) => { window.location.href = resp.portalUrl; },
28
30
  'Could not open the billing portal. Please try again.',
31
+ (err) => {
32
+ if (isPermissionDenied(err)) this.errorMessage.set('You are not permitted to manage billing.');
33
+ },
29
34
  );
30
35
  }
31
36
  }
@@ -29,7 +29,7 @@ export class LoginComponent extends BaseAsync {
29
29
  protected fb = inject(FormBuilder);
30
30
  protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
31
31
  protected readonly loggedOut = inject(ActivatedRoute).snapshot.queryParamMap.get('loggedOut') === 'true';
32
- /** OAuth session-cookie handoff failure from BaseAuthService, shown below. */
32
+ /** OAuth session hand-off failure from BaseAuthService, shown below. */
33
33
  protected readonly sessionHandoffError = this.auth.sessionHandoffError;
34
34
 
35
35
  /**
@@ -64,8 +64,8 @@ export class LoginComponent extends BaseAsync {
64
64
 
65
65
  /**
66
66
  * loginSocial already ran completeLogin, which owns post-login navigation
67
- * (route init, or the OAuth cookie handoff). Handlers must NOT navigate here —
68
- * doing so abandons a pending handoff before its cookie is set. Downstream
67
+ * (route init, or the OAuth session hand-off). Handlers must NOT navigate here —
68
+ * doing so abandons a pending hand-off before it redirects. Downstream
69
69
  * social-login handlers should follow the same rule.
70
70
  */
71
71
  protected onSocialSuccess(_: LoginResponseSocial): void {}
package/src/config.ts CHANGED
@@ -67,11 +67,11 @@ export interface SailGuiConfig {
67
67
  accountDeletedRoute?: string;
68
68
 
69
69
  // ── OAuth authorization-flow bridge (for an OAuth AS on a different host) ──
70
- /** Backend path that mirrors the bearer JWT into an HttpOnly session cookie so
71
- * a cross-site OAuth `/authorize` can see the session, e.g. '/api/v1/auth/session-cookie'.
72
- * When set, login POSTs here before handing off to an allowed ?return= host. */
73
- sessionCookieUrl?: string;
74
- /** Hostnames a post-login `?return=` may redirect to (open-redirect allowlist),
70
+ /** Base URL of keel's OAuth authorization server, e.g. 'https://mcp.example.com'.
71
+ * When set, a login carrying an allowed ?return= POSTs it to
72
+ * `{oauthServerUrl}/oauth/session/handoff` and navigates to the URL keel answers. */
73
+ oauthServerUrl?: string;
74
+ /** Hostnames a post-login `?return=` may name (open-redirect allowlist),
75
75
  * e.g. ['mcp.example.com']. Anything not listed is ignored. */
76
76
  allowedReturnHosts?: string[];
77
77
 
@@ -24,6 +24,7 @@ export interface AgencyClient {
24
24
  acceptedAt?: string;
25
25
  expired?: boolean;
26
26
  billingModel?: string;
27
+ roles?: AgencyRoleGrant[];
27
28
  }
28
29
 
29
30
  export interface AgencyInvite {
@@ -42,6 +43,13 @@ export interface AgencyDelegation {
42
43
  agencyName: string;
43
44
  billingModel?: string;
44
45
  grantedAt: string;
46
+ roles: AgencyRoleGrant[];
47
+ }
48
+
49
+ /** One `agency_delegation_role` code a client grants its agency; no expiresAt is open-ended. */
50
+ export interface AgencyRoleGrant {
51
+ role: string;
52
+ expiresAt?: string;
45
53
  }
46
54
 
47
55
  export interface AgencyEarningsBalance {
@@ -8,6 +8,7 @@ import {
8
8
  AgencyInvite,
9
9
  AgencyPayoutProfileOptions,
10
10
  AgencyProfile,
11
+ AgencyRoleGrant,
11
12
  } from '../model/agency';
12
13
  import { BaseRestService } from './base_rest.service';
13
14
  import { RestURL } from './rest_url';
@@ -73,4 +74,10 @@ export class AgencyService extends BaseRestService {
73
74
  revokeDelegation(clientPartnerId = 0): Observable<{ status: string }> {
74
75
  return this.http.post<{ status: string }>(this.url(RestURL.agencyRevokeDelegationURL), { clientPartnerId });
75
76
  }
77
+
78
+ /** Replace the whole role set the client grants its agency; an empty list
79
+ * removes access without revoking the delegation. */
80
+ setDelegationRoles(roles: AgencyRoleGrant[], clientPartnerId = 0): Observable<{ status: string }> {
81
+ return this.http.post<{ status: string }>(this.url(RestURL.agencyDelegationRolesURL), { clientPartnerId, roles });
82
+ }
76
83
  }
@@ -25,6 +25,20 @@ 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 {
29
+ if (status === 400) return 'This sign-in link is not valid. Start again from the application you were connecting.';
30
+ if (status === 401) return 'Your session was not accepted. Please sign in again.';
31
+ return 'Could not complete sign-in. Please try again.';
32
+ }
33
+
34
+ function sameOrigin(a: string, b: string): boolean {
35
+ try {
36
+ return new URL(a, window.location.origin).origin === new URL(b, window.location.origin).origin;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
28
42
  /** Sort dropdown options by their visible label so long lookup lists are scannable. */
29
43
  const byCaption = (a: ConstantValue, b: ConstantValue) =>
30
44
  (a.Caption ?? '').localeCompare(b.Caption ?? '', undefined, { numeric: true, sensitivity: 'base' });
@@ -124,8 +138,8 @@ export abstract class BaseAuthService extends BaseRestService {
124
138
  /** Last appdata load failure (null when none). */
125
139
  readonly appDataError = signal<unknown>(null);
126
140
 
127
- /** Set when the OAuth session-cookie handoff fails; the UI shows it and lets the
128
- * user retry instead of being redirected into a cookieless login loop. */
141
+ /** Set when the OAuth session hand-off fails; the UI shows it and the user
142
+ * signs in again to retry instead of being redirected without a session. */
129
143
  readonly sessionHandoffError = signal<string | null>(null);
130
144
 
131
145
  /**
@@ -203,42 +217,61 @@ export abstract class BaseAuthService extends BaseRestService {
203
217
  this.sessionGeneration++;
204
218
  this.storeTokens(token, refreshToken);
205
219
  this.isLoggedIn.set(true);
220
+ this.sessionHandoffError.set(null);
206
221
  resetAuthCircuit(); // a new session must not inherit a prior session's open circuit
207
222
  const ret = this.validatedReturnUrl();
208
- if (ret && this.guiConfig.sessionCookieUrl) {
209
- // OAuth flow: mirror the bearer into a cookie, then hand off to the
210
- // (cross-origin) authorization server via a full navigation. Only hand off
211
- // once the cookie is actually set — redirecting after a failed cookie request
212
- // sends the user on without a session and loops them back to login.
213
- this.sessionHandoffError.set(null);
214
- this.http.post(this.url(this.guiConfig.sessionCookieUrl), {}, { withCredentials: true })
215
- .pipe(take(1))
216
- .subscribe({
217
- next: () => window.location.assign(ret),
218
- error: (err) => {
219
- console.error('Session cookie handoff failed:', err);
220
- this.sessionHandoffError.set('Could not complete sign-in. Please try again.');
221
- },
222
- });
223
+ const oauthServer = this.oauthServerBase();
224
+ if (ret && oauthServer) {
225
+ this.handOffSession(oauthServer, ret, token);
223
226
  return;
224
227
  }
225
228
  this.loadAppData();
226
229
  this.initRoutes();
227
230
  }
228
231
 
229
- /** A post-login `?return=` URL, only when its host is in allowedReturnHosts and
230
- * the scheme is https (or loopback) — an open-redirect guard. '' otherwise. */
232
+ /** Exchange the bearer session for the authorization server's own cookie session
233
+ * (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 } }>(
237
+ oauthServer + '/oauth/session/handoff',
238
+ { return: ret },
239
+ { headers: { Authorization: `Bearer ${token}` } },
240
+ ).pipe(take(1)).subscribe({
241
+ next: (res) => {
242
+ const redirect = res?.data?.redirect;
243
+ if (typeof redirect !== 'string' || !sameOrigin(redirect, oauthServer)) {
244
+ console.error('Session handoff returned an unexpected redirect:', redirect);
245
+ this.sessionHandoffError.set('Could not complete sign-in. Please try again.');
246
+ return;
247
+ }
248
+ window.location.assign(redirect);
249
+ },
250
+ error: (err: HttpErrorResponse) => {
251
+ console.error('Session handoff failed:', err);
252
+ this.sessionHandoffError.set(handoffErrorMessage(err.status));
253
+ },
254
+ });
255
+ }
256
+
257
+ /** guiConfig.oauthServerUrl without trailing slashes; '' when unset. */
258
+ private oauthServerBase(): string {
259
+ return (this.guiConfig.oauthServerUrl ?? '').replace(/\/+$/, '');
260
+ }
261
+
262
+ /** The raw post-login `?return=` value, only when its host is in allowedReturnHosts
263
+ * and the scheme is https (or loopback) — an open-redirect guard. '' otherwise. */
231
264
  private validatedReturnUrl(): string {
232
265
  const raw = new URLSearchParams(window.location.search).get('return');
233
266
  if (!raw) return '';
234
267
  try {
235
- const u = new URL(raw, window.location.origin);
268
+ const u = new URL(raw);
236
269
  if (u.username || u.password) return ''; // reject userinfo ("user@host") spoofing
237
270
  const host = u.hostname.toLowerCase();
238
271
  const okScheme = u.protocol === 'https:' || host === 'localhost' || host === '127.0.0.1';
239
272
  const allowed = (this.guiConfig.allowedReturnHosts ?? []).some((h) => h.toLowerCase() === host);
240
273
  if (okScheme && allowed) {
241
- return u.toString();
274
+ return raw;
242
275
  }
243
276
  } catch { /* malformed → reject */ }
244
277
  return '';
@@ -564,6 +597,7 @@ export abstract class BaseAuthService extends BaseRestService {
564
597
  this.cache = undefined;
565
598
  this.authIndex.clear();
566
599
  this.appDataError.set(null);
600
+ this.sessionHandoffError.set(null);
567
601
  this.refreshInFlight = null;
568
602
  this.sessionGeneration++; // in-flight loads and refreshes from this session are ignored
569
603
  this.appData$.next(null); // live subscribers see menus clear; identity stays stable
@@ -45,7 +45,7 @@ export class BillingService extends BaseRestService {
45
45
  return this.http.get<Subscription>(this.url(RestURL.subscriptionURL));
46
46
  }
47
47
 
48
- /** Cancel auto-renew on the current subscription. */
48
+ /** Cancel auto-renew on the current subscription. 403 without PARTNER_PLAN_SUBSCRIPTION / CANCEL. */
49
49
  cancelSubscription(): Observable<void> {
50
50
  return this.http.post<void>(this.url(RestURL.cancelSubURL), {});
51
51
  }
@@ -53,7 +53,7 @@ export class BillingService extends BaseRestService {
53
53
  /**
54
54
  * Upgrade / downgrade the current subscription to `planId` — distinct from
55
55
  * first-time checkout. Routes to keel's `SubscriptionLifecycle.ChangePlan`
56
- * (atomic close+open) via an endpoint the app exposes.
56
+ * (atomic close+open). 403 without PARTNER_PLAN_SUBSCRIPTION / CHANGE.
57
57
  */
58
58
  changePlan(planId: string): Observable<void> {
59
59
  return this.http.post<void>(this.url(RestURL.changePlanURL), { planId });
@@ -86,7 +86,7 @@ export class BillingService extends BaseRestService {
86
86
  /**
87
87
  * Create a provider customer-portal session; caller redirects to the URL so
88
88
  * the partner can manage their payment method / subscription on the
89
- * provider's hosted page.
89
+ * provider's hosted page. 403 without PARTNER_PLAN_SUBSCRIPTION / PORTAL.
90
90
  */
91
91
  createPortalSession(): Observable<PortalResponse> {
92
92
  return this.http.post<PortalResponse>(this.url(RestURL.portalURL), {});
@@ -77,6 +77,7 @@ export const RestURL = {
77
77
  agencyAcceptInviteURL: '/api/v1/agency/invite/accept',
78
78
  agencyDelegationURL: '/api/v1/agency/delegation',
79
79
  agencyRevokeDelegationURL: '/api/v1/agency/delegation/revoke',
80
+ agencyDelegationRolesURL: '/api/v1/agency/delegation/roles',
80
81
  // User payment methods — list is keel's generic REST CRUD; set_default and
81
82
  // remove are keel UserPaymentMethodHandler table actions.
82
83
  paymentMethodSetDefaultURL: '/api/v1/user_payment_method/set_default',
@@ -31,6 +31,11 @@ export function isTransportFailure(err: unknown): boolean {
31
31
  return statusOf(err) === 0;
32
32
  }
33
33
 
34
+ /** True for keel's 403: the session is valid but lacks the grant for this action. */
35
+ export function isPermissionDenied(err: unknown): boolean {
36
+ return statusOf(err) === 403;
37
+ }
38
+
34
39
  /**
35
40
  * Human-readable message from any sail error shape — SailApiError (BackendService),
36
41
  * raw HttpErrorResponse (direct HttpClient calls), or plain Error.