@nauticana/sail 1.1.32 → 1.1.34

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 (42) hide show
  1. package/README.md +35 -19
  2. package/package.json +1 -1
  3. package/src/agent/component/approval_inbox.html +63 -0
  4. package/src/agent/component/approval_inbox.ts +91 -0
  5. package/src/agent/component/decision_timeline.html +4 -4
  6. package/src/agent/component/decision_timeline.ts +2 -0
  7. package/src/agent/component/prompt_section_editor.html +4 -3
  8. package/src/agent/component/prompt_section_editor.ts +12 -5
  9. package/src/agent/component/release_sections.html +1 -1
  10. package/src/agent/component/release_sections.ts +2 -1
  11. package/src/agent/component/studio_page.html +269 -0
  12. package/src/agent/component/studio_page.ts +346 -0
  13. package/src/agent/component/studio_tab.ts +14 -0
  14. package/src/agent/component/turn_event_list.html +9 -0
  15. package/src/agent/index.ts +6 -0
  16. package/src/agent/model/agent_studio.ts +122 -9
  17. package/src/agent/model/approval.ts +42 -0
  18. package/src/agent/model/conversation.ts +9 -1
  19. package/src/agent/model/decision.ts +2 -1
  20. package/src/agent/model/turn_event.ts +10 -0
  21. package/src/agent/service/agent_studio.service.ts +36 -1
  22. package/src/agent/service/approval.service.ts +18 -0
  23. package/src/agent/util/release_diff.ts +33 -0
  24. package/src/agent/util/studio_errors.ts +11 -0
  25. package/src/agent/util/turn_timeline.ts +7 -1
  26. package/src/component/login/account_deletion.html +17 -4
  27. package/src/component/login/account_deletion.ts +35 -28
  28. package/src/component/login/login_component.html +11 -0
  29. package/src/component/login/login_component.ts +2 -1
  30. package/src/component/login/social_login.ts +6 -5
  31. package/src/component/security/sessions.html +46 -0
  32. package/src/component/security/sessions.ts +45 -0
  33. package/src/component/security/twofactor_verify.html +12 -8
  34. package/src/component/security/twofactor_verify.ts +2 -1
  35. package/src/index.ts +2 -0
  36. package/src/model/appdata.ts +12 -0
  37. package/src/model/auth.ts +17 -2
  38. package/src/service/auth.service.ts +93 -48
  39. package/src/service/caption_pipe.ts +26 -0
  40. package/src/service/label.service.ts +5 -4
  41. package/src/service/rest_url.ts +3 -0
  42. package/src/util/errors.ts +7 -1
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.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.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
 
@@ -16,7 +16,7 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
16
16
  | **Form components** | `DynamicField`, `RecordForm`, `TableForm` |
17
17
  | **Navigation** | `Navigation` (sidenav + toolbar with menu, responsive) |
18
18
  | **Login** | `LoginComponent`, `RegisterComponent`, `ChpassComponent`, `ConfirmRegisterComponent`, `ConfirmChpassComponent` |
19
- | **Security** | `TwoFactorSetupComponent`, `TwoFactorVerifyComponent`, `TrustedDevicesComponent`, `AccountDeletionComponent` |
19
+ | **Security** | `TwoFactorSetupComponent`, `TwoFactorVerifyComponent`, `TrustedDevicesComponent`, `SessionsComponent`, `AccountDeletionComponent` |
20
20
  | **Account** | `MyAccountComponent` (self-service hub), `ProfileEditorComponent` (name/locale immediate; email/phone verify-before-apply) |
21
21
  | **Auth** | `ConsentGateComponent`, `OtpInputComponent`, `SocialLoginComponent`, `SsoLoginComponent`, `SsoReturnComponent` |
22
22
  | **Billing** | `PlanSelectorComponent`, `PriceSelectorComponent`, `CheckoutButtonComponent`, `PaymentMethodsComponent`, `PortalButtonComponent`, `UsageMeterComponent`, `StatusChipComponent`, `TrialBannerComponent`, `SeatSelectorComponent`, `DunningBannerComponent` |
@@ -221,7 +221,7 @@ Key CSS classes used by components:
221
221
 
222
222
  /* Security */
223
223
  .twofactor-qr, .twofactor-backup, .backup-code-list,
224
- .trusted-devices-table
224
+ .trusted-devices-table, .sessions-table
225
225
  ```
226
226
 
227
227
  ## How it works
@@ -264,7 +264,7 @@ Default page size is 100, capped at 1000 server-side. Sail maps `limit`, `offset
264
264
 
265
265
  ## Backend-driven constant captions
266
266
 
