@nauticana/sail 1.1.33 → 1.1.35
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 +36 -23
- package/package.json +1 -1
- package/src/agent/component/approval_inbox.html +110 -0
- package/src/agent/component/approval_inbox.ts +129 -0
- package/src/agent/component/assistant_activity.html +28 -0
- package/src/agent/component/assistant_activity.ts +40 -0
- package/src/agent/component/decision_timeline.html +4 -4
- package/src/agent/component/decision_timeline.ts +2 -0
- package/src/agent/component/prompt_section_editor.html +4 -3
- package/src/agent/component/prompt_section_editor.ts +12 -5
- package/src/agent/component/release_sections.html +1 -1
- package/src/agent/component/release_sections.ts +2 -1
- package/src/agent/component/studio_page.html +303 -0
- package/src/agent/component/studio_page.ts +403 -0
- package/src/agent/component/studio_tab.ts +14 -0
- package/src/agent/component/turn_event_list.html +9 -0
- package/src/agent/component/turn_thread.html +27 -0
- package/src/agent/component/turn_thread.ts +83 -0
- package/src/agent/index.ts +8 -0
- package/src/agent/model/agent_studio.ts +185 -9
- package/src/agent/model/approval.ts +78 -0
- package/src/agent/model/conversation.ts +40 -1
- package/src/agent/model/decision.ts +14 -9
- package/src/agent/model/turn_event.ts +10 -0
- package/src/agent/service/agent_studio.service.ts +65 -1
- package/src/agent/service/approval.service.ts +38 -0
- package/src/agent/service/conversation.service.ts +10 -0
- package/src/agent/util/release_diff.ts +33 -0
- package/src/agent/util/studio_errors.ts +11 -0
- package/src/agent/util/turn_timeline.ts +7 -1
- package/src/component/agency/agency_earnings_summary.html +3 -3
- package/src/component/agency/agency_earnings_summary.ts +2 -2
- package/src/component/agency/agency_payout_history.html +1 -1
- package/src/component/agency/agency_payout_history.ts +2 -3
- package/src/component/agency/commission_ledger.html +1 -1
- package/src/component/agency/commission_ledger.ts +2 -3
- package/src/component/billing/plan_selector.ts +1 -1
- package/src/component/billing/price_selector.ts +5 -5
- package/src/component/login/account_deletion.html +17 -4
- package/src/component/login/account_deletion.ts +35 -28
- package/src/component/login/social_login.ts +6 -5
- package/src/component/payout/bank_info_form.html +15 -6
- package/src/component/payout/bank_info_form.ts +28 -72
- package/src/component/security/api_keys.html +78 -0
- package/src/component/security/api_keys.ts +90 -0
- package/src/component/security/connected_apps.html +25 -0
- package/src/component/security/connected_apps.ts +40 -0
- package/src/index.ts +8 -0
- package/src/model/access.ts +28 -0
- package/src/model/appdata.ts +41 -67
- package/src/model/auth.ts +17 -2
- package/src/model/geo.ts +12 -0
- package/src/service/access.service.ts +57 -0
- package/src/service/auth.service.ts +10 -4
- package/src/service/caption_pipe.ts +32 -0
- package/src/service/currency.service.ts +51 -0
- package/src/service/label.service.ts +5 -4
- package/src/service/money_pipe.ts +27 -0
- package/src/service/rest_service.ts +1 -1
- package/src/service/rest_url.ts +4 -0
- package/src/util/money.ts +18 -34
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.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.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
|
|
|
@@ -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`, `SessionsComponent`, `AccountDeletionComponent` |
|
|
19
|
+
| **Security** | `TwoFactorSetupComponent`, `TwoFactorVerifyComponent`, `TrustedDevicesComponent`, `SessionsComponent`, `ApiKeysComponent`, `ConnectedAppsComponent`, `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` |
|
|
@@ -29,9 +29,9 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
29
29
|
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `AgencyService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `PartnerDocumentService`, `NotificationInboxService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
30
30
|
| **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `AbstractEditableView`, `BaseAsync`, `BaseRestService` |
|
|
31
31
|
| **Config** | `SAIL_GUI_CONFIG`, `SailGuiConfig`, `configureRestUrls()` |
|
|
32
|
-
| **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `TableActionParameter`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `
|
|
32
|
+
| **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `TableActionParameter`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `Currency`, `Country`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, `InboxMessage`/`InboxPage`, `PartnerDocument`/`DocumentUploadRequest`, 2FA types, etc. |
|
|
33
33
|
| **Decorators** | `@IsSailString()`, `@IsSailNumeric()` (class-validator based) |
|
|
34
|
-
| **Utils** | `titleCase()`, `fromPriceLabel()`, `
|
|
34
|
+
| **Utils** | `titleCase()`, `fromPriceLabel()`, `formatMinor()`, `CurrencyService`, `sailMoney` pipe |
|
|
35
35
|
|
|
36
36
|
## Quick start
|
|
37
37
|
|
|
@@ -262,9 +262,13 @@ this.backend.listPaginated<MyRow>('orders', { Status: 'open' }, {
|
|
|
262
262
|
|
|
263
263
|
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.
|
|
264
264
|
|
|
265
|
+
## Money
|
|
266
|
+
|
|
267
|
+
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.
|
|
268
|
+
|
|
265
269
|
## Backend-driven constant captions
|
|
266
270
|
|
|
267
|
-
`LabelService` loads one
|
|
271
|
+
`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
272
|
|
|
269
273
|
```typescript
|
|
270
274
|
this.labels.loadLabels('ride_type').subscribe(() => {
|
|
@@ -410,6 +414,8 @@ The login flow redirects `twoFactorRequired` responses to `/login/2fa`. With `tw
|
|
|
410
414
|
|
|
411
415
|
`SessionsComponent` (`<sail-sessions>`) lists the sessions with their `sign_in_method` captions, marks the current one, and signs out any other.
|
|
412
416
|
|
|
417
|
+
`ApiKeysComponent` (`<sail-api-keys [scopes]>`) lists the partner's keys and uses the backend-provided `generate` and `restrict` table actions; controls appear only for granted actions. Pass the scopes the app offers (`LabelOption[]`) after filtering out scopes the caller may not mint. The role also needs generic `SELECT` and `DELETE` grants to list and revoke keys. `ConnectedAppsComponent` (`<sail-connected-apps>`) lists and disconnects OAuth clients through keel's `GrantHandler` routes (v1.2.112, `RestURL.oauthGrantsURL` and `oauthGrantRevokeURL`).
|
|
418
|
+
|
|
413
419
|
### Cross-origin cookie requirement
|
|
414
420
|
|
|
415
421
|
The "trust this device" feature (keel v0.9+) is backed by a server-set HttpOnly cookie named `keel_td`. The lifecycle is entirely server-driven:
|
|
@@ -498,9 +504,8 @@ unless the user holds `PARTNER_PLAN_SUBSCRIPTION` `CANCEL` / `CHANGE` / `PORTAL`
|
|
|
498
504
|
</sail-price-selector>
|
|
499
505
|
```
|
|
500
506
|
|
|
501
|
-
`<sail-price-selector>` is presentational (a `radiogroup` of offers), formats each
|
|
507
|
+
`<sail-price-selector>` is presentational (a `radiogroup` of offers), formats each `amountMinor` at the offer's `exponent`, and maps PERIOD_TYPE codes to copy via `[cycleLabels]`. It emits the full `PlanPrice` so you keep the chosen terms, not just the id.
|
|
502
508
|
|
|
503
|
-
> **Migration.** The flat `PublicPlan.priceId` is deprecated (keel v1.0.5 dropped `subscription_plan.provider_price_id`) — read the id from the selected offer (`offer().priceId`). **keel v1.0.7 also removed `PublicPlan.monthlyCost`/`annualCost`** from both `/public/plans` and the billing catalog, so any plan-card code reading them now gets `undefined`. Derive a display price from the offers with `fromPriceLabel(plan.prices)` (used by `<sail-plan-selector>`), or render the per-offer amounts via `<sail-price-selector>`. These fields are removed from sail's `PublicPlan` type so the compiler flags any remaining references.
|
|
504
509
|
|
|
505
510
|
**Checkout button** — calls `createCheckout()` and redirects to the provider-hosted URL. Feed it the chosen offer's `priceId`:
|
|
506
511
|
```html
|
|
@@ -684,7 +689,7 @@ A complete screen is the pieces above assembled over `BillingService` — no met
|
|
|
684
689
|
|
|
685
690
|
## Agent UI (v1.1.20)
|
|
686
691
|
|
|
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.
|
|
692
|
+
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.13.
|
|
688
693
|
|
|
689
694
|
```ts
|
|
690
695
|
import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@nauticana/sail/agent';
|
|
@@ -692,7 +697,13 @@ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@
|
|
|
692
697
|
|
|
693
698
|
| Export | Purpose |
|
|
694
699
|
|---|---|
|
|
695
|
-
| `AgentStudioService` | Client for
|
|
700
|
+
| `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. |
|
|
701
|
+
| `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. |
|
|
702
|
+
| `sail-studio-page` Guardrails tab | Shown when the `features` manifest lists `guardrails`: inherited layers read-only, the agent's own rules as an editable JSON array saved against `expected_agent_revision`, and its safety events paged by `before`. Edit needs the feature's `EDIT` action. The rail captions Scout's fixed readiness values (`AGENT_READINESS_CAPTIONS`). |
|
|
703
|
+
| `sail-assistant-activity` | The tenant's decision records from Scout's `decisions` feature (`GET /api/agent-studio/decisions`, `AGENT_AUDIT` `VIEW`), newest first and paged by `next_before`: when, action, resource, outcome caption, who (`[who]` names a principal), app (`client_ref`), and whom it acted for. `[category]` and `[outcome]` filter. |
|
|
704
|
+
| `sail-turn-thread` | The signed-in person's turns from `GET {base}turns` (`ConversationService.turns`), newest first, paged by `next_before` and optionally narrowed to `[workRef]`: `turn_status` caption, settled cost in minor units with its exponent, agent and release version. Each `task_kind` renders its result through `<ng-template sailTurnResult="kind" let-turn>`; the first page reloads every `[pollMs]` while a turn is nonterminal. |
|
|
705
|
+
| `sail-approval-inbox` / `ApprovalService` | The signed-in person's pending approvals (`GET /api/approval/pending`) and MCP confirmations (`GET /api/approval/confirmations`) in one list. Approvals are approved, rejected, or edited (`POST /api/approval/resolve`); confirmations are confirmed, declined, withdrawn, or reconciled as executed or failed through `/api/approval/confirmation/*`, each control shown only when Scout says the person may. A `409` reloads the list; `(decided)` and `(confirmationChanged)` emit the result. |
|
|
706
|
+
| `studioErrorMessage(err)` | One message for a failed Studio call: the conflict cause, the field errors, or the detail. |
|
|
696
707
|
| `studioConflictCause(err)` | `'revision'` (reload the draft), `'sealed'` (a layer sits under a sealed one) or `'other'` for a `409`. |
|
|
697
708
|
| `studioFieldErrors(err)` / `retryAfterSeconds(err)` | Field errors of a `400`; `Retry-After` of a `429`/`503`. |
|
|
698
709
|
| `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. |
|
|
@@ -705,7 +716,7 @@ import { AgentStudioService, PromptLanguageEditorComponent, TurnStream } from '@
|
|
|
705
716
|
| `ConversationService` | Transport for Scout's conversation bridge: `submit`, `stream(requestId, turnStream)`, `cancel`. Mount point injected through `CONVERSATION_BASE_PATH`. |
|
|
706
717
|
| `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. |
|
|
707
718
|
|
|
708
|
-
|
|
719
|
+
`DecisionRecord` is Scout's `api.DecisionRecord` (outcome `A`, `D`, or `P`, captioned from `audit_decision_outcome`); the timeline takes the records of one request, the activity table pages the tenant's. 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.
|
|
709
720
|
|
|
710
721
|
### Conversation transport (v1.1.22)
|
|
711
722
|
|
|
@@ -719,7 +730,7 @@ providers: [{ provide: CONVERSATION_BASE_PATH, useValue: '/api/wingmate/' }]
|
|
|
719
730
|
|
|
720
731
|
```ts
|
|
721
732
|
const stream = new TurnStream(requestId);
|
|
722
|
-
this.conversation.submit({ request_id: requestId, conversation_id: conversationId,
|
|
733
|
+
this.conversation.submit({ request_id: requestId, conversation_id: conversationId, agent_type: 'wingmate', input })
|
|
723
734
|
.pipe(switchMap(() => this.conversation.stream(requestId, stream)), takeUntilDestroyed())
|
|
724
735
|
.subscribe({ error: (err) => this.onStreamError(err) });
|
|
725
736
|
```
|
|
@@ -813,10 +824,9 @@ pending profile. The ledger and payout-history components show default empty
|
|
|
813
824
|
messages; pass `[emptyMessage]="''"` when a composed page should omit empty
|
|
814
825
|
regions entirely.
|
|
815
826
|
|
|
816
|
-
All accounting values are integer minor units
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
being summed into a misleading total.
|
|
827
|
+
All accounting values are integer minor units rendered through the `sailMoney` pipe at the
|
|
828
|
+
exponent of keel's `currency` catalog (see [Money](#money)); earnings stay separated by
|
|
829
|
+
currency rather than being summed into a misleading total.
|
|
820
830
|
|
|
821
831
|
**Delegation roles (v1.1.27, keel v1.2.89).** `AgencyDelegation.roles` (always present) and
|
|
822
832
|
`AgencyClient.roles` carry `AgencyRoleGrant { role, expiresAt? }`; no `expiresAt` is
|
|
@@ -1016,7 +1026,7 @@ Config required in `SAIL_GUI_CONFIG`:
|
|
|
1016
1026
|
}
|
|
1017
1027
|
```
|
|
1018
1028
|
|
|
1019
|
-
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).
|
|
1029
|
+
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).
|
|
1020
1030
|
|
|
1021
1031
|
For backward compatibility, the older OAuth-code flow `BaseAuthService.loginWithGoogle(code)` (hits `/public/login/google`) is still supported; prefer `loginSocial` for new code.
|
|
1022
1032
|
|
|
@@ -1383,14 +1393,16 @@ These are App Store / Play Store compliance primitives from keel. All are opt-in
|
|
|
1383
1393
|
|
|
1384
1394
|
### Re-authentication gate
|
|
1385
1395
|
|
|
1386
|
-
The four sensitive endpoints below require **recent re-authentication** before they will run — pass `password`
|
|
1396
|
+
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.
|
|
1387
1397
|
|
|
1388
1398
|
| Method | Required re-auth |
|
|
1389
1399
|
|--------|-----------------|
|
|
1390
1400
|
| `setup2FA(reauth)` | `password` (or `twoFactorCode` when re-rotating) |
|
|
1391
1401
|
| `disable2FA(password, code)` | **both** `password` and current TOTP `code` |
|
|
1392
|
-
| `deleteAccount(reauth, reason?)` | `password`
|
|
1393
|
-
| `logoutEverywhere(reauth)` | `password`
|
|
1402
|
+
| `deleteAccount(reauth, reason?)` | `password`, `twoFactorCode` or `reauthCode` |
|
|
1403
|
+
| `logoutEverywhere(reauth)` | `password`, `twoFactorCode` or `reauthCode` |
|
|
1404
|
+
|
|
1405
|
+
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).
|
|
1394
1406
|
|
|
1395
1407
|
The `ReauthCredentials` type is exported as a shared shape:
|
|
1396
1408
|
```typescript
|
|
@@ -1402,10 +1414,10 @@ auth.deleteAccount(reauth, 'Closing my account.').subscribe();
|
|
|
1402
1414
|
### Account deletion
|
|
1403
1415
|
|
|
1404
1416
|
```html
|
|
1405
|
-
<sail-account-deletion [confirmationText]="'DELETE'"></sail-account-deletion>
|
|
1417
|
+
<sail-account-deletion [confirmationText]="'DELETE'" reauthMethod="code"></sail-account-deletion>
|
|
1406
1418
|
```
|
|
1407
1419
|
|
|
1408
|
-
Typed-confirmation UX: the destructive button is disabled until the user types the exact `confirmationText` (default `DELETE`) **and**
|
|
1420
|
+
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`).
|
|
1409
1421
|
|
|
1410
1422
|
### Logout everywhere
|
|
1411
1423
|
|
|
@@ -1433,6 +1445,7 @@ Platform codes: `'I'` iOS, `'A'` Android, `'W'` Web. The token acquisition (Capa
|
|
|
1433
1445
|
|----------|---------------|
|
|
1434
1446
|
| `DELETE /api/user/account` | `deleteAccount(reauth, reason?)` |
|
|
1435
1447
|
| `POST /api/user/logout-everywhere` | `logoutEverywhere(reauth)` |
|
|
1448
|
+
| `POST /api/user/reauth/send` | `sendReauthCode(channel?)` |
|
|
1436
1449
|
| `POST /api/push/register` | `registerPushToken(platform, token, appVersion?, deviceModel?)` |
|
|
1437
1450
|
| `POST /api/push/revoke` | `revokePushToken(token)` |
|
|
1438
1451
|
|
|
@@ -1601,10 +1614,10 @@ The v0.6 / v0.7 line introduced three additive feature groups against keel v0.7.
|
|
|
1601
1614
|
|---|---|---|
|
|
1602
1615
|
| `@nauticana/sail` | `PayoutService` | keel/payout API client — onboarding, reuse, status, bank-version replacement, beneficiary registration |
|
|
1603
1616
|
| `@nauticana/sail` | `PayoutProviderOnboardingComponent` (`<sail-payout-provider-onboarding>`) | Drop-in onboarding step with reuse picker, hosted-KYC handoff, and asynchronous-confirmation output |
|
|
1604
|
-
| `@nauticana/sail` | `PayoutBankInfoFormComponent` (`<sail-payout-bank-info-form>`) | Tax + payout details form
|
|
1617
|
+
| `@nauticana/sail` | `PayoutBankInfoFormComponent` (`<sail-payout-bank-info-form>`) | Tax + payout details form; countries from keel's `country` table, tax-id types from the `tax_id_type` domain |
|
|
1605
1618
|
| `@nauticana/sail` | `UserPaymentMethodService` | Saved-card list / remove / set-default API client |
|
|
1606
1619
|
| `@nauticana/sail` | `UserPaymentMethodsComponent` (`<sail-user-payment-methods>`) | List of saved cards/wallets with set-default + remove |
|
|
1607
|
-
| `@nauticana/sail` | `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `
|
|
1620
|
+
| `@nauticana/sail` | `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `UserPaymentMethod` | Model types |
|
|
1608
1621
|
|
|
1609
1622
|
### 2. New keel endpoints
|
|
1610
1623
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.35",
|
|
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,110 @@
|
|
|
1
|
+
<div class="approval-inbox">
|
|
2
|
+
<section class="approval-inbox__list" aria-label="Pending approvals">
|
|
3
|
+
@if (requests().length === 0 && confirmations().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(key(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
|
+
@for (c of confirmations(); track confirmationKey(c)) {
|
|
19
|
+
<li>
|
|
20
|
+
<button type="button" class="approval-inbox__item" [class.selected]="confirmationKey(c) === selectedKey()"
|
|
21
|
+
[attr.aria-current]="confirmationKey(c) === selectedKey()" (click)="select(confirmationKey(c))">
|
|
22
|
+
<span class="approval-inbox__summary">{{ c.tool }} · {{ c.status | sailCaption:'mcp_confirmation_status' }}</span>
|
|
23
|
+
@if (c.status === 'P') {
|
|
24
|
+
<span class="approval-inbox__deadline">Expires {{ c.expires_at | date: 'medium' }}</span>
|
|
25
|
+
}
|
|
26
|
+
</button>
|
|
27
|
+
</li>
|
|
28
|
+
}
|
|
29
|
+
</ul>
|
|
30
|
+
<button matButton type="button" [disabled]="loading()" (click)="load()">Refresh</button>
|
|
31
|
+
</section>
|
|
32
|
+
|
|
33
|
+
@if (selected(); as request) {
|
|
34
|
+
<section class="approval-inbox__decision" aria-label="Decision">
|
|
35
|
+
<h3>{{ request.summary || request.action }}</h3>
|
|
36
|
+
<dl>
|
|
37
|
+
<dt>Action</dt><dd>{{ request.action }}</dd>
|
|
38
|
+
<dt>Resource</dt><dd>{{ request.resource }}</dd>
|
|
39
|
+
<dt>Effect</dt><dd>{{ request.output_class | sailCaption:'agent_output_class' }}</dd>
|
|
40
|
+
<dt>Risk tier</dt><dd>{{ request.risk_tier }}</dd>
|
|
41
|
+
<dt>Required by</dt><dd>{{ request.rule_source | sailCaption:'approval_rule_source' }} · {{ request.rule_id }}</dd>
|
|
42
|
+
@if (request.approver_reference; as approver) {
|
|
43
|
+
<dt>Assigned to</dt><dd>{{ approver.kind | sailCaption:'approval_approver_kind' }} {{ approver.ref }} (step {{ request.route_step }})</dd>
|
|
44
|
+
}
|
|
45
|
+
@if (request.deadline_at) {
|
|
46
|
+
<dt>Deadline</dt><dd>{{ request.deadline_at | date: 'medium' }}</dd>
|
|
47
|
+
}
|
|
48
|
+
@if (request.revises_step_id) {
|
|
49
|
+
<dt>Revises</dt><dd>step {{ request.revises_step_id }}</dd>
|
|
50
|
+
}
|
|
51
|
+
<dt>Requested</dt><dd>{{ request.created_at | date: 'medium' }}</dd>
|
|
52
|
+
</dl>
|
|
53
|
+
|
|
54
|
+
<mat-form-field>
|
|
55
|
+
<mat-label>Reason (optional)</mat-label>
|
|
56
|
+
<textarea matInput rows="2" [value]="reason()" (input)="reason.set($any($event.target).value)"></textarea>
|
|
57
|
+
</mat-form-field>
|
|
58
|
+
<mat-form-field>
|
|
59
|
+
<mat-label>Revised tool arguments (JSON object, for Edit)</mat-label>
|
|
60
|
+
<textarea matInput rows="4" [value]="revisedArguments()" (input)="revisedArguments.set($any($event.target).value)"></textarea>
|
|
61
|
+
</mat-form-field>
|
|
62
|
+
|
|
63
|
+
<div class="approval-inbox__actions">
|
|
64
|
+
<button matButton="elevated" type="button" [disabled]="loading()" (click)="decide('A')">Approve</button>
|
|
65
|
+
<button matButton type="button" class="warn" [disabled]="loading()" (click)="decide('X')">Reject</button>
|
|
66
|
+
<button matButton type="button" [disabled]="loading() || !revisedArguments().trim()" (click)="decide('E')">Edit and run</button>
|
|
67
|
+
</div>
|
|
68
|
+
</section>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@if (selectedConfirmation(); as c) {
|
|
72
|
+
<section class="approval-inbox__decision" aria-label="Confirmation">
|
|
73
|
+
<h3>{{ c.tool }}</h3>
|
|
74
|
+
<dl>
|
|
75
|
+
<dt>Status</dt><dd>{{ c.status | sailCaption:'mcp_confirmation_status' }}@if (c.expired) { (expired) }</dd>
|
|
76
|
+
<dt>Requested by</dt><dd>{{ c.maker.kind }} {{ c.maker.id }}@if (c.client_ref) { through {{ c.client_ref }} }</dd>
|
|
77
|
+
<dt>Requested</dt><dd>{{ c.created_at | date: 'medium' }} · expires {{ c.expires_at | date: 'medium' }}</dd>
|
|
78
|
+
@if (c.approval_required) { <dt>Maker-checker</dt><dd>A second person must approve</dd> }
|
|
79
|
+
@if (c.decider; as d) { <dt>Decided</dt><dd>{{ d.kind }} {{ d.id }} · {{ c.decided_at | date: 'medium' }} {{ c.decision_note }}</dd> }
|
|
80
|
+
@if (c.reconciler; as r) { <dt>Reconciled</dt><dd>{{ r.kind }} {{ r.id }} {{ c.reconcile_note }}</dd> }
|
|
81
|
+
@if (c.error) { <dt>Error</dt><dd>{{ c.error }}</dd> }
|
|
82
|
+
</dl>
|
|
83
|
+
@if (c.preview !== undefined) { <pre class="approval-inbox__preview">{{ c.preview | json }}</pre> }
|
|
84
|
+
|
|
85
|
+
@if (c.can_decide || c.can_reconcile) {
|
|
86
|
+
<mat-form-field>
|
|
87
|
+
<mat-label>Note (optional)</mat-label>
|
|
88
|
+
<textarea matInput rows="2" [value]="reason()" (input)="reason.set($any($event.target).value)"></textarea>
|
|
89
|
+
</mat-form-field>
|
|
90
|
+
}
|
|
91
|
+
<div class="approval-inbox__actions">
|
|
92
|
+
@if (c.can_decide) {
|
|
93
|
+
<button matButton="elevated" type="button" [disabled]="loading()" (click)="confirm(true)">Confirm</button>
|
|
94
|
+
<button matButton type="button" class="warn" [disabled]="loading()" (click)="confirm(false)">Decline</button>
|
|
95
|
+
}
|
|
96
|
+
@if (c.can_withdraw) {
|
|
97
|
+
<button matButton type="button" [disabled]="loading()" (click)="withdraw()">Withdraw</button>
|
|
98
|
+
}
|
|
99
|
+
@if (c.can_reconcile) {
|
|
100
|
+
<button matButton type="button" [disabled]="loading()" (click)="reconcile('D')">Mark executed</button>
|
|
101
|
+
<button matButton type="button" [disabled]="loading()" (click)="reconcile('F')">Mark failed</button>
|
|
102
|
+
}
|
|
103
|
+
</div>
|
|
104
|
+
</section>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@if (errorMessage()) {
|
|
108
|
+
<p class="approval-inbox__error" role="alert">{{ errorMessage() }}</p>
|
|
109
|
+
}
|
|
110
|
+
</div>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { DatePipe, JsonPipe } 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 { Observable, forkJoin } from 'rxjs';
|
|
7
|
+
import { BaseAsync } from '../../component/abstract/base_async';
|
|
8
|
+
import { CaptionPipe } from '../../service/caption_pipe';
|
|
9
|
+
import { ApprovalRequest, ApprovalVerdict, Confirmation } from '../model/approval';
|
|
10
|
+
import { ApprovalService } from '../service/approval.service';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The signed-in person's pending approvals and MCP confirmations with a decision view: approve,
|
|
14
|
+
* reject, or edit a tool call's arguments; confirm, decline, withdraw, or reconcile a confirmation.
|
|
15
|
+
* A 409 (reassigned or decided elsewhere) reloads the list.
|
|
16
|
+
* Selector: <sail-approval-inbox>.
|
|
17
|
+
*/
|
|
18
|
+
@Component({
|
|
19
|
+
selector: 'sail-approval-inbox',
|
|
20
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
21
|
+
imports: [DatePipe, JsonPipe, MatButtonModule, MatFormFieldModule, MatInputModule, CaptionPipe],
|
|
22
|
+
templateUrl: './approval_inbox.html',
|
|
23
|
+
})
|
|
24
|
+
export class ApprovalInboxComponent extends BaseAsync implements OnInit {
|
|
25
|
+
private readonly approvals = inject(ApprovalService);
|
|
26
|
+
|
|
27
|
+
readonly decided = output<ApprovalRequest>();
|
|
28
|
+
readonly confirmationChanged = output<Confirmation>();
|
|
29
|
+
|
|
30
|
+
readonly requests = signal<ApprovalRequest[]>([]);
|
|
31
|
+
readonly confirmations = signal<Confirmation[]>([]);
|
|
32
|
+
readonly selectedKey = signal('');
|
|
33
|
+
readonly reason = signal('');
|
|
34
|
+
readonly revisedArguments = signal('');
|
|
35
|
+
readonly selected = computed(() => this.requests().find((r) => this.key(r) === this.selectedKey()));
|
|
36
|
+
readonly selectedConfirmation = computed(() => this.confirmations().find((c) => this.confirmationKey(c) === this.selectedKey()));
|
|
37
|
+
|
|
38
|
+
ngOnInit(): void {
|
|
39
|
+
this.load();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
load(): void {
|
|
43
|
+
this.run(forkJoin([this.approvals.pending(), this.approvals.confirmations()]), ([requests, confirmations]) => {
|
|
44
|
+
this.requests.set(requests);
|
|
45
|
+
this.confirmations.set(confirmations);
|
|
46
|
+
if (!this.selected() && !this.selectedConfirmation()) this.select(requests[0] ? this.key(requests[0]) : confirmations[0] ? this.confirmationKey(confirmations[0]) : '');
|
|
47
|
+
}, 'Failed to load approvals.');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
select(key: string): void {
|
|
51
|
+
this.selectedKey.set(key);
|
|
52
|
+
this.reason.set('');
|
|
53
|
+
this.revisedArguments.set('');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
confirm(approve: boolean): void {
|
|
57
|
+
const c = this.selectedConfirmation();
|
|
58
|
+
if (!c) return;
|
|
59
|
+
this.applyConfirmation(this.approvals.decideConfirmation(c, approve, this.reason().trim()));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
withdraw(): void {
|
|
63
|
+
const c = this.selectedConfirmation();
|
|
64
|
+
if (!c || !window.confirm('Withdraw this confirmation?')) return;
|
|
65
|
+
this.run(this.approvals.withdrawConfirmation(c), () => this.load(), 'Failed to withdraw the confirmation.');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
reconcile(outcome: 'D' | 'F'): void {
|
|
69
|
+
const c = this.selectedConfirmation();
|
|
70
|
+
if (!c) return;
|
|
71
|
+
this.applyConfirmation(this.approvals.reconcileConfirmation(c, outcome, this.reason().trim()));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private applyConfirmation(request: Observable<Confirmation>): void {
|
|
75
|
+
this.run(request, (result) => {
|
|
76
|
+
this.confirmations.update((rows) => rows.map((c) => c.id === result.id && c.tenant_id === result.tenant_id ? result : c));
|
|
77
|
+
this.reason.set('');
|
|
78
|
+
this.confirmationChanged.emit(result);
|
|
79
|
+
}, 'Failed to record the decision.', (err) => this.reloadOnConflict(err));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
confirmationKey(c: Confirmation): string {
|
|
83
|
+
return `${c.tenant_id}/${c.id}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
decide(verdict: ApprovalVerdict): void {
|
|
87
|
+
const request = this.selected();
|
|
88
|
+
if (!request) return;
|
|
89
|
+
let revised: Record<string, unknown> | undefined;
|
|
90
|
+
if (verdict === 'E') {
|
|
91
|
+
revised = parseObject(this.revisedArguments());
|
|
92
|
+
if (!revised) {
|
|
93
|
+
this.errorMessage.set('Revised arguments must be a JSON object.');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
this.run(this.approvals.resolve({
|
|
98
|
+
request_id: request.request_id,
|
|
99
|
+
execution_step_id: request.execution_step_id,
|
|
100
|
+
verdict,
|
|
101
|
+
proposed_digest: request.proposed_digest,
|
|
102
|
+
reason: this.reason().trim() || undefined,
|
|
103
|
+
revised_arguments: revised,
|
|
104
|
+
}), (result) => {
|
|
105
|
+
this.requests.update((rows) => rows.filter((r) => this.key(r) !== this.key(request)));
|
|
106
|
+
this.select(this.requests()[0] ? this.key(this.requests()[0]) : '');
|
|
107
|
+
this.decided.emit(result);
|
|
108
|
+
}, 'Failed to record the decision.', (err) => this.reloadOnConflict(err));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private reloadOnConflict(err: unknown): void {
|
|
112
|
+
if ((err as { status?: number })?.status !== 409) return;
|
|
113
|
+
this.load();
|
|
114
|
+
this.errorMessage.set('This request was reassigned or decided elsewhere; the list was refreshed.');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
key(request: ApprovalRequest): string {
|
|
118
|
+
return `${request.request_id}#${request.execution_step_id}`;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseObject(text: string): Record<string, unknown> | undefined {
|
|
123
|
+
try {
|
|
124
|
+
const value: unknown = JSON.parse(text);
|
|
125
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<div class="assistant-activity">
|
|
2
|
+
@if (records().length > 0) {
|
|
3
|
+
<table class="assistant-activity__table">
|
|
4
|
+
<thead>
|
|
5
|
+
<tr><th>When</th><th>Action</th><th>Resource</th><th>Outcome</th><th>By</th><th>App</th><th>Acting for</th></tr>
|
|
6
|
+
</thead>
|
|
7
|
+
<tbody>
|
|
8
|
+
@for (record of records(); track record.id) {
|
|
9
|
+
<tr [class]="'assistant-activity__row assistant-activity__row--' + record.outcome">
|
|
10
|
+
<td>{{ record.occurred_at | date: 'medium' }}</td>
|
|
11
|
+
<td><code>{{ record.action }}</code></td>
|
|
12
|
+
<td>{{ record.resource }}</td>
|
|
13
|
+
<td>{{ record.outcome | sailCaption:'audit_decision_outcome' }}@if (record.reason) { <span class="assistant-activity__reason">({{ record.reason }})</span> }</td>
|
|
14
|
+
<td>{{ who()(record) }}</td>
|
|
15
|
+
<td>{{ record.client_ref }}</td>
|
|
16
|
+
<td>@if (record.authority; as a) { {{ a.subject.kind }} {{ a.subject.id }} }</td>
|
|
17
|
+
</tr>
|
|
18
|
+
}
|
|
19
|
+
</tbody>
|
|
20
|
+
</table>
|
|
21
|
+
} @else if (!loading()) {
|
|
22
|
+
<p class="assistant-activity__empty">No assistant activity yet.</p>
|
|
23
|
+
}
|
|
24
|
+
@if (nextBefore()) {
|
|
25
|
+
<button matButton type="button" [disabled]="loading()" (click)="load(nextBefore())">Load more</button>
|
|
26
|
+
}
|
|
27
|
+
@if (errorMessage()) { <p class="assistant-activity__error" role="alert">{{ errorMessage() }}</p> }
|
|
28
|
+
</div>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { DatePipe } from '@angular/common';
|
|
2
|
+
import { ChangeDetectionStrategy, Component, OnInit, inject, input, signal } from '@angular/core';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { BaseAsync } from '../../component/abstract/base_async';
|
|
5
|
+
import { CaptionPipe } from '../../service/caption_pipe';
|
|
6
|
+
import { DecisionOutcome, DecisionRecord } from '../model/decision';
|
|
7
|
+
import { AgentStudioService } from '../service/agent_studio.service';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What assistants did in the tenant: Scout's decision records (`decisions` feature, `AGENT_AUDIT`
|
|
11
|
+
* `VIEW`), newest first and paged by `next_before`. Selector: <sail-assistant-activity>.
|
|
12
|
+
*/
|
|
13
|
+
@Component({
|
|
14
|
+
selector: 'sail-assistant-activity',
|
|
15
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
|
+
imports: [DatePipe, MatButtonModule, CaptionPipe],
|
|
17
|
+
templateUrl: './assistant_activity.html',
|
|
18
|
+
})
|
|
19
|
+
export class AssistantActivityComponent extends BaseAsync implements OnInit {
|
|
20
|
+
private readonly studio = inject(AgentStudioService);
|
|
21
|
+
|
|
22
|
+
readonly category = input('');
|
|
23
|
+
readonly outcome = input<DecisionOutcome | undefined>(undefined);
|
|
24
|
+
/** Who acted, as the app names its principals; kind and id by default. */
|
|
25
|
+
readonly who = input<(record: DecisionRecord) => string>((r) => `${r.principal.kind} ${r.principal.id}`);
|
|
26
|
+
|
|
27
|
+
readonly records = signal<DecisionRecord[]>([]);
|
|
28
|
+
readonly nextBefore = signal(0);
|
|
29
|
+
|
|
30
|
+
ngOnInit(): void {
|
|
31
|
+
this.load();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
load(before = 0): void {
|
|
35
|
+
this.run(this.studio.decisions(before, 0, this.category(), this.outcome()), (page) => {
|
|
36
|
+
this.records.update((rows) => before ? [...rows, ...page.records] : page.records);
|
|
37
|
+
this.nextBefore.set(page.next_before ?? 0);
|
|
38
|
+
}, 'Failed to load assistant activity.');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -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 === '
|
|
9
|
-
[class.decision-record--deny]="record.outcome === '
|
|
10
|
-
[class.decision-record--pending]="record.outcome === '
|
|
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
|
-
|
|
41
|
-
|
|
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 === '
|
|
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: '
|
|
105
|
+
merge_mode: 'A',
|
|
99
106
|
sealed: false,
|
|
100
107
|
editable: true,
|
|
101
108
|
instruction: '',
|