@nauticana/sail 1.1.6 → 1.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,7 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
18
18
  | **Account** | `MyAccountComponent` (self-service hub), `ProfileEditorComponent` (name/locale immediate; email/phone verify-before-apply) |
19
19
  | **Auth** | `ConsentGateComponent`, `OtpInputComponent`, `SocialLoginComponent` |
20
20
  | **Billing** | `PlanSelectorComponent`, `PriceSelectorComponent`, `CheckoutButtonComponent`, `PaymentMethodsComponent`, `PortalButtonComponent`, `UsageMeterComponent`, `StatusChipComponent`, `TrialBannerComponent`, `SeatSelectorComponent`, `DunningBannerComponent` |
21
+ | **Dashboard** | `DashboardShellComponent`, `DataCardComponent`, `LockedOverlayComponent`, `ActionCenterComponent`, `EntitySelectorComponent`, `VerificationFlowComponent` |
21
22
  | **Payout** | `PayoutProviderOnboardingComponent`, `PayoutBankInfoFormComponent` |
22
23
  | **Payments / SCA** | `UserPaymentMethodsComponent`, `ScaConfirmComponent`, `ScaConfirmer` (port), `SCA_CONFIRMER` (token) |
23
24
  | **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
@@ -143,7 +144,9 @@ bootstrapApplication(App, {
143
144
  });
144
145
  ```
145
146
 
146
- > **Auth-loop breaker.** `authInterceptor` carries a built-in circuit breaker: after 5 `401`/`403`s on token-bearing keel API calls within 10s it opens for 30s and refuses those requests **locally** (never sent), so a stale/rejected session can't drive the app to hammer the API and the edge/CDN. A later success closes it; login (no bearer) is never blocked, so recovery works. No wiring needed beyond registering `authInterceptor`.
147
+ > **Auth-loop breaker.** `authInterceptor` carries a built-in circuit breaker: after 5 `401`s on token-bearing keel API calls within 10s it opens for 30s and refuses those requests **locally** (never sent), so a stale/rejected session can't drive the app to hammer the API and the edge/CDN. Only `401` counts — a `403` is a legitimate permission denial, not a session failure. A later success **or accepting a new token** (login) closes it, so a freshly authenticated session is never blocked. No wiring needed beyond registering `authInterceptor`.
148
+
149
+ > **Phone-number change (opt-in).** `ProfileEditorComponent` and `MyAccountComponent` gate phone change behind a `phoneChangeEnabled` input (default `false`, since it needs an SMS provider — keel returns 503 without `Notify`). Enable it once your backend has SMS wired: `<sail-my-account [phoneChangeEnabled]="true" />` or `<sail-profile-editor [phoneChangeEnabled]="true" />`.
147
150
 
148
151
  ### 5. Create the root component
149
152
 
@@ -505,7 +508,7 @@ readonly meters = toSignal(this.billing.listUsage(), { initialValue: [] });
505
508
 
506
509
  ### Redirect-action (v1.0.0)
507
510
 
508
- A `TableAction` with `kind: 'redirect'` makes `BaseTable.executeAction` POST and then follow the returned `{ url }` instead of refreshing — so checkout / portal / any provider-hosted handoff can be declared as a basis `table_action` row with **no bespoke component**. The backend action handler returns `{ "url": "https://…" }`.
511
+ A `TableAction` with `kind: 'R'` (redirect — the `constant_value` code) makes `BaseTable.executeAction` POST and then follow the returned `{ url }` instead of refreshing — so checkout / portal / any provider-hosted handoff can be declared as a basis `table_action` row with **no bespoke component**. The backend action handler returns `{ "url": "https://…" }`.
509
512
 
510
513
  > **Response-shape contract.** The generic redirect-action path reads a bare `url` because it's polymorphic — one dispatch for checkout / portal / export / etc., where the action's identity lives in the metadata, not the payload. keel's `WrapTableAction` enforces no shape; your inner handler must return `{ url }`. The *typed* billing endpoints keep specific names instead (`createCheckout()` → `checkoutUrl`, `createPortalSession()` → `portalUrl`). So the same portal concept has two response shapes depending on the path: wire it as `<sail-portal-button>` (typed → `portalUrl`) **or** as a `table_action` redirect (generic → `url`), not both.
511
514
 
@@ -750,6 +753,25 @@ Under the hood, the component calls `BaseAuthService.loginSocial(provider, idTok
750
753
 
751
754
  For backward compatibility, the older OAuth-code flow `BaseAuthService.loginWithGoogle(code)` (hits `/public/login/google`) is still supported; prefer `loginSocial` for new code.
752
755
 
756
+ ## Dashboard components
757
+
758
+ Horizontal building blocks for an entitlement-aware dashboard. All are presentational (signals in, events out) and ship no CSS — the app styles the documented class hooks and supplies the metric data, chart, and action logic.
759
+
760
+ - **`DashboardShellComponent`** (`sail-dashboard-shell`) — responsive card-grid layout. `[heading]` + a `[dashboardActions]` projection slot for the header; project cards into the default slot. Styles: `.dashboard-shell` / `.dashboard-header` / `.dashboard-grid`.
761
+ - **`DataCardComponent`** (`sail-data-card`) — card chrome for one visualization with `[state]` ∈ `loading | ready | empty | error | locked`. The chart is supplied as an `<ng-template>` and instantiated **only** in `ready`, so during loading/error/locked it never exists (no hidden canvas). `description` (required) is the screen-reader text alternative; `[tableHeaders]`/`[tableRows]` render a visually-hidden (`.sr-only`) data table. Emits `upgrade` / `retry`.
762
+ - **`LockedOverlayComponent`** (`sail-locked-overlay`) — entitlement gate. Wraps protected content given as an `<ng-template>`; when `[locked]` the template is never instantiated, so **no data exists behind the gate** (DOM, canvas, or network) — the strict free-tier rule. Emits `upgrade`.
763
+ - **`ActionCenterComponent`** (`sail-action-center`) — prioritized next-best-action list from `[actions]: ActionItem[]` (sorted by `priority`, done items show a check). Emits `act`.
764
+ - **`EntitySelectorComponent`** (`sail-entity-selector`) — scope dropdown over `[entities]: EntityOption[]`; resolves `[selected]` or the first active entity and emits `selectionChange`.
765
+ - **`VerificationFlowComponent`** (`sail-verification-flow`) — self-service request → enter-code → verified UI (a 2FA sibling), backend-agnostic: emits `requestVerification` / `confirmCode`, driven by `[step]` + `[errorMessage]` the host feeds back.
766
+
767
+ ```html
768
+ <sail-data-card heading="Trends" [state]="state()" description="Weekly visibility trend" (upgrade)="goUpgrade()">
769
+ <ng-template><canvas baseChart [data]="chartData()" [type]="'line'"></canvas></ng-template>
770
+ </sail-data-card>
771
+ ```
772
+
773
+ Models: `DataCardState`, `ActionItem`, `EntityOption`, `VerificationStep`.
774
+
753
775
  ## Adding a new domain table
754
776
 
755
777
  When your project owns a table that isn't part of basis (e.g. `favorite_location`, `support_ticket`, `vehicle`), follow the pattern below so the row plays nicely with keel's generic CRUD endpoints and the sail `<table-edit>` / `<table-list>` components.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.6",
3
+ "version": "1.1.8",
4
4
  "description": "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.",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -2,7 +2,7 @@
2
2
  <div class="auth-card">
3
3
  <h2 class="auth-title">My Account</h2>
4
4
 
5
- <sail-profile-editor></sail-profile-editor>
5
+ <sail-profile-editor [phoneChangeEnabled]="phoneChangeEnabled()"></sail-profile-editor>
6
6
 
7
7
  <section class="account-section">
8
8
  <h3>Security</h3>
@@ -1,4 +1,4 @@
1
- import { ChangeDetectionStrategy, Component } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, input } from '@angular/core';
2
2
  import { RouterLink } from '@angular/router';