267
- `LabelService` loads one keel `constant_value` domain from the backend-provided `ApplicationData.ConstantCache` and caches its value-to-caption map. The domain is the backend `constant_id` (for example `ride_type`), not a locale: keel's constant catalogue does not contain a language column. It does not call generic `constant_value` CRUD, which is normally restricted to administrative roles.
267
+ `LabelService` loads one caption map from the backend-provided `ApplicationData`: a keel `constant_value` domain from `ConstantCache` by constant id, or a catalog table registered in `foreign_key_lookup` from `TableCache` by table name. `sailCaption` is the pipe form: `{{ code | sailCaption:'constant_id' }}`. The domain is the backend `constant_id` (for example `ride_type`), not a locale: keel's constant catalogue does not contain a language column. It does not call generic `constant_value` CRUD, which is normally restricted to administrative roles.
268
268
 
269
269
  ```typescript
270
270
  this.labels.loadLabels('ride_type').subscribe(() => {
@@ -331,6 +331,11 @@ origin, is not followed. Failures set `BaseAuthService.sessionHandoffError` (ren
331
331
  `<sail-login>`) and never redirect: 400 means keel rejected the return URL, 401 means the user
332
332
  signs in again to retry. The authorization server must allow the SPA origin by CORS.
333
333
 
334
+ With `prompt=login` or `prompt=select_account`, `<sail-login>` holds back the stored session and
335
+ offers sign-out (`isLoggedIn` stays false); account selection also offers `continueSession()`. A
336
+ fresh sign-in, including its 2FA step, keeps the hand-off query. Any sign-in revokes the refresh
337
+ token it replaces.
338
+
334
339
  Set `passwordPolicyUrl` to the app's public route for keel's `PublicHandler.GetPasswordPolicy`
335
340
  (e.g. `'/public/v1/auth/password/policy'`). `BaseAuthService.ensurePasswordPolicy()` fetches it
336
341
  once into the `passwordRules` signal, and `passwordPolicyValidator(() => auth.passwordRules())`
@@ -348,7 +353,7 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
348
353
  | `POST /public/otp/send` | Send OTP code to phone or email; returns opaque `otpToken` |
349
354
  | `POST /public/otp/verify` | Verify OTP code with `otpToken`, returns JWT |
350
355
  | `POST /public/otp/resend` | Re-issue OTP for an existing `otpToken` |
351
- | `POST /public/2fa/verify` | Login-time TOTP verification (uses `loginToken`) |
356
+ | `POST /public/2fa/verify` | Login-time TOTP or emailed step-up code verification (uses `loginToken`) |
352
357
  | `POST /public/2fa/backup-verify` | Login-time backup-code verification |
353
358
  | `POST /public/register` | Start a signup from a `PartnerRegistration`; emails the confirmation code |
354
359
  | `POST /public/register/confirm` | Confirm `?email=&code=`; answers session tokens and the partner result (`confirmRegister`) |
@@ -367,6 +372,8 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
367
372
  | `POST /api/user/2fa/disable` | Disable 2FA — **requires password + current TOTP code** |
368
373
  | `GET /api/user/trusted-device/list` | List trusted devices |
369
374
  | `POST /api/user/trusted-device/revoke` | Revoke a trusted device |
375
+ | `GET /api/user/sessions` | The caller's live sign-in sessions (`getSessions`) |
376
+ | `POST /api/user/sessions/revoke` | Sign out one session `{id}` (`revokeSession`); 404 `session_not_found` |
370
377
  | `POST /api/user/logout-everywhere` | Sign out of all devices — **requires re-auth** |
371
378
  | `DELETE /api/user/account` | Soft-delete the caller's account — **requires re-auth** |
372
379
  | `GET /api/notifications` | Notification inbox page — `?before=<id>&limit=<n>`, newest first (keel v1.2.71) |
@@ -382,13 +389,13 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
382
389
  | `GET /api/billing/invoices` | Invoice history |
383
390
  | `GET /api/billing/payment-methods` | Saved payment methods |
384
391
 
385
- Device registration happens within `/public/2fa/verify` when `trustDevice=true`. Since **keel v0.9 / sail v0.9.0**, the trusted-device credential is a server-minted `keel_td` cookie (HttpOnly + Secure + SameSite=Strict) — not a client-supplied fingerprint. sail sets `withCredentials: true` on the login and 2FA-verify calls so the cookie round-trips; see [Enabling 2FA → Cross-origin cookie requirement](#cross-origin-cookie-requirement) below.
392
+ Device registration happens within `/public/2fa/verify` when `trustDevice=true`. Since **keel v0.9 / sail v0.9.0**, the trusted-device credential is a server-minted `keel_td` cookie (HttpOnly + Secure + SameSite=Strict) — not a client-supplied fingerprint. sail sets `withCredentials: true` on every session-minting call (password, Google, social, OTP, 2FA and backup verify, registration confirm and exchange) so this cookie and keel's `keel_device` cookie (keel v1.2.106: a recognized device gets no new-device notice or step-up) round-trip; see [Enabling 2FA → Cross-origin cookie requirement](#cross-origin-cookie-requirement) below.
386
393
 
387
394
  ## Enabling 2FA in your project
388
395
 
389
396
  Add the 2FA verification route to your `publicRoutes` config:
390
397
  ```typescript
