@nauticana/sail 0.5.0

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.
Files changed (70) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +741 -0
  3. package/package.json +58 -0
  4. package/src/component/abstract/base_async.ts +49 -0
  5. package/src/component/abstract/base_form.ts +11 -0
  6. package/src/component/abstract/base_table.ts +320 -0
  7. package/src/component/abstract/base_view.ts +93 -0
  8. package/src/component/billing/checkout_button.html +10 -0
  9. package/src/component/billing/checkout_button.ts +57 -0
  10. package/src/component/billing/payment_methods.html +27 -0
  11. package/src/component/billing/payment_methods.ts +36 -0
  12. package/src/component/billing/plan_selector.html +24 -0
  13. package/src/component/billing/plan_selector.ts +29 -0
  14. package/src/component/form/form_field.html +54 -0
  15. package/src/component/form/form_field.ts +156 -0
  16. package/src/component/form/form_record.html +14 -0
  17. package/src/component/form/form_record.ts +56 -0
  18. package/src/component/form/form_table.html +19 -0
  19. package/src/component/form/form_table.ts +58 -0
  20. package/src/component/login/account_deletion.html +42 -0
  21. package/src/component/login/account_deletion.ts +79 -0
  22. package/src/component/login/chpass_component.html +58 -0
  23. package/src/component/login/chpass_component.ts +96 -0
  24. package/src/component/login/confirm_chpass_component.html +47 -0
  25. package/src/component/login/confirm_chpass_component.ts +70 -0
  26. package/src/component/login/confirm_register_component.html +37 -0
  27. package/src/component/login/confirm_register_component.ts +71 -0
  28. package/src/component/login/consent_gate.html +30 -0
  29. package/src/component/login/consent_gate.ts +109 -0
  30. package/src/component/login/login_component.html +43 -0
  31. package/src/component/login/login_component.ts +73 -0
  32. package/src/component/login/otp_input.html +38 -0
  33. package/src/component/login/otp_input.ts +104 -0
  34. package/src/component/login/register_component.html +132 -0
  35. package/src/component/login/register_component.ts +184 -0
  36. package/src/component/login/social_login.html +13 -0
  37. package/src/component/login/social_login.ts +120 -0
  38. package/src/component/navigation/navigation.html +49 -0
  39. package/src/component/navigation/navigation.ts +69 -0
  40. package/src/component/security/trusted_devices.html +76 -0
  41. package/src/component/security/trusted_devices.ts +92 -0
  42. package/src/component/security/twofactor_setup.html +75 -0
  43. package/src/component/security/twofactor_setup.ts +106 -0
  44. package/src/component/security/twofactor_verify.html +45 -0
  45. package/src/component/security/twofactor_verify.ts +90 -0
  46. package/src/component/table/table_detail.html +53 -0
  47. package/src/component/table/table_detail.ts +133 -0
  48. package/src/component/table/table_edit.html +58 -0
  49. package/src/component/table/table_edit.ts +163 -0
  50. package/src/component/table/table_list.html +41 -0
  51. package/src/component/table/table_list.ts +100 -0
  52. package/src/component/table/table_lookup.html +35 -0
  53. package/src/component/table/table_lookup.ts +88 -0
  54. package/src/component/table/table_search.html +17 -0
  55. package/src/component/table/table_search.ts +80 -0
  56. package/src/config.ts +67 -0
  57. package/src/index.ts +41 -0
  58. package/src/model/appdata.ts +266 -0
  59. package/src/model/auth.ts +134 -0
  60. package/src/model/common.ts +115 -0
  61. package/src/model/decorator.ts +74 -0
  62. package/src/service/api-response.interceptor.ts +26 -0
  63. package/src/service/auth.interceptor.ts +11 -0
  64. package/src/service/auth.service.ts +576 -0
  65. package/src/service/billing.service.ts +65 -0
  66. package/src/service/label.service.ts +18 -0
  67. package/src/service/rest_service.ts +94 -0
  68. package/src/service/rest_url.ts +42 -0
  69. package/src/service/script_loader.ts +31 -0
  70. package/tsconfig.json +16 -0