3
3
  import { MatButtonModule } from '@angular/material/button';
4
4
  import { ProfileEditorComponent } from './profile_editor';
@@ -12,4 +12,8 @@ import { ProfileEditorComponent } from './profile_editor';
12
12
  changeDetection: ChangeDetectionStrategy.OnPush,
13
13
  imports: [RouterLink, MatButtonModule, ProfileEditorComponent],
14
14
  })
15
- export class MyAccountComponent {}
15
+ export class MyAccountComponent {
16
+ /** Opt-in: set true once the host's backend has an SMS provider, to expose
17
+ * phone-number change in the embedded profile editor. */
18
+ readonly phoneChangeEnabled = input(false);
19
+ }
@@ -45,6 +45,26 @@
45
45
 
46
46
  <section class="account-section">
47
47
  <h3>Phone</h3>
48
- <p>{{ phone() || 'Not set' }}</p>
49
- @if (!phoneChangeEnabled) { <p class="auth-instructions">Phone change is coming soon.</p> }
48
+ @if (!phoneChangeEnabled()) {
49
+ <p>{{ phone() || 'Not set' }}</p>
50
+ } @else if (phonePhase() === 'idle') {
51
+ <p>{{ phone() || 'Not set' }}</p>
52
+ <button matButton (click)="startPhoneChange()">Change phone</button>
53
+ } @else if (phonePhase() === 'enter') {
54
+ <mat-form-field appearance="outline">
55
+ <mat-label>New phone</mat-label>
56
+ <input matInput type="tel" [formControl]="newPhone" required>
57
+ </mat-form-field>
58
+ <button matButton="elevated" class="primary" (click)="sendPhoneCode()" [disabled]="newPhone.invalid || loading()">Send code</button>
59
+ <button matButton (click)="cancelPhoneChange()">Cancel</button>
60
+ } @else {
61
+ @if (phoneMsg()) { <p>{{ phoneMsg() }}</p> }
62
+ <mat-form-field appearance="outline">
63
+ <mat-label>Confirmation code</mat-label>
64
+ <input matInput [formControl]="phoneCode" inputmode="numeric" required>
65
+ </mat-form-field>
66
+ <button matButton="elevated" class="primary" (click)="confirmPhoneChange()" [disabled]="phoneCode.invalid || loading()">Confirm</button>
67
+ <button matButton (click)="cancelPhoneChange()">Cancel</button>
68
+ }
69
+ @if (phoneErr()) { <div class="auth-error" role="alert">{{ phoneErr() }}</div> }
50
70
  </section>