391
- import { TwoFactorVerifyComponent, TwoFactorSetupComponent, TrustedDevicesComponent } from '@nauticana/sail';
398
+ import { SessionsComponent, TrustedDevicesComponent, TwoFactorSetupComponent, TwoFactorVerifyComponent } from '@nauticana/sail';
392
399
 
393
400
  // In publicRoutes:
394
401
  { path: 'login/2fa', component: TwoFactorVerifyComponent }
@@ -396,9 +403,12 @@ import { TwoFactorVerifyComponent, TwoFactorSetupComponent, TrustedDevicesCompon
396
403
  // In extraRoutes or authenticated routes (optional — for user self-service):
397
404
  { path: 'security/2fa', component: TwoFactorSetupComponent }
398
405
  { path: 'security/devices', component: TrustedDevicesComponent }
406
+ { path: 'security/sessions', component: SessionsComponent }
399
407
  ```
400
408
 
401
- The login flow handles 2FA automatically: when the backend returns `twoFactorRequired: true`, sail redirects to `/login/2fa`. No other code changes needed.
409
+ The login flow redirects `twoFactorRequired` responses to `/login/2fa`. With `twoFactorMethod: 'email'`, the page explains the new-device check and hides device trust and backup codes. Sign-in refusals `signin_network` and `stepup_unavailable` have user-facing messages.
410
+
411
+ `SessionsComponent` (`<sail-sessions>`) lists the sessions with their `sign_in_method` captions, marks the current one, and signs out any other.
402
412
 
403
413
  ### Cross-origin cookie requirement
404
414
 
@@ -407,7 +417,7 @@ The "trust this device" feature (keel v0.9+) is backed by a server-set HttpOnly
407
417
  1. On `POST /public/2fa/verify` with `trustDevice: true`, keel mints a 32-byte secret, stores its SHA-256, and returns the raw secret to the browser **only** as a `Set-Cookie: keel_td=…` header (never in the JSON body).
408
418
  2. On the next `POST /public/login/local` (or `/public/login/gmail`), the browser sends the `keel_td` cookie back, keel hashes it, finds the matching row, and **skips the 2FA prompt**.
409
419
 
410
- For the browser to store and resend that cookie, the requests must be credentialed. sail already sets `withCredentials: true` on `login()`, `loginWithGoogle()`, `verify2FALogin()`, and `verifyBackupCode()` — **you do not add this in your app code**. But the keel backend must permit credentialed CORS, or the browser drops the cookie and every login re-prompts for 2FA:
420
+ For the browser to store and resend that cookie, the requests must be credentialed. sail already sets `withCredentials: true` on these calls — **you do not add this in your app code**. But the keel backend must permit credentialed CORS, or the browser drops the cookie and every login re-prompts for 2FA:
411
421
 
412
422
  - **Same-origin** deployments (SPA served from the same host as the API) work with no extra config — credentialed same-origin requests are always allowed.
413
423
  - **Cross-origin** deployments (SPA on a different host/port than the API) require the keel `HttpBackend` to set:
@@ -674,7 +684,7 @@ A complete screen is the pieces above assembled over `BillingService` — no met
674
684
 
675
685
  ## Agent UI (v1.1.20)
676
686
 
677
- Agent Studio and agent-conversation UI live in a secondary entry point, `@nauticana/sail/agent`. Nothing in it is exported from `@nauticana/sail`, so an app that never imports it bundles none of it. It needs no additional peer dependency.
687
+ Agent Studio and agent-conversation UI live in a secondary entry point, `@nauticana/sail/agent`. Nothing in it is exported from `@nauticana/sail`, so an app that never imports it bundles none of it. It needs no additional peer dependency. The wire types match Scout v1.2.8.
678
688
 
679
689
  ```ts
680
690
  import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@nauticana/sail/agent';
@@ -682,7 +692,10 @@ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@
682
692
 
683
693
  | Export | Purpose |
684
694
  |---|---|
685
- | `AgentStudioService` | Client for the thirteen `studio-v2` routes under `/api/agent-studio/` (fixed, unversioned paths). Models mirror the backend's JSON names. |
695
+ | `AgentStudioService` | Client for Scout's core Studio routes under `/api/agent-studio/` (fixed, unversioned paths): the `features` manifest, list, draft, settings, create, rename, archive, enable, test, publish, restore, reset, default, history, audit, release sections, models. Models mirror the backend's JSON names. |
696
+ | `sail-studio-page` | Embeddable Studio: an agent rail grouped by `[groupBy]` (agent type by default; pass a function for workflow or position), and Overview, Prompts, Settings (scoped bindings and what they resolve to), Test, and History (diff, restore, audit) tabs. Product tabs are `<ng-template sailStudioTab="id" label="Label" let-agent>`. A control shows only when the `agents` feature lists its action and the caller holds the `AGENT_STUDIO` grant at `agent:<name>` (`agent_type:<type>` for create); hiding is never the authorization. `hasUnsavedChanges()` serves a `canDeactivate` guard. |
697
+ | `sail-approval-inbox` / `ApprovalService` | The signed-in person's pending approvals (`GET /api/approval/pending`) and a decision view: approve, reject, or edit a tool call's arguments (`POST /api/approval/resolve`). A `409` reloads the list; `(decided)` emits the result. |
698
+ | `studioErrorMessage(err)` | One message for a failed Studio call: the conflict cause, the field errors, or the detail. |
686
699
  | `studioConflictCause(err)` | `'revision'` (reload the draft), `'sealed'` (a layer sits under a sealed one) or `'other'` for a `409`. |
687
700
  | `studioFieldErrors(err)` / `retryAfterSeconds(err)` | Field errors of a `400`; `Retry-After` of a `429`/`503`. |
688
701
  | `sail-prompt-language-editor` | One language's prompt sections. Emits the whole language on every edit — a save must send every layer back, because an editable layer missing from the request ends its binding. Emits `resetRequested` with the scope, section, and language to reset. |
@@ -695,7 +708,7 @@ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@
695
708
  | `ConversationService` | Transport for Scout's conversation bridge: `submit`, `stream(requestId, turnStream)`, `cancel`. Mount point injected through `CONVERSATION_BASE_PATH`. |
696
709
  | `SseParser` | Incremental `text/event-stream` parser: `push(chunk)` returns whole events out of chunks that split anywhere, including between the CR and LF of a CRLF; `finish()` flushes what the closing body left pending. |
697
710
 
698
- Map your audit rows onto `DecisionRecord` to feed the timeline. Style the `.prompt-*`, `.release-*`, `.turn-*`, and `.decision-*` class hooks.
711
+ Map your audit rows onto `DecisionRecord` (outcome `A`, `D`, or `P`, captioned from `audit_decision_outcome`) to feed the timeline. Style the `.prompt-*`, `.release-*`, `.turn-*`, `.decision-*`, `.studio__*`, and `.approval-inbox__*` class hooks. Scout's short codes render through `sailCaption`, a pipe over the backend's constant captions.
699
712
 
700
713
  ### Conversation transport (v1.1.22)
701
714
 
@@ -709,7 +722,7 @@ providers: [{ provide: CONVERSATION_BASE_PATH, useValue: '/api/wingmate/' }]
709
722
 
710
723
  ```ts
711
724
  const stream = new TurnStream(requestId);
712
- this.conversation.submit({ request_id: requestId, conversation_id: conversationId, agent_id: 'wingmate', input })
725
+ this.conversation.submit({ request_id: requestId, conversation_id: conversationId, agent_type: 'wingmate', input })
713
726
  .pipe(switchMap(() => this.conversation.stream(requestId, stream)), takeUntilDestroyed())
714
727
  .subscribe({ error: (err) => this.onStreamError(err) });
715
728
  ```
@@ -1006,7 +1019,7 @@ Config required in `SAIL_GUI_CONFIG`:
1006
1019
  }
