@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.
- package/LICENSE +201 -0
- package/README.md +741 -0
- package/package.json +58 -0
- package/src/component/abstract/base_async.ts +49 -0
- package/src/component/abstract/base_form.ts +11 -0
- package/src/component/abstract/base_table.ts +320 -0
- package/src/component/abstract/base_view.ts +93 -0
- package/src/component/billing/checkout_button.html +10 -0
- package/src/component/billing/checkout_button.ts +57 -0
- package/src/component/billing/payment_methods.html +27 -0
- package/src/component/billing/payment_methods.ts +36 -0
- package/src/component/billing/plan_selector.html +24 -0
- package/src/component/billing/plan_selector.ts +29 -0
- package/src/component/form/form_field.html +54 -0
- package/src/component/form/form_field.ts +156 -0
- package/src/component/form/form_record.html +14 -0
- package/src/component/form/form_record.ts +56 -0
- package/src/component/form/form_table.html +19 -0
- package/src/component/form/form_table.ts +58 -0
- package/src/component/login/account_deletion.html +42 -0
- package/src/component/login/account_deletion.ts +79 -0
- package/src/component/login/chpass_component.html +58 -0
- package/src/component/login/chpass_component.ts +96 -0
- package/src/component/login/confirm_chpass_component.html +47 -0
- package/src/component/login/confirm_chpass_component.ts +70 -0
- package/src/component/login/confirm_register_component.html +37 -0
- package/src/component/login/confirm_register_component.ts +71 -0
- package/src/component/login/consent_gate.html +30 -0
- package/src/component/login/consent_gate.ts +109 -0
- package/src/component/login/login_component.html +43 -0
- package/src/component/login/login_component.ts +73 -0
- package/src/component/login/otp_input.html +38 -0
- package/src/component/login/otp_input.ts +104 -0
- package/src/component/login/register_component.html +132 -0
- package/src/component/login/register_component.ts +184 -0
- package/src/component/login/social_login.html +13 -0
- package/src/component/login/social_login.ts +120 -0
- package/src/component/navigation/navigation.html +49 -0
- package/src/component/navigation/navigation.ts +69 -0
- package/src/component/security/trusted_devices.html +76 -0
- package/src/component/security/trusted_devices.ts +92 -0
- package/src/component/security/twofactor_setup.html +75 -0
- package/src/component/security/twofactor_setup.ts +106 -0
- package/src/component/security/twofactor_verify.html +45 -0
- package/src/component/security/twofactor_verify.ts +90 -0
- package/src/component/table/table_detail.html +53 -0
- package/src/component/table/table_detail.ts +133 -0
- package/src/component/table/table_edit.html +58 -0
- package/src/component/table/table_edit.ts +163 -0
- package/src/component/table/table_list.html +41 -0
- package/src/component/table/table_list.ts +100 -0
- package/src/component/table/table_lookup.html +35 -0
- package/src/component/table/table_lookup.ts +88 -0
- package/src/component/table/table_search.html +17 -0
- package/src/component/table/table_search.ts +80 -0
- package/src/config.ts +67 -0
- package/src/index.ts +41 -0
- package/src/model/appdata.ts +266 -0
- package/src/model/auth.ts +134 -0
- package/src/model/common.ts +115 -0
- package/src/model/decorator.ts +74 -0
- package/src/service/api-response.interceptor.ts +26 -0
- package/src/service/auth.interceptor.ts +11 -0
- package/src/service/auth.service.ts +576 -0
- package/src/service/billing.service.ts +65 -0
- package/src/service/label.service.ts +18 -0
- package/src/service/rest_service.ts +94 -0
- package/src/service/rest_url.ts +42 -0
- package/src/service/script_loader.ts +31 -0
- 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.
|