Skip to content

nauticana/sail

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

22 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@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_action framework (per-table custom buttons surfaced in TableList / TableSearch / TableEdit / TableDetail); see the Migrating to v0.7.0 §5 — TableAction section for the seed shape (basis table_action + authorization_object + authorization_object_action rows) and the keel/README Table Actions section for backend wiring via handler.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-validator

This 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 to baseUrl).

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-table

How it works

sail follows a metadata-driven architecture. On login, the backend returns ApplicationData containing:

  • MainMenu — menu structure with pages and permissions
  • Permissions — role-based access control entries
  • TableDefinitions — column metadata, types, validation rules, foreign keys
  • Apis — REST endpoint mappings per table
  • ConstantCache / TableCache — dropdown/lookup values

BaseAuthService.initRoutes() dynamically builds Angular routes from this metadata. Each menu item automatically gets a TableSearch or TableList route with the correct API endpoint and table metadata. Custom components can override specific menu items via menuItemRouteOverrides in the config.

List pagination

keel REST list responses are paginated:

{ "items": [...], "limit": 100, "offset": 0, "total": 12345 }

BackendService.list<T>() continues to return Observable<T[]> — sail unwraps items for you. To access the metadata, use listPaginated<T>():

this.backend.listPaginated<MyRow>('orders', { _limit: '50', _offset: '0' })
    .subscribe((page) => {
      this.rows.set(page.items);
      this.total.set(page.total);   // total rows matching the filter
    });

Default page size is 100, capped at 1000 server-side. Pass _limit / _offset in the filter map to control paging.

Configuration reference

interface SailGuiConfig {
  opField: string;                    // Operation field name (default: 'op_code')
  hiddenFields: string[];             // Fields hidden from forms (default: ['op_code', 'PartnerId'])
  appTitle?: string;                  // Shown on login pages
  googleMapsApiKey?: string;          // For RegisterComponent geocoding
  dashboardComponent?: Type<any>;     // Component for /dashboard route
  publicRoutes?: Routes;              // Routes available before login
  publicRouteLinks?: RouteLink[];     // Links shown in toolbar when logged out
  loginFooterLinks?: RouteLink[];     // Extra links below login form
  extraRoutes?: (data) => Routes;     // Dynamic routes from ApplicationData
  menuItemRouteOverrides?: {          // Map RestUri to custom component
    [restUriPattern: string]: Type<unknown>;  // Supports 'exact' and 'prefix/*'
  };

  // Social / consent / account-deletion config
  googleClientId?: string;            // Google Identity Services client ID
  appleServiceId?: string;            // Apple Services ID
  appleRedirectUri?: string;          // Apple Sign-In redirect URI
  privacyPolicyUrl?: string;          // Linked from ConsentGateComponent
  defaultPolicyVersion?: string;      // Content hash of the deployed policy
  defaultPolicyLanguage?: string;     // ISO 639-1 fallback language
  accountDeletedRoute?: string;       // Route after account deletion (default '/login/local')
}

Backend endpoints (keel 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 allowlistspriceId, successUrl, and cancelUrl must each match the server-side AllowedPriceIDs / AllowedRedirectHosts allowlists; otherwise the request is rejected with 400. Configure these on your keel deployment.

AllowedRedirectHosts matching is hostname-only by default — entries without a colon (e.g. app.example.com) tolerate any port. Add an explicit port (e.g. app.example.com:8443) only when port-strict matching is intentional.

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/sail

Or to install a specific version:

npm install @nauticana/sail@0.5.0

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.com

2. 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 build

Type 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 build

Type errors after the upgrade fall into four buckets:

  • This expression is not callable. Type 'String' has no call signatures. — a template still reads tableName / apiName as a plain field. Add (). See step 1.
  • Cannot assign to 'tableName' because it is a read-only property. — a subclass declared readonly tableName = input(''), conflicting with the inherited writable signal. Use the tableNameInput = input('', { alias: 'tableName' }) + effect() pattern from step 1 instead.
  • Property 'X' does not exist on type 'EventEmitter<T>'. — you still import EventEmitter. Replace the field with output<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-formsail-*)

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 shared inject(HttpClient) and url(path) helper. BackendService, BaseAuthService, BillingService, PayoutService, and UserPaymentMethodService all extend it. The 27 hand-spelled RestURL.httpHost + RestURL.xURL getters in BaseAuthService are now readonly fields built via this.url(...).
  • BaseTable.requireAuth(check, verb) replaces 8 copies of alert('Missing authorization to … records') across the codebase.
  • BaseView.openRecordDialog(record, isNew) collapses the duplicated addRecord / editRecord dialog-open bodies.
  • linkedSignal replaces the v0.8.0 input() + signal + effect() pattern across TableList, TableSearch, TableEdit, TableDetail, and DynamicField. 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/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"