package/README.md ADDED
@@ -0,0 +1,741 @@
1
+ # @nauticana/sail
2
+
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
+
5
+ > **Compatibility:** sail **v0.5.x** targets keel **v0.5.x**. The two libraries are versioned in lock-step.
6
+
7
+ ## What it provides
8
+
9
+ | Category | Exports |
10
+ |----------|---------|
11
+ | **Table components** | `TableList`, `TableSearch`, `TableEdit`, `TableDetail`, `TableLookup` |
12
+ | **Form components** | `DynamicField`, `RecordForm`, `TableForm` |
13
+ | **Navigation** | `Navigation` (sidenav + toolbar with menu, responsive) |
14
+ | **Login** | `LoginComponent`, `RegisterComponent`, `ChpassComponent`, `ConfirmRegisterComponent`, `ConfirmChpassComponent` |
15
+ | **Security** | `TwoFactorSetupComponent`, `TwoFactorVerifyComponent`, `TrustedDevicesComponent`, `AccountDeletionComponent` |
16
+ | **Auth** | `ConsentGateComponent`, `OtpInputComponent`, `SocialLoginComponent` |
17
+ | **Billing** | `PlanSelectorComponent`, `CheckoutButtonComponent`, `PaymentMethodsComponent` |
18
+ | **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere), `BillingService`, `BackendService`, `LabelService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
19
+ | **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `BaseAsync` |
20
+ | **Config** | `SAIL_GUI_CONFIG`, `SailGuiConfig`, `configureRestUrls()` |
21
+ | **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `TrustedDevice`, `PublicPlan`, `PaymentMethod`, `Subscription`, `Invoice`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, 2FA types, etc. |
22
+ | **Decorators** | `@IsString()`, `@IsNumeric()` (class-validator based) |
23
+
24
+ ## Quick start
25
+
26
+ ### 1. Install
27
+
28
+ sail is published on the public npm registry — no `.npmrc` or registry override needed:
29
+
30
+ ```bash
31
+ npm install @nauticana/sail class-validator
32
+ ```
33
+
34
+ This adds the following to your `package.json`:
35
+
36
+ ```json
37
+ "dependencies": {
38
+ "@nauticana/sail": "^0.5.0",
39
+ "class-validator": "^0.15.1"
40
+ }
41
+ ```
42
+
43
+ ### 2. Configure tsconfig paths
44
+
45
+ Since sail ships raw TypeScript source, you need a path mapping so the Angular compiler can resolve and compile it. Add to your `tsconfig.json`:
46
+
47
+ ```json
48
+ "paths": {
49
+ "@nauticana/sail": ["./node_modules/@nauticana/sail/src/index"]
50
+ }
51
+ ```
52
+
53
+ > **Note:** If your tsconfig has `"baseUrl": "src"`, use `"../node_modules/@nauticana/sail/src/index"` instead (paths resolve relative to `baseUrl`).
54
+
55
+ Suppress class-validator CommonJS warnings in `angular.json`:
56
+ ```json
57
+ "build": {
58
+ "options": {
59
+ "allowedCommonJsDependencies": ["class-validator"]
60
+ }
61
+ }
62
+ ```
63
+
64
+ ### 3. Create your AuthService
65
+
66
+ ```typescript
67
+ // src/service/auth.service.ts
68
+ import { Injectable } from '@angular/core';
69
+ import { BaseAuthService, configureRestUrls } from '@nauticana/sail';
70
+ import { environment } from '../environment/environment';
71
+
72
+ @Injectable({ providedIn: 'root' })
73
+ export class AuthService extends BaseAuthService {
74
+ constructor() {
75
+ super();
76
+ configureRestUrls(environment.httphost);
77
+ }
78
+
79
+ // Add project-specific auth methods here (e.g., loginWithGoogle override, OTP, etc.)
80
+ }
81
+ ```
82
+
83
+ ### 4. Bootstrap your app
84
+
85
+ ```typescript
86
+ // src/main.ts
87
+ import { bootstrapApplication } from '@angular/platform-browser';
88
+ import { provideZonelessChangeDetection } from '@angular/core';
89
+ import { provideRouter } from '@angular/router';
90
+ import { provideHttpClient, withInterceptors } from '@angular/common/http';
91
+ import { SAIL_GUI_CONFIG, BaseAuthService, authInterceptor, apiResponseInterceptor, TwoFactorVerifyComponent } from '@nauticana/sail';
92
+ import { AuthService } from './service/auth.service';
93
+ import { DashUser } from './component/dashboard/dash_user';
94
+ import { App } from './app/app';
95
+
96
+ bootstrapApplication(App, {
97
+ providers: [
98
+ provideZonelessChangeDetection(),
99
+ provideHttpClient(withInterceptors([apiResponseInterceptor, authInterceptor])),
100
+ provideRouter([]),
101
+ { provide: BaseAuthService, useExisting: AuthService },
102
+ {
103
+ provide: SAIL_GUI_CONFIG,
104
+ useValue: {
105
+ opField: 'op_code',
106
+ hiddenFields: ['op_code', 'PartnerId'],
107
+ appTitle: 'My App',
108
+ dashboardComponent: DashUser,
109
+ publicRoutes: [
110
+ { path: 'login/local', loadComponent: () => import('@nauticana/sail').then(m => m.LoginComponent) },
111
+ { path: 'login/register', loadComponent: () => import('@nauticana/sail').then(m => m.RegisterComponent) },
112
+ { path: 'login/chpass', loadComponent: () => import('@nauticana/sail').then(m => m.ChpassComponent) },
113
+ { path: 'login/2fa', component: TwoFactorVerifyComponent },
114
+ { path: 'confirm/register', loadComponent: () => import('@nauticana/sail').then(m => m.ConfirmRegisterComponent) },
115
+ { path: 'confirm/password', loadComponent: () => import('@nauticana/sail').then(m => m.ConfirmChpassComponent) },
116
+ ],
117
+ publicRouteLinks: [
118
+ { label: 'Login', routerLink: '/login/local' },
119
+ { label: 'Register', routerLink: '/login/register' },
120
+ ],
121
+ loginFooterLinks: [
122
+ { label: 'Sign in with Google', routerLink: '/login/google' },
123
+ ],
124
+ // Map backend menu items to custom components
125
+ menuItemRouteOverrides: {
126
+ // 'external_connection': SocialConnectionComponent,
127
+ // 'analytic/*': TableReport,
128
+ },
129
+ },
130
+ },
131
+ ],
132
+ });
133
+ ```
134
+
135
+ ### 5. Create the root component
136
+
137
+ ```typescript
138
+ // src/app/app.ts
139
+ import { Component, ChangeDetectionStrategy } from '@angular/core';
140
+ import { Navigation } from '@nauticana/sail';
141
+
142
+ @Component({
143
+ selector: 'app-root',
144
+ changeDetection: ChangeDetectionStrategy.OnPush,
145
+ imports: [Navigation],
146
+ template: `<app-navigation><span toolbar-title>My App</span></app-navigation>`,
147
+ })
148
+ export class App {}
149
+ ```
150
+
151
+ ### 6. Provide global styles
152
+
153
+ All sail components use `ViewEncapsulation.None` — they ship no CSS. Your project must provide a global stylesheet covering sail selectors.
154
+
155
+ **Required structural styles** (without these, the sidenav collapses and gets cut off). Add to your `src/styles.css`:
156
+
157
+ ```css
158
+ .sidenav-container { height: 100vh; }
159
+ .sidenav { width: 250px; }
160
+ ```
161
+
162
+ Key CSS classes used by components:
163
+
164
+ ```
165
+ /* Layout */
166
+ .auth-container, .auth-card, .auth-header, .auth-title, .auth-form,
167
+ .auth-actions, .auth-footer, .auth-app-title, .auth-checkbox,
168
+ .auth-section-label, .auth-instructions, .form-row, .register-card
169
+
170
+ /* Feedback */
171
+ .auth-error, .auth-success, .geocode-status, .geocode-loading,
172
+ .geocode-success, .geocode-error
173
+
174
+ /* Tables */
175
+ .edit-container, .actions-bar, .tab-content, .empty-state,
176
+ .detail-actions, .search-actions, .select-btn, .form-container
177
+
178
+ /* Navigation */
179
+ .sidenav-container, .sidenav, .toolbar-spacer
180
+
181
+ /* State classes */
182
+ .deleted-record, .updated-record, .new-record
183
+
184
+ /* Buttons (replace deprecated Material color attributes) */
185
+ .primary, .accent, .warn, .current-device-badge
186
+
187
+ /* Security */
188
+ .twofactor-qr, .twofactor-backup, .backup-code-list,
189
+ .trusted-devices-table
190
+ ```
191
+
192
+ ## How it works
193
+
194
+ sail follows a **metadata-driven** architecture. On login, the backend returns `ApplicationData` containing:
195
+
196
+ - **MainMenu** — menu structure with pages and permissions
197
+ - **Permissions** — role-based access control entries
198
+ - **TableDefinitions** — column metadata, types, validation rules, foreign keys
199
+ - **Apis** — REST endpoint mappings per table
200
+ - **ConstantCache / TableCache** — dropdown/lookup values
201
+
202
+ `BaseAuthService.initRoutes()` dynamically builds Angular routes from this metadata. Each menu item automatically gets a `TableSearch` or `TableList` route with the correct API endpoint and table metadata. Custom components can override specific menu items via `menuItemRouteOverrides` in the config.
203
+
204
+ ## List pagination
205
+
206
+ keel REST list responses are paginated:
207
+
208
+ ```json
209
+ { "items": [...], "limit": 100, "offset": 0, "total": 12345 }
210
+ ```
211
+
212
+ `BackendService.list<T>()` continues to return `Observable<T[]>` — sail unwraps `items` for you. To access the metadata, use `listPaginated<T>()`:
213
+
214
+ ```typescript
215
+ this.backend.listPaginated<MyRow>('orders', { _limit: '50', _offset: '0' })
216
+ .subscribe((page) => {
217
+ this.rows.set(page.items);
218
+ this.total.set(page.total); // total rows matching the filter
219
+ });
220
+ ```
221
+
222
+ Default page size is 100, capped at 1000 server-side. Pass `_limit` / `_offset` in the filter map to control paging.
223
+
224
+ ## Configuration reference
225
+
226
+ ```typescript
227
+ interface SailGuiConfig {
228
+ opField: string; // Operation field name (default: 'op_code')
229
+ hiddenFields: string[]; // Fields hidden from forms (default: ['op_code', 'PartnerId'])
230
+ appTitle?: string; // Shown on login pages
231
+ googleMapsApiKey?: string; // For RegisterComponent geocoding
232
+ dashboardComponent?: Type<any>; // Component for /dashboard route
233
+ publicRoutes?: Routes; // Routes available before login
234
+ publicRouteLinks?: RouteLink[]; // Links shown in toolbar when logged out
235
+ loginFooterLinks?: RouteLink[]; // Extra links below login form
236
+ extraRoutes?: (data) => Routes; // Dynamic routes from ApplicationData
237
+ menuItemRouteOverrides?: { // Map RestUri to custom component
238
+ [restUriPattern: string]: Type<unknown>; // Supports 'exact' and 'prefix/*'
239
+ };
240
+
241
+ // Social / consent / account-deletion config
242
+ googleClientId?: string; // Google Identity Services client ID
243
+ appleServiceId?: string; // Apple Services ID
244
+ appleRedirectUri?: string; // Apple Sign-In redirect URI
245
+ privacyPolicyUrl?: string; // Linked from ConsentGateComponent
246
+ defaultPolicyVersion?: string; // Content hash of the deployed policy
247
+ defaultPolicyLanguage?: string; // ISO 639-1 fallback language
248
+ accountDeletedRoute?: string; // Route after account deletion (default '/login/local')
249
+ }
250
+ ```
251
+
252
+ ## Backend endpoints (keel v0.5)
253
+
254
+ | Endpoint | Purpose |
255
+ |----------|---------|
256
+ | `POST /public/login/local` | Username/password login with 2FA + trusted-device support |
257
+ | `POST /public/login/google` | Gmail OAuth-code login (legacy; prefer `/public/login/social`) |
258
+ | `POST /public/login/social` | ID-token social login (Google, Apple) |
259
+ | `POST /public/otp/send` | Send OTP code to phone or email; returns opaque `otpToken` |
260
+ | `POST /public/otp/verify` | Verify OTP code with `otpToken`, returns JWT |
261
+ | `POST /public/otp/resend` | Re-issue OTP for an existing `otpToken` |
262
+ | `POST /public/2fa/verify` | Login-time TOTP verification (uses `loginToken`) |
263
+ | `POST /public/2fa/backup-verify` | Login-time backup-code verification |
264
+ | `GET /api/config/appdata` | Metadata (menus, permissions, table definitions) |
265
+ | `POST/GET/DELETE /api/{version}/{table}/list\|get\|post\|delete` | CRUD operations (paginated `list`) |
266
+ | `POST /api/user/2fa/setup` | Generate TOTP secret, QR URI, and backup codes — **requires re-auth** |
267
+ | `POST /api/user/2fa/verify` | Confirm 2FA setup by verifying TOTP code |
268
+ | `POST /api/user/2fa/disable` | Disable 2FA — **requires password + current TOTP code** |
269
+ | `GET /api/user/trusted-device/list` | List trusted devices |
270
+ | `POST /api/user/trusted-device/revoke` | Revoke a trusted device |
271
+ | `POST /api/user/logout-everywhere` | Sign out of all devices — **requires re-auth** |
272
+ | `DELETE /api/user/account` | Soft-delete the caller's account — **requires re-auth** |
273
+ | `POST /api/push/register` | Register an FCM / APNs token |
274
+ | `POST /api/push/revoke` | Revoke an FCM / APNs token |
275
+ | `POST /api/billing/checkout` | Create provider-hosted checkout session — **JWT-gated, allowlist-validated** |
276
+ | `GET /public/plans` | Subscription plan catalog (unauthenticated) |
277
+ | `GET /api/billing/subscription` | Active subscription for the partner |
278
+ | `POST /api/billing/subscription/cancel` | Cancel auto-renew |
279
+ | `GET /api/billing/invoices` | Invoice history |
280
+ | `GET /api/billing/payment-methods` | Saved payment methods |
281
+
282
+ Device registration happens within `/public/2fa/verify` when `trustDevice=true`.
283
+
284
+ ## Enabling 2FA in your project
285
+
286
+ Add the 2FA verification route to your `publicRoutes` config:
287
+ ```typescript
288
+ import { TwoFactorVerifyComponent, TwoFactorSetupComponent, TrustedDevicesComponent } from '@nauticana/sail';
289
+
290
+ // In publicRoutes:
291
+ { path: 'login/2fa', component: TwoFactorVerifyComponent }
292
+
293
+ // In extraRoutes or authenticated routes (optional — for user self-service):
294
+ { path: 'security/2fa', component: TwoFactorSetupComponent }
295
+ { path: 'security/devices', component: TrustedDevicesComponent }
296
+ ```
297
+
298
+ The login flow handles 2FA automatically: when the backend returns `twoFactorRequired: true`, sail redirects to `/login/2fa`. No other code changes needed.
299
+
300
+ ## Billing
301
+
302
+ sail ships a shared `BillingService` and three billing components backed by keel's payment endpoints (see [keel/SHARED_PAYMENT.md](https://github.com/nauticana/keel/blob/main/SHARED_PAYMENT.md) for the provider contract — Stripe by default, pluggable).
303
+
304
+ ### Service
305
+
306
+ ```typescript
307
+ import { BillingService } from '@nauticana/sail';
308
+
309
+ @Component({ /* ... */ })
310
+ export class PricingPage {
311
+ private billing = inject(BillingService);
312
+
313
+ readonly plans = toSignal(this.billing.listPlans(), { initialValue: [] });
314
+ readonly sub = toSignal(this.billing.getSubscription());
315
+ }
316
+ ```
317
+
318
+ | Method | Endpoint | Returns |
319
+ |--------|----------|---------|
320
+ | `listPlans()` | `GET /public/plans` | `Observable<PublicPlan[]>` |
321
+ | `createCheckout(req)` | `POST /api/billing/checkout` | `Observable<CheckoutResponse>` |
322
+ | `getSubscription()` | `GET /api/billing/subscription` | `Observable<Subscription>` |
323
+ | `cancelSubscription()` | `POST /api/billing/subscription/cancel` | `Observable<void>` |
324
+ | `listInvoices()` | `GET /api/billing/invoices` | `Observable<Invoice[]>` |
325
+ | `listPaymentMethods()` | `GET /api/billing/payment-methods` | `Observable<PaymentMethod[]>` |
326
+
327
+ ### Components
328
+
329
+ **Plan picker** — pure presentational, no API calls:
330
+ ```html
331
+ <app-plan-selector
332
+ [plans]="plans()"
333
+ [selected]="selectedPlan()"
334
+ [features]="{ PRO: ['Unlimited users', '24/7 support'] }"
335
+ (selectionChange)="selectedPlan.set($event)">
336
+ </app-plan-selector>
337
+ ```
338
+
339
+ **Checkout button** — calls `createCheckout()` and redirects to the provider-hosted URL. `PublicPlan.priceId` lets you wire the picker directly to checkout without a local mapping table:
340
+ ```html
341
+ <app-checkout-button
342
+ [priceId]="selectedPlan().priceId!"
343
+ [mode]="'subscription'"
344
+ [successUrl]="'https://app.example.com/billing/done'"
345
+ [cancelUrl]="'https://app.example.com/billing'"
346
+ [email]="userEmail">
347
+ </app-checkout-button>
348
+ ```
349
+
350
+ For one-off charges use `[mode]="'payment'"`. For "save a card without charging" (Stripe SetupIntent), use `[mode]="'setup'"` and **omit** `[priceId]` — keel rejects a non-empty `priceId` in setup mode with 400.
351
+
352
+ > **keel allowlists** — `priceId`, `successUrl`, and `cancelUrl` must each match the server-side `AllowedPriceIDs` / `AllowedRedirectHosts` allowlists; otherwise the request is rejected with 400. Configure these on your keel deployment.
353
+
354
+ > **`AllowedRedirectHosts` matching** is hostname-only by default — entries without a colon (e.g. `app.example.com`) tolerate any port. Add an explicit port (e.g. `app.example.com:8443`) only when port-strict matching is intentional.
355
+
356
+ **Metadata stringification.** `CheckoutRequest.metadata` keys and values are typed as strings because that's what providers store. keel stringifies numeric and boolean metadata server-side, so a `partner_id: 42` written in your domain handler arrives back in webhook payloads as `"42"`. Stringify on the way in:
357
+
358
+ ```typescript
359
+ metadata: {
360
+ partner_id: String(partnerId),
361
+ source: 'pricing-page',
362
+ }
363
+ ```
364
+
365
+ **Payment methods** — lists saved methods with loading and empty states:
366
+ ```html
367
+ <app-payment-methods></app-payment-methods>
368
+ ```
369
+
370
+ ### Registration → checkout flow
371
+
372
+ 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.
373
+
374
+ ### `BaseAsync`
375
+
376
+ Both `CheckoutButtonComponent` and `PaymentMethodsComponent` extend `BaseAsync` — an abstract class that bundles `loading()`, `errorMessage()`, `successMessage()` signals and a `run(obs, onSuccess, fallbackError)` helper. Reuse it in your own async components:
377
+
378
+ ```typescript
379
+ import { BaseAsync, BillingService } from '@nauticana/sail';
380
+
381
+ @Component({ /* ... */ })
382
+ export class MyWidget extends BaseAsync {
383
+ private billing = inject(BillingService);
384
+
385
+ cancel() {
386
+ this.run(
387
+ this.billing.cancelSubscription(),
388
+ () => this.successMessage.set('Cancelled.'),
389
+ 'Could not cancel.',
390
+ );
391
+ }
392
+ }
393
+ ```
394
+
395
+ Template:
396
+ ```html
397
+ <button (click)="cancel()" [disabled]="loading()">Cancel plan</button>
398
+ @if (errorMessage()) { <div class="auth-error">{{ errorMessage() }}</div> }
399
+ @if (successMessage()) { <div class="auth-success">{{ successMessage() }}</div> }
400
+ ```
401
+
402
+ ### Suggested styles for billing components
403
+
404
+ Add to `src/styles.css` to match the rest of the library:
405
+ ```css
406
+ /* Plan selector */
407
+ .plan-selector { display: flex; gap: 1rem; flex-wrap: wrap; }
408
+ .plan-card { flex: 1 1 220px; padding: 1rem; border: 1px solid #ccc; border-radius: 8px; }
409
+ .plan-selected { border-color: #1976d2; box-shadow: 0 0 0 2px #1976d2; }
410
+ .plan-caption { margin: 0 0 .5rem; }
411
+ .plan-price .plan-amount { font-size: 1.5rem; font-weight: 600; }
412
+ .plan-features { padding-left: 1.2rem; }
413
+
414
+ /* Payment methods */
415
+ .payment-methods-list { list-style: none; padding: 0; }
416
+ .payment-method { display: flex; justify-content: space-between; padding: .5rem 0; }
417
+ .payment-default-badge { font-size: .75rem; background: #e0e0e0; padding: 2px 8px; border-radius: 12px; }
418
+
419
+ /* Checkout button */
420
+ .checkout-button { display: flex; flex-direction: column; gap: .5rem; }
421
+ ```
422
+
423
+ ## Consent capture
424
+
425
+ `ConsentGateComponent` is a reusable signup-consent primitive that mirrors keel's `user.SignupConsent` structure. It always renders two required checkboxes (`privacy_policy`, `cross_border`) and lets you declare any number of optional consents.
426
+
427
+ ```typescript
428
+ import { ConsentGateComponent, ConsentOption, ConsentState, ConsentType } from '@nauticana/sail';
429
+
430
+ @Component({
431
+ imports: [ConsentGateComponent],
432
+ template: `
433
+ <app-consent-gate
434
+ [policyVersion]="'v1'"
435
+ [policyLanguage]="'en'"
436
+ [optionalConsents]="extras"
437
+ (consentStateChange)="onConsent($event)">
438
+ </app-consent-gate>
439
+ `,
440
+ })
441
+ export class SignupPage {
442
+ readonly extras: ConsentOption[] = [
443
+ { id: ConsentType.VIDEO_OPT_IN, label: 'Record my trips on video', hint: 'Optional; change in settings later.' },
444
+ { id: ConsentType.MARKETING, label: 'Email me product updates' },
445
+ ];
446
+
447
+ onConsent(state: ConsentState) {
448
+ // state.consents is Record<string, boolean>, state.valid is false until
449
+ // both required checkboxes are ticked.
450
+ }
451
+ }
452
+ ```
453
+
454
+ The component relies on `config.privacyPolicyUrl`, `config.defaultPolicyVersion`, and `config.defaultPolicyLanguage` as fallbacks when the inputs are omitted.
455
+
456
+ ## Phone / email OTP login
457
+
458
+ keel issues an opaque server-side `otpToken` from `sendOtp()` (32 random bytes, base64-URL, bound to the user_id in cache for ~5 min). Echo it back verbatim to `verifyOtp()` / `resendOtp()`. The login fall-through still returns 200 with a token on unknown contacts (no SMS dispatched), so the response shape never leaks which numbers are registered.
459
+
460
+ `BaseAuthService` provides `sendOtp()`, `resendOtp()`, and `verifyOtp()`. Pair them with the presentational `OtpInputComponent` for a complete OTP screen:
461
+
462
+ ```typescript
463
+ // src/page/otp_confirm.ts
464
+ import { Component, inject, signal } from '@angular/core';
465
+ import { Router } from '@angular/router';
466
+ import { BaseAuthService, OtpInputComponent } from '@nauticana/sail';
467
+
468
+ @Component({
469
+ imports: [OtpInputComponent],
470
+ template: `
471
+ <app-otp-input
472
+ [contact]="contact()"
473
+ [length]="6"
474
+ (codeComplete)="onVerify($event)"
475
+ (resend)="onResend()">
476
+ </app-otp-input>
477
+ @if (error()) { <div class="auth-error">{{ error() }}</div> }
478
+ `,
479
+ })
480
+ export class OtpConfirmPage {
481
+ private auth = inject(BaseAuthService);
482
+ private router = inject(Router);
483
+ readonly contact = signal('+1 (416) 555-1234');
484
+ readonly otpToken = signal('');
485
+ readonly error = signal('');
486
+
487
+ onVerify(code: string) {
488
+ this.auth.verifyOtp({ otpToken: this.otpToken(), code }).subscribe({
489
+ next: () => this.router.navigate(['/dashboard']),
490
+ error: (err) => this.error.set(err.error?.message ?? 'Invalid code.'),
491
+ });
492
+ }
493
+
494
+ onResend() {
495
+ this.auth.resendOtp(this.otpToken()).subscribe();
496
+ }
497
+ }
498
+ ```
499
+
500
+ Override `verifyOtp()` in your own `AuthService extends BaseAuthService` when you need role routing.
501
+
502
+ | Endpoint | Service method |
503
+ |----------|---------------|
504
+ | `POST /public/otp/send` | `sendOtp(req)` — returns `{ otpToken }` |
505
+ | `POST /public/otp/resend` | `resendOtp(otpToken, purpose?)` |
506
+ | `POST /public/otp/verify` | `verifyOtp({ otpToken, code })` — auto-completes login on success |
507
+
508
+ ## Social login
509
+
510
+ `SocialLoginComponent` renders Google / Apple buttons using each provider's official SDK. It loads the SDKs dynamically via `loadScript()` — nothing is bundled into your app.
511
+
512
+ ```html
513
+ <app-social-login
514
+ [providers]="['google', 'apple']"
515
+ [consent]="consentState"
516
+ (loginSuccess)="onSuccess($event)"
517
+ (loginError)="onError($event)">
518
+ </app-social-login>
519
+ ```
520
+
521
+ Config required in `SAIL_GUI_CONFIG`:
522
+
523
+ ```typescript
524
+ {
525
+ googleClientId: 'xxxxx.apps.googleusercontent.com',
526
+ appleServiceId: 'com.example.app.web',
527
+ appleRedirectUri: 'https://example.com/login', // must match Apple Services ID
528
+ }
529
+ ```
530
+
531
+ Under the hood, the component calls `BaseAuthService.loginSocial(provider, idToken, consent)` → `POST /public/login/social` and emits `loginSuccess: LoginResponseSocial`. The session is completed automatically (token stored, app data loaded, routes initialized).
532
+
533
+ For backward compatibility, the older OAuth-code flow `BaseAuthService.loginWithGoogle(code)` (hits `/public/login/google`) is still supported; prefer `loginSocial` for new code.
534
+
535
+ ## Account deletion / logout everywhere / push tokens
536
+
537
+ These are App Store / Play Store compliance primitives from keel. All are opt-in.
538
+
539
+ ### Re-authentication gate
540
+
541
+ The four sensitive endpoints below require **recent re-authentication** before they will run — pass `password` and/or `twoFactorCode` to prove the user is still present at the keyboard. The shipped components already capture this; only callers using the service methods directly need to do this.
542
+
543
+ | Method | Required re-auth |
544
+ |--------|-----------------|
545
+ | `setup2FA(reauth)` | `password` (or `twoFactorCode` when re-rotating) |
546
+ | `disable2FA(password, code)` | **both** `password` and current TOTP `code` |
547
+ | `deleteAccount(reauth, reason?)` | `password` (or `twoFactorCode`) |
548
+ | `logoutEverywhere(reauth)` | `password` (or `twoFactorCode`) |
549
+
550
+ The `ReauthCredentials` type is exported as a shared shape:
551
+ ```typescript
552
+ import { ReauthCredentials } from '@nauticana/sail';
553
+ const reauth: ReauthCredentials = { password: 'hunter2' };
554
+ auth.deleteAccount(reauth, 'Closing my account.').subscribe();
555
+ ```
556
+
557
+ ### Account deletion
558
+
559
+ ```html
560
+ <app-account-deletion [confirmationText]="'DELETE'"></app-account-deletion>
561
+ ```
562
+
563
+ Typed-confirmation UX: the destructive button is disabled until the user types the exact `confirmationText` (default `DELETE`) **and** confirms their password. Optional reason textarea is forwarded to the backend. On success, local session is cleared and the router navigates to `config.accountDeletedRoute` (default `/login/local`).
564
+
565
+ ### Logout everywhere
566
+
567
+ `TrustedDevicesComponent` ships a "Sign out of all devices" button that reveals a password field, then calls `BaseAuthService.logoutEverywhere({ password })` (`POST /api/user/logout-everywhere`). It also displays a single-device-mode banner when configured:
568
+
569
+ ```html
570
+ <app-trusted-devices [singleDeviceSession]="user.singleDeviceSession"></app-trusted-devices>
571
+ ```
572
+
573
+ Pass `singleDeviceSession` from your login response (the field is part of `LoginResponse2FA`). When true, the banner reads: *"This account is in single-device mode — signing in on another device will sign you out here."*
574
+
575
+ ### Push tokens
576
+
577
+ For mobile apps / web push, register your FCM / APNs token after login:
578
+
579
+ ```typescript
580
+ auth.registerPushToken('I', fcmToken, '1.2.3', 'iPhone 15').subscribe();
581
+ // On logout or device rotation:
582
+ auth.revokePushToken(oldToken).subscribe();
583
+ ```
584
+
585
+ Platform codes: `'I'` iOS, `'A'` Android, `'W'` Web. The token acquisition (Capacitor / web-push / Firebase SDK) stays in the consumer app — sail only owns the server call.
586
+
587
+ | Endpoint | Service method |
588
+ |----------|---------------|
589
+ | `DELETE /api/user/account` | `deleteAccount(reauth, reason?)` |
590
+ | `POST /api/user/logout-everywhere` | `logoutEverywhere(reauth)` |
591
+ | `POST /api/push/register` | `registerPushToken(platform, token, appVersion?, deviceModel?)` |
592
+ | `POST /api/push/revoke` | `revokePushToken(token)` |
593
+
594
+ ### Suggested styles for auth components
595
+
596
+ ```css
597
+ /* Consent gate */
598
+ .consent-form { display: flex; flex-direction: column; gap: 12px; }
599
+ .consent-row { font-size: 14px; line-height: 1.4; }
600
+ .consent-hint { display: block; margin-top: 4px; font-size: 12px; color: #666; }
601
+
602
+ /* OTP input */
603
+ .otp-container { display: flex; flex-direction: column; align-items: center; padding: 16px 24px; }
604
+ .otp-sent-to { margin: 0; color: #666; font-size: 14px; }
605
+ .otp-contact { margin: 4px 0 24px; font-weight: 600; font-size: 16px; }
606
+ .otp-digits { display: flex; gap: 8px; margin-bottom: 16px; }
607
+ .otp-digit { width: 40px; height: 48px; border: 2px solid #ddd; border-radius: 8px;
608
+ display: flex; align-items: center; justify-content: center;
609
+ font-size: 20px; font-weight: 600; }
610
+ .otp-digit.active { border-color: #1976d2; }
611
+ .otp-keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
612
+ width: 100%; max-width: 300px; }
613
+ .keypad-key { height: 56px; font-size: 22px; font-weight: 500; border-radius: 12px; }
614
+ .keypad-spacer { height: 56px; }
615
+
616
+ /* Social login */
617
+ .social-login { display: flex; flex-direction: column; gap: 8px; align-items: stretch; }
618
+ .social-apple-btn { height: 44px; border-radius: 22px; background: #000; color: #fff;
619
+ border: 0; font-weight: 600; cursor: pointer; }
620
+
621
+ /* Trusted devices single-device banner */
622
+ .single-device-banner { padding: 12px; border-radius: 8px; background: #fff3cd;
623
+ color: #856404; margin-bottom: 16px; font-size: 14px; }
624
+ ```
625
+
626
+ ## Updating to the latest version
627
+
628
+ ```bash
629
+ npm update @nauticana/sail
630
+ ```
631
+
632
+ Or to install a specific version:
633
+
634
+ ```bash
635
+ npm install @nauticana/sail@0.5.0
636
+ ```
637
+
638
+ ## Migrating to v0.5.0
639
+
640
+ The downstream code adopting this library was previously written against the legacy frontend library (renamed to sail) targeting basis backend (renamed to keel). v0.5.0 aligns sail with keel v0.5.0 and renames all `Basis*` symbols to `Sail*`. There is no backward-compatibility shim — apply every step below.
641
+
642
+ ### 1. Update `package.json`
643
+
644
+ ```diff
645
+ - "@aspect/gui": "github:nauticana/sail"
646
+ + "@nauticana/sail": "^0.5.0"
647
+ ```
648
+
649
+ sail is now on the public npm registry. If your project carries a leftover `.npmrc` pointing at GitHub Packages from the legacy setup, **delete it** — the public registry is the default and no override is needed:
650
+
651
+ ```diff
652
+ - @nauticana:registry=https://npm.pkg.github.com
653
+ ```
654
+
655
+ ### 2. Update `tsconfig.json` paths
656
+
657
+ ```diff
658
+ - "@aspect/gui": ["./node_modules/@aspect/gui/src/index"]
659
+ + "@nauticana/sail": ["./node_modules/@nauticana/sail/src/index"]
660
+ ```
661
+
662
+ If your `tsconfig.json` has `"baseUrl": "src"`, the relative path is `"../node_modules/@nauticana/sail/src/index"`.
663
+
664
+ ### 3. Rename imports in your `src/`
665
+
666
+ Find-and-replace across the entire `src/` tree:
667
+
668
+ | Replace | With |
669
+ |---|---|
670
+ | `@aspect/gui` | `@nauticana/sail` |
671
+ | `BASIS_GUI_CONFIG` | `SAIL_GUI_CONFIG` |
672
+ | `BasisGuiConfig` | `SailGuiConfig` |
673
+
674
+ Both the injection token and the interface were renamed.
675
+
676
+ ### 4. Update OTP flow — opaque `otpToken` replaces `sessionId`
677
+
678
+ keel v0.5 issues a server-side opaque `otpToken` from `sendOtp()` instead of returning the raw `sessionId` (`= user_account.id`). The token is bound to the user_id in keel's cache for ~5 minutes. The change closes a user-id brute-force vector and removes the `sessionId` / `isNewUser` enumeration leaks.
679
+
680
+ Wherever you stored the OTP `sessionId`, swap it for `otpToken: string`.
681
+
682
+ ```diff
683
+ - readonly sessionId = signal(0);
684
+ + readonly otpToken = signal('');
685
+
686
+ // sendOtp response shape:
687
+ - // { sessionId: number; isNewUser: boolean }
688
+ + // { otpToken: string }
689
+
690
+ this.auth.sendOtp({ contact: phone, purpose: 'login' }).subscribe({
691
+ - next: (resp) => this.sessionId.set(resp.sessionId),
692
+ + next: (resp) => this.otpToken.set(resp.otpToken),
693
+ });
694
+
695
+ // verifyOtp request shape:
696
+ - this.auth.verifyOtp({ sessionId: this.sessionId(), code }).subscribe(...)
697
+ + this.auth.verifyOtp({ otpToken: this.otpToken(), code }).subscribe(...)
698
+
699
+ // resendOtp signature changed:
700
+ - this.auth.resendOtp(this.sessionId()).subscribe();
701
+ + this.auth.resendOtp(this.otpToken()).subscribe();
702
+ ```
703
+
704
+ `sendOtp()` always returns 200 — even on unknown contacts on the login path, the response shape is identical (with a server-issued fake token). This is intentional anti-enumeration; verify will fail with a generic 401. No client-side handling needed.
705
+
706
+ `OtpVerifyResponse` no longer carries `isNewUser`. If your app branches on first-time-user, detect it after the JWT lands by inspecting your own user-state load.
707
+
708
+ ### 5. Update consumer subscriptions
709
+
710
+ `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.
711
+
712
+ For pagination metadata, use `listPaginated<T>()`:
713
+
714
+ ```typescript
715
+ backend.listPaginated<MyRow>('orders', { _limit: '50', _offset: '0' })
716
+ .subscribe(page => {
717
+ this.rows.set(page.items);
718
+ this.total.set(page.total);
719
+ });
720
+ ```
721
+
722
+ ### 6. Optional: replace deprecated bootstrap
723
+
724
+ If you bootstrapped with `provideZoneChangeDetection`, switch to `provideZonelessChangeDetection` (Angular 21 default). The shipped `@nauticana/sail` components are signal-based and zoneless-safe.
725
+
726
+ ### 7. Clean install
727
+
728
+ ```bash
729
+ rm -rf node_modules package-lock.json
730
+ npm install
731
+ ng build
732
+ ```
733
+
734
+ Type errors after upgrade fall into two buckets:
735
+
736
+ - **`Cannot find name 'BasisGuiConfig'`** — you missed a rename in step 3. Search again.
737
+ - **`Property 'sessionId' does not exist on type 'OtpResponse'`** — finish step 4 in the file flagged.
738
+
739
+ ### 8. Backend alignment
740
+
741
+ This library only talks to keel **v0.5.x**. Older keel servers will reject the `otpToken` field on verify with HTTP 400. Upgrade keel and sail together. See [keel/README.md → Migration Guide](https://github.com/nauticana/keel/blob/main/README.md) for the matching backend changes.