@@ -1,4 +1,4 @@
1
- import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, OnInit, inject, input, signal } from '@angular/core';
2
2
  import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
3
3
  import { MatButtonModule } from '@angular/material/button';
4
4
  import { MatFormFieldModule } from '@angular/material/form-field';
@@ -6,9 +6,9 @@ import { MatInputModule } from '@angular/material/input';
6
6
  import { BaseAuthService } from '../../service/auth.service';
7
7
  import { UserProfile } from '../../model/auth';
8
8
 
9
- // Self-service profile editor: name/locale save immediately; email change is
10
- // verify-before-apply (code to the new address). Phone change is gated until
11
- // the backend has an SMS provider.
9
+ // Self-service profile editor: name/locale save immediately; email and phone
10
+ // change are verify-before-apply (code to the new value). Phone change is shown
11
+ // only when the host enables phoneChangeEnabled (its backend has an SMS provider).
12
12
  @Component({
13
13
  selector: 'sail-profile-editor',
14
14
  templateUrl: './profile_editor.html',
@@ -19,8 +19,8 @@ export class ProfileEditorComponent implements OnInit {
19
19
  private readonly auth = inject(BaseAuthService);
20
20
  private readonly fb = inject(FormBuilder);
21
21
 
22
- // Flip true once the backend wires an SMS provider for phone codes.
23
- readonly phoneChangeEnabled = false;
22
+ // Set by the host once its backend wires an SMS provider for phone codes.
23
+ readonly phoneChangeEnabled = input(false);
24
24
 
25
25
  readonly loading = signal(false);
26
26
  readonly profileMsg = signal('');
@@ -40,6 +40,12 @@ export class ProfileEditorComponent implements OnInit {
40
40
  readonly newEmail = new FormControl('', [Validators.required, Validators.email]);
41
41
  readonly emailCode = new FormControl('', [Validators.required]);
42
42
 
43
+ readonly phonePhase = signal<'idle' | 'enter' | 'code'>('idle');
44
+ readonly phoneMsg = signal('');
45
+ readonly phoneErr = signal('');
46
+ readonly newPhone = new FormControl('', [Validators.required]);
47
+ readonly phoneCode = new FormControl('', [Validators.required]);
48
+
43
49
  ngOnInit(): void {
44
50
  this.auth.getProfile().subscribe({
45
51
  next: (p: UserProfile) => {
@@ -95,6 +101,38 @@ export class ProfileEditorComponent implements OnInit {
95
101
  });
96
102
  }
97
103
 
104
+ startPhoneChange(): void {
105
+ this.phonePhase.set('enter');
106
+ this.phoneMsg.set('');
107
+ this.phoneErr.set('');
108
+ }
109
+
110
+ cancelPhoneChange(): void {
111
+ this.phonePhase.set('idle');
112
+ this.newPhone.reset();
113
+ this.phoneCode.reset();
114
+ }
115
+
116
+ sendPhoneCode(): void {
117
+ if (this.newPhone.invalid) return;
118
+ this.loading.set(true);
119
+ this.phoneErr.set('');
120
+ this.auth.requestPhoneChange(this.newPhone.value!).subscribe({
121
+ next: () => { this.loading.set(false); this.phonePhase.set('code'); this.phoneMsg.set('We sent a code to your new number.'); },
122
+ error: (e) => { this.loading.set(false); this.phoneErr.set(this.msg(e, 'Could not send a code.')); },
123
+ });
124
+ }
125
+
126
+ confirmPhoneChange(): void {
127
+ if (this.phoneCode.invalid) return;
128
+ this.loading.set(true);
129
+ this.phoneErr.set('');
130
+ this.auth.confirmPhoneChange(this.newPhone.value!, Number(this.phoneCode.value)).subscribe({
131
+ next: () => { this.loading.set(false); this.phone.set(this.newPhone.value!); this.cancelPhoneChange(); this.profileMsg.set('Phone updated.'); },
132
+ error: (e) => { this.loading.set(false); this.phoneErr.set(this.msg(e, 'Invalid or expired code.')); },
133
+ });
134
+ }
135
+
98
136
  private msg(e: { error?: { detail?: string; message?: string } }, fallback: string): string {
99
137
  return e?.error?.detail || e?.error?.message || fallback;
100
138
  }
@@ -0,0 +1,24 @@
1
+ <section class="action-center">
2
+ @if (heading()) { <h2 class="action-center-title">{{ heading() }}</h2> }
3
+ @if (sorted().length === 0) {
4
+ <p class="action-center-empty">{{ emptyMessage() }}</p>
5
+ } @else {
6
+ <ul class="action-center-list">
7
+ @for (a of sorted(); track a.id) {
8
+ <li class="action-item" [class.action-done]="a.done" [attr.data-priority]="a.priority ?? 0">
9
+ <div class="action-body">
10
+ <span class="action-title">{{ a.title }}</span>
11
+ <span class="action-reason">{{ a.reason }}</span>
12
+ </div>
13
+ @if (a.done) {
14
+ <span class="action-complete">
15
+ <mat-icon aria-hidden="true">check_circle</mat-icon><span class="sr-only">Done</span>
16
+ </span>
17
+ } @else {
18
+ <button matButton="elevated" type="button" class="primary" (click)="act.emit(a)">{{ a.ctaLabel }}</button>
19
+ }
20
+ </li>
21
+ }
22
+ </ul>
23
+ }
24
+ </section>
@@ -0,0 +1,27 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input, output } from '@angular/core';
2
+ import { MatButtonModule } from '@angular/material/button';
3
+ import { MatIconModule } from '@angular/material/icon';
4
+ import { ActionItem } from '../../model/dashboard';
5
+
6
+ /**
7
+ * Prioritized next-best-action list (guided completion, FR-008). Renders each
8
+ * action with its reason and a single CTA, sorted by descending priority;
9
+ * completed items show a done state instead of a button. Pure presentational —
10
+ * the host performs the action on `act`.
11
+ */
12
+ @Component({
13
+ selector: 'sail-action-center',
14
+ templateUrl: './action_center.html',
15
+ changeDetection: ChangeDetectionStrategy.OnPush,
16
+ encapsulation: ViewEncapsulation.None,
17
+ imports: [MatButtonModule, MatIconModule],
18
+ })
19
+ export class ActionCenterComponent {
20
+ readonly actions = input<ActionItem[]>([]);
21
+ readonly heading = input<string>('Recommended actions');
22
+ readonly emptyMessage = input<string>("You're all caught up.");
23
+ readonly act = output<ActionItem>();
24
+
25
+ readonly sorted = computed<ActionItem[]>(() =>
26
+ [...this.actions()].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)));
27
+ }
@@ -0,0 +1,13 @@
1
+ <section class="dashboard-shell">
2
+ @if (heading()) {
3
+ <header class="dashboard-header">
4
+ <h1 class="dashboard-title">{{ heading() }}</h1>
5
+ <div class="dashboard-actions">
6
+ <ng-content select="[dashboardActions]"></ng-content>
7
+ </div>
8
+ </header>
9
+ }
10
+ <div class="dashboard-grid">
11
+ <ng-content></ng-content>
12
+ </div>
13
+ </section>
@@ -0,0 +1,17 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, input } from '@angular/core';
2
+
3
+ /**
4
+ * Responsive card-grid dashboard shell. Pure layout: an optional heading row with
5
+ * a projected actions slot ([dashboardActions]), then a grid of projected cards.
6
+ * Ships no CSS — the app styles `.dashboard-shell` / `.dashboard-header` /
7
+ * `.dashboard-grid`.
8
+ */
9
+ @Component({
10
+ selector: 'sail-dashboard-shell',
11
+ templateUrl: './dashboard_shell.html',
12
+ changeDetection: ChangeDetectionStrategy.OnPush,
13
+ encapsulation: ViewEncapsulation.None,
14
+ })
15
+ export class DashboardShellComponent {
16
+ readonly heading = input<string>('');
17
+ }
@@ -0,0 +1,37 @@
1
+ <section class="data-card" [attr.data-state]="state()" [attr.aria-busy]="state() === 'loading'">
2
+ @if (heading()) { <h2 class="data-card-title">{{ heading() }}</h2> }
3
+
4
+ @switch (state()) {
5
+ @case ('loading') {
6
+ <div class="data-card-loading"><mat-spinner diameter="28"></mat-spinner></div>
7
+ }
8
+ @case ('ready') {
9
+ <p class="sr-only">{{ description() }}</p>
10
+ @if (chart(); as tpl) { <ng-container [ngTemplateOutlet]="tpl"></ng-container> }
11
+ @if (tableRows().length) {
12
+ <table class="sr-only data-card-table">
13
+ <caption>{{ description() }}</caption>
14
+ @if (tableHeaders().length) {
15
+ <thead><tr>@for (h of tableHeaders(); track $index) { <th>{{ h }}</th> }</tr></thead>
16
+ }
17
+ <tbody>
18
+ @for (row of tableRows(); track $index) {
19
+ <tr>@for (cell of row; track $index) { <td>{{ cell }}</td> }</tr>
20
+ }
21
+ </tbody>
22
+ </table>
23
+ }
24
+ }
25
+ @case ('empty') { <p class="data-card-empty">{{ emptyMessage() }}</p> }
26
+ @case ('error') {
27
+ <p class="data-card-error" role="alert">{{ errorMessage() }}</p>
28
+ <button matButton type="button" (click)="retry.emit()">Retry</button>
29
+ }
30
+ @case ('locked') {
31
+ <div class="data-card-locked" role="group" [attr.aria-label]="(heading() || 'Insight') + ' — locked'">
32
+ <p class="locked-message">{{ lockedMessage() }}</p>
33
+ <button matButton="elevated" type="button" class="primary" (click)="upgrade.emit()">{{ ctaLabel() }}</button>
34
+ </div>
35
+ }
36
+ }
37
+ </section>
@@ -0,0 +1,41 @@
1
+ import { ChangeDetectionStrategy, Component, TemplateRef, ViewEncapsulation, contentChild, input, output } from '@angular/core';
2
+ import { NgTemplateOutlet } from '@angular/common';
3
+ import { MatButtonModule } from '@angular/material/button';
4
+ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
5
+ import { DataCardState } from '../../model/dashboard';
6
+
7
+ /**
8
+ * Card chrome for a single data visualization. The app supplies the chart itself
9
+ * as an <ng-template> (bring your own chart library — keel apps use ng2-charts);
10
+ * the card instantiates it ONLY in the 'ready' state, so during
11
+ * loading/empty/error/locked the chart never exists (no hidden canvas, no data
12
+ * leak when locked).
13
+ *
14
+ * Accessibility: `description` is a required text alternative announced in place
15
+ * of the visual chart; `tableHeaders`/`tableRows`, when supplied, render a
16
+ * visually-hidden data table (`.sr-only`, app-styled) as the non-visual equivalent.
17
+ */
18
+ @Component({
19
+ selector: 'sail-data-card',
20
+ templateUrl: './data_card.html',
21
+ changeDetection: ChangeDetectionStrategy.OnPush,
22
+ encapsulation: ViewEncapsulation.None,
23
+ imports: [NgTemplateOutlet, MatButtonModule, MatProgressSpinnerModule],
24
+ })
25
+ export class DataCardComponent {
26
+ readonly heading = input<string>('');
27
+ readonly state = input<DataCardState>('loading');
28
+ /** Required text alternative for screen readers — the non-visual equivalent. */
29
+ readonly description = input.required<string>();
30
+ readonly errorMessage = input<string>('Could not load this data.');
31
+ readonly emptyMessage = input<string>('No data yet.');
32
+ readonly lockedMessage = input<string>('Upgrade to unlock this insight.');
33
+ readonly ctaLabel = input<string>('Upgrade');
34
+ readonly tableHeaders = input<string[]>([]);
35
+ readonly tableRows = input<(string | number)[][]>([]);
36
+ readonly upgrade = output<void>();
37
+ readonly retry = output<void>();
38
+
39
+ /** The chart, provided as a single <ng-template>; instantiated only when ready. */
40
+ readonly chart = contentChild(TemplateRef);
41
+ }
@@ -0,0 +1,8 @@
1
+ <mat-form-field appearance="outline" class="entity-selector">
2
+ <mat-label>{{ label() }}</mat-label>
3
+ <mat-select [value]="value()" (selectionChange)="onChange($event.value)">
4
+ @for (e of entities(); track e.id) {
5
+ <mat-option [value]="e.id" [disabled]="e.active === false">{{ e.label }}</mat-option>
6
+ }
7
+ </mat-select>
8
+ </mat-form-field>
@@ -0,0 +1,50 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, effect, input, output, signal } from '@angular/core';
2
+ import { MatFormFieldModule } from '@angular/material/form-field';
3
+ import { MatSelectModule } from '@angular/material/select';
4
+ import { EntityOption } from '../../model/dashboard';
5
+
6
+ /**
7
+ * Scope selector for the active entity (e.g. business). Resolves the selection
8
+ * from `selected` when valid, else the first active option, and emits it — so the
9
+ * host's dependent data loads for a sensible default without extra wiring.
10
+ */
11
+ @Component({
12
+ selector: 'sail-entity-selector',
13
+ templateUrl: './entity_selector.html',
14
+ changeDetection: ChangeDetectionStrategy.OnPush,
15
+ encapsulation: ViewEncapsulation.None,
16
+ imports: [MatFormFieldModule, MatSelectModule],
17
+ })
18
+ export class EntitySelectorComponent {
19
+ readonly entities = input<EntityOption[]>([]);
20
+ readonly selected = input<string | undefined>(undefined);
21
+ readonly label = input<string>('Business');
22
+ readonly selectionChange = output<string | undefined>();
23
+
24
+ readonly value = signal<string | undefined>(undefined);
25
+
26
+ constructor() {
27
+ // Explicit selection wins; else keep the current pick if still valid; else the
28
+ // first active entity. "Valid" means present AND active, so a scope that goes
29
+ // inactive (or vanishes) is dropped. Emit on every change, including a clear
30
+ // (undefined) when no active option remains, so the host stops showing stale data.
31
+ effect(() => {
32
+ const opts = this.entities();
33
+ const isActive = (id?: string) => !!id && opts.some((e) => e.id === id && e.active !== false);
34
+ const cur = this.value();
35
+ const resolved =
36
+ isActive(this.selected()) ? this.selected()
37
+ : isActive(cur) ? cur
38
+ : opts.find((e) => e.active !== false)?.id;
39
+ if (resolved !== cur) {
40
+ this.value.set(resolved);
41
+ this.selectionChange.emit(resolved);
42
+ }
43
+ });
44
+ }
45
+
46
+ onChange(id: string): void {
47
+ this.value.set(id);
48
+ this.selectionChange.emit(id);
49
+ }
50
+ }
@@ -0,0 +1,8 @@
1
+ @if (locked()) {
2
+ <div class="locked-overlay" role="group" [attr.aria-label]="(feature() || 'Feature') + ' — locked'">
3
+ <p class="locked-message">{{ message() }}</p>
4
+ <button matButton="elevated" type="button" class="primary" (click)="upgrade.emit()">{{ ctaLabel() }}</button>
5
+ </div>
6
+ } @else if (content(); as tpl) {
7
+ <ng-container [ngTemplateOutlet]="tpl"></ng-container>
8
+ }
@@ -0,0 +1,32 @@
1
+ import { ChangeDetectionStrategy, Component, TemplateRef, ViewEncapsulation, contentChild, input, output } from '@angular/core';
2
+ import { NgTemplateOutlet } from '@angular/common';
3
+ import { MatButtonModule } from '@angular/material/button';
4
+
5
+ /**
6
+ * Entitlement gate that renders an upgrade prompt over a feature. The protected
7
+ * content is supplied as an <ng-template> and is instantiated ONLY when unlocked —
8
+ * so when locked there is nothing behind it: no DOM nodes, no chart canvas, no
9
+ * data binding, no triggered network call. This is the strict free-tier rule
10
+ * (DASHBOARD.md): a locked card provably exposes no data.
11
+ *
12
+ * <sail-locked-overlay [locked]="!entitled()" feature="Trends" (upgrade)="goUpgrade()">
13
+ * <ng-template><canvas baseChart ...></canvas></ng-template>
14
+ * </sail-locked-overlay>
15
+ */
16
+ @Component({
17
+ selector: 'sail-locked-overlay',
18
+ templateUrl: './locked_overlay.html',
19
+ changeDetection: ChangeDetectionStrategy.OnPush,
20
+ encapsulation: ViewEncapsulation.None,
21
+ imports: [NgTemplateOutlet, MatButtonModule],
22
+ })
23
+ export class LockedOverlayComponent {
24
+ readonly locked = input.required<boolean>();
25
+ readonly feature = input<string>('');
26
+ readonly message = input<string>('Upgrade to unlock this feature.');
27
+ readonly ctaLabel = input<string>('Upgrade');
28
+ readonly upgrade = output<void>();
29
+
30
+ /** The protected content, provided as a single <ng-template> by the consumer. */
31
+ readonly content = contentChild(TemplateRef);
32
+ }
@@ -0,0 +1,32 @@
1
+ <div class="verification-flow" [attr.data-step]="step()">
2
+ @switch (step()) {
3
+ @case ('verified') {
4
+ <p class="verification-success" role="status">Verified.</p>
5
+ }
6
+ @case ('idle') {
7
+ <p class="verification-intro">
8
+ Verify ownership@if (target()) { of {{ target() }} } to unlock trusted features.
9
+ </p>
10
+ <button matButton="elevated" type="button" class="primary" (click)="requestVerification.emit()">
11
+ Start verification
12
+ </button>
13
+ }
14
+ @default {
15
+ <p class="verification-intro">Enter the code we sent@if (target()) { to {{ target() }} }.</p>
16
+ <mat-form-field appearance="outline">
17
+ <mat-label>Verification code</mat-label>
18
+ <input matInput #codeInput name="code" autocomplete="one-time-code"
19
+ [value]="code()" (input)="code.set(codeInput.value)"
20
+ [disabled]="step() === 'verifying'">
21
+ </mat-form-field>
22
+ @if (errorMessage()) { <p class="verification-error" role="alert">{{ errorMessage() }}</p> }
23
+ <div class="verification-actions">
24
+ <button matButton="elevated" type="button" class="primary"
25
+ [disabled]="step() === 'verifying' || !code().trim()" (click)="submit()">
26
+ @if (step() === 'verifying') { Verifying… } @else { Confirm }
27
+ </button>
28
+ <button matButton type="button" (click)="requestVerification.emit()">Resend code</button>
29
+ </div>
30
+ }
31
+ }
32
+ </div>
@@ -0,0 +1,36 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, input, output, signal } from '@angular/core';
2
+ import { MatButtonModule } from '@angular/material/button';
3
+ import { MatFormFieldModule } from '@angular/material/form-field';
4
+ import { MatInputModule } from '@angular/material/input';
5
+ import { VerificationStep } from '../../model/dashboard';
6
+
7
+ /**
8
+ * Self-service verification flow — a sibling to the 2FA verify component, but
9
+ * backend-agnostic: it owns the request → enter-code → confirmed UI and emits
10
+ * `requestVerification` / `confirmCode`, while the host wires the actual endpoints
11
+ * and feeds `step` + `errorMessage` back (daxoom wires business verify/confirm).
12
+ */
13
+ @Component({
14
+ selector: 'sail-verification-flow',
15
+ templateUrl: './verification_flow.html',
16
+ changeDetection: ChangeDetectionStrategy.OnPush,
17
+ encapsulation: ViewEncapsulation.None,
18
+ imports: [MatButtonModule, MatFormFieldModule, MatInputModule],
19
+ })
20
+ export class VerificationFlowComponent {
21
+ readonly step = input<VerificationStep>('idle');
22
+ readonly errorMessage = input<string>('');
23
+ /** The email/phone/identifier being verified, shown in the copy. */
24
+ readonly target = input<string>('');
25
+ readonly requestVerification = output<void>();
26
+ readonly confirmCode = output<string>();
27
+
28
+ readonly code = signal('');
29
+
30
+ submit(): void {
31
+ const c = this.code().trim();
32
+ if (c) {
33
+ this.confirmCode.emit(c);
34
+ }
35
+ }
36
+ }
@@ -9,6 +9,9 @@
9
9
  @if (loggedOut) {
10
10
  <p class="auth-message" role="status" aria-live="polite">You have been logged out successfully.</p>
11
11
  }
12
+ @if (sessionHandoffError()) {
13
+ <p class="auth-error" role="alert">{{ sessionHandoffError() }}</p>
14
+ }
12
15
  <form [formGroup]="loginForm" (ngSubmit)="login()" class="auth-form">
13
16
  <mat-form-field appearance="outline">
14
17
  <mat-label>Username</mat-label>
@@ -1,7 +1,7 @@
1
1
  import { ChangeDetectionStrategy, Component, computed, inject, ViewEncapsulation } from '@angular/core';
2
2
  import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
3
3
  import { BaseAuthService } from '../../service/auth.service';
4
- import { ActivatedRoute, Router, RouterLink } from '@angular/router';
4
+ import { ActivatedRoute, RouterLink } from '@angular/router';
5
5
  import { MatButtonModule } from '@angular/material/button';
6
6
  import { MatFormFieldModule } from "@angular/material/form-field";
7
7
  import { MatInputModule } from '@angular/material/input';
@@ -25,10 +25,11 @@ import { SocialLoginComponent } from './social_login';
25
25
  })
26
26
  export class LoginComponent {
27
27
  private auth = inject(BaseAuthService);
28
- private router = inject(Router);
29
28
  protected fb = inject(FormBuilder);
30
29
  protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
31
30
  protected readonly loggedOut = inject(ActivatedRoute).snapshot.queryParamMap.get('loggedOut') === 'true';
31
+ /** OAuth session-cookie handoff failure from BaseAuthService, shown below. */
32
+ protected readonly sessionHandoffError = this.auth.sessionHandoffError;
32
33
 
33
34
  /**
34
35
  * Auto-show the embedded SocialLoginComponent whenever at least one
@@ -58,14 +59,12 @@ export class LoginComponent {
58
59
  }
59
60
 
60
61
  /**
61
- * SocialLoginComponent emits the response after BaseAuthService.loginSocial
62
- * persists the JWT internally. Mirror the same post-login navigation that
63
- * password-login uses (root route by default; consumers override via
64
- * BaseAuthService.navigateToRoleHome when role-aware routing lands).
62
+ * loginSocial already ran completeLogin, which owns post-login navigation
63
+ * (route init, or the OAuth cookie handoff). Handlers must NOT navigate here —
64
+ * doing so abandons a pending handoff before its cookie is set. Downstream
65
+ * social-login handlers should follow the same rule.
65
66
  */
66
- protected onSocialSuccess(_: LoginResponseSocial): void {
67
- this.router.navigateByUrl('/');
68
- }
67
+ protected onSocialSuccess(_: LoginResponseSocial): void {}
69
68
 
70
69
  protected onSocialError(err: Error): void {
71
70
  console.error('social login failed:', err);
package/src/index.ts CHANGED
@@ -2,8 +2,15 @@ export * from './config';
2
2
  export * from './model/appdata';
3
3
  export * from './model/auth';
4
4
  export * from './model/common';
5
+ export * from './model/dashboard';
5
6
  export * from './model/decorator';
6
7
  export * from './component/abstract/base_table';
8
+ export * from './component/dashboard/dashboard_shell';
9
+ export * from './component/dashboard/data_card';
10
+ export * from './component/dashboard/locked_overlay';
11
+ export * from './component/dashboard/action_center';
12
+ export * from './component/dashboard/entity_selector';
13
+ export * from './component/dashboard/verification_flow';
7
14
  export * from './component/abstract/base_form';
8
15
  export * from './component/abstract/base_view';
9
16
  export * from './component/abstract/base_async';
@@ -83,7 +83,7 @@ export interface TableAction {
83
83
  confirmMessage?: string;
84
84
  authorityObject: string; // uppercased table_name — for canExecute()
85
85
  authorityCheck: string; // uppercased action_name — for canExecute()
86
- kind?: 'P' | 'R' | 'V'; // P post (default) / R redirect (follow {url}) / V reveal (show returned secret once)
86
+ kind?: 'P' | 'R' | 'V'; // constant_value codes: P post (default) / R redirect (follow {url}) / V reveal (show returned secret once)
87
87
  }
88
88
 
89
89
  export interface ForeignKey {
@@ -0,0 +1,24 @@
1
+ /** Lifecycle state of a data/chart card (sail-data-card). */
2
+ export type DataCardState = 'loading' | 'ready' | 'empty' | 'error' | 'locked';
3
+
4
+ /** A prioritized, completable next-best-action (sail-action-center, FR-008). */
5
+ export interface ActionItem {
6
+ id: string;
7
+ title: string;
8
+ reason: string;
9
+ ctaLabel: string;
10
+ /** Higher sorts first; defaults to 0. */
11
+ priority?: number;
12
+ done?: boolean;
13
+ }
14
+
15
+ /** One selectable scope entity (e.g. a business) for sail-entity-selector. */
16
+ export interface EntityOption {
17
+ id: string;
18
+ label: string;
19
+ /** Defaults to true; the selector defaults to the first active entity. */
20
+ active?: boolean;
21
+ }
22
+
23
+ /** Step of the self-service verification flow (sail-verification-flow). */
24
+ export type VerificationStep = 'idle' | 'requested' | 'verifying' | 'verified' | 'error';
@@ -2,12 +2,11 @@ import { HttpErrorResponse, HttpEventType, HttpInterceptorFn } from '@angular/co
2
2
  import { catchError, tap, throwError } from 'rxjs';
3
3
  import { isKeelApiUrl } from './rest_url';
4
4
 
5
- // Auth-loop breaker: repeated 401/403s on token-bearing API calls (a stale or
6
- // rejected session) can drive the app to hammer the API — and the edge/CDN in
7
- // front of it — indefinitely. After AUTH_FAIL_THRESHOLD failures within
8
- // AUTH_FAIL_WINDOW_MS the circuit opens for AUTH_CIRCUIT_COOLDOWN_MS: authed API
9
- // requests are refused locally (never sent) so the storm stops at the browser. A
10
- // later success closes it; login (no bearer) is never blocked, so recovery works.
5
+ // Auth-loop breaker: repeated 401s on token-bearing API calls (a stale session) can
6
+ // drive the app to hammer the API/CDN. After AUTH_FAIL_THRESHOLD within
7
+ // AUTH_FAIL_WINDOW_MS the circuit opens for AUTH_CIRCUIT_COOLDOWN_MS — authed
8
+ // requests are refused locally. Only 401 counts (403 is a permission denial); a
9
+ // success or accepting a new token closes it.
11
10
  const AUTH_FAIL_WINDOW_MS = 10_000;
12
11
  const AUTH_FAIL_THRESHOLD = 5;
13
12
  const AUTH_CIRCUIT_COOLDOWN_MS = 30_000;
@@ -36,7 +35,7 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
36
35
  }
37
36
  }),