7. Clean install + recompile

rm -rf node_modules package-lock.json
npm install
ng build

Expected 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-deps prompts on npm install — your project still has TS 6.x. Downgrade per step 6.
  • TS2445: Property 'xInput' is protected and only accessible within class … during ng build from a downstream project, pointing at sail's own *.html files (e.g. [tableName]="..." in table_edit.html) — upgrade to sail v0.8.1 patch (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, so input('', { alias: 'tableName' }) declared as protected readonly tableNameInput is unreachable from the parent template that binds [tableName]=…. The fix in your own subclasses, if you copied the v0.8.1 pattern: drop the protected / private modifier on any aliased input() field, leave it readonly.

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:

  1. Capture from the send response. After auth.sendOtp({...}).subscribe(...), store res.resendCountdownSec in 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
          },
        });
      },
    });
  2. 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 onResend responses.

    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 / @let blocks. *ngIf, *ngFor, *ngSwitch are legacy.
  • Bindings, not directives, for class / style. [class.foo]="cond", [style.width.px]="n" — don't use ngClass / ngStyle.
  • Material 21 M3 button selectors. See step 5 above. mat-raised-button / mat-stroked-button etc. work but are not the current API.
  • No color="primary|accent|warn" on Material components. Use the .primary / .accent / .warn global 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 as this.x() / {{ x() }}. Keep input() / output() fields publicly accessible (readonly without private/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, declare value = model('') rather than rolling your own @Output() valueChange.
  • inject() for dependencies. No constructor parameter injection. Field-level inject(Token) keeps the constructor zero-arg and lets the class be subclassed without parameter forwarding.
  • ChangeDetectionStrategy.OnPush on 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' or styles: '...').

State and reactivity

  • signal() for component state, computed() for derived state, effect() for side effects that depend on signals. Don't roll your own BehaviorSubject for 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, but valueChanges and route streams must have it. Call it in a constructor or other injection context.
  • toSignal() for converting an Observable<T> into a signal at component boundaries — useful for BillingService.listPlans() and similar one-shots you read in templates.
  • No mutate() on signals — it was removed. Use update(fn) or set(newValue).

Router

  • Standalone isActive(url, router, options?) function from @angular/router. Returns Signal<boolean>. Router.prototype.isActive(...) was @deprecated in Angular 21.1.
  • provideRouter([...]) for bootstrap. No RouterModule.forRoot.
  • Component input binding for route params: enable withComponentInputBinding() and declare id = input.required<string>() on the routed component — drops the ActivatedRoute injection.

HTTP

  • provideHttpClient(withInterceptors([...])). No HttpClientModule.
  • Functional interceptors, not class-based — sail's authInterceptor and apiResponseInterceptor are already functional; follow the same shape for your own.

Forms

  • Reactive forms over template-driven. sail uses FormGroup / FormBuilder throughout.
  • fb.nonNullable.group({...}) for strict typing. Avoids string | null everywhere.

Bootstrap

  • bootstrapApplication() with standalone components. No @NgModule, no AppModule.
  • provideZonelessChangeDetection() — Angular 21's default. sail's components are signal-based and zoneless-safe.

Images

  • NgOptimizedImage for static <img> with known dimensions. Exception: base64 / data URIs aren't supported by NgOptimizedImage — use a plain <img> for those (the 2FA QR code in TwoFactorSetupComponent is the canonical case).

Tooling

  • TypeScript ~6.0.3 — within Angular 21.2's >=5.9 <6.1 peer range. Bump from ~5.9.x.
  • @angular/* ^21.2.0 for the matching set: core, common, compiler, forms, router, cdk, material.
  • npm update periodically to pull patch releases within the caret range.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors