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. Use 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. 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.
| Category | Exports |
|---|---|
| Table components | TableList, TableSearch, TableEdit, TableDetail, TableLookup |
| Form components | DynamicField, RecordForm, TableForm |
| Navigation | Navigation (sidenav + toolbar with menu, responsive) |
| Login | LoginComponent, RegisterComponent, ChpassComponent, ConfirmRegisterComponent, ConfirmChpassComponent |
| Security | TwoFactorSetupComponent, TwoFactorVerifyComponent, TrustedDevicesComponent, AccountDeletionComponent |
| Auth | ConsentGateComponent, OtpInputComponent, SocialLoginComponent |
| Billing | PlanSelectorComponent, CheckoutButtonComponent, PaymentMethodsComponent |
| Services | BaseAuthService (OTP / social / push / deleteAccount / logoutEverywhere), BillingService, BackendService, LabelService, loadScript(), authInterceptor, apiResponseInterceptor |
| Abstracts | BaseTable, BaseForm, BaseView, BaseAsync |
| Config | SAIL_GUI_CONFIG, SailGuiConfig, configureRestUrls() |
| Models | ApplicationData, TableDefinition, SiudAction, ApplicationMenu, ConstantValue, UserAccount, RestReport, TrustedDevice, PublicPlan, PaymentMethod, Subscription, Invoice, OtpRequest/OtpResponse, SignupConsent, ConsentState, ConsentOption, SocialProvider, PushPlatform, 2FA types, etc. |
| Decorators | @IsString(), @IsNumeric() (class-validator based) |
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": "^0.5.0",
"class-validator": "^0.15.1"
}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"]
}
}// 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.)
}// 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' },
],
loginFooterLinks: [
{ label: 'Sign in with Google', routerLink: '/login/google' },
],
// Map backend menu items to custom components
menuItemRouteOverrides: {
// 'external_connection': SocialConnectionComponent,
// 'analytic/*': TableReport,
},
},
},
],
});// 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 {}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
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.
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.
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')
}| 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.
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.
sail ships a shared BillingService and three billing components backed by keel's payment endpoints (see keel/SHARED_PAYMENT.md for the provider contract — Stripe by default, pluggable).
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> |
listInvoices() |
GET /api/billing/invoices |
Observable<Invoice[]> |
listPaymentMethods() |
GET /api/billing/payment-methods |
Observable<PaymentMethod[]> |
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>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:
<sail-checkout-button
[priceId]="selectedPlan().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>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.
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> }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; }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.
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 |
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.
These are App Store / Play Store compliance primitives from keel. All are opt-in.
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();<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).
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."
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) |
/* 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; }npm update @nauticana/sailOr to install a specific version:
npm install @nauticana/sail@0.5.0The 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.
- "@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.com- "@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".
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.
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.
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);
});If you bootstrapped with provideZoneChangeDetection, switch to provideZonelessChangeDetection (Angular 21 default). The shipped @nauticana/sail components are signal-based and zoneless-safe.
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.
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.
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 |
| 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 |
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.
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.
<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.
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.
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.
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 |
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']);
}
}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.
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.
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.
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.
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) |
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> |
<sail-social-login> |
<app-plan-selector> |
<sail-plan-selector> |
<app-checkout-button> |
<sail-checkout-button> |
<app-payment-methods> |
<sail-payment-methods> |
<table-list> |
<sail-table-list> |
<table-search> |
<sail-table-search> |
<table-edit> |
<sail-table-edit> |
<table-detail> |
<sail-table-detail> |
<table-lookup> |
<sail-table-lookup> |
<table-report> |
<sail-table-report> |
<table-form> |
<sail-table-form> |
<record-form> |
<sail-record-form> |
<dynamic-field> |
<sail-dynamic-field> |
The payout / user-payment-method selectors (<sail-payout-bank-info-form>, <sail-payout-provider-onboarding>, <sail-user-payment-methods>) were already on sail-* in v0.7.0 — no change there.
Why: the Angular style guide assigns app-* to the consumer application (Angular CLI defaults to it for new components). A library shipping app-* selectors collides with the downstream's own components and reads ambiguously in mixed templates. v0.8.1 makes the prefix uniform across sail and unambiguous against your code.
executeAction() was duplicated three times across TableList, TableSearch, and TableDetail in v0.8.0. v0.8.1 moves the body into BaseTable with two protected hooks:
protected beforeExecuteAction(action: TableAction, record?: Record<string, unknown>): boolean { return true; }
protected onActionSuccess(action: TableAction, record?: Record<string, unknown>): void { /* no-op */ }For your subclasses: if you have a custom view that extends BaseTable / BaseForm / BaseView and renders the action toolbar, you no longer write your own executeAction() — inherit it. Override onActionSuccess() to refresh your screen (the way TableList re-fetches) and beforeExecuteAction() to add per-screen pre-flight guards (the way TableDetail blocks unsaved rows).
For your templates: unchanged. Action buttons still call (click)="executeAction(action, record)".
Bonus: TableEdit now renders per-record actions too (v0.8.0 was missing them) and re-fetches the record after a successful action.
ChpassComponent, ConfirmChpassComponent, ConfirmRegisterComponent, TwoFactorSetupComponent, and TwoFactorVerifyComponent were re-declaring their own errorMessage / successMessage signals and hand-rolled subscribe blocks. They now extends BaseAsync and use this.run(obs, onSuccess, fallback) — public template API is unchanged ({{ errorMessage() }} / {{ successMessage() }} still work).
BaseRestService(src/service/base_rest.service.ts) owns the sharedinject(HttpClient)andurl(path)helper.BackendService,BaseAuthService,BillingService,PayoutService, andUserPaymentMethodServiceall extend it. The 27 hand-spelledRestURL.httpHost + RestURL.xURLgetters inBaseAuthServiceare nowreadonlyfields built viathis.url(...).BaseTable.requireAuth(check, verb)replaces 8 copies ofalert('Missing authorization to … records')across the codebase.BaseView.openRecordDialog(record, isNew)collapses the duplicatedaddRecord/editRecorddialog-open bodies.linkedSignalreplaces the v0.8.0input() + signal + effect()pattern acrossTableList,TableSearch,TableEdit,TableDetail, andDynamicField. One-line declarations per field; behaviour is unchanged (empty input doesn't override a.set()value).
If your downstream component extends BaseTable / BaseView and uses the alias-input pattern from v0.8.0 (input + effect), you can optionally migrate to linkedSignal — but the old pattern still compiles.
import { input, linkedSignal } from '@angular/core';
export class MyView extends BaseView {
// Aliased input MUST be public — Angular's strict template type-checker
// resolves `[tableName]="x"` from a parent template back to this field
// by name, and a `private` / `protected` modifier rejects the binding
// with TS2445. The override signal can be any visibility.
readonly tableNameInput = input('', { alias: 'tableName' });
override readonly tableName = linkedSignal<string, string>({
source: () => this.tableNameInput(),
computation: (v, prev) => v || prev?.value || '', // preserve .set() when input goes empty
});
}The computation callback runs whenever the source changes. Returning v || prev?.value gives "use the bound value if provided, else keep the last non-empty value we had locally" — same semantics the v0.8.0 effect() carried.
getCaption() and colCaption() previously cached on first read, so tableName updates after first render returned stale captions. They now recompute each call (cost is negligible). Navigation reads allMenuItems via toSignal(getMenus(), { initialValue: [] }) instead of a manual subscribe in ngOnInit.
@angular/compiler-cli@21.2 declares typescript >=5.9 <6.1 as a peer, but downstream projects report that TS 6.0.3 requires --legacy-peer-deps on npm install because of transitive package mismatches. To keep the install path clean for all consumers, v0.8.1 pins to ~5.9.3. Bump your downstream's package.json to match:
- "typescript": "~6.0.x"
+ "typescript": "~5.9.3"rm -rf node_modules package-lock.json
npm install
ng buildExpected errors after the upgrade:
'app-*' is not a known element— selector rename, see step 1.'@Output' is deprecated— leftover from the v0.8.0 migration; finish it.peerinvalid/--legacy-peer-depsprompts onnpm install— your project still has TS 6.x. Downgrade per step 6.TS2445: Property 'xInput' is protected and only accessible within class …duringng buildfrom a downstream project, pointing at sail's own*.htmlfiles (e.g.[tableName]="..."intable_edit.html) — upgrade to sailv0.8.1patch (published after the initial v0.8.1 cut) where the aliased input fields are public. The cause: Angular's strict template type-checker resolves the alias back to the declared field, soinput('', { alias: 'tableName' })declared asprotected readonly tableNameInputis unreachable from the parent template that binds[tableName]=…. The fix in your own subclasses, if you copied the v0.8.1 pattern: drop theprotected/privatemodifier on any aliasedinput()field, leave itreadonly.
v0.8.1 targets keel v0.8.3. The shared keel API surface didn't grow in this release; the version pin tracks keel's matching v0.8.x line. See keel/README.md → Migration Guide for the matching backend changes.
Additive change. v0.8.1 consumers keep compiling.
OtpResponse (model/auth.ts) gained an optional resendCountdownSec?: number field. keel v0.8.4 populates it from --otp_ttl_seconds so the resend timer in OtpInputComponent can match the OTP code's actual server-side lifetime instead of relying on the client-side fallback (still 30s for back-compat with older keel deployments / dev mocks that don't return the field).
export interface OtpResponse {
otpToken: string;
resendCountdownSec?: number; // NEW — present when keel >= v0.8.4
}OtpInputComponent.resendCountdownSec (input, default 30) is unchanged. The recommended pattern is for the page that calls sendOtp to capture res.resendCountdownSec from the response and pass it to the input so the timer reflects server intent.
Two-step plumbing on the page that owns the OTP screen:
-
Capture from the send response. After
auth.sendOtp({...}).subscribe(...), storeres.resendCountdownSecin component state and forward it via router state when navigating to the confirm screen.this.auth.sendOtp({ contact, contactType, purpose: 'login' }).subscribe({ next: (res) => { this.router.navigate(['/login/confirm'], { state: { otpToken: res.otpToken, contact, resendCountdownSec: res.resendCountdownSec, // forward server value }, }); }, });
-
Read on the confirm screen + bind to the input. Default a local signal to 30 (the legacy fallback) so older keels and the dev OTP mock keep working, then overwrite from router state and from
onResendresponses.readonly resendCountdownSec = signal(30); constructor() { const state = this.router.getCurrentNavigation()?.extras.state; if (state && typeof state['resendCountdownSec'] === 'number') { this.resendCountdownSec.set(state['resendCountdownSec']); } } onResend() { this.auth.sendOtp({...}).subscribe({ next: (res) => { if (typeof res.resendCountdownSec === 'number') { this.resendCountdownSec.set(res.resendCountdownSec); } }, }); }
<sail-otp-input [contact]="contact()" [resendCountdownSec]="resendCountdownSec()" (codeComplete)="verifyOtp($event)" (resend)="onResend()" />
v0.8.2 targets keel v0.8.4. The new resendCountdownSec response field is documented in keel/README.md → Migration Guide (v0.8.4). Sail will tolerate older keels that don't return the field — the input falls back to its 30s default.
When you extend sail in your own app, apply the same patterns sail itself follows. Downstream projects that copied templates or scaffolding from earlier sail versions will benefit from the same sweep. This list is a checklist — none are sail-specific.
- Native control flow. Use
@if/@for/@switch/@letblocks.*ngIf,*ngFor,*ngSwitchare legacy. - Bindings, not directives, for class / style.
[class.foo]="cond",[style.width.px]="n"— don't usengClass/ngStyle. - Material 21 M3 button selectors. See step 5 above.
mat-raised-button/mat-stroked-buttonetc. work but are not the current API. - No
color="primary|accent|warn"on Material components. Use the.primary/.accent/.warnglobal CSS classes. - Template spread inside expressions (Angular 21). When you have a TS helper that only exists to merge objects for a binding, fold it into the template:
[config]="{ ...base, label: 'Override' }",[items]="[...defaults, ...extra]",[out]="fn(...args)". (Note: only valid inside array literals, object literals, or call expressions — there is no JSX-style host-attribute spread.) - No structural directive on the same element as a component selector. Already enforced by native control flow.
- Signal-based inputs/outputs.
input()/output()instead of@Input()/@Output(). Read asthis.x()/{{ x() }}. Keepinput()/output()fields publicly accessible (readonlywithoutprivate/protected). Angular's strict template type-checker resolves an{ alias: 'foo' }back to the declared field name and rejects parent bindings against a non-public field with TS2445. model()for two-way bindings. When the parent needs[(value)]binding semantics, declarevalue = model('')rather than rolling your own@Output() valueChange.inject()for dependencies. No constructor parameter injection. Field-levelinject(Token)keeps the constructor zero-arg and lets the class be subclassed without parameter forwarding.ChangeDetectionStrategy.OnPushon every component. Signal-based code is fully compatible with OnPush; without it, change detection still runs needlessly.host: { ... }metadata, not@HostBinding/@HostListener. Same effect, no decorators.- Omit
standalone: true. It's the default in Angular v20+. Setting it explicitly is noise. - Move HTML and styles to sibling files when they grow past a few lines (
templateUrl: './*.html',styleUrl: './*.scss'orstyles: '...').
signal()for component state,computed()for derived state,effect()for side effects that depend on signals. Don't roll your ownBehaviorSubjectfor local UI state.linkedSignal()for "use this input value by default, but allow local override" patterns.takeUntilDestroyed()on long-lived observables (valueChanges,route.queryParams,route.paramMap, custom intervals). One-shot HTTP requests can skip it, butvalueChangesand route streams must have it. Call it in a constructor or other injection context.toSignal()for converting anObservable<T>into a signal at component boundaries — useful forBillingService.listPlans()and similar one-shots you read in templates.- No
mutate()on signals — it was removed. Useupdate(fn)orset(newValue).
- Standalone
isActive(url, router, options?)function from@angular/router. ReturnsSignal<boolean>.Router.prototype.isActive(...)was@deprecatedin Angular 21.1. provideRouter([...])for bootstrap. NoRouterModule.forRoot.- Component input binding for route params: enable
withComponentInputBinding()and declareid = input.required<string>()on the routed component — drops theActivatedRouteinjection.
provideHttpClient(withInterceptors([...])). NoHttpClientModule.- Functional interceptors, not class-based — sail's
authInterceptorandapiResponseInterceptorare already functional; follow the same shape for your own.
- Reactive forms over template-driven. sail uses
FormGroup/FormBuilderthroughout. fb.nonNullable.group({...})for strict typing. Avoidsstring | nulleverywhere.
bootstrapApplication()with standalone components. No@NgModule, noAppModule.provideZonelessChangeDetection()— Angular 21's default. sail's components are signal-based and zoneless-safe.
NgOptimizedImagefor static<img>with known dimensions. Exception: base64 / data URIs aren't supported byNgOptimizedImage— use a plain<img>for those (the 2FA QR code inTwoFactorSetupComponentis the canonical case).
- TypeScript
~6.0.3— within Angular 21.2's>=5.9 <6.1peer range. Bump from~5.9.x. @angular/*^21.2.0for the matching set: core, common, compiler, forms, router, cdk, material.npm updateperiodically to pull patch releases within the caret range.