38
37
  catchError((err: HttpErrorResponse) => {
39
- if (authedApi && (err.status === 401 || err.status === 403)) {
38
+ if (authedApi && err.status === 401) {
40
39
  recordAuthFailure();
41
40
  }
42
41
  return throwError(() => err);
@@ -70,3 +69,9 @@ function clearAuthFailures(): void {
70
69
  sessionStorage.removeItem(FAIL_KEY);
71
70
  sessionStorage.removeItem(OPEN_KEY);
72
71
  }
72
+
73
+ /** Close the circuit on accepting a new token, so a freshly logged-in session
74
+ * isn't blocked by failures from a prior stale session. */
75
+ export function resetAuthCircuit(): void {
76
+ clearAuthFailures();
77
+ }
@@ -16,6 +16,7 @@ import { ApplicationMenu, ConstantValue } from '../model/common';
16
16
  import { CanActivateFn, CanDeactivateFn, Router, Routes } from '@angular/router';
17
17
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../config';
18
18
  import { BaseRestService } from './base_rest.service';
19
+ import { resetAuthCircuit } from './auth.interceptor';
19
20
 
20
21
  /** Sort dropdown options by their visible label so long lookup lists are scannable. */
21
22
  const byCaption = (a: ConstantValue, b: ConstantValue) =>
@@ -61,6 +62,10 @@ export abstract class BaseAuthService extends BaseRestService {
61
62
  token: string | null = null;
62
63
  readonly isLoggedIn = signal(false);
63
64
 
65
+ /** Set when the OAuth session-cookie handoff fails; the UI shows it and lets the
66
+ * user retry instead of being redirected into a cookieless login loop. */
67
+ readonly sessionHandoffError = signal<string | null>(null);
68
+
64
69
  /**
65
70
  * Multi-role app support. Consumers that distinguish rider/driver/admin
66
71
  * (or any equivalent split) call setRole() after login, then
@@ -117,15 +122,22 @@ export abstract class BaseAuthService extends BaseRestService {
117
122
  this.token = token;
118
123
  this.isLoggedIn.set(true);
119
124
  localStorage.setItem('jwt', token);
125
+ resetAuthCircuit(); // a new session must not inherit a prior session's open circuit
120
126
  const ret = this.validatedReturnUrl();
121
127
  if (ret && this.guiConfig.sessionCookieUrl) {
122
128
  // OAuth flow: mirror the bearer into a cookie, then hand off to the
123
- // (cross-origin) authorization server via a full navigation.
129
+ // (cross-origin) authorization server via a full navigation. Only hand off
130
+ // once the cookie is actually set — redirecting after a failed cookie request
131
+ // sends the user on without a session and loops them back to login.
132
+ this.sessionHandoffError.set(null);
124
133
  this.http.post(this.url(this.guiConfig.sessionCookieUrl), {}, { withCredentials: true })
125
134
  .pipe(take(1))
126
135
  .subscribe({
127
136
  next: () => window.location.assign(ret),
128
- error: () => window.location.assign(ret),
137
+ error: (err) => {
138
+ console.error('Session cookie handoff failed:', err);
139
+ this.sessionHandoffError.set('Could not complete sign-in. Please try again.');
140
+ },
129
141
  });
130
142
  return;
131
143
  }