1007
1020
  ```
1008
1021
 
1009
- 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).
1022
+ Under the hood, the component calls `BaseAuthService.loginSocial(provider, idToken, consent)` → `POST /public/login/social` and emits `loginSuccess: LoginResponseSocial`. Apple sends the user's name only on the first authorization and never in the ID token, so that sign-in carries it as `socialName: {firstName?, lastName?}` for the app to pre-fill its profile. The session is completed automatically (token stored, app data loaded, routes initialized).
1010
1023
 
1011
1024
  For backward compatibility, the older OAuth-code flow `BaseAuthService.loginWithGoogle(code)` (hits `/public/login/google`) is still supported; prefer `loginSocial` for new code.
1012
1025
 
@@ -1373,14 +1386,16 @@ These are App Store / Play Store compliance primitives from keel. All are opt-in
1373
1386
 
1374
1387
  ### Re-authentication gate
1375
1388
 
1376
- 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.
1389
+ The four sensitive endpoints below require **recent re-authentication** before they will run — pass `password`, `twoFactorCode` or `reauthCode` 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.
1377
1390
 
1378
1391
  | Method | Required re-auth |
1379
1392
  |--------|-----------------|
1380
1393
  | `setup2FA(reauth)` | `password` (or `twoFactorCode` when re-rotating) |
1381
1394
  | `disable2FA(password, code)` | **both** `password` and current TOTP `code` |
1382
- | `deleteAccount(reauth, reason?)` | `password` (or `twoFactorCode`) |
1383
- | `logoutEverywhere(reauth)` | `password` (or `twoFactorCode`) |
1395
+ | `deleteAccount(reauth, reason?)` | `password`, `twoFactorCode` or `reauthCode` |
1396
+ | `logoutEverywhere(reauth)` | `password`, `twoFactorCode` or `reauthCode` |
1397
+
1398
+ An account without a password re-authenticates with `reauthCode`, a one-time code sent by `sendReauthCode(channel?)` (`POST /api/user/reauth/send`; no channel prefers email).
1384
1399
 
1385
1400
  The `ReauthCredentials` type is exported as a shared shape:
1386
1401
  ```typescript
@@ -1392,10 +1407,10 @@ auth.deleteAccount(reauth, 'Closing my account.').subscribe();
1392
1407
  ### Account deletion
1393
1408
 
1394
1409
  ```html
1395
- <sail-account-deletion [confirmationText]="'DELETE'"></sail-account-deletion>
1410
+ <sail-account-deletion [confirmationText]="'DELETE'" reauthMethod="code"></sail-account-deletion>
1396
1411
  ```
1397
1412
 
1398
- 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`).
1413
+ Typed-confirmation UX: the destructive button is disabled until the user types the exact `confirmationText` (default `DELETE`) **and** re-authenticates. `reauthMethod` (`password` default, or `code`) picks the initial method; the user can switch, and the code path sends, resends and submits a `reauthCode`. Optional reason textarea is forwarded to the backend. On success, local session is cleared and the router navigates to `config.accountDeletedRoute` (default `/login/local`).
1399
1414
 
1400
1415
  ### Logout everywhere
1401
1416
 
@@ -1423,6 +1438,7 @@ Platform codes: `'I'` iOS, `'A'` Android, `'W'` Web. The token acquisition (Capa
1423
1438
  |----------|---------------|
1424
1439
  | `DELETE /api/user/account` | `deleteAccount(reauth, reason?)` |
1425
1440
  | `POST /api/user/logout-everywhere` | `logoutEverywhere(reauth)` |
1441
+ | `POST /api/user/reauth/send` | `sendReauthCode(channel?)` |
1426
1442
  | `POST /api/push/register` | `registerPushToken(platform, token, appVersion?, deviceModel?)` |
1427
1443
  | `POST /api/push/revoke` | `revokePushToken(token)` |
1428
1444
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.32",
3
+ "version": "1.1.34",
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": {
@@ -0,0 +1,63 @@
1
+ <div class="approval-inbox">
2
+ <section class="approval-inbox__list" aria-label="Pending approvals">
3
+ @if (requests().length === 0 && !loading()) {
4
+ <p class="approval-inbox__empty">Nothing is waiting for your decision.</p>
5
+ }
6
+ <ul>
7
+ @for (request of requests(); track key(request)) {
8
+ <li>
9
+ <button type="button" class="approval-inbox__item" [class.selected]="key(request) === selectedKey()"
10
+ [attr.aria-current]="key(request) === selectedKey()" (click)="select(request)">
11
+ <span class="approval-inbox__summary">{{ request.summary || request.action }}</span>
12
+ @if (request.deadline_at) {
13
+ <span class="approval-inbox__deadline">Due {{ request.deadline_at | date: 'medium' }}</span>
14
+ }
15
+ </button>
16
+ </li>
17
+ }
18
+ </ul>
19
+ <button matButton type="button" [disabled]="loading()" (click)="load()">Refresh</button>
20
+ </section>
21
+
22
+ @if (selected(); as request) {
23
+ <section class="approval-inbox__decision" aria-label="Decision">
24
+ <h3>{{ request.summary || request.action }}</h3>
25
+ <dl>
26
+ <dt>Action</dt><dd>{{ request.action }}</dd>
27
+ <dt>Resource</dt><dd>{{ request.resource }}</dd>
28
+ <dt>Effect</dt><dd>{{ request.output_class | sailCaption:'agent_output_class' }}</dd>
29
+ <dt>Risk tier</dt><dd>{{ request.risk_tier }}</dd>
30
+ <dt>Required by</dt><dd>{{ request.rule_source | sailCaption:'approval_rule_source' }} · {{ request.rule_id }}</dd>
31
+ @if (request.approver_reference; as approver) {
32
+ <dt>Assigned to</dt><dd>{{ approver.kind | sailCaption:'approval_approver_kind' }} {{ approver.ref }} (step {{ request.route_step }})</dd>
33
+ }
34
+ @if (request.deadline_at) {
35
+ <dt>Deadline</dt><dd>{{ request.deadline_at | date: 'medium' }}</dd>
36
+ }
37
+ @if (request.revises_step_id) {
38
+ <dt>Revises</dt><dd>step {{ request.revises_step_id }}</dd>
39
+ }
40
+ <dt>Requested</dt><dd>{{ request.created_at | date: 'medium' }}</dd>
41
+ </dl>
42
+
43
+ <mat-form-field>
44
+ <mat-label>Reason (optional)</mat-label>
45
+ <textarea matInput rows="2" [value]="reason()" (input)="reason.set($any($event.target).value)"></textarea>
46
+ </mat-form-field>
47
+ <mat-form-field>
48
+ <mat-label>Revised tool arguments (JSON object, for Edit)</mat-label>
49
+ <textarea matInput rows="4" [value]="revisedArguments()" (input)="revisedArguments.set($any($event.target).value)"></textarea>
50
+ </mat-form-field>
51
+
52
+ <div class="approval-inbox__actions">
53
+ <button matButton="elevated" type="button" [disabled]="loading()" (click)="decide('A')">Approve</button>
54
+ <button matButton type="button" class="warn" [disabled]="loading()" (click)="decide('X')">Reject</button>
55
+ <button matButton type="button" [disabled]="loading() || !revisedArguments().trim()" (click)="decide('E')">Edit and run</button>
56
+ </div>
57
+ </section>
58
+ }
59
+
60
+ @if (errorMessage()) {
61
+ <p class="approval-inbox__error" role="alert">{{ errorMessage() }}</p>
62
+ }
63
+ </div>
@@ -0,0 +1,91 @@
1
+ import { DatePipe } from '@angular/common';
2
+ import { ChangeDetectionStrategy, Component, OnInit, computed, inject, output, signal } from '@angular/core';
3
+ import { MatButtonModule } from '@angular/material/button';
4
+ import { MatFormFieldModule } from '@angular/material/form-field';
5
+ import { MatInputModule } from '@angular/material/input';
6
+ import { BaseAsync } from '../../component/abstract/base_async';
7
+ import { CaptionPipe } from '../../service/caption_pipe';
8
+ import { ApprovalRequest, ApprovalVerdict } from '../model/approval';
9
+ import { ApprovalService } from '../service/approval.service';
10
+
11
+ /**
12
+ * The signed-in person's pending approvals and a decision view: approve, reject, or edit a
13
+ * tool call's arguments. A 409 (reassigned or decided elsewhere) reloads the list.
14
+ * Selector: <sail-approval-inbox>.
15
+ */
16
+ @Component({
17
+ selector: 'sail-approval-inbox',
18
+ changeDetection: ChangeDetectionStrategy.OnPush,
19
+ imports: [DatePipe, MatButtonModule, MatFormFieldModule, MatInputModule, CaptionPipe],
20
+ templateUrl: './approval_inbox.html',
21
+ })
22
+ export class ApprovalInboxComponent extends BaseAsync implements OnInit {
23
+ private readonly approvals = inject(ApprovalService);
24
+
25
+ readonly decided = output<ApprovalRequest>();
26
+
27
+ readonly requests = signal<ApprovalRequest[]>([]);
28
+ readonly selectedKey = signal('');
29
+ readonly reason = signal('');
30
+ readonly revisedArguments = signal('');
31
+ readonly selected = computed(() => this.requests().find((r) => this.key(r) === this.selectedKey()));
32
+
33
+ ngOnInit(): void {
34
+ this.load();
35
+ }
36
+
37
+ load(): void {
38
+ this.run(this.approvals.pending(), (rows) => {
39
+ this.requests.set(rows);
40
+ if (!this.selected()) this.select(rows[0]);
41
+ }, 'Failed to load approvals.');
42
+ }
43
+
44
+ select(request: ApprovalRequest | undefined): void {
45
+ this.selectedKey.set(request ? this.key(request) : '');
46
+ this.reason.set('');
47
+ this.revisedArguments.set('');
48
+ }
49
+
50
+ decide(verdict: ApprovalVerdict): void {
51
+ const request = this.selected();
52
+ if (!request) return;
53
+ let revised: Record<string, unknown> | undefined;
54
+ if (verdict === 'E') {
55
+ revised = parseObject(this.revisedArguments());
56
+ if (!revised) {
57
+ this.errorMessage.set('Revised arguments must be a JSON object.');
58
+ return;
59
+ }
60
+ }
61
+ this.run(this.approvals.resolve({
62
+ request_id: request.request_id,
63
+ execution_step_id: request.execution_step_id,
64
+ verdict,
65
+ proposed_digest: request.proposed_digest,
66
+ reason: this.reason().trim() || undefined,
67
+ revised_arguments: revised,
68
+ }), (result) => {
69
+ this.requests.update((rows) => rows.filter((r) => this.key(r) !== this.key(request)));
70
+ this.select(this.requests()[0]);
71
+ this.decided.emit(result);
72
+ }, 'Failed to record the decision.', (err) => {
73
+ if ((err as { status?: number })?.status !== 409) return;
74
+ this.load();
75
+ this.errorMessage.set('This request was reassigned or decided elsewhere; the list was refreshed.');
76
+ });
77
+ }
78
+
79
+ key(request: ApprovalRequest): string {
80
+ return `${request.request_id}#${request.execution_step_id}`;
81
+ }
82
+ }
83
+
84
+ function parseObject(text: string): Record<string, unknown> | undefined {
85
+ try {
86
+ const value: unknown = JSON.parse(text);
87
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
@@ -5,14 +5,14 @@
5
5
  @for (record of group.records; track record.id) {
6
6
  <li
7
7
  class="decision-record"
8
- [class.decision-record--allow]="record.outcome === 'allow'"
9
- [class.decision-record--deny]="record.outcome === 'deny'"
10
- [class.decision-record--pending]="record.outcome === 'pending'"
8
+ [class.decision-record--allow]="record.outcome === 'A'"
9
+ [class.decision-record--deny]="record.outcome === 'D'"
10
+ [class.decision-record--pending]="record.outcome === 'P'"
11
11
  >
12
12
  <time class="decision-record__time" [attr.datetime]="record.occurred_at">{{ record.occurred_at }}</time>
13
13
  <span class="decision-record__action">{{ record.action }}</span>
14
14
  <span class="decision-record__resource">{{ record.resource }}</span>
15
- <span class="decision-record__outcome">{{ record.outcome }}</span>
15
+ <span class="decision-record__outcome">{{ record.outcome | sailCaption:'audit_decision_outcome' }}</span>
16
16
  <span class="decision-record__principal">{{ record.principal.kind }} {{ record.principal.id }}</span>
17
17
  @if (record.reason) {
18
18
  <p class="decision-record__reason">{{ record.reason }}</p>
@@ -1,4 +1,5 @@
1
1
  import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
2
+ import { CaptionPipe } from '../../service/caption_pipe';
2
3
  import { DecisionCategoryGroup, DecisionRecord } from '../model/decision';
3
4
 
4
5
  /**
@@ -8,6 +9,7 @@ import { DecisionCategoryGroup, DecisionRecord } from '../model/decision';
8
9
  @Component({
9
10
  selector: 'sail-decision-timeline',
10
11
  changeDetection: ChangeDetectionStrategy.OnPush,
12
+ imports: [CaptionPipe],
11
13
  templateUrl: './decision_timeline.html',
12
14
  })
13
15
  export class DecisionTimelineComponent {
@@ -37,8 +37,9 @@
37
37
  <mat-form-field>
38
38
  <mat-label>Merge mode</mat-label>
39
39
  <mat-select formControlName="merge_mode">
40
- <mat-option value="append">Append</mat-option>
41
- <mat-option value="replace">Replace</mat-option>
40
+ @for (mode of mergeModes(); track mode.code) {
41
+ <mat-option [value]="mode.code">{{ mode.caption }}</mat-option>
42
+ }
42
43
  </mat-select>
43
44
  </mat-form-field>
44
45
  <mat-form-field>
@@ -64,7 +65,7 @@
64
65
  } @else {
65
66
  <dl class="prompt-layer__readonly">
66
67
  <dt>Merge mode</dt>
67
- <dd>{{ row.layer.merge_mode }}</dd>
68
+ <dd>{{ row.layer.merge_mode | sailCaption:'config_merge_mode' }}</dd>
68
69
  <dt>Instruction</dt>
69
70
  <dd class="prompt-layer__text">{{ row.layer.instruction }}</dd>
70
71
  <dt>Output</dt>
@@ -1,5 +1,5 @@
1
- import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal, untracked } from '@angular/core';
2
- import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
1
+ import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal, untracked } from '@angular/core';
2
+ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
3
3
  import { FormArray, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors } from '@angular/forms';
4
4
  import { MatButtonModule } from '@angular/material/button';
5
5
  import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -7,6 +7,9 @@ import { MatFormFieldModule } from '@angular/material/form-field';
7
7
  import { MatIconModule } from '@angular/material/icon';
8
8
  import { MatInputModule } from '@angular/material/input';
9
9
  import { MatSelectModule } from '@angular/material/select';
10
+ import { map } from 'rxjs';
11
+ import { CaptionPipe } from '../../service/caption_pipe';
12
+ import { LabelOption, LabelService } from '../../service/label.service';
10
13
  import { AgentMergeMode, AgentPromptLayer, AgentPromptSection } from '../model/agent_studio';
11
14
 
12
15
  type LayerForm = FormGroup<{
@@ -25,7 +28,7 @@ export type EditableLayerSlot = 'type' | 'agent';
25
28
 
26
29
  function layerContentRequired(group: LayerForm): ValidationErrors | null {
27
30
  const { instruction, output, merge_mode } = group.getRawValue();
28
- if (merge_mode === 'replace') return instruction.trim() ? null : { instructionRequired: true };
31
+ if (merge_mode === 'R') return instruction.trim() ? null : { instructionRequired: true };
29
32
  return instruction.trim() || output.trim() ? null : { contentRequired: true };
30
33
  }
31
34
 
@@ -37,7 +40,7 @@ function layerContentRequired(group: LayerForm): ValidationErrors | null {
37
40
  @Component({
38
41
  selector: 'sail-prompt-section-editor',
39
42
  changeDetection: ChangeDetectionStrategy.OnPush,
40
- imports: [ReactiveFormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule],
43
+ imports: [ReactiveFormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule, CaptionPipe],
41
44
  templateUrl: './prompt_section_editor.html',
42
45
  })
43
46
  export class PromptSectionEditorComponent {
@@ -49,6 +52,10 @@ export class PromptSectionEditorComponent {
49
52
  readonly sectionChange = output<AgentPromptSection>();
50
53
  readonly validChange = output<boolean>();
51
54
 
55
+ private readonly labels = inject(LabelService);
56
+ /** A prompt layer appends or replaces; intersect applies to other settings only. */
57
+ protected readonly mergeModes = toSignal(this.labels.loadLabels('config_merge_mode').pipe(
58
+ map(() => this.labels.options('config_merge_mode').filter((o) => o.code !== 'I'))), { initialValue: [] as LabelOption[] });
52
59
  protected readonly forms = new FormArray<LayerForm>([]);
53
60
  protected readonly rows = signal<LayerRow[]>([]);
54
61
  private emitted?: AgentPromptSection;
@@ -95,7 +102,7 @@ export class PromptSectionEditorComponent {
95
102
  const layer: AgentPromptLayer = {
96
103
  scope_id: slot === 'type' ? this.typeScopeId() : this.agentScopeId(),
97
104
  scope_kind: '',
98
- merge_mode: 'append',
105
+ merge_mode: 'A',
99
106
  sealed: false,
100
107
  editable: true,
101
108
  instruction: '',
@@ -6,7 +6,7 @@
6
6
  <h4 class="release-section__caption">{{ section.caption }}</h4>
7
7
  <p class="release-section__source">
8
8
  Decided by {{ section.source.scope_kind }} scope {{ section.source.scope_id }}
9
- ({{ section.source.merge_mode }})
9
+ ({{ section.source.merge_mode | sailCaption:'config_merge_mode' }})
10
10
  @if (section.source.sealed) {
11
11
  <span class="release-section__sealed">
12
12
  <mat-icon aria-hidden="true">lock</mat-icon>
@@ -1,5 +1,6 @@
1
1
  import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
2
2
  import { MatIconModule } from '@angular/material/icon';
3
+ import { CaptionPipe } from '../../service/caption_pipe';
3
4
  import { AgentReleaseSection } from '../model/agent_studio';
4
5
 
5
6
  interface ReleaseLanguageGroup {
@@ -14,7 +15,7 @@ interface ReleaseLanguageGroup {
14
15
  @Component({
15
16
  selector: 'sail-release-sections',
16
17
  changeDetection: ChangeDetectionStrategy.OnPush,
17
- imports: [MatIconModule],
18
+ imports: [MatIconModule, CaptionPipe],
18
19
  templateUrl: './release_sections.html',
19
20
  })
20
21
  export class ReleaseSectionsComponent {