@nauticana/sail 1.1.44 → 1.1.45

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.
Files changed (51) hide show
  1. package/README.md +31 -7
  2. package/package.json +1 -1
  3. package/src/agent/component/studio_page.ts +35 -20
  4. package/src/component/abstract/base_form.ts +5 -0
  5. package/src/component/abstract/base_table.ts +23 -29
  6. package/src/component/abstract/base_view.ts +26 -17
  7. package/src/component/account/profile_editor.html +15 -8
  8. package/src/component/account/profile_editor.ts +12 -7
  9. package/src/component/agency/agency_clients.html +4 -1
  10. package/src/component/agency/agency_clients.ts +6 -3
  11. package/src/component/dialog/confirm_dialog.ts +29 -0
  12. package/src/component/form/field_error.ts +26 -0
  13. package/src/component/form/form_field.html +5 -10
  14. package/src/component/form/form_field.ts +10 -0
  15. package/src/component/login/chpass_component.html +4 -1
  16. package/src/component/login/chpass_component.ts +6 -3
  17. package/src/component/login/confirm_chpass_component.html +4 -1
  18. package/src/component/login/confirm_chpass_component.ts +6 -3
  19. package/src/component/login/confirm_register_component.html +3 -1
  20. package/src/component/login/confirm_register_component.ts +6 -3
  21. package/src/component/login/login_component.html +3 -1
  22. package/src/component/login/login_component.ts +6 -3
  23. package/src/component/login/register_component.html +14 -1
  24. package/src/component/login/register_component.ts +6 -3
  25. package/src/component/navigation/navigation.html +4 -1
  26. package/src/component/navigation/navigation.ts +24 -5
  27. package/src/component/oauth_connection/oauth_connection_panel.html +4 -0
  28. package/src/component/oauth_connection/oauth_connection_panel.ts +9 -5
  29. package/src/component/payout/bank_info_form.html +6 -2
  30. package/src/component/payout/bank_info_form.ts +6 -3
  31. package/src/component/security/api_keys.html +6 -2
  32. package/src/component/security/api_keys.ts +13 -7
  33. package/src/component/security/connected_apps.ts +7 -5
  34. package/src/component/security/sessions.ts +11 -9
  35. package/src/component/security/trusted_devices.html +2 -1
  36. package/src/component/security/trusted_devices.ts +18 -13
  37. package/src/component/security/twofactor_setup.html +7 -3
  38. package/src/component/security/twofactor_setup.ts +8 -5
  39. package/src/component/security/twofactor_verify.html +2 -1
  40. package/src/component/security/twofactor_verify.ts +6 -3
  41. package/src/component/table/table_detail.ts +1 -1
  42. package/src/component/table/table_edit.ts +27 -5
  43. package/src/component/table/table_list.html +21 -5
  44. package/src/component/table/table_list.ts +74 -67
  45. package/src/component/table/table_search.ts +2 -2
  46. package/src/index.ts +4 -0
  47. package/src/model/appdata.ts +3 -0
  48. package/src/service/auth.service.ts +19 -6
  49. package/src/service/billing.service.ts +8 -6
  50. package/src/service/prompt.service.ts +27 -0
  51. package/src/util/forms.ts +12 -0
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  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](https://github.com/nauticana/keel) Go backend.
4
4
 
5
- > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.43 adopts keel v1.2.125 (plan-change overages), sail v1.1.42 adopts keel v1.2.118 (RFC 3339 timestamps, `X-Time-Zone`), sail v1.1.41 adopts keel v1.2.117 (scheduled plan changes), sail v1.1.39 and v1.1.40 adopt keel v1.2.115 (usage captions, provider-managed subscriptions, the connect disconnect route), sail v1.1.37 adopts keel v1.2.114 (scopes validated against a constant list), sail v1.1.36 adopts keel v1.2.113 (API key expiry, caller-aware scope policy), sail v1.1.35 adopts keel v1.2.112 (minor-unit money, the `currency` catalog, OAuth grant routes), sail v1.1.33 adopts keel v1.2.106 (`prompt` on the login page, new-device step-up, sign-in sessions), sail v1.1.32 adopts keel v1.2.101 (the OAuth connect callback ticket), sail v1.1.31 adopts keel v1.2.100 (tenant single sign-on, DNS and HTTP-file domain verification), sail v1.1.30 adopts keel v1.2.99 (password routes; a password change signs out), sail v1.1.29 adopts keel v1.2.94 (signup: confirmation signs in, partner setup, sign-in hand-off code), sail v1.1.28 adopts keel v1.2.93 (absolute `return` for the OAuth hand-off), sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
5
+ > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.45 adopts keel v1.2.126 (a plan change takes the offer), sail v1.1.43 adopts keel v1.2.125 (plan-change overages), sail v1.1.42 adopts keel v1.2.118 (RFC 3339 timestamps, `X-Time-Zone`), sail v1.1.41 adopts keel v1.2.117 (scheduled plan changes), sail v1.1.39 and v1.1.40 adopt keel v1.2.115 (usage captions, provider-managed subscriptions, the connect disconnect route), sail v1.1.37 adopts keel v1.2.114 (scopes validated against a constant list), sail v1.1.36 adopts keel v1.2.113 (API key expiry, caller-aware scope policy), sail v1.1.35 adopts keel v1.2.112 (minor-unit money, the `currency` catalog, OAuth grant routes), sail v1.1.33 adopts keel v1.2.106 (`prompt` on the login page, new-device step-up, sign-in sessions), sail v1.1.32 adopts keel v1.2.101 (the OAuth connect callback ticket), sail v1.1.31 adopts keel v1.2.100 (tenant single sign-on, DNS and HTTP-file domain verification), sail v1.1.30 adopts keel v1.2.99 (password routes; a password change signs out), sail v1.1.29 adopts keel v1.2.94 (signup: confirmation signs in, partner setup, sign-in hand-off code), sail v1.1.28 adopts keel v1.2.93 (absolute `return` for the OAuth hand-off), sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
6
6
 
7
7
  **Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows. `passwordPolicyValidator` + `BaseAuthService.ensurePasswordPolicy()` — validate passwords against keel's policy (via `SailGuiConfig.passwordPolicyUrl`); see Configuration reference.
8
8
 
@@ -209,11 +209,14 @@ Key CSS classes used by components:
209
209
  .geocode-success, .geocode-error
210
210
 
211
211
  /* Tables */
212
- .edit-container, .actions-bar, .tab-content, .empty-state,
212
+ .edit-container, .actions-bar, .tab-content, .empty-state, .empty-state__filters,
213
213
  .detail-actions, .search-actions, .select-btn, .form-container
214
214
 
215
- /* Navigation */
216
- .sidenav-container, .sidenav, .toolbar-spacer
215
+ /* Navigation: hide .sail-skip-link until focused; .sail-main takes focus after navigation */
216
+ .sidenav-container, .sidenav, .toolbar-spacer, .sail-skip-link, .sail-main
217
+
218
+ /* Fields */
219
+ .field-editable, .field-readonly
217
220
 
218
221
  /* State classes */
219
222
  .deleted-record, .updated-record, .new-record
@@ -264,6 +267,16 @@ this.backend.listPaginated<MyRow>('orders', { Status: 'open' }, {
264
267
 
265
268
  Default page size is 100, capped at 1000 server-side. Sail maps `limit`, `offset`, and `orderBy` to keel's `_limit`, `_offset`, and `_order` query controls. Existing callers that put those string controls in the filter map remain compatible; explicit `PageRequest` values take precedence.
266
269
 
270
+ ## Generic screens: lists, edits, confirmations
271
+
272
+ - **List URL state.** `TableList` reads `_order` (`Column` or `Column DESC`, a visible column), `_limit` (one of the page sizes) and `_offset` as controls, never as filters, ignores invalid values, and writes them back on sort and page changes; sorting is server-side across all pages and resets the offset. Every other query parameter is a filter.
273
+ - **Loading and empty states.** While a request runs the list shows a `mat-progress-bar` and sets `aria-busy`; only the latest request may replace rows. With filters active the empty state reads "No {caption} match your search", lists the filters and offers Clear search (keeps sort and page size); otherwise it shows `[emptyMessage]` or "No {caption} yet".
274
+ - **Unsaved changes.** `BaseForm.hasUnsavedChanges()` is the dirty-state contract; `TableEdit` reports changed scalars and child rows marked insert, update or delete. Cancel and the dialog close button confirm before discarding. `TableList` delegates the contract to its open dialog, which stays open across navigation until `BaseAuthService.canDeactivate` decides; a custom dialog without the method keeps its behavior.
275
+ - **Confirmations and notices.** `PromptService.confirm({ title, message?, action, destructive? })` returns `Observable<boolean>` from a Material dialog; `ask(options, then)` runs `then` only on the action; `notify(message)` shows a snackbar. Deletes, revocations, discards, `TableAction.confirmMessage`, and the route guard use it; an ordinary save is not confirmed. Table views report failures in their `role="alert"` region through `reportError()`.
276
+ - **Invalid submit.** Primary actions stay enabled while a form is invalid; `submittable(control, scope)` marks it touched and focuses the first invalid field in `scope`. `<mat-error sailFieldError></mat-error>` (`FieldError`) shows the field's first validation error.
277
+ - **Read-only fields.** `DynamicField` renders read-only values with native `readonly`, so they stay focusable and copyable; a read-only boolean shows "Yes" or "No". The lookup button stays disabled.
278
+ - **Page shell.** `sail-navigation` renders a "Skip to content" link and wraps the outlet in `<main id="sail-main" tabindex="-1">`, focused after a path change (not on first load, a query-only change, or while the handset menu is open). Generated routes take the menu caption as their title, suffixed with `appTitle`.
279
+
267
280
  ## Money
268
281
 
269
282
  keel stores money as integer minor units and serves the `currency` table (`code`, `caption`, `exponent`) as the only source of exponents. `formatMinor(minor, currency, exponent)` renders an amount; `Intl` supplies the symbol and grouping only. Billing payloads (`PlanPrice`, `Subscription`, `Invoice`) carry `exponent` beside their `…Minor` fields, so they render without a catalog: `{{ invoice.totalMinor | sailMoney:invoice.currency:invoice.exponent }}`. Amounts without an exponent, such as the agency ledger, use `{{ minor | sailMoney:currency }}`: the pipe loads the catalog once through `CurrencyService` (generic REST `currency`, so the role needs `TABLE SELECT currency`), renders `''` until it arrives, and throws for a code the catalog lacks rather than guessing decimals.
@@ -472,7 +485,7 @@ export class PricingPage {
472
485
  | `createCheckout(req)` | `POST /api/billing/checkout` | `Observable<CheckoutResponse>` |
473
486
  | `getSubscription()` | `GET /api/billing/subscription` | `Observable<Subscription>` |
474
487
  | `cancelSubscription()` | `POST /api/billing/subscription/cancel` | `Observable<void>` |
475
- | `changePlan(planId)` | `POST /api/billing/subscription/change` | `Observable<PlanChangeResult>` |
488
+ | `changePlan(planId, offer?)` | `POST /api/billing/subscription/change` | `Observable<PlanChangeResult>` |
476
489
  | `checkChange(planId)` | `POST /api/billing/subscription/change/check` | `Observable<{ overages: PlanOverage[] }>` |
477
490
  | `setSeats(seats)` | `POST /api/billing/subscription/seats` | `Observable<void>` |
478
491
  | `listInvoices()` | `GET /api/billing/invoices` | `Observable<Invoice[]>` |
@@ -656,7 +669,7 @@ An action with `parameters` (keel v1.2.75 `table_action_parameter`) opens a dial
656
669
 
657
670
  ### Plan change vs first-time checkout (v1.0.1)
658
671
 
659
- First-time checkout uses `<sail-checkout-button>` (provider-hosted). For an **existing** subscription, upgrade/downgrade is a distinct path — `BillingService.changePlan(planId)` routes to keel's `SubscriptionLifecycle.ChangePlan` via an endpoint the app exposes and answers `{ status: 'changed' | 'scheduled' }`: a scheduled change sets `Subscription.scheduledAt` and `scheduledPlanId`, and re-selecting the current plan cancels it. A paid target the partner exceeds (more live-counted resources than it allows) is refused with 409; call `checkChange(planId)` first to list each `PlanOverage` to remove. Wire the picker's selection to whichever path applies:
672
+ First-time checkout uses `<sail-checkout-button>` (provider-hosted). For an **existing** subscription, upgrade/downgrade is a distinct path — `BillingService.changePlan(planId, offer?)` routes to keel's `SubscriptionLifecycle.ChangePlan` via an endpoint the app exposes and answers `{ status: 'changed' | 'scheduled' }`: a scheduled change sets `Subscription.scheduledAt` and `scheduledPlanId`, and re-selecting the current plan cancels it. A paid target the partner exceeds (more live-counted resources than it allows) is refused with 409; call `checkChange(planId)` first to list each `PlanOverage` to remove. Wire the picker's selection to whichever path applies:
660
673
 
661
674
  ```typescript
662
675
  onPlanPicked(planId: string) {
@@ -664,6 +677,15 @@ onPlanPicked(planId: string) {
664
677
  }
665
678
  ```
666
679
 
680
+ `offer` (keel v1.2.126) is one of the target plan's `prices` — a `PlanPrice` fits — and moves the subscription to that billing cycle and term, on the same plan or another; omitted, keel keeps the current terms. For a paid subscriber whose plan sells more than one offer, show `<sail-price-selector>` and pass the chosen offer; `Subscription.billingCycle` marks the current one:
681
+
682
+ ```html
683
+ @if ((currentPlan()?.prices?.length ?? 0) > 1) {
684
+ <sail-price-selector [prices]="currentPlan()!.prices!" [selected]="offer()?.priceId"
685
+ (selectionChange)="billing.changePlan(sub()!.planId, $event).subscribe()" />
686
+ }
687
+ ```
688
+
667
689
  `<sail-plan-selector>` varies its CTA copy by `PublicPlan.activationMode` ("Start trial" for `T`, "Subscribe" for `P`, "Activate" for `F`); override or extend via `[ctaLabels]="{ S: 'Add seats' }"`.
668
690
 
669
691
  ### Dunning banner (v1.0.1)
@@ -1273,7 +1295,9 @@ catalog and any routing via the outputs.
1273
1295
  provider redirects straight to consent. `[entityId]` scopes per-business connections and
1274
1296
  reloads on change (0 = tenant-wide). Emits `connected` / `disconnected`.
1275
1297
  Ships no CSS — style `.connections`, `.connection-card`, `.connection-card__{head,title,status,actions}`,
1276
- `.connection-form`, `.connection-panel__error`, and `.status--{connected,error,pending}`.
1298
+ `.connection-form`, `.connection-panel__error`, `.connection-panel__notice` (a live region, always
1299
+ rendered), `.connection-card__checked`, and `.status--{connected,error,pending}`. A passing Test
1300
+ says so in the notice; the tile shows when the connection was last checked.
1277
1301
  - Each `OAuthProviderField` has a **`role`**: `credential` (sealed secret), `endpoint` (stored
1278
1302
  as `api_endpoint`), or `param` (OAuth authorize query param) — so field order is
1279
1303
  irrelevant. `icon` is a Material name or an image URL.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.44",
3
+ "version": "1.1.45",
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
  "exports": {
@@ -12,6 +12,8 @@ import { Observable, forkJoin, map } from 'rxjs';
12
12
  import { BaseAuthService } from '../../service/auth.service';
13
13
  import { CaptionPipe } from '../../service/caption_pipe';
14
14
  import { LabelOption, LabelService } from '../../service/label.service';
15
+ import { PromptService } from '../../service/prompt.service';
16
+ import { ConfirmOptions } from '../../component/dialog/confirm_dialog';
15
17
  import {
16
18
  AGENT_READINESS_CAPTIONS, AgentAuditEvent, AgentReadiness, AgentDraft, AgentGuardrails, AgentLanguageDraft, AgentMergeMode,
17
19
  AgentModelSelection, AgentRelease, AgentSetting, AgentSettings, AgentSummary, AgentTestResult, GuardrailRule,
@@ -31,6 +33,7 @@ const CORE_TABS = [
31
33
  { id: 'history', label: 'History' },
32
34
  ];
33
35
  const GUARDRAILS_TAB = { id: 'guardrails', label: 'Guardrails' };
36
+ const DISCARD: ConfirmOptions = { title: 'Discard unsaved changes?', action: 'Discard changes', destructive: true };
34
37
 
35
38
  /**
36
39
  * Embeddable Agent Studio over Scout's core `agents` feature: a grouped agent rail and tabs
@@ -53,6 +56,7 @@ export class StudioPageComponent implements OnInit {
53
56
  private readonly auth = inject(BaseAuthService);
54
57
  private readonly destroyRef = inject(DestroyRef);
55
58
  private readonly labels = inject(LabelService);
59
+ private readonly prompt = inject(PromptService);
56
60
 
57
61
  /** Rail group of an agent; its type by default. */
58
62
  readonly groupBy = input<(agent: AgentSummary) => string>((agent) => agent.agent_type);
@@ -145,7 +149,14 @@ export class StudioPageComponent implements OnInit {
145
149
 
146
150
  select(agentName: string, force = false): void {
147
151
  if (!force && agentName === this.selectedName()) return;
148
- if (!force && this.dirty() && !window.confirm('Discard unsaved changes for this agent?')) return;
152
+ if (force || !this.dirty()) {
153
+ this.open(agentName);
154
+ return;
155
+ }
156
+ this.prompt.ask({ ...DISCARD, title: 'Discard unsaved changes for this agent?' }, () => this.open(agentName));
157
+ }
158
+
159
+ private open(agentName: string): void {
149
160
  this.selectedName.set(agentName);
150
161
  this.draft.set(null);
151
162
  this.settings.set(null);
@@ -206,7 +217,7 @@ export class StudioPageComponent implements OnInit {
206
217
  }
207
218
 
208
219
  discard(): void {
209
- if (window.confirm('Discard unsaved changes?')) this.draft.set(JSON.parse(this.baseline()) as AgentDraft);
220
+ this.prompt.ask(DISCARD, () => this.draft.set(JSON.parse(this.baseline()) as AgentDraft));
210
221
  }
211
222
 
212
223
  save(): void {
@@ -230,21 +241,24 @@ export class StudioPageComponent implements OnInit {
230
241
 
231
242
  makeDefault(): void {
232
243
  const d = this.draft();
233
- if (!d || !window.confirm(`Make "${d.display_name}" the default ${d.agent_type} agent?`)) return;
234
- this.act(this.studio.setDefault({ agent_name: d.agent_name, expected_type_defaults_revision: d.expected_type_defaults_revision }), (rows) => {
235
- this.summaries.set(rows);
236
- this.loadAgent();
237
- }, d.agent_name);
244
+ if (!d) return;
245
+ this.prompt.ask({ title: `Make "${d.display_name}" the default ${d.agent_type} agent?`, action: 'Make default' }, () =>
246
+ this.act(this.studio.setDefault({ agent_name: d.agent_name, expected_type_defaults_revision: d.expected_type_defaults_revision }), (rows) => {
247
+ this.summaries.set(rows);
248
+ this.loadAgent();
249
+ }, d.agent_name));
238
250
  }
239
251
 
240
252
  setArchived(archived: boolean): void {
241
253
  const d = this.draft();
242
- if (!d || (archived && !window.confirm(`Archive "${d.display_name}"?`))) return;
254
+ if (!d) return;
243
255
  const request = { agent_name: d.agent_name, expected_agent_revision: d.expected_agent_revision };
244
- this.act(archived ? this.studio.archive(request) : this.studio.unarchive(request), (summary) => {
256
+ const apply = () => this.act(archived ? this.studio.archive(request) : this.studio.unarchive(request), (summary) => {
245
257
  this.applyRevisions(summary.agent_revision);
246
258
  this.loadList();
247
259
  }, d.agent_name);
260
+ if (archived) this.prompt.ask({ title: `Archive "${d.display_name}"?`, action: 'Archive agent', destructive: true }, apply);
261
+ else apply();
248
262
  }
249
263
 
250
264
  /** Creates an agent of the selected agent's type, copied from its saved draft. */
@@ -258,11 +272,12 @@ export class StudioPageComponent implements OnInit {
258
272
 
259
273
  reset(target: PromptResetTarget): void {
260
274
  const d = this.draft();
261
- if (!d || !window.confirm('Reset these prompt sections? Publish to make it live.')) return;
262
- this.act(this.studio.reset({
263
- agent_name: d.agent_name, ...target,
264
- expected_agent_revision: d.expected_agent_revision, expected_type_defaults_revision: d.expected_type_defaults_revision,
265
- }), (draft) => this.applyDraft(draft), d.agent_name);
275
+ if (!d) return;
276
+ this.prompt.ask({ title: 'Reset these prompt sections?', message: 'Publish to make it live.', action: 'Reset sections', destructive: true }, () =>
277
+ this.act(this.studio.reset({
278
+ agent_name: d.agent_name, ...target,
279
+ expected_agent_revision: d.expected_agent_revision, expected_type_defaults_revision: d.expected_type_defaults_revision,
280
+ }), (draft) => this.applyDraft(draft), d.agent_name));
266
281
  }
267
282
 
268
283
  saveSetting(binding: Pick<AgentSetting, 'scope_id' | 'resource_kind' | 'resource_id' | 'merge_mode' | 'sealed'>, valueText: string, remove = false): void {
@@ -314,12 +329,12 @@ export class StudioPageComponent implements OnInit {
314
329
  }
315
330
 
316
331
  restore(version: number): void {
317
- if (!window.confirm(`Restore version ${version}? It is published again as a new version.`)) return;
318
- this.act(this.studio.restore({ agent_name: this.selectedName(), version }), (release) => {
319
- this.successMessage.set(`Version ${release.version} published from version ${version}.`);
320
- this.loadList();
321
- this.loadAgent();
322
- }, this.selectedName());
332
+ this.prompt.ask({ title: `Restore version ${version}?`, message: 'It is published again as a new version.', action: 'Restore version' }, () =>
333
+ this.act(this.studio.restore({ agent_name: this.selectedName(), version }), (release) => {
334
+ this.successMessage.set(`Version ${release.version} published from version ${version}.`);
335
+ this.loadList();
336
+ this.loadAgent();
337
+ }, this.selectedName()));
323
338
  }
324
339
 
325
340
  loadHistory(): void {
@@ -6,6 +6,11 @@ export abstract class BaseForm extends BaseTable {
6
6
  readonly isNew = signal(false);
7
7
  readonly title = signal('');
8
8
 
9
+ /** Unsaved-changes contract read by `BaseAuthService.canDeactivate` and the list dialog host. */
10
+ hasUnsavedChanges(): boolean {
11
+ return false;
12
+ }
13
+
9
14
  override isReadOnly(fieldName: string): boolean {
10
15
  // keel column_display_attribute: 'R' is always display-only; 'I'
11
16
  // (insert-only) is editable while creating, locked once it exists.
@@ -10,9 +10,10 @@ import { MatDialog } from "@angular/material/dialog";
10
10
  import { RevealDialog } from "../table/reveal_dialog";
11
11
  import { ActionParamsDialog } from "../table/action_params_dialog";
12
12
  import { IdentityProviderConfigureDialog } from "../sso/identity_provider_configure_dialog";
13
+ import { PromptService } from "../../service/prompt.service";
13
14
 
14
15
 
15
- /** CRUD verb used by `requireAuth()` to compose the "Missing authorization" alert. */
16
+ /** CRUD verb used by `requireAuth()` to compose the "Missing authorization" message. */
16
17
  export type AuthVerb = 'create' | 'read' | 'update' | 'delete' | 'view';
17
18
 
18
19
  /** ISO-like timestamp prefix — gate for comparing two strings as instants. */
@@ -27,8 +28,9 @@ const ACTION_DIALOGS: Record<string, Type<ActionParamsDialog>> = {
27
28
  export abstract class BaseTable {
28
29
  protected readonly cacheService = inject(BaseAuthService);
29
30
  protected readonly backendService = inject(BackendService);
30
- // Action dialogs are rare — resolve MatDialog lazily so every BaseTable subclass doesn't eagerly depend on it.
31
+ // Dialogs are rare — resolve them lazily so every BaseTable subclass doesn't eagerly depend on them.
31
32
  readonly #injector = inject(Injector);
33
+ protected get prompt(): PromptService { return this.#injector.get(PromptService); }
32
34
  protected readonly config: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
33
35
  protected readonly dialogWidth: string = '400px';
34
36
  /** Subclasses supply the source — a plain signal or a linkedSignal over an input. */
@@ -72,19 +74,18 @@ export abstract class BaseTable {
72
74
  /** True when the record is marked for delete (op_code === 'D'). */
73
75
  isDeleted(record: Record<string, unknown>): boolean { return record[this.config.opField] === OpCode.Delete; }
74
76
 
75
- /**
76
- * Authorization guard with shared "Missing authorization" copy. Pass the
77
- * permission predicate (typically a `canX()` method on this class) and
78
- * the verb that fills the alert. Returns true when allowed.
79
- *
80
- * if (!this.requireAuth(() => this.canDelete(), 'delete')) return;
81
- */
77
+ /** Authorization guard: `if (!this.requireAuth(() => this.canDelete(), 'delete')) return;` */
82
78
  protected requireAuth(check: () => boolean, verb: AuthVerb): boolean {
83
79
  if (check()) return true;
84
- alert(`Missing authorization to ${verb} records`);
80
+ this.reportError(`Missing authorization to ${verb} records.`);
85
81
  return false;
86
82
  }
87
83
 
84
+ /** Surfaces a failure; views with a `role="alert"` region override this to use it. */
85
+ protected reportError(message: string): void {
86
+ this.prompt.notify(message);
87
+ }
88
+
88
89
  /**
89
90
  * The table's registered TableAction list (basis `table_action` rows).
90
91
  * Templates use the memoized `tableActions` / `recordActions` instead.
@@ -128,24 +129,13 @@ export abstract class BaseTable {
128
129
  }
129
130
 
130
131
  /**
131
- * Fire a TableAction. Template method:
132
- *
133
- * 1. Authorization check + alert (shared)
134
- * 2. Subclass-specific pre-flight guard via `beforeExecuteAction()` —
135
- * returns false to abort silently (the hook has already shown its
136
- * own alert).
137
- * 3. A parameter dialog when the action declares `parameters`
138
- * (it shows `confirmMessage`), otherwise the confirm prompt
139
- * 4. POST `{}` for table-level actions or `primaryKeyValues(record)`
140
- * for record-specific actions, merged with the parameter values
141
- * 5. On success, dispatch `onActionSuccess()` so subclasses can refresh
142
- * their views; on error, log and alert.
143
- *
144
- * Subclasses typically only override the two hooks.
132
+ * Fire a TableAction: authorization, the `beforeExecuteAction()` hook, a parameter
133
+ * dialog (which shows `confirmMessage`) or the shared confirmation, then POST the
134
+ * row key merged with the parameters and dispatch `onActionSuccess()`.
145
135
  */
146
136
  executeAction(action: TableAction, record?: Record<string, unknown>): void {
147
137
  if (!this.canExecuteAction(action)) {
148
- alert(`Missing authorization for action ${action.authorityObject}/${action.authorityCheck}`);
138
+ this.reportError(`Missing authorization for action ${action.authorityObject}/${action.authorityCheck}.`);
149
139
  return;
150
140
  }
151
141
  if (!this.beforeExecuteAction(action, record)) return;
@@ -161,8 +151,12 @@ export abstract class BaseTable {
161
151
  });
162
152
  return;
163
153
  }
164
- if (action.confirmMessage && !confirm(action.confirmMessage)) return;
165
- this.postAction(action, key, record);
154
+ if (!action.confirmMessage) {
155
+ this.postAction(action, key, record);
156
+ return;
157
+ }
158
+ this.prompt.ask({ title: action.caption, message: action.confirmMessage, action: action.caption },
159
+ () => this.postAction(action, key, record));
166
160
  }
167
161
 
168
162
  private postAction(action: TableAction, body: Record<string, unknown>, record?: Record<string, unknown>): void {
@@ -182,14 +176,14 @@ export abstract class BaseTable {
182
176
  },
183
177
  error: (err) => {
184
178
  console.error(`Action ${action.action} failed`, err);
185
- alert(errorDetail(err, `Failed to ${action.caption}`));
179
+ this.reportError(errorDetail(err, `Failed to ${action.caption}`));
186
180
  },
187
181
  });
188
182
  }
189
183
 
190
184
  /**
191
185
  * Pre-flight hook for `executeAction()`. Return false to abort the action
192
- * (the override should `alert()` or otherwise surface why). Default: allow.
186
+ * (the override should `reportError()` why). Default: allow.
193
187
  */
194
188
  protected beforeExecuteAction(_action: TableAction, _record?: Record<string, unknown>): boolean {
195
189
  return true;
@@ -1,6 +1,6 @@
1
- import { computed, Directive, inject, signal, Type, WritableSignal } from "@angular/core";
1
+ import { computed, DestroyRef, Directive, inject, signal, Type, WritableSignal } from "@angular/core";
2
2
  import { BaseTable } from "./base_table";
3
- import { MatDialog } from "@angular/material/dialog";
3
+ import { MatDialog, MatDialogRef } from "@angular/material/dialog";
4
4
 
5
5
  /** Read-only listing surface; complete as-is (TableLookup). Editing views extend AbstractEditableView. */
6
6
  @Directive()
@@ -14,6 +14,10 @@ export abstract class BaseView extends BaseTable {
14
14
 
15
15
  protected readonly hasLinkPermission = computed(() => this.canUpdate() || this.canRead());
16
16
 
17
+ protected override reportError(message: string): void {
18
+ this.viewError.set(message);
19
+ }
20
+
17
21
  /** Whether an "actions" column is appended for privileged users; lookup views return false. */
18
22
  protected includeActionsColumn(): boolean {
19
23
  return true;
@@ -40,17 +44,25 @@ export abstract class BaseView extends BaseTable {
40
44
  export abstract class AbstractEditableView extends BaseView {
41
45
  protected readonly dialog = inject(MatDialog);
42
46
  readonly dialogComponent: WritableSignal<Type<unknown> | undefined> = signal(undefined);
47
+ private activeDialog?: MatDialogRef<unknown>;
48
+
49
+ constructor() {
50
+ super();
51
+ // The dialog outlives navigation (closeOnNavigation: false) until the route guard approves it.
52
+ inject(DestroyRef).onDestroy(() => this.activeDialog?.close());
53
+ }
54
+
55
+ /** Read by `BaseAuthService.canDeactivate`; delegates to an open dialog implementing the contract. */
56
+ hasUnsavedChanges(): boolean {
57
+ const form = this.activeDialog?.componentInstance as { hasUnsavedChanges?: () => boolean } | undefined;
58
+ return form?.hasUnsavedChanges?.() ?? false;
59
+ }
43
60
 
44
61
  protected abstract handleDelete(record: Record<string, unknown>): void;
45
62
 
46
63
  protected abstract handleUpdate(result: Record<string, unknown>, isNew: boolean, original: Record<string, unknown>): void;
47
64
 
48
- /**
49
- * Shared row-dialog opener. Builds the dialog data and wires the result
50
- * straight into `handleUpdate()` (subclass-specific). Used by both
51
- * `addRecord()` (new row + empty `original`) and `editRecord()` (existing
52
- * row passed through verbatim).
53
- */
65
+ /** Opens the row dialog and hands its result to `handleUpdate()`. */
54
66
  private openRecordDialog(record: Record<string, unknown>, isNew: boolean): void {
55
67
  const component = this.dialogComponent();
56
68
  if (!component) {
@@ -61,13 +73,16 @@ export abstract class AbstractEditableView extends BaseView {
61
73
  const apiName = this.apiName();
62
74
  if (apiName) dialogData['apiName'] = apiName;
63
75
 
64
- this.dialog.open(component, {
76
+ const ref = this.activeDialog = this.dialog.open(component, {
65
77
  width: '90vw',
66
78
  maxWidth: '1200px',
67
79
  maxHeight: '90vh',
68
80
  disableClose: true,
81
+ closeOnNavigation: false,
69
82
  data: dialogData,
70
- }).afterClosed().subscribe((result) => {
83
+ });
84
+ ref.afterClosed().subscribe((result) => {
85
+ if (this.activeDialog === ref) this.activeDialog = undefined;
71
86
  if (result) this.handleUpdate(result, isNew, isNew ? {} : record);
72
87
  });
73
88
  }
@@ -84,13 +99,7 @@ export abstract class AbstractEditableView extends BaseView {
84
99
  this.openRecordDialog(record, true);
85
100
  }
86
101
 
87
- /**
88
- * Open the row dialog. Renamed conceptually from "edit": when the user
89
- * has only `canRead()`, we still want to show them the record + child rows.
90
- * The dialog itself (TableEdit) handles read-only mode — its Edit/New/Delete
91
- * buttons are gated by the same permissions.
92
- * Block only when neither read nor update is granted.
93
- */
102
+ /** Opens the row dialog; read-only users see the record, the dialog gates its own actions. */
94
103
  editRecord(record: Record<string, unknown>) {
95
104
  if (!this.canUpdate() && !this.requireAuth(() => this.canRead(), 'view')) return;
96
105
  this.openRecordDialog(record, false);
@@ -1,25 +1,28 @@
1
- <section class="account-section">
1
+ <section #profileSection class="account-section">
2
2
  <h3>Profile</h3>
3
3
  <form [formGroup]="profileForm" (ngSubmit)="saveProfile()" class="auth-form">
4
4
  <mat-form-field appearance="outline">
5
5
  <mat-label>First name</mat-label>
6
6
  <input matInput formControlName="firstName" required>
7
+ <mat-error sailFieldError></mat-error>
7
8
  </mat-form-field>
8
9
  <mat-form-field appearance="outline">
9
10
  <mat-label>Last name</mat-label>
10
11
  <input matInput formControlName="lastName">
12
+ <mat-error sailFieldError></mat-error>
11
13
  </mat-form-field>
12
14
  <mat-form-field appearance="outline">
13
15
  <mat-label>Locale</mat-label>
14
16
  <input matInput formControlName="locale" placeholder="en">
17
+ <mat-error sailFieldError></mat-error>
15
18
  </mat-form-field>
16
- <button matButton="elevated" class="primary" type="submit" [disabled]="!profileForm.valid || loading()">Save</button>
19
+ <button matButton="elevated" class="primary" type="submit" [disabled]="loading()">Save</button>
17
20
  </form>
18
21
  @if (profileMsg()) { <div class="auth-success" role="status">{{ profileMsg() }}</div> }
19
22
  @if (profileErr()) { <div class="auth-error" role="alert">{{ profileErr() }}</div> }
20
23
  </section>
21
24
 
22
- <section class="account-section">
25
+ <section #emailSection class="account-section">
23
26
  <h3>Email</h3>
24
27
  @if (emailPhase() === 'idle') {
25
28
  <p>{{ email() || 'Not set' }}</p>
@@ -28,22 +31,24 @@
28
31
  <mat-form-field appearance="outline">
29
32
  <mat-label>New email</mat-label>
30
33
  <input matInput type="email" [formControl]="newEmail" required>
34
+ <mat-error sailFieldError></mat-error>
31
35
  </mat-form-field>
32
- <button matButton="elevated" class="primary" (click)="sendEmailCode()" [disabled]="newEmail.invalid || loading()">Send code</button>
36
+ <button matButton="elevated" class="primary" (click)="sendEmailCode()" [disabled]="loading()">Send code</button>
33
37
  <button matButton (click)="cancelEmailChange()">Cancel</button>
34
38
  } @else {
35
39
  @if (emailMsg()) { <p>{{ emailMsg() }}</p> }
36
40
  <mat-form-field appearance="outline">
37
41
  <mat-label>Confirmation code</mat-label>
38
42
  <input matInput [formControl]="emailCode" inputmode="numeric" required>
43
+ <mat-error sailFieldError></mat-error>
39
44
  </mat-form-field>
40
- <button matButton="elevated" class="primary" (click)="confirmEmailChange()" [disabled]="emailCode.invalid || loading()">Confirm</button>
45
+ <button matButton="elevated" class="primary" (click)="confirmEmailChange()" [disabled]="loading()">Confirm</button>
41
46
  <button matButton (click)="cancelEmailChange()">Cancel</button>
42
47
  }
43
48
  @if (emailErr()) { <div class="auth-error" role="alert">{{ emailErr() }}</div> }
44
49
  </section>
45
50
 
46
- <section class="account-section">
51
+ <section #phoneSection class="account-section">
47
52
  <h3>Phone</h3>
48
53
  @if (!phoneChangeEnabled()) {
49
54
  <p>{{ phone() || 'Not set' }}</p>
@@ -54,16 +59,18 @@
54
59
  <mat-form-field appearance="outline">
55
60
  <mat-label>New phone</mat-label>
56
61
  <input matInput type="tel" [formControl]="newPhone" required>
62
+ <mat-error sailFieldError></mat-error>
57
63
  </mat-form-field>
58
- <button matButton="elevated" class="primary" (click)="sendPhoneCode()" [disabled]="newPhone.invalid || loading()">Send code</button>
64
+ <button matButton="elevated" class="primary" (click)="sendPhoneCode()" [disabled]="loading()">Send code</button>
59
65
  <button matButton (click)="cancelPhoneChange()">Cancel</button>
60
66
  } @else {
61
67
  @if (phoneMsg()) { <p>{{ phoneMsg() }}</p> }
62
68
  <mat-form-field appearance="outline">
63
69
  <mat-label>Confirmation code</mat-label>
64
70
  <input matInput [formControl]="phoneCode" inputmode="numeric" required>
71
+ <mat-error sailFieldError></mat-error>
65
72
  </mat-form-field>
66
- <button matButton="elevated" class="primary" (click)="confirmPhoneChange()" [disabled]="phoneCode.invalid || loading()">Confirm</button>
73
+ <button matButton="elevated" class="primary" (click)="confirmPhoneChange()" [disabled]="loading()">Confirm</button>
67
74
  <button matButton (click)="cancelPhoneChange()">Cancel</button>
68
75
  }
69
76
  @if (phoneErr()) { <div class="auth-error" role="alert">{{ phoneErr() }}</div> }
@@ -1,4 +1,6 @@
1
- import { ChangeDetectionStrategy, Component, OnInit, inject, input, signal } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, ElementRef, OnInit, inject, input, signal, viewChild } from '@angular/core';
2
+ import { submittable } from '../../util/forms';
3
+ import { FieldError } from '../form/field_error';
2
4
  import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
3
5
  import { MatButtonModule } from '@angular/material/button';
4
6
  import { MatFormFieldModule } from '@angular/material/form-field';
@@ -13,9 +15,12 @@ import { UserProfile } from '../../model/auth';
13
15
  selector: 'sail-profile-editor',
14
16
  templateUrl: './profile_editor.html',
15
17
  changeDetection: ChangeDetectionStrategy.OnPush,
16
- imports: [ReactiveFormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
18
+ imports: [FieldError, ReactiveFormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
17
19
  })
18
20
  export class ProfileEditorComponent implements OnInit {
21
+ private readonly profileSection = viewChild<ElementRef<HTMLElement>>('profileSection');
22
+ private readonly emailSection = viewChild<ElementRef<HTMLElement>>('emailSection');
23
+ private readonly phoneSection = viewChild<ElementRef<HTMLElement>>('phoneSection');
19
24
  private readonly auth = inject(BaseAuthService);
20
25
  private readonly fb = inject(FormBuilder);
21
26
 
@@ -58,7 +63,7 @@ export class ProfileEditorComponent implements OnInit {
58
63
  }
59
64
 
60
65
  saveProfile(): void {
61
- if (this.profileForm.invalid) return;
66
+ if (!submittable(this.profileForm, this.profileSection()?.nativeElement)) return;
62
67
  const v = this.profileForm.getRawValue();
63
68
  this.loading.set(true);
64
69
  this.profileMsg.set('');
@@ -82,7 +87,7 @@ export class ProfileEditorComponent implements OnInit {
82
87
  }
83
88
 
84
89
  sendEmailCode(): void {
85
- if (this.newEmail.invalid) return;
90
+ if (!submittable(this.newEmail, this.emailSection()?.nativeElement)) return;
86
91
  this.loading.set(true);
87
92
  this.emailErr.set('');
88
93
  this.auth.requestEmailChange(this.newEmail.value!).subscribe({
@@ -92,7 +97,7 @@ export class ProfileEditorComponent implements OnInit {
92
97
  }
93
98
 
94
99
  confirmEmailChange(): void {
95
- if (this.emailCode.invalid) return;
100
+ if (!submittable(this.emailCode, this.emailSection()?.nativeElement)) return;
96
101
  this.loading.set(true);
97
102
  this.emailErr.set('');
98
103
  this.auth.confirmEmailChange(this.newEmail.value!, Number(this.emailCode.value)).subscribe({
@@ -114,7 +119,7 @@ export class ProfileEditorComponent implements OnInit {
114
119
  }
115
120
 
116
121
  sendPhoneCode(): void {
117
- if (this.newPhone.invalid) return;
122
+ if (!submittable(this.newPhone, this.phoneSection()?.nativeElement)) return;
118
123
  this.loading.set(true);
119
124
  this.phoneErr.set('');
120
125
  this.auth.requestPhoneChange(this.newPhone.value!).subscribe({
@@ -124,7 +129,7 @@ export class ProfileEditorComponent implements OnInit {
124
129
  }
125
130
 
126
131
  confirmPhoneChange(): void {
127
- if (this.phoneCode.invalid) return;
132
+ if (!submittable(this.phoneCode, this.phoneSection()?.nativeElement)) return;
128
133
  this.loading.set(true);
129
134
  this.phoneErr.set('');
130
135
  this.auth.confirmPhoneChange(this.newPhone.value!, Number(this.phoneCode.value)).subscribe({
@@ -17,16 +17,19 @@
17
17
  <mat-form-field appearance="outline">
18
18
  <mat-label>Client name</mat-label>
19
19
  <input matInput formControlName="name" required />
20
+ <mat-error sailFieldError></mat-error>
20
21
  </mat-form-field>
21
22
  <mat-form-field appearance="outline">
22
23
  <mat-label>Email</mat-label>
23
24
  <input matInput formControlName="email" type="email" />
25
+ <mat-error sailFieldError></mat-error>
24
26
  </mat-form-field>
25
27
  <mat-form-field appearance="outline">
26
28
  <mat-label>Website</mat-label>
27
29
  <input matInput formControlName="website" />
30
+ <mat-error sailFieldError></mat-error>
28
31
  </mat-form-field>
29
- <button matButton="filled" type="submit" [disabled]="addForm.invalid || !canManage()">Add client</button>
32
+ <button matButton="filled" type="submit" [disabled]="!canManage()">Add client</button>
30
33
  </form>
31
34
 
32
35
  <p class="agency-clients__count">Active clients: {{ activeClients() }}</p>
@@ -1,4 +1,6 @@
1
- import { ChangeDetectionStrategy, Component, OnInit, computed, inject, input, output, signal } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, OnInit, computed, inject, input, output, signal, ElementRef } from '@angular/core';
2
+ import { submittable } from '../../util/forms';
3
+ import { FieldError } from '../form/field_error';
2
4
  import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
3
5
  import { MatButtonModule } from '@angular/material/button';
4
6
  import { MatFormFieldModule } from '@angular/material/form-field';
@@ -16,7 +18,7 @@ import { AgencyStatusBannerComponent } from './agency_status_banner';
16
18
  selector: 'sail-agency-clients',
17
19
  changeDetection: ChangeDetectionStrategy.OnPush,
18
20
  imports: [
19
- ReactiveFormsModule,
21
+ FieldError, ReactiveFormsModule,
20
22
  MatButtonModule,
21
23
  MatFormFieldModule,
22
24
  MatIconModule,
@@ -41,6 +43,7 @@ export class AgencyClientsComponent implements OnInit {
41
43
  readonly cancelled = output<AgencyClient>();
42
44
  readonly billingModelChanged = output<{ client: AgencyClient; billingModel: 'R' | 'W' }>();
43
45
 
46
+ private readonly host: HTMLElement = inject(ElementRef).nativeElement;
44
47
  private readonly agency = inject(AgencyService);
45
48
  private readonly snack = inject(MatSnackBar);
46
49
  private readonly formBuilder = inject(FormBuilder);
@@ -103,7 +106,7 @@ export class AgencyClientsComponent implements OnInit {
103
106
  }
104
107
 
105
108
  addClient(): void {
106
- if (this.addForm.invalid || !this.canManage()) return;
109
+ if (!this.canManage() || !submittable(this.addForm, this.host)) return;
107
110
  const value = this.addForm.getRawValue();
108
111
  this.agency.addClients([value]).subscribe({
109
112
  next: () => {