@nauticana/sail
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 Go backend.
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_actionframework (per-table custom buttons surfaced inTableList/TableSearch/TableEdit/TableDetail); see the Migrating to v0.7.0 §5 — TableAction section for the seed shape (basistable_action+authorization_object+authorization_object_actionrows) and the keel/README Table Actions section for backend wiring viahandler.WrapTableAction.
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.
What it provides
| Category | Exports |
|---|---|
| Table components | TableList, TableSearch, TableEdit, TableDetail, TableLookup, TableReport |
| Form components | DynamicField, RecordForm, TableForm |
| Navigation | Navigation (sidenav + toolbar with menu, responsive) |
| Login | LoginComponent, RegisterComponent, ChpassComponent, ConfirmRegisterComponent, ConfirmChpassComponent |
| Security | TwoFactorSetupComponent, TwoFactorVerifyComponent, TrustedDevicesComponent, AccountDeletionComponent |
| Account | MyAccountComponent (self-service hub), ProfileEditorComponent (name/locale immediate; email/phone verify-before-apply) |
| Auth | ConsentGateComponent, OtpInputComponent, SocialLoginComponent |
| Billing | PlanSelectorComponent, PriceSelectorComponent, CheckoutButtonComponent, PaymentMethodsComponent, PortalButtonComponent, UsageMeterComponent, StatusChipComponent, TrialBannerComponent, SeatSelectorComponent, DunningBannerComponent |
| Payout | PayoutProviderOnboardingComponent, PayoutBankInfoFormComponent |
| Payments / SCA | UserPaymentMethodsComponent, ScaConfirmComponent, ScaConfirmer (port), SCA_CONFIRMER (token) |
| Services | BaseAuthService (OTP / social / push / deleteAccount / logoutEverywhere / profile: getProfile, updateProfile, request+confirmEmailChange, request+confirmPhoneChange), BillingService, BackendService, PayoutService, UserPaymentMethodService, loadScript(), authInterceptor, apiResponseInterceptor |
| Abstracts | BaseTable, BaseForm, BaseView, BaseAsync, BaseRestService |
| Config | SAIL_GUI_CONFIG, SailGuiConfig, configureRestUrls() |
| Models | ApplicationData, TableDefinition, SiudAction, ApplicationMenu, ConstantValue, UserAccount, RestReport, ReportParam, TrustedDevice, PublicPlan, PlanPrice, PaymentMethod, Subscription, Invoice, CheckoutRequest/CheckoutResponse, PortalResponse, UsageMeter, ChargeResult, UserPaymentMethod, TableAction, ReusableAccount, PayoutOnboardingSession, BankInfoFormValue, CountryProfile, OtpRequest/OtpResponse, SignupConsent, ConsentState, ConsentOption, SocialProvider, PushPlatform, 2FA types, etc. |
| Decorators | @IsString(), @IsNumeric() (class-validator based) |
| Utils | titleCase(), fromPriceLabel() |
Quick start
1. Install
sail is published on the public npm registry — no .npmrc or registry override needed:
npm install @nauticana/sail class-validatorThis adds the following to your package.json:
"dependencies": {
"@nauticana/sail": "^1.0.0",
"class-validator": "^0.15.1"
}2. Configure tsconfig paths
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:
"paths": {
"@nauticana/sail": ["./node_modules/@nauticana/sail/src/index"]
}Note: If your tsconfig has
"baseUrl": "src", use"../node_modules/@nauticana/sail/src/index"instead (paths resolve relative tobaseUrl).
Suppress class-validator CommonJS warnings in angular.json:
"build": {
"options": {
"allowedCommonJsDependencies": ["class-validator"]
}
}3. Create your AuthService
// src/service/auth.service.ts
import { Injectable } from '@angular/core';
import { BaseAuthService, configureRestUrls } from '@nauticana/sail';
import { environment } from '../environment/environment';
@Injectable({ providedIn: 'root' })
export class AuthService extends BaseAuthService {
constructor() {
super();
configureRestUrls(environment.httphost);
}
// Add project-specific auth methods here (e.g., loginWithGoogle override, OTP, etc.)
}4. Bootstrap your app
// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { SAIL_GUI_CONFIG, BaseAuthService, authInterceptor, apiResponseInterceptor, TwoFactorVerifyComponent } from '@nauticana/sail';
import { AuthService } from './service/auth.service';
import { DashUser } from './component/dashboard/dash_user';
import { App } from './app/app';
bootstrapApplication(App, {
providers: [
provideZonelessChangeDetection(),
provideHttpClient(withInterceptors([apiResponseInterceptor, authInterceptor])),
provideRouter([]),
{ provide: BaseAuthService, useExisting: AuthService },
{
provide: SAIL_GUI_CONFIG,
useValue: {
opField: 'op_code',
hiddenFields: ['op_code', 'PartnerId'],
appTitle: 'My App',
dashboardComponent: DashUser,
publicRoutes: [
{ path: 'login/local', loadComponent: () => import('@nauticana/sail').then(m => m.LoginComponent) },
{ path: 'login/register', loadComponent: () => import('@nauticana/sail').then(m => m.RegisterComponent) },
{ path: 'login/chpass', loadComponent: () => import('@nauticana/sail').then(m => m.ChpassComponent) },
{ path: 'login/2fa', component: TwoFactorVerifyComponent },
{ path: 'confirm/register', loadComponent: () => import('@nauticana/sail').then(m => m.ConfirmRegisterComponent) },
{ path: 'confirm/password', loadComponent: () => import('@nauticana/sail').then(m => m.ConfirmChpassComponent) },
],
publicRouteLinks: [
{ label: 'Login', routerLink: '/login/local' },
{ label: 'Register', routerLink: '/login/register' },
],
// Shown in the toolbar before Logout when logged in. Mount the matching
// authenticated route to MyAccountComponent (its links expect
// /account/2fa, /account/devices, /account/delete, /login/chpass).
accountLink: { label: 'My Account', routerLink: '/account' },
loginFooterLinks: [
{ label: 'Sign in with Google', routerLink: '/login/google' },
],
// Map backend menu items to custom components
menuItemRouteOverrides: {
// 'external_connection': SocialConnectionComponent,
// 'analytic/*': TableReport,
},
},
},
],
});Auth-loop breaker.
authInterceptorcarries a built-in circuit breaker: after 5401/403s on token-bearing keel API calls within 10s it opens for 30s and refuses those requests locally (never sent), so a stale/rejected session can't drive the app to hammer the API and the edge/CDN. A later success closes it; login (no bearer) is never blocked, so recovery works. No wiring needed beyond registeringauthInterceptor.
5. Create the root component
// src/app/app.ts
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { Navigation } from '@nauticana/sail';
@Component({
selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Navigation],
template: `<sail-navigation><span toolbar-title>My App</span></sail-navigation>`,
})
export class App {}6. Provide global styles
All sail components use ViewEncapsulation.None — they ship no CSS. Your project must provide a global stylesheet covering sail selectors.
Required structural styles (without these, the sidenav collapses and gets cut off). Add to your src/styles.css:
.sidenav-container { height: 100vh; }
.sidenav { width: 250px; }Key CSS classes used by components:
/* Layout */
.auth-container, .auth-card, .auth-header, .auth-title, .auth-form,
.auth-actions, .auth-footer, .auth-app-title, .auth-checkbox,
.auth-section-label, .auth-instructions, .form-row, .register-card
/* Feedback */
.auth-error, .auth-success, .geocode-status, .geocode-loading,
.geocode-success, .geocode-error
/* Tables */
.edit-container, .actions-bar, .tab-content, .empty-state,
.detail-actions, .search-actions, .select-btn, .form-container
/* Navigation */
.sidenav-container, .sidenav, .toolbar-spacer
/* State classes */
.deleted-record, .updated-record, .new-record
/* Buttons (replace deprecated Material color attributes) */
.primary, .accent, .warn, .current-device-badge
/* Security */
.twofactor-qr, .twofactor-backup, .backup-code-list,
.trusted-devices-table
How it works
sail follows a metadata-driven architecture. On login, the backend returns ApplicationData containing:
- MainMenu — menu structure with pages and permissions
- Permissions — role-based access control entries
- TableDefinitions — column metadata, types, validation rules, foreign keys
- Apis — REST endpoint mappings per table
- ConstantCache / TableCache — dropdown/lookup values
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.
List pagination
keel REST list responses are paginated:
{ "items": [...], "limit": 100, "offset": 0, "total": 12345 }BackendService.list<T>() continues to return Observable<T[]> — sail unwraps items for you. To access the metadata, use listPaginated<T>():
this.backend.listPaginated<MyRow>('orders', { _limit: '50', _offset: '0' })
.subscribe((page) => {
this.rows.set(page.items);
this.total.set(page.total); // total rows matching the filter
});Default page size is 100, capped at 1000 server-side. Pass _limit / _offset in the filter map to control paging.
Configuration reference
interface SailGuiConfig {
opField: string; // Operation field name (default: 'op_code')
hiddenFields: string[]; // Fields hidden from forms (default: ['op_code', 'PartnerId'])
appTitle?: string; // Shown on login pages
googleMapsApiKey?: string; // For RegisterComponent geocoding
dashboardComponent?: Type<any>; // Component for /dashboard route
publicRoutes?: Routes; // Routes available before login
publicRouteLinks?: RouteLink[]; // Links shown in toolbar when logged out
loginFooterLinks?: RouteLink[]; // Extra links below login form
extraRoutes?: (data) => Routes; // Dynamic routes from ApplicationData
menuItemRouteOverrides?: { // Map RestUri to custom component
[restUriPattern: string]: Type<unknown>; // Supports 'exact' and 'prefix/*'
};
// Social / consent / account-deletion config
googleClientId?: string; // Google Identity Services client ID
appleServiceId?: string; // Apple Services ID
appleRedirectUri?: string; // Apple Sign-In redirect URI
privacyPolicyUrl?: string; // Linked from ConsentGateComponent
defaultPolicyVersion?: string; // Content hash of the deployed policy
defaultPolicyLanguage?: string; // ISO 639-1 fallback language
accountDeletedRoute?: string; // Route after account deletion (default '/login/local')
}Backend endpoints (keel v1.0)
| Endpoint | Purpose |
|---|---|
POST /public/login/local |
Username/password login with 2FA + trusted-device support |
POST /public/login/google |
Gmail OAuth-code login (legacy; prefer /public/login/social) |
POST /public/login/social |
ID-token social login (Google, Apple) |
POST /public/otp/send |
Send OTP code to phone or email; returns opaque otpToken |
POST /public/otp/verify |
Verify OTP code with otpToken, returns JWT |
POST /public/otp/resend |
Re-issue OTP for an existing otpToken |
POST /public/2fa/verify |
Login-time TOTP verification (uses loginToken) |
POST /public/2fa/backup-verify |
Login-time backup-code verification |
GET /api/config/appdata |
Metadata (menus, permissions, table definitions) |
POST/GET/DELETE /api/{version}/{table}/list|get|post|delete |
CRUD operations (paginated list) |
POST /api/user/2fa/setup |
Generate TOTP secret, QR URI, and backup codes — requires re-auth |
POST /api/user/2fa/verify |
Confirm 2FA setup by verifying TOTP code |
POST /api/user/2fa/disable |
Disable 2FA — requires password + current TOTP code |
GET /api/user/trusted-device/list |
List trusted devices |
POST /api/user/trusted-device/revoke |
Revoke a trusted device |
POST /api/user/logout-everywhere |
Sign out of all devices — requires re-auth |
DELETE /api/user/account |
Soft-delete the caller's account — requires re-auth |
POST /api/push/register |
Register an FCM / APNs token |
POST /api/push/revoke |
Revoke an FCM / APNs token |
POST /api/billing/checkout |
Create provider-hosted checkout session — JWT-gated, allowlist-validated |
GET /public/plans |
Subscription plan catalog (unauthenticated) |
GET /api/billing/subscription |
Active subscription for the partner |
POST /api/billing/subscription/cancel |
Cancel auto-renew |
GET /api/billing/invoices |
Invoice history |
GET /api/billing/payment-methods |
Saved payment methods |
Device registration happens within /public/2fa/verify when trustDevice=true. Since keel v0.9 / sail v0.9.0, the trusted-device credential is a server-minted keel_td cookie (HttpOnly + Secure + SameSite=Strict) — not a client-supplied fingerprint. sail sets withCredentials: true on the login and 2FA-verify calls so the cookie round-trips; see Enabling 2FA → Cross-origin cookie requirement below.
Enabling 2FA in your project
Add the 2FA verification route to your publicRoutes config:
import { TwoFactorVerifyComponent, TwoFactorSetupComponent, TrustedDevicesComponent } from '@nauticana/sail';
// In publicRoutes:
{ path: 'login/2fa', component: TwoFactorVerifyComponent }
// In extraRoutes or authenticated routes (optional — for user self-service):
{ path: 'security/2fa', component: TwoFactorSetupComponent }
{ path: 'security/devices', component: TrustedDevicesComponent }The login flow handles 2FA automatically: when the backend returns twoFactorRequired: true, sail redirects to /login/2fa. No other code changes needed.
Cross-origin cookie requirement
The "trust this device" feature (keel v0.9+) is backed by a server-set HttpOnly cookie named keel_td. The lifecycle is entirely server-driven:
- On
POST /public/2fa/verifywithtrustDevice: true, keel mints a 32-byte secret, stores its SHA-256, and returns the raw secret to the browser only as aSet-Cookie: keel_td=…header (never in the JSON body). - On the next
POST /public/login/local(or/public/login/gmail), the browser sends thekeel_tdcookie back, keel hashes it, finds the matching row, and skips the 2FA prompt.
For the browser to store and resend that cookie, the requests must be credentialed. sail already sets withCredentials: true on login(), loginWithGoogle(), verify2FALogin(), and verifyBackupCode() — you do not add this in your app code. But the keel backend must permit credentialed CORS, or the browser drops the cookie and every login re-prompts for 2FA:
- Same-origin deployments (SPA served from the same host as the API) work with no extra config — credentialed same-origin requests are always allowed.
- Cross-origin deployments (SPA on a different host/port than the API) require the keel
HttpBackendto set:AllowCredentials: true→ emitsAccess-Control-Allow-Credentials: trueOrigin= the exact SPA origin (e.g.https://app.example.com). A wildcard*is rejected by the browser for credentialed responses — keel's CORS layer enforces this.
If "trust this device" appears to do nothing (user is 2FA-prompted on every login despite checking the box), the cause is almost always a missing AllowCredentials / wildcard-origin on the backend — open devtools and confirm the keel_td cookie is actually set after a successful verify.
Session token storage
The session JWT and the short-lived loginToken are kept in localStorage (the trusted-device secret is the only HttpOnly cookie). This is a deliberate trade-off for a token-based SPA, but it means any XSS in the consuming app can read the session token. Harden accordingly: ship a strict Content-Security-Policy, keep third-party script surface minimal, and prefer short JWT lifetimes on the keel side.
Billing
sail ships a shared BillingService and ten billing components backed by keel's payment endpoints (see keel/SHARED_PAYMENT.md for the provider contract — Stripe by default, pluggable).
Service
import { BillingService } from '@nauticana/sail';
@Component({ /* ... */ })
export class PricingPage {
private billing = inject(BillingService);
readonly plans = toSignal(this.billing.listPlans(), { initialValue: [] });
readonly sub = toSignal(this.billing.getSubscription());
}| Method | Endpoint | Returns |
|---|---|---|
listPlans() |
GET /public/plans |
Observable<PublicPlan[]> |
createCheckout(req) |
POST /api/billing/checkout |
Observable<CheckoutResponse> |
getSubscription() |
GET /api/billing/subscription |
Observable<Subscription> |
cancelSubscription() |
POST /api/billing/subscription/cancel |
Observable<void> |
changePlan(planId) |
POST /api/billing/subscription/change |
Observable<void> |
setSeats(seats) |
POST /api/billing/subscription/seats |
Observable<void> |
listInvoices() |
GET /api/billing/invoices |
Observable<Invoice[]> |
listPaymentMethods() |
GET /api/billing/payment-methods |
Observable<PaymentMethod[]> |
createPortalSession() |
POST /api/billing/portal |
Observable<PortalResponse> |
listUsage() |
GET /api/billing/usage |
Observable<UsageMeter[]> |
Components
Plan picker — pure presentational, no API calls:
<sail-plan-selector
[plans]="plans()"
[selected]="selectedPlan()"
[features]="{ PRO: ['Unlimited users', '24/7 support'] }"
(selectionChange)="selectedPlan.set($event)">
</sail-plan-selector>Offer / price picker (v1.0.5) — a plan no longer has a single price. keel v1.0.5 sells per-plan offers in the subscription_plan_price table (billing cycle × commitment term, each with its own provider_price_id), surfaced as PublicPlan.prices: PlanPrice[]. The customer picks an offer at checkout; its priceId is what flows to <sail-checkout-button>:
<sail-price-selector
[prices]="selectedPlan().prices ?? []"
[selected]="offer()?.priceId"
(selectionChange)="offer.set($event)">
</sail-price-selector><sail-price-selector> is presentational (a radiogroup of offers), formats each amount with Intl from the offer's currency (so JPY/BHD render with the right number of decimals), and maps PERIOD_TYPE codes to copy via [cycleLabels]. It emits the full PlanPrice so you keep the chosen terms, not just the id.
Migration. The flat
PublicPlan.priceIdis deprecated (keel v1.0.5 droppedsubscription_plan.provider_price_id) — read the id from the selected offer (offer().priceId). keel v1.0.7 also removedPublicPlan.monthlyCost/annualCostfrom both/public/plansand the billing catalog, so any plan-card code reading them now getsundefined. Derive a display price from the offers withfromPriceLabel(plan.prices)(used by<sail-plan-selector>), or render the per-offer amounts via<sail-price-selector>. These fields are removed from sail'sPublicPlantype so the compiler flags any remaining references.
Checkout button — calls createCheckout() and redirects to the provider-hosted URL. Feed it the chosen offer's priceId:
<sail-checkout-button
[priceId]="offer()!.priceId!"
[mode]="'subscription'"
[successUrl]="'https://app.example.com/billing/done'"
[cancelUrl]="'https://app.example.com/billing'"
[email]="userEmail">
</sail-checkout-button>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.
keel allowlists —
priceId,successUrl, andcancelUrlmust each match the server-sideAllowedPriceIDs/AllowedRedirectHostsallowlists; otherwise the request is rejected with 400. Configure these on your keel deployment.
AllowedRedirectHostsmatching 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.
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:
metadata: {
partner_id: String(partnerId),
source: 'pricing-page',
}Payment methods — lists saved methods with loading and empty states:
<sail-payment-methods></sail-payment-methods>Registration → checkout flow
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.
BaseAsync
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:
import { BaseAsync, BillingService } from '@nauticana/sail';
@Component({ /* ... */ })
export class MyWidget extends BaseAsync {
private billing = inject(BillingService);
cancel() {
this.run(
this.billing.cancelSubscription(),
() => this.successMessage.set('Cancelled.'),
'Could not cancel.',
);
}
}Template:
<button (click)="cancel()" [disabled]="loading()">Cancel plan</button>
@if (errorMessage()) { <div class="auth-error">{{ errorMessage() }}</div> }
@if (successMessage()) { <div class="auth-success">{{ successMessage() }}</div> }Suggested styles for billing components
Add to src/styles.css to match the rest of the library:
/* Plan selector */
.plan-selector { display: flex; gap: 1rem; flex-wrap: wrap; }
.plan-card { flex: 1 1 220px; padding: 1rem; border: 1px solid #ccc; border-radius: 8px; }
.plan-selected { border-color: #1976d2; box-shadow: 0 0 0 2px #1976d2; }
.plan-caption { margin: 0 0 .5rem; }
.plan-price .plan-amount { font-size: 1.5rem; font-weight: 600; }
.plan-features { padding-left: 1.2rem; }
/* Payment methods */
.payment-methods-list { list-style: none; padding: 0; }
.payment-method { display: flex; justify-content: space-between; padding: .5rem 0; }
.payment-default-badge { font-size: .75rem; background: #e0e0e0; padding: 2px 8px; border-radius: 12px; }
/* Checkout button */
.checkout-button { display: flex; flex-direction: column; gap: .5rem; }Customer portal (v1.0.0)
createPortalSession() + <sail-portal-button> mirror checkout — POST /api/billing/portal, then redirect to the provider-hosted customer portal where the partner manages their payment method / subscription:
<sail-portal-button [label]="'Manage billing'"></sail-portal-button>Usage meters (v1.0.0)
listUsage() returns UsageMeter[] ({ resource, used, limit }, matching keel's UsageItem); <sail-usage-meter> renders one progress bar per resource (a limit < 0 shows "Unlimited"):
readonly meters = toSignal(this.billing.listUsage(), { initialValue: [] });<sail-usage-meter [meters]="meters()"></sail-usage-meter>Redirect-action (v1.0.0)
A TableAction with kind: 'redirect' makes BaseTable.executeAction POST and then follow the returned { url } instead of refreshing — so checkout / portal / any provider-hosted handoff can be declared as a basis table_action row with no bespoke component. The backend action handler returns { "url": "https://…" }.
Response-shape contract. The generic redirect-action path reads a bare
urlbecause it's polymorphic — one dispatch for checkout / portal / export / etc., where the action's identity lives in the metadata, not the payload. keel'sWrapTableActionenforces no shape; your inner handler must return{ url }. The typed billing endpoints keep specific names instead (createCheckout()→checkoutUrl,createPortalSession()→portalUrl). So the same portal concept has two response shapes depending on the path: wire it as<sail-portal-button>(typed →portalUrl) or as atable_actionredirect (generic →url), not both.
Status chip (v1.0.1)
<sail-status-chip> resolves a code to its caption from the client-cache constant_value dictionary — no per-app @switch. It defaults to the SUBSCRIPTION_STATUS domain (so T trialing / X past-due render with no app code) and works for any constant domain via [domain]. The code is exposed as [attr.data-status] so the app can colour the chip:
<sail-status-chip [value]="sub()?.status"></sail-status-chip>
<sail-status-chip [value]="invoice.status" domain="INVOICE_STATUS"></sail-status-chip>Replaces a downstream app's hardcoded status
@switch(e.g. seo's): captions are backend-authoritative — when a dictionary row is absent the raw code shows, never a fabricated label.
Trial banner (v1.0.1)
<sail-trial-banner> shows "Trial ends in N days" from Subscription.trialEnd; it renders nothing when the value is empty, unparseable, or already past, so it can sit unconditionally in the template:
<sail-trial-banner [trialEnd]="sub()?.trialEnd"></sail-trial-banner>Seat selector (v1.0.1)
<sail-seat-selector> is a presentational quantity stepper seeded from Subscription.seats. Feed the result into checkout ([quantity] / metadata) for new subs, or BillingService.setSeats() for an existing one:
<sail-seat-selector [seats]="sub()?.seats ?? 1" [min]="1" (seatsChange)="seats.set($event)"></sail-seat-selector>Plan change vs first-time checkout (v1.0.1)
First-time checkout uses <sail-checkout-button> (provider-hosted). For an existing subscription, upgrade/downgrade is a distinct path — BillingService.changePlan(planId) routes to keel's SubscriptionLifecycle.ChangePlan via an endpoint the app exposes. Wire the picker's selection to whichever path applies:
onPlanPicked(planId: string) {
this.sub() ? this.billing.changePlan(planId).subscribe() : this.startCheckout(planId);
}<sail-plan-selector> varies its CTA copy by PublicPlan.activationMode ("Start trial" for T, "Subscribe" for P, "Activate" for F); override or extend via [ctaLabels]="{ S: 'Add seats' }".
Dunning banner (v1.0.1)
<sail-dunning-banner> shows only when Subscription.status === 'X' (past-due / keel SetDunningState), prompting the partner to update their card and deep-linking to the provider portal via an embedded <sail-portal-button>:
<sail-dunning-banner [status]="sub()?.status"></sail-dunning-banner>Compose a full Billing screen
A complete screen is the pieces above assembled over BillingService — no metadata CRUD (billing data is partner-scoped, read-mostly, money-formatted, with an external-redirect action, which TableList/TableEdit don't model):
<sail-dunning-banner [status]="sub()?.status"></sail-dunning-banner>
<sail-trial-banner [trialEnd]="sub()?.trialEnd"></sail-trial-banner>
<sail-status-chip [value]="sub()?.status"></sail-status-chip>
<sail-plan-selector [plans]="plans()" [selected]="sub()?.planId" (selectionChange)="onPlanPicked($event)"></sail-plan-selector>
<sail-price-selector [prices]="selectedPlan()?.prices ?? []" [selected]="offer()?.priceId" (selectionChange)="offer.set($event)"></sail-price-selector>
<sail-seat-selector [seats]="sub()?.seats ?? 1" (seatsChange)="seats.set($event)"></sail-seat-selector>
<sail-checkout-button [priceId]="offer()!.priceId!" [quantity]="seats()" [successUrl]="okUrl" [cancelUrl]="backUrl"></sail-checkout-button>
<sail-usage-meter [meters]="usage()"></sail-usage-meter>
<sail-payment-methods></sail-payment-methods>
<sail-portal-button></sail-portal-button>
<!-- invoices: render listInvoices() rows directly -->Off-session SCA / 3DS confirmation (v1.0.x)
When an app charges a rider's vaulted card off-session (keel payment.ChargeClient / StripeChargeClient), the provider may return requires_action — the cardholder must complete a Strong Customer Authentication (3DS) challenge. keel surfaces the hooks on ChargeResult (ClientSecret, ActionURL); sail standardizes the client-side confirmation so it isn't rebuilt per app.
sail stays provider-agnostic: it ships no payment-provider SDK and no default confirmer. It provides a port (ScaConfirmer), the SCA_CONFIRMER injection token, and <sail-sca-confirm>, which:
- redirects to
ChargeResult.actionUrlwhen present (provider-hosted challenge — no SDK needed); else - delegates the inline
ChargeResult.clientSecretto the app-registeredSCA_CONFIRMER.
Backend contract (app-owned)
Off-session charging is a worker/handler concern, so the app owns the charge endpoint. Have it call keel's ChargeClient.Charge and return the normalized result as camelCase JSON matching sail's ChargeResult:
// POST /api/<app>/charge → 200
{ "status": "requires_action", "clientSecret": "pi_..._secret_...", "actionUrl": "" }
// or { "status": "succeeded", "providerChargeId": "pi_..." }
// or { "status": "failed", "error": "card_declined" }
ChargeRequest.Metadata(keel v1.0.4) carriesride_id/user_id/tip_amountthrough to the settled charge'sPaymentEvent.Metadata, so the webhook can correlate and split the fare — set it on the backend, not the client.
Frontend adoption (downstream projects)
1. Implement the provider confirmer (Stripe shown; lives in the app, behind an optional @stripe/stripe-js peer dep — non-Stripe apps skip it):
import { Injectable } from '@angular/core';
import { loadStripe } from '@stripe/stripe-js';
import { ScaConfirmer, ScaResult } from '@nauticana/sail';
@Injectable({ providedIn: 'root' })
export class StripeScaConfirmer implements ScaConfirmer {
async confirm(clientSecret: string): Promise<ScaResult> {
const stripe = await loadStripe(/* publishable key from backend cache */);
if (!stripe) return { outcome: 'failed', error: 'Stripe.js failed to load' };
const { error } = await stripe.handleNextAction({ clientSecret });
return error ? { outcome: 'failed', error: error.message } : { outcome: 'succeeded' };
}
}2. Register it at composition time (app.config.ts / main.ts):
import { SCA_CONFIRMER } from '@nauticana/sail';
providers: [
{ provide: SCA_CONFIRMER, useExisting: StripeScaConfirmer },
]3. Drop the component into the post-charge flow — bind the charge response and react to the outcome:
<sail-sca-confirm
[result]="charge()"
(confirmed)="onPaid()"
(failed)="onScaFailed($event)">
</sail-sca-confirm><sail-sca-confirm> renders nothing for succeeded, the decline message for failed, and a confirm button for requires_action. The publishable key is backend-authoritative — read it from the auth/app-data cache, never hardcode a fallback.
Consent capture
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.
import { ConsentGateComponent, ConsentOption, ConsentState, ConsentType } from '@nauticana/sail';
@Component({
imports: [ConsentGateComponent],
template: `
<sail-consent-gate
[policyVersion]="'v1'"
[policyLanguage]="'en'"
[optionalConsents]="extras"
(consentStateChange)="onConsent($event)">
</sail-consent-gate>
`,
})
export class SignupPage {
readonly extras: ConsentOption[] = [
{ id: ConsentType.VIDEO_OPT_IN, label: 'Record my trips on video', hint: 'Optional; change in settings later.' },
{ id: ConsentType.MARKETING, label: 'Email me product updates' },
];
onConsent(state: ConsentState) {
// state.consents is Record<string, boolean>, state.valid is false until
// both required checkboxes are ticked.
}
}The component relies on config.privacyPolicyUrl, config.defaultPolicyVersion, and config.defaultPolicyLanguage as fallbacks when the inputs are omitted.
Phone / email OTP login
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.
BaseAuthService provides sendOtp(), resendOtp(), and verifyOtp(). Pair them with the presentational OtpInputComponent for a complete OTP screen:
// src/page/otp_confirm.ts
import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { BaseAuthService, OtpInputComponent } from '@nauticana/sail';
@Component({
imports: [OtpInputComponent],
template: `
<sail-otp-input
[contact]="contact()"
[length]="6"
(codeComplete)="onVerify($event)"
(resend)="onResend()">
</sail-otp-input>
@if (error()) { <div class="auth-error">{{ error() }}</div> }
`,
})
export class OtpConfirmPage {
private auth = inject(BaseAuthService);
private router = inject(Router);
readonly contact = signal('+1 (416) 555-1234');
readonly otpToken = signal('');
readonly error = signal('');
onVerify(code: string) {
this.auth.verifyOtp({ otpToken: this.otpToken(), code }).subscribe({
next: () => this.router.navigate(['/dashboard']),
error: (err) => this.error.set(err.error?.message ?? 'Invalid code.'),
});
}
onResend() {
this.auth.resendOtp(this.otpToken()).subscribe();
}
}Override verifyOtp() in your own AuthService extends BaseAuthService when you need role routing.
| Endpoint | Service method |
|---|---|
POST /public/otp/send |
sendOtp(req) — returns { otpToken } |
POST /public/otp/resend |
resendOtp(otpToken, purpose?) |
POST /public/otp/verify |
verifyOtp({ otpToken, code }) — auto-completes login on success |
Social login
SocialLoginComponent renders Google / Apple buttons using each provider's official SDK. It loads the SDKs dynamically via loadScript() — nothing is bundled into your app.
<sail-social-login
[providers]="['google', 'apple']"
[consent]="consentState"
(loginSuccess)="onSuccess($event)"
(loginError)="onError($event)">
</sail-social-login>Config required in SAIL_GUI_CONFIG:
{
googleClientId: 'xxxxx.apps.googleusercontent.com',
appleServiceId: 'com.example.app.web',
appleRedirectUri: 'https://example.com/login', // must match Apple Services ID
}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).
For backward compatibility, the older OAuth-code flow BaseAuthService.loginWithGoogle(code) (hits /public/login/google) is still supported; prefer loginSocial for new code.
Adding a new domain table
When your project owns a table that isn't part of basis (e.g. favorite_location, support_ticket, vehicle), follow the pattern below so the row plays nicely with keel's generic CRUD endpoints and the sail <table-edit> / <table-list> components.
1. Backend: register the route
Add a rest_api_header seed row so keel mounts the CRUD endpoints at boot:
INSERT INTO rest_api_header (id, master_table)
VALUES ('favorite_location', 'favorite_location');This exposes GET/POST /api/v1/favorite_location/list,get,post,delete. The binary reads rest_api_header once at startup, so restart httpsrv after seeding.
2. Frontend: model class extends SiudAction
Every row submitted through keel's bulk-POST endpoint is dispatched by an op_code field — I insert, U update, D delete, R recurse-children, S select-only (no-op). Rows without op_code are silently skipped: the handler still returns 201, but no SQL runs. The way to stay safe is to extend SiudAction (which carries op_code: OpCodeValue defaulted to 'S') rather than declaring a plain interface.
import { SiudAction } from '@nauticana/sail';
export class FavoriteLocation extends SiudAction {
Id?: number;
UserId?: number;
Label?: string;
Address?: string;
Lat?: number;
Lng?: number;
SortOrder?: number;
}Field names are PascalCase — keel marshals SQL columns to column.pascal_name (i.e. user_id → UserId) for the wire format, and the <table-edit> component generator follows the same convention. Snake_case fields will round-trip in some paths but break others (e.g. <table-edit> form binding); pick PascalCase up front.
3. Frontend: flip op_code before save
When the row leaves the UI, set op_code to the operation you actually want:
import { OpCode } from '@nauticana/sail';
import { BackendService } from 'service/rest_service';
import { FavoriteLocation } from 'model/tripdb';
const backend = inject(BackendService);
const row = new FavoriteLocation();
row.op_code = existing?.Id ? OpCode.Update : OpCode.Insert;
row.Id = existing?.Id;
row.Label = 'Home';
row.Address = '...';
backend.post<FavoriteLocation>('v1/favorite_location', [row]).subscribe();The <table-edit> and <table-list> components handle this automatically — they tag fetched rows 'S', flip to 'U' on dirty-edit, 'I' for new rows, and 'D' when the user deletes. You only need to set op_code manually when bypassing those components (custom save flows, bulk imports, etc.).
4. Backend permissions
Grant TABLE SELECT/INSERT/UPDATE/DELETE to the relevant roles in authorization_role_permission. For owner-scoped tables (rows have a user_id FK to user_account), keel auto-detects UserSpecific from the FK + column-name combination and pins the session's user id on insert + filters list/get to the owner automatically — no extra wiring needed.
INSERT INTO authorization_role_permission (role_id, authorization_object_id, action, low_limit) VALUES
('RIDER', 'TABLE', 'INSERT', 'favorite_location'),
('RIDER', 'TABLE', 'UPDATE', 'favorite_location'),
('RIDER', 'TABLE', 'DELETE', 'favorite_location');
-- SELECT is granted via the wildcard ('RIDER', 'TABLE', 'SELECT', '*') if you have one.Footguns
- Plain interfaces instead of
extends SiudAction—op_codefield never exists, every row in a bulk POST is silently skipped, handler returns 201, table stays empty. Symptom: "saving says success but the list comes back empty." - Snake_case fields — keel marshals PascalCase, so a snake_case
addressfield on the wire serializes asAddressfrom keel but your client TS expectsaddress. Mixed payloads break in subtle ways (read paths show data, write paths look like no-ops). - Forgetting to restart httpsrv after seeding
rest_api_header— the route table is built once at startup. New rows = 404 until restart.
Tuning edit-form fields (column_display_attribute)
The generic edit form renders each column from keel's metadata. By default:
- Textarea vs input follows the column's
InputType— keel setstextareaforTEXT,textforVARCHAR. (Earlier builds keyed offSize > 80, which turned longVARCHARs into textareas; it now trustsInputType.) - Read-only applies only to primary keys (and FK columns in child rows).
To override per column without changing the schema, seed keel's
column_display_attribute table (table_name, column_name, display_mode, display_width, display_rows). sail honours three optional TableColumn
fields it produces:
| Field | Effect |
|---|---|
DisplayMode |
R read-only (shown display-only; hidden when empty, so a new record's audit fields disappear and on edit only populated ones show). H hidden everywhere — the new-record builder omits it so the DB default/sequence/trigger fills it. D editable, prefilled with the column default on a new record. I insert-only — editable while creating, locked once it exists. U update-stamp — display-only like R in the UI, but keel auto-sets it on every UPDATE (now() for timestamps, user_id for integers), so updated_at/updated_by need no DB trigger. Empty/NULL = editable. |
display_rows |
Textarea height. 1 forces a single-line input — keep a column TEXT (so bulk-loaded values don't overflow) yet render it on one line. |
display_width |
Field max-width in px. |
R/H/I are also enforced server-side by keel (excluded from generic
INSERT/UPDATE), so the modes are real, not just cosmetic. No rows seeded =
unchanged behaviour. Requires keel with the column_display_attribute
feature; rebuild httpsrv after seeding (metadata is read once at startup).
Account deletion / logout everywhere / push tokens
These are App Store / Play Store compliance primitives from keel. All are opt-in.
Re-authentication gate
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.
| Method | Required re-auth |
|---|---|
setup2FA(reauth) |
password (or twoFactorCode when re-rotating) |
disable2FA(password, code) |
both password and current TOTP code |
deleteAccount(reauth, reason?) |
password (or twoFactorCode) |
logoutEverywhere(reauth) |
password (or twoFactorCode) |
The ReauthCredentials type is exported as a shared shape:
import { ReauthCredentials } from '@nauticana/sail';
const reauth: ReauthCredentials = { password: 'hunter2' };
auth.deleteAccount(reauth, 'Closing my account.').subscribe();Account deletion
<sail-account-deletion [confirmationText]="'DELETE'"></sail-account-deletion>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).
Logout everywhere
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:
<sail-trusted-devices [singleDeviceSession]="user.singleDeviceSession"></sail-trusted-devices>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."
Push tokens
For mobile apps / web push, register your FCM / APNs token after login:
auth.registerPushToken('I', fcmToken, '1.2.3', 'iPhone 15').subscribe();
// On logout or device rotation:
auth.revokePushToken(oldToken).subscribe();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.
| Endpoint | Service method |
|---|---|
DELETE /api/user/account |
deleteAccount(reauth, reason?) |
POST /api/user/logout-everywhere |
logoutEverywhere(reauth) |
POST /api/push/register |
registerPushToken(platform, token, appVersion?, deviceModel?) |
POST /api/push/revoke |
revokePushToken(token) |
Suggested styles for auth components
/* Consent gate */
.consent-form { display: flex; flex-direction: column; gap: 12px; }
.consent-row { font-size: 14px; line-height: 1.4; }
.consent-hint { display: block; margin-top: 4px; font-size: 12px; color: #666; }
/* OTP input */
.otp-container { display: flex; flex-direction: column; align-items: center; padding: 16px 24px; }
.otp-sent-to { margin: 0; color: #666; font-size: 14px; }
.otp-contact { margin: 4px 0 24px; font-weight: 600; font-size: 16px; }
.otp-digits { display: flex; gap: 8px; margin-bottom: 16px; }
.otp-digit { width: 40px; height: 48px; border: 2px solid #ddd; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 20px; font-weight: 600; }
.otp-digit.active { border-color: #1976d2; }
.otp-keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
width: 100%; max-width: 300px; }
.keypad-key { height: 56px; font-size: 22px; font-weight: 500; border-radius: 12px; }
.keypad-spacer { height: 56px; }
/* Social login */
.social-login { display: flex; flex-direction: column; gap: 8px; align-items: stretch; }
.social-apple-btn { height: 44px; border-radius: 22px; background: #000; color: #fff;
border: 0; font-weight: 600; cursor: pointer; }
/* Trusted devices single-device banner */
.single-device-banner { padding: 12px; border-radius: 8px; background: #fff3cd;
color: #856404; margin-bottom: 16px; font-size: 14px; }Updating to the latest version
npm update @nauticana/sailOr to install a specific version:
npm install @nauticana/sail@1.0.7Migrating to v0.5.0
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.
1. Update package.json
- "@aspect/gui": "github:nauticana/sail"
+ "@nauticana/sail": "^0.5.0"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:
- @nauticana:registry=https://npm.pkg.github.com2. Update tsconfig.json paths
- "@aspect/gui": ["./node_modules/@aspect/gui/src/index"]
+ "@nauticana/sail": ["./node_modules/@nauticana/sail/src/index"]If your tsconfig.json has "baseUrl": "src", the relative path is "../node_modules/@nauticana/sail/src/index".
3. Rename imports in your src/
Find-and-replace across the entire src/ tree:
| Replace | With |
|---|---|
@aspect/gui |
@nauticana/sail |
BASIS_GUI_CONFIG |
SAIL_GUI_CONFIG |
BasisGuiConfig |
SailGuiConfig |
Both the injection token and the interface were renamed.
4. Update OTP flow — opaque otpToken replaces sessionId
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.
Wherever you stored the OTP sessionId, swap it for otpToken: string.
- readonly sessionId = signal(0);
+ readonly otpToken = signal('');
// sendOtp response shape:
- // { sessionId: number; isNewUser: boolean }
+ // { otpToken: string }
this.auth.sendOtp({ contact: phone, purpose: 'login' }).subscribe({
- next: (resp) => this.sessionId.set(resp.sessionId),
+ next: (resp) => this.otpToken.set(resp.otpToken),
});
// verifyOtp request shape:
- this.auth.verifyOtp({ sessionId: this.sessionId(), code }).subscribe(...)
+ this.auth.verifyOtp({ otpToken: this.otpToken(), code }).subscribe(...)
// resendOtp signature changed:
- this.auth.resendOtp(this.sessionId()).subscribe();
+ this.auth.resendOtp(this.otpToken()).subscribe();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.
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.
5. Update consumer subscriptions
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.
For pagination metadata, use listPaginated<T>():
backend.listPaginated<MyRow>('orders', { _limit: '50', _offset: '0' })
.subscribe(page => {
this.rows.set(page.items);
this.total.set(page.total);
});6. Optional: replace deprecated bootstrap
If you bootstrapped with provideZoneChangeDetection, switch to provideZonelessChangeDetection (Angular 21 default). The shipped @nauticana/sail components are signal-based and zoneless-safe.
7. Clean install
rm -rf node_modules package-lock.json
npm install
ng buildType errors after upgrade fall into two buckets:
Cannot find name 'BasisGuiConfig'— you missed a rename in step 3. Search again.Property 'sessionId' does not exist on type 'OtpResponse'— finish step 4 in the file flagged.
8. Backend alignment
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 for the matching backend changes.
Migrating to v0.7.0 — payout, user payment methods, table actions
The v0.6 / v0.7 line introduced three additive feature groups against keel v0.7.x. All net-new exports; no breaking changes from v0.5.x. Pull what you need.
| Version | Surface |
|---|---|
| v0.6.0 | Payout onboarding (KYC) + multi-partner account reuse |
| v0.6.1 | End-user saved payment methods (cards / wallets) |
| v0.7.0 | TableAction — backend-defined custom buttons on table screens |
1. New exports
| From | Export | Purpose |
|---|---|---|
@nauticana/sail |
PayoutService |
keel/payout API client — hosted-KYC launch, reuse flow, status |
@nauticana/sail |
PayoutProviderOnboardingComponent (<sail-payout-provider-onboarding>) |
Drop-in onboarding step with reuse picker + hosted-KYC launcher |
@nauticana/sail |
PayoutBankInfoFormComponent (<sail-payout-bank-info-form>) |
Tax + payout details form (country / currency / tax ID / billing address / agreement) |
@nauticana/sail |
UserPaymentMethodService |
Saved-card list / delete / set-default API client |
@nauticana/sail |
UserPaymentMethodsComponent (<sail-user-payment-methods>) |
List of saved cards/wallets with set-default + delete |
@nauticana/sail |
TableAction, ReusableAccount, PayoutOnboardingSession, BankInfoFormValue, CountryProfile, UserPaymentMethod, DEFAULT_COUNTRY_PROFILES |
Model types / defaults |
2. New keel endpoints
Make sure your keel deployment exposes these — they're all under /api/v1/:
| Endpoint | Method | Purpose |
|---|---|---|
/api/v1/payout/onboard/start |
POST | Open hosted-KYC; returns { url, externalAccountId, expiresAt } |
/api/v1/payout/reusable |
POST | List provider accounts the user has on other partners |
/api/v1/payout/reusable/link |
POST | Copy a providerAccountId onto the active partner's user_bank_info row |
/api/v1/payout/status |
POST | { complete: true } once the active partner's row has a providerAccountId |
/api/v1/payment-methods/set-default |
POST | Atomic multi-row UPDATE — sets one row as default, clears the rest |
/api/v1/{table}/{action_name} |
POST | Per-table custom actions resolved from basis.table_action |
list / delete for the user_payment_method table go through keel's generic REST CRUD (/api/v1/user_payment_method/list|delete) — user_payment_method is a UserSpecific basis table, so keel auto-scopes reads to the caller and owner-locks DELETE. No custom endpoint needed for those two.
3. Payout onboarding wiring
Drop the two components into a wizard step. Routing between them stays in the consumer app — sail emits events, doesn't navigate.
@switch (step()) {
@case ('bank') {
<sail-payout-bank-info-form
(submitted)="onBankInfo($event)"
(back)="step.set('plan')">
</sail-payout-bank-info-form>
}
@case ('provider') {
<sail-payout-provider-onboarding
(linked)="step.set('done')"
(started)="step.set('waiting')"
(skipped)="step.set('done')"
(back)="step.set('bank')">
</sail-payout-provider-onboarding>
}
}BankInfoFormValue maps 1:1 to basis.user_bank_info columns. The consumer decides where to POST it — typically a direct generic-CRUD insert against /api/v1/user_bank_info. The provider account itself is created by <sail-payout-provider-onboarding> via PayoutService.startOnboarding() → hosted KYC → webhook back to keel.
For apps that operate on a single keel partner, the reuse picker stays empty and only the "Start onboarding" CTA renders. Multi-partner apps get the picker for free.
4. Saved payment methods wiring
<sail-user-payment-methods
[title]="'Payment Methods'"
(addClicked)="goToSetupIntent()"
(defaultChanged)="onDefaultChanged($event)"
(deleted)="onDeleted($event)">
</sail-user-payment-methods>(addClicked) is the only event you must wire — sail intentionally does not ship a SetupIntent UI (providers differ too much; consumer flows them through Stripe Elements / Apple Pay / Google Pay / etc.). Listen for the event and route to your own SetupIntent screen.
5. TableAction — backend-defined per-table actions
v0.7.0 surfaces a new TableAction channel: keel's REST engine ships per-table action buttons from basis.table_action. The shipped TableList, TableSearch, TableEdit, and TableDetail templates render these automatically (toolbar for table-level actions, per-row icon buttons for record-specific ones). Authorization gating mirrors canRead/canCreate etc. — canExecuteAction(action) is checked against (authorityObject, authorityCheck, table_name).
For your own components that subclass BaseView / BaseForm, expose the same buttons in your templates:
@for (action of getActions(false); track action.action) {
@if (canExecuteAction(action)) {
<button matButton="outlined" type="button"
[title]="action.caption"
(click)="executeAction(action)">
@if (action.icon) { <mat-icon>{{ action.icon }}</mat-icon> }
{{ action.caption }}
</button>
}
}
@for (action of getActions(true); track action.action) {
@if (canExecuteAction(action)) {
<button matIconButton type="button"
[title]="action.caption"
(click)="executeAction(action, record)">
<mat-icon>{{ action.icon || 'play_arrow' }}</mat-icon>
</button>
}
}executeAction() is provided by sail's table components (it POSTs the row's primary-key columns to action.method). If you wrote your own subclass and want the same behaviour, inject BackendService and call backendService.executeAction(action.method, body). The body is {} for table-level actions and the row PK (via primaryKeyValues(record)) for record-specific ones.
6. Backend alignment
v0.6 / v0.7 require keel v0.7.x. The earlier keel v0.5.x server doesn't expose /api/v1/payout/*, /api/v1/payment-methods/set-default, or the basis.table_action seed data. Upgrade keel and sail together.
Migrating to v0.8.0 — signal-driven modernization
v0.8.0 finishes the Angular-signals migration that v0.5–v0.7 started incrementally. Every @Input() / @Output() decorator, EventEmitter, plain-field-on-BaseTable access, and legacy Material M2 button selector is gone. Downstream code that extends sail's abstract classes or copies its template patterns will need the steps below — there is no shim.
The TS-side core peer requirements bump with this release:
| v0.7.x | v0.8.x | |
|---|---|---|
| Angular | ^21.0.0 |
^21.2.0 |
| TypeScript | ~5.9.2 |
~6.0.3 |
1. BaseTable.tableName is now a WritableSignal<string>
The single highest-impact change. Before:
// BaseTable
tableName = '';
// Subclass
@Input() override tableName = '';
// In your code
if (this.tableName === 'orders') { ... }
this.tableName = 'orders';
// In your template
[tableName]="tableName"
{{ tableName }}After:
// BaseTable
readonly tableName: WritableSignal<string> = signal('');
// In your code
if (this.tableName() === 'orders') { ... }
this.tableName.set('orders');
// In your template
[tableName]="tableName()"
{{ tableName() }}If your subclass exposes tableName as a route/parent input, declare an aliased input() and sync it into the inherited signal in the constructor:
import { effect, input } from '@angular/core';
export class YourTableComponent extends BaseForm {
readonly tableNameInput = input('', { alias: 'tableName' });
constructor() {
super();
effect(() => {
const v = this.tableNameInput();
if (v) this.tableName.set(v);
});
}
ngOnInit() {
// Route-data fallback now uses .set(), not assignment:
if (!this.tableName() && data['tableName']) this.tableName.set(data['tableName']);
}
}2. BaseView.apiName and BaseView.dialogComponent are signals too
Same migration path as tableName. The TableList/TableLookup pattern in v0.5–v0.7 read these as plain strings and assigned in ngOnInit; they are now WritableSignal<string> / WritableSignal<any>. Update subclass code:
- if (!this.apiName && data['apiName']) this.apiName = data['apiName'];
- this.backendService.list<MyRow>(this.apiName, terms).subscribe(...);
+ if (!this.apiName() && data['apiName']) this.apiName.set(data['apiName']);
+ this.backendService.list<MyRow>(this.apiName(), terms).subscribe(...);If you bind [apiName] / [dialogComponent] on a sail subclass from a parent template, declare aliased inputs and sync via effect(), exactly like tableNameInput above.
3. @Input() / @Output() → input() / output()
Every decorator-based input/output across sail is now signal-based. Update your own components to match. The signal-input requires calling the field as a function inside the class and in templates.
- @Input() title = 'Payments';
- @Output() saved = new EventEmitter<Payment>();
+ readonly title = input('Payments');
+ readonly saved = output<Payment>();
// class body
- this.title // string
- this.saved.emit(p);
+ this.title() // string (signal read)
+ this.saved.emit(p); // unchanged
// template
- {{ title }}
+ {{ title() }}EventEmitter is no longer imported from @angular/core in sail; outputs created with output<T>() are returned as OutputEmitterRef<T> with the same .emit(v) API.
For inputs whose parent value can change at runtime and you also want a local writable copy (the old "default value, then override in ngOnInit" pattern), use the alias + effect template above. If you only need the parent value reactively, just call this.foo() everywhere.
4. Inline component templates moved to sibling .html
UserPaymentMethodsComponent, PayoutBankInfoFormComponent, and PayoutProviderOnboardingComponent no longer ship inline template: strings — they reference templateUrl: './*.html' in the same folder. The compiled output is identical; downstream consumers that just import the component classes need no change. If you have a fork that edits these templates inline, port your edits into the new .html files.
5. Clean install + recompile
rm -rf node_modules package-lock.json
npm install
ng buildType errors after the upgrade fall into four buckets:
This expression is not callable. Type 'String' has no call signatures.— a template still readstableName/apiNameas a plain field. Add(). See step 1.Cannot assign to 'tableName' because it is a read-only property.— a subclass declaredreadonly tableName = input(''), conflicting with the inherited writable signal. Use thetableNameInput = input('', { alias: 'tableName' })+effect()pattern from step 1 instead.Property 'X' does not exist on type 'EventEmitter<T>'.— you still importEventEmitter. Replace the field withoutput<T>()per step 3.'@Input' is deprecated/ Angular language-service squiggles on decorators — step 3.
Migrating to v0.8.1 — selector standardisation, OOP cleanup, keel v0.8.3 alignment
v0.8.1 is a polish pass on top of v0.8.0's signal migration. The biggest change is the component selector prefix: every sail component is now sail-*, where v0.8.0 still shipped a mix of app-* (older components) and sail-* (the v0.6 / v0.7 additions). The rest of the release is internal OOP cleanup that's mostly invisible to downstream code, plus a TypeScript downgrade and a keel pin.
| v0.8.0 | v0.8.1 | |
|---|---|---|
| keel | v0.8.0 |
v0.8.3 |
| TypeScript | ~6.0.3 |
~5.9.3 |
| Component selector prefix | app-* (older) + sail-* (newer) |
sail-* (uniform) |
1. Selector rename — app-* → sail-* (and table-* / dynamic-field / record-form → sail-*)
Every sail component selector now starts with sail-. This is the breaking change: any downstream template that embeds a sail component needs the prefix updated. Search-and-replace across your templates:
| Old | New |
|---|---|
<app-navigation> |
<sail-navigation> |
<app-login> |
<sail-login> |
<app-register> |
<sail-register> |
<app-chpass> |
<sail-chpass> |
<app-confirm-register> |
<sail-confirm-register> |
<app-confirm-chpass> |
<sail-confirm-chpass> |
<app-twofactor-setup> |
<sail-twofactor-setup> |
<app-twofactor-verify> |
<sail-twofactor-verify> |
<app-trusted-devices> |
<sail-trusted-devices> |
<app-account-deletion> |
<sail-account-deletion> |
<app-consent-gate> |
<sail-consent-gate> |
<app-otp-input> |
<sail-otp-input> |
<app-social-login> |
`<sai |