@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.
- package/README.md +31 -7
- package/package.json +1 -1
- package/src/agent/component/studio_page.ts +35 -20
- package/src/component/abstract/base_form.ts +5 -0
- package/src/component/abstract/base_table.ts +23 -29
- package/src/component/abstract/base_view.ts +26 -17
- package/src/component/account/profile_editor.html +15 -8
- package/src/component/account/profile_editor.ts +12 -7
- package/src/component/agency/agency_clients.html +4 -1
- package/src/component/agency/agency_clients.ts +6 -3
- package/src/component/dialog/confirm_dialog.ts +29 -0
- package/src/component/form/field_error.ts +26 -0
- package/src/component/form/form_field.html +5 -10
- package/src/component/form/form_field.ts +10 -0
- package/src/component/login/chpass_component.html +4 -1
- package/src/component/login/chpass_component.ts +6 -3
- package/src/component/login/confirm_chpass_component.html +4 -1
- package/src/component/login/confirm_chpass_component.ts +6 -3
- package/src/component/login/confirm_register_component.html +3 -1
- package/src/component/login/confirm_register_component.ts +6 -3
- package/src/component/login/login_component.html +3 -1
- package/src/component/login/login_component.ts +6 -3
- package/src/component/login/register_component.html +14 -1
- package/src/component/login/register_component.ts +6 -3
- package/src/component/navigation/navigation.html +4 -1
- package/src/component/navigation/navigation.ts +24 -5
- package/src/component/oauth_connection/oauth_connection_panel.html +4 -0
- package/src/component/oauth_connection/oauth_connection_panel.ts +9 -5
- package/src/component/payout/bank_info_form.html +6 -2
- package/src/component/payout/bank_info_form.ts +6 -3
- package/src/component/security/api_keys.html +6 -2
- package/src/component/security/api_keys.ts +13 -7
- package/src/component/security/connected_apps.ts +7 -5
- package/src/component/security/sessions.ts +11 -9
- package/src/component/security/trusted_devices.html +2 -1
- package/src/component/security/trusted_devices.ts +18 -13
- package/src/component/security/twofactor_setup.html +7 -3
- package/src/component/security/twofactor_setup.ts +8 -5
- package/src/component/security/twofactor_verify.html +2 -1
- package/src/component/security/twofactor_verify.ts +6 -3
- package/src/component/table/table_detail.ts +1 -1
- package/src/component/table/table_edit.ts +27 -5
- package/src/component/table/table_list.html +21 -5
- package/src/component/table/table_list.ts +74 -67
- package/src/component/table/table_search.ts +2 -2
- package/src/index.ts +4 -0
- package/src/model/appdata.ts +3 -0
- package/src/service/auth.service.ts +19 -6
- package/src/service/billing.service.ts +8 -6
- package/src/service/prompt.service.ts +27 -0
- 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`,
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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
|
|
234
|
-
this.
|
|
235
|
-
this.
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
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
|
|
262
|
-
this.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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"
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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
|
-
|
|
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 (
|
|
165
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
})
|
|
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]="
|
|
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]="
|
|
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]="
|
|
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]="
|
|
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]="
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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]="
|
|
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.
|
|
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: () => {
|