@nauticana/sail
v0.8.4
Published
Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.
Maintainers
Readme
@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. 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.
What it provides
| 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) |
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": "^0.5.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' },
],
loginFooterLinks: [
{ label: 'Sign in with Google', routerLink: '/login/google' },
],
// Map backend menu items to custom components
menuItemRouteOverrides: {
// 'external_connection': SocialConnectionComponent,
// 'analytic/*': TableReport,
},
},
},
],
});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-tableHow 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 v0.5)
| 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.
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.
Billing
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).
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> |
| listInvoices() | GET /api/billing/invoices | Observable<Invoice[]> |
| listPaymentMethods() | GET /api/billing/payment-methods | Observable<PaymentMethod[]> |
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>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>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; }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.
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/[email protected]Migrating 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> | <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.
2. Internal: TableAction template-method pull-up
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.
3. Internal: BaseAsync pulled up onto 5 more components
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).
4. Internal: BaseRestService + requireAuth() + openRecordDialog() + linkedSignal alias-inputs
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.
5. Internal: BaseTable.caption is no longer memoised; Navigation.allMenuItems via toSignal
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.
6. TypeScript pinned to ~5.9.3
@angular/[email protected] 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"7. Clean install + recompile
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.
8. Backend alignment
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.
Migrating to v0.8.2 — server-driven OTP resend cooldown
Additive change. v0.8.1 consumers keep compiling.
What changed
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.
Adoption
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()" />
Backend alignment
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.
Modernization items
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.
Templates
- 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.
Components and directives
- 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: '...').
State and reactivity
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).
Router
- 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.
HTTP
provideHttpClient(withInterceptors([...])). NoHttpClientModule.- Functional interceptors, not class-based — sail's
authInterceptorandapiResponseInterceptorare already functional; follow the same shape for your own.
Forms
- Reactive forms over template-driven. sail uses
FormGroup/FormBuilderthroughout. fb.nonNullable.group({...})for strict typing. Avoidsstring | nulleverywhere.
Bootstrap
bootstrapApplication()with standalone components. No@NgModule, noAppModule.provideZonelessChangeDetection()— Angular 21's default. sail's components are signal-based and zoneless-safe.
Images
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).
Tooling
- 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.
