@nauticana/sail 1.1.2 → 1.1.4

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
@@ -4,6 +4,8 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
4
4
 
5
5
  > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.0.x ↔ keel v1.0.x**. 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
+ **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.
8
+
7
9
  ## What it provides
8
10
 
9
11
  | Category | Exports |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
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": {
package/src/config.ts CHANGED
@@ -59,6 +59,15 @@ export interface SailGuiConfig {
59
59
  defaultPolicyLanguage?: string;
60
60
  /** Route to navigate to after account deletion. Defaults to '/login/local'. */
61
61
  accountDeletedRoute?: string;
62
+
63
+ // ── OAuth authorization-flow bridge (for an OAuth AS on a different host) ──
64
+ /** Backend path that mirrors the bearer JWT into an HttpOnly session cookie so
65
+ * a cross-site OAuth `/authorize` can see the session, e.g. '/api/v1/auth/session-cookie'.
66
+ * When set, login POSTs here before handing off to an allowed ?return= host. */
67
+ sessionCookieUrl?: string;
68
+ /** Hostnames a post-login `?return=` may redirect to (open-redirect allowlist),
69
+ * e.g. ['mcp.example.com']. Anything not listed is ignored. */
70
+ allowedReturnHosts?: string[];
62
71
  }
63
72
 
64
73
  export const SAIL_GUI_CONFIG = new InjectionToken<SailGuiConfig>('SailGuiConfig');
@@ -106,14 +106,51 @@ export abstract class BaseAuthService extends BaseRestService {
106
106
  ).subscribe((data: ApplicationData) => this.appData$.next(data));
107
107
  }
108
108
 
109
+ /** Adopt an externally-minted JWT (registration/SSO handoff flows) and run the
110
+ * full post-login sequence — store under the canonical key, load appdata, init
111
+ * routes. Apps must use this instead of writing localStorage directly. */
112
+ acceptToken(token: string) {
113
+ this.completeLogin(token);
114
+ }
115
+
109
116
  private completeLogin(token: string) {
110
117
  this.token = token;
111
118
  this.isLoggedIn.set(true);
112
119
  localStorage.setItem('jwt', token);
120
+ const ret = this.validatedReturnUrl();
121
+ if (ret && this.guiConfig.sessionCookieUrl) {
122
+ // OAuth flow: mirror the bearer into a cookie, then hand off to the
123
+ // (cross-origin) authorization server via a full navigation.
124
+ this.http.post(this.url(this.guiConfig.sessionCookieUrl), {}, { withCredentials: true })
125
+ .pipe(take(1))
126
+ .subscribe({
127
+ next: () => window.location.assign(ret),
128
+ error: () => window.location.assign(ret),
129
+ });
130
+ return;
131
+ }
113
132
  this.loadAppData();
114
133
  this.initRoutes();
115
134
  }
116
135
 
136
+ /** A post-login `?return=` URL, only when its host is in allowedReturnHosts and
137
+ * the scheme is https (or loopback) — an open-redirect guard. '' otherwise. */
138
+ private validatedReturnUrl(): string {
139
+ const raw = new URLSearchParams(window.location.search).get('return');
140
+ if (!raw) return '';
141
+ try {
142
+ const u = new URL(raw, window.location.origin);
143
+ if (u.username || u.password) return ''; // reject userinfo ("user@host") spoofing
144
+ const host = u.hostname.toLowerCase();
145
+ const okScheme = u.protocol === 'https:' || host === 'localhost' || host === '127.0.0.1';
146
+ const allowed = (this.guiConfig.allowedReturnHosts ?? []).some((h) => h.toLowerCase() === host);
147
+ if (okScheme && allowed) {
148
+ return u.toString();
149
+ }
150
+ } catch { /* malformed → reject */ }
151
+ return '';
152
+ }
153
+
117
154
  login(username: string, password: string) {
118
155
  // withCredentials so the browser sends the server-set `keel_td` cookie
119
156
  // (keel v0.9 trusted-device secret). When it maps to a trusted row, keel
@@ -1,5 +1,6 @@
1
- import { HttpClient } from '@angular/common/http';
1
+ import { HttpClient, HttpParams } from '@angular/common/http';
2
2
  import { inject } from '@angular/core';
3
+ import { Observable } from 'rxjs';
3
4
  import { RestURL } from './rest_url';
4
5
 
5
6
  /**
@@ -17,4 +18,14 @@ export abstract class BaseRestService {
17
18
  protected url(path: string): string {
18
19
  return RestURL.httpHost + path;
19
20
  }
21
+
22
+ /** GET an analytic endpoint (`{host}{api_prefix}analytic/<endpoint>`), returning
23
+ * its rows. Optional flat string params become the query string. */
24
+ protected analytic<T>(endpoint: string, params?: Record<string, string>): Observable<T[]> {
25
+ let p = new HttpParams();
26
+ for (const k in params) {
27
+ if (Object.prototype.hasOwnProperty.call(params, k)) p = p.set(k, params[k]);
28
+ }
29
+ return this.http.get<T[]>(this.url(RestURL.api_prefix + 'analytic/' + endpoint), { params: p });
30
+ }
20
31
  }