@nauticana/sail 1.1.34 → 1.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +23 -16
  2. package/package.json +1 -1
  3. package/src/agent/component/approval_inbox.html +93 -22
  4. package/src/agent/component/approval_inbox.ts +199 -17
  5. package/src/agent/component/assistant_activity.html +28 -0
  6. package/src/agent/component/assistant_activity.ts +40 -0
  7. package/src/agent/component/studio_page.html +35 -1
  8. package/src/agent/component/studio_page.ts +67 -10
  9. package/src/agent/component/turn_thread.html +27 -0
  10. package/src/agent/component/turn_thread.ts +94 -0
  11. package/src/agent/index.ts +2 -0
  12. package/src/agent/model/agent_studio.ts +67 -1
  13. package/src/agent/model/approval.ts +45 -1
  14. package/src/agent/model/conversation.ts +35 -1
  15. package/src/agent/model/decision.ts +20 -8
  16. package/src/agent/service/agent_studio.service.ts +29 -0
  17. package/src/agent/service/approval.service.ts +24 -4
  18. package/src/agent/service/conversation.service.ts +10 -0
  19. package/src/component/agency/agency_accept.ts +2 -8
  20. package/src/component/agency/agency_earnings_summary.html +3 -3
  21. package/src/component/agency/agency_earnings_summary.ts +2 -2
  22. package/src/component/agency/agency_payout_history.html +1 -1
  23. package/src/component/agency/agency_payout_history.ts +2 -3
  24. package/src/component/agency/commission_ledger.html +1 -1
  25. package/src/component/agency/commission_ledger.ts +2 -3
  26. package/src/component/billing/plan_selector.ts +1 -1
  27. package/src/component/billing/price_selector.ts +5 -5
  28. package/src/component/oauth_connection/oauth_connection_panel.ts +2 -1
  29. package/src/component/payout/bank_info_form.html +15 -6
  30. package/src/component/payout/bank_info_form.ts +28 -72
  31. package/src/component/security/api_keys.html +85 -0
  32. package/src/component/security/api_keys.ts +96 -0
  33. package/src/component/security/connected_apps.html +25 -0
  34. package/src/component/security/connected_apps.ts +40 -0
  35. package/src/index.ts +7 -0
  36. package/src/model/access.ts +37 -0
  37. package/src/model/appdata.ts +41 -67
  38. package/src/model/geo.ts +12 -0
  39. package/src/service/access.service.ts +56 -0
  40. package/src/service/auth.service.ts +2 -1
  41. package/src/service/caption_pipe.ts +8 -2
  42. package/src/service/currency.service.ts +51 -0
  43. package/src/service/money_pipe.ts +27 -0
  44. package/src/service/rest_service.ts +1 -1
  45. package/src/service/rest_url.ts +3 -0
  46. package/src/util/errors.ts +5 -4
  47. package/src/util/money.ts +18 -34
@@ -7,6 +7,7 @@ import {
7
7
  ConversationStreamError,
8
8
  ConversationStreamFailure,
9
9
  ConversationTurnAccepted,
10
+ ConversationTurnPage,
10
11
  ConversationTurnRequest,
11
12
  } from '../model/conversation';
12
13
  import { TurnReplyFrame } from '../model/reply_frame';
@@ -34,6 +35,15 @@ export class ConversationService extends BaseRestService {
34
35
  return this.http.post<ConversationTurnAccepted>(this.conversationUrl('turn'), request);
35
36
  }
36
37
 
38
+ /** The person's turns, newest first; `before` continues from a page's `next_before`. */
39
+ turns(workRef = '', before = '', limit = 0): Observable<ConversationTurnPage> {
40
+ const params: Record<string, string> = {};
41
+ if (workRef) params['work_ref'] = workRef;
42
+ if (before) params['before'] = before;
43
+ if (limit) params['limit'] = String(limit);
44
+ return this.http.get<ConversationTurnPage>(this.conversationUrl('turns'), { params });
45
+ }
46
+
37
47
  /** Cancel is a request: the turn stays open until its final frame arrives. */
38
48
  cancel(requestId: string, reason?: string): Observable<ConversationTurnAccepted> {
39
49
  return this.http.post<ConversationTurnAccepted>(this.conversationUrl('turn/cancel'), {
@@ -3,7 +3,7 @@ import { MatButtonModule } from '@angular/material/button';
3
3
  import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
4
4
  import { AgencyInvite } from '../../model/agency';
5
5
  import { AgencyService } from '../../service/agency.service';
6
- import { errorCode, errorDetail } from '../../util/errors';
6
+ import { errorCode, errorDetail, httpStatus } from '../../util/errors';
7
7
 
8
8
  const NO_PARTNER_CODE = 'agency_no_partner';
9
9
 
@@ -71,7 +71,7 @@ export class AgencyAcceptComponent implements OnInit {
71
71
  },
72
72
  error: (error: unknown) => {
73
73
  this.accepting.set(false);
74
- if (this.statusOf(error) === 409 && errorCode(error) === NO_PARTNER_CODE) {
74
+ if (httpStatus(error) === 409 && errorCode(error) === NO_PARTNER_CODE) {
75
75
  localStorage.setItem(this.tokenStorageKey(), this.activeToken);
76
76
  this.needsBusiness.set(true);
77
77
  return;
@@ -85,10 +85,4 @@ export class AgencyAcceptComponent implements OnInit {
85
85
  localStorage.setItem(this.tokenStorageKey(), this.activeToken);
86
86
  this.businessSetupRequested.emit(this.activeToken);
87
87
  }
88
-
89
- private statusOf(error: unknown): number | undefined {
90
- return typeof error === 'object' && error !== null && 'status' in error
91
- ? Number((error as { status?: unknown }).status)
92
- : undefined;
93
- }
94
88
  }
@@ -10,15 +10,15 @@
10
10
  @for (balance of value.balances; track balance.currency) {
11
11
  <div class="agency-earnings-summary__metric" [attr.data-currency]="balance.currency">
12
12
  <span class="agency-earnings-summary__label">Held ({{ balance.currency }})</span>
13
- <span class="agency-earnings-summary__value">{{ format(balance.heldMinor, balance.currency) }}</span>
13
+ <span class="agency-earnings-summary__value">{{ balance.heldMinor | sailMoney:balance.currency }}</span>
14
14
  </div>
15
15
  <div class="agency-earnings-summary__metric" [attr.data-currency]="balance.currency">
16
16
  <span class="agency-earnings-summary__label">Due ({{ balance.currency }})</span>
17
- <span class="agency-earnings-summary__value">{{ format(balance.dueMinor, balance.currency) }}</span>
17
+ <span class="agency-earnings-summary__value">{{ balance.dueMinor | sailMoney:balance.currency }}</span>
18
18
  </div>
19
19
  <div class="agency-earnings-summary__metric" [attr.data-currency]="balance.currency">
20
20
  <span class="agency-earnings-summary__label">Paid ({{ balance.currency }})</span>
21
- <span class="agency-earnings-summary__value">{{ format(balance.paidMinor, balance.currency) }}</span>
21
+ <span class="agency-earnings-summary__value">{{ balance.paidMinor | sailMoney:balance.currency }}</span>
22
22
  </div>
23
23
  }
24
24
  }
@@ -1,15 +1,15 @@
1
1
  import { ChangeDetectionStrategy, Component, input } from '@angular/core';
2
2
  import { AgencyEarnings } from '../../model/agency';
3
- import { formatMinorCurrency } from '../../util/money';
3
+ import { MoneyPipe } from '../../service/money_pipe';
4
4
 
5
5
  @Component({
6
6
  selector: 'sail-agency-earnings-summary',
7
7
  changeDetection: ChangeDetectionStrategy.OnPush,
8
+ imports: [MoneyPipe],
8
9
  templateUrl: './agency_earnings_summary.html',
9
10
  host: { class: 'agency-earnings-summary' },
10
11
  })
11
12
  export class AgencyEarningsSummaryComponent {
12
13
  readonly earnings = input<AgencyEarnings | null>(null);
13
14
  readonly activeClients = input(0);
14
- readonly format = formatMinorCurrency;
15
15
  }
@@ -6,7 +6,7 @@
6
6
  @for (payout of payouts(); track payout.id) {
7
7
  <tr>
8
8
  <td>{{ payout.cutoffAt | date: 'yyyy-MM':'UTC' }}</td>
9
- <td>{{ format(payout.amountMinor, payout.currency) }}</td>
9
+ <td>{{ payout.amountMinor | sailMoney:payout.currency }}</td>
10
10
  <td><sail-status-chip domain="agency_payout_status" [value]="payout.status" /></td>
11
11
  <td>{{ payout.paidAt ? (payout.paidAt | date: 'mediumDate':'UTC') : '' }}</td>
12
12
  </tr>
@@ -1,18 +1,17 @@
1
1
  import { DatePipe } from '@angular/common';
2
2
  import { ChangeDetectionStrategy, Component, input } from '@angular/core';
3
3
  import { AgencyPayout } from '../../model/agency';
4
- import { formatMinorCurrency } from '../../util/money';
4
+ import { MoneyPipe } from '../../service/money_pipe';
5
5
  import { StatusChipComponent } from '../billing/status_chip';
6
6
 
7
7
  @Component({
8
8
  selector: 'sail-agency-payout-history',
9
9
  changeDetection: ChangeDetectionStrategy.OnPush,
10
- imports: [DatePipe, StatusChipComponent],
10
+ imports: [DatePipe, StatusChipComponent, MoneyPipe],
11
11
  templateUrl: './agency_payout_history.html',
12
12
  host: { class: 'agency-payout-history' },
13
13
  })
14
14
  export class AgencyPayoutHistoryComponent {
15
15
  readonly payouts = input<AgencyPayout[]>([]);
16
16
  readonly emptyMessage = input('No agency payouts yet.');
17
- readonly format = formatMinorCurrency;
18
17
  }
@@ -7,7 +7,7 @@
7
7
  <tr [class.commission-ledger__reversal]="entry.entryType === 'R'">
8
8
  <td>{{ entry.clientName }}</td>
9
9
  <td>{{ entry.periodStart | date: 'yyyy-MM-dd':'UTC' }} – {{ entry.periodEnd | date: 'yyyy-MM-dd':'UTC' }}</td>
10
- <td>{{ format(entry.amountMinor, entry.currency) }}</td>
10
+ <td>{{ entry.amountMinor | sailMoney:entry.currency }}</td>
11
11
  <td><sail-status-chip domain="agency_commission_status" [value]="entry.status" /></td>
12
12
  </tr>
13
13
  }
@@ -1,18 +1,17 @@
1
1
  import { DatePipe } from '@angular/common';
2
2
  import { ChangeDetectionStrategy, Component, input } from '@angular/core';
3
3
  import { AgencyEarningEntry } from '../../model/agency';
4
- import { formatMinorCurrency } from '../../util/money';
4
+ import { MoneyPipe } from '../../service/money_pipe';
5
5
  import { StatusChipComponent } from '../billing/status_chip';
6
6
 
7
7
  @Component({
8
8
  selector: 'sail-commission-ledger',
9
9
  changeDetection: ChangeDetectionStrategy.OnPush,
10
- imports: [DatePipe, StatusChipComponent],
10
+ imports: [DatePipe, StatusChipComponent, MoneyPipe],
11
11
  templateUrl: './commission_ledger.html',
12
12
  host: { class: 'commission-ledger' },
13
13
  })
14
14
  export class CommissionLedgerComponent {
15
15
  readonly entries = input<AgencyEarningEntry[]>([]);
16
16
  readonly emptyMessage = input('No commission activity yet.');
17
- readonly format = formatMinorCurrency;
18
17
  }
@@ -37,7 +37,7 @@ export class PlanSelectorComponent {
37
37
  const overrides = this.ctaLabels();
38
38
  const out: Record<string, string> = {};
39
39
  for (const plan of this.plans()) {
40
- const mode = plan.activationMode ?? '';
40
+ const mode = plan.activationMode;
41
41
  out[plan.id] = overrides[mode] ?? PlanSelectorComponent.DEFAULT_CTA[mode] ?? 'Choose';
42
42
  }
43
43
  return out;
@@ -1,12 +1,12 @@
1
1
  import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input, output } from '@angular/core';
2
2
  import { PlanPrice } from '../../model/appdata';
3
- import { formatCurrency, PERIOD_TYPE_INFO } from '../../util/money';
3
+ import { formatMinor, PERIOD_TYPE_INFO } from '../../util/money';
4
4
 
5
5
  /** Per-offer view model for accessible, currency-formatted rendering. */
6
6
  interface OfferRow {
7
7
  key: string; // stable id for tracking / selection (priceId or composite)
8
8
  price: PlanPrice;
9
- amountText: string; // currency-formatted major amount
9
+ amountText: string; // amount formatted at the offer's exponent
10
10
  cycleText: string; // "Monthly" / "Annual" / …
11
11
  termText: string; // commitment note, '' when the term is a single cycle
12
12
  }
@@ -16,8 +16,8 @@ interface OfferRow {
16
16
  * sells several offers (billing cycle × commitment term); the customer picks one
17
17
  * at checkout, then its `priceId` feeds `<sail-checkout-button [priceId]>`.
18
18
  *
19
- * Pure presentational — stateless. Amounts are formatted with `Intl` from each
20
- * offer's currency, so non-2-decimal currencies (JPY 0, BHD 3) render correctly.
19
+ * Pure presentational — stateless. Amounts are minor units formatted at the
20
+ * exponent keel sends with each offer.
21
21
  * Period codes map to copy via `[cycleLabels]` (override/extend the defaults).
22
22
  */
23
23
  @Component({
@@ -45,7 +45,7 @@ export class PriceSelectorComponent {
45
45
  return {
46
46
  key: p.priceId || `${p.billingCycle}-${p.termType}-${p.termCount}`,
47
47
  price: p,
48
- amountText: formatCurrency(p.amount, p.currency),
48
+ amountText: formatMinor(p.amountMinor, p.currency, p.exponent),
49
49
  cycleText: cycleLabel(p.billingCycle),
50
50
  termText: singleCycle ? '' : `${p.termCount}-${termNoun(p.termType)} commitment`,
51
51
  };
@@ -7,6 +7,7 @@ import { MatIconModule } from '@angular/material/icon';
7
7
  import { MatFormFieldModule } from '@angular/material/form-field';
8
8
  import { MatInputModule } from '@angular/material/input';
9
9
  import { OAuthConnectionService } from '../../service/oauth_connection.service';
10
+ import { httpStatus } from '../../util/errors';
10
11
  import { OAuthConnectionStatus, OAuthConnectionView, PartnerCredential, OAuthProviderDescriptor, OAuthProviderFieldRole } from '../../model/oauth_connection';
11
12
 
12
13
  /**
@@ -90,7 +91,7 @@ export class OAuthConnectionPanelComponent implements OnInit {
90
91
  }
91
92
 
92
93
  private completeMsg(e: unknown): string {
93
- const status = (e as { status?: number })?.status;
94
+ const status = httpStatus(e);
94
95
  if (status === 403) return 'This connection was started by another user — start again from this account.';
95
96
  if (status === 400) return 'The connection link expired — start again.';
96
97
  return this.msg(e, 'Could not complete the connection.');
@@ -9,12 +9,12 @@
9
9
  <mat-form-field appearance="outline" class="full-width">
10
10
  <mat-label>Country</mat-label>
11
11
  <mat-select formControlName="countryCode">
12
- @for (c of countryProfiles(); track c.code) {
13
- <mat-option [value]="c.code">{{ c.label }} ({{ c.currency }})</mat-option>
12
+ @for (c of options()?.countries ?? []; track c.Id) {
13
+ <mat-option [value]="c.Id">{{ c.Caption }} ({{ c.Currency }})</mat-option>
14
14
  }
15
15
  </mat-select>
16
16
  </mat-form-field>
17
- <p class="hint">Sets your tax ID format + payout currency</p>
17
+ <p class="hint">Sets your payout currency</p>
18
18
 
19
19
  <mat-form-field appearance="outline" class="full-width">
20
20
  <mat-label>Account holder name</mat-label>
@@ -23,10 +23,19 @@
23
23
  <p class="hint">Legal name on the bank account (must match government ID)</p>
24
24
 
25
25
  <mat-form-field appearance="outline" class="full-width">
26
- <mat-label>{{ taxIdLabel() }}</mat-label>
27
- <input matInput formControlName="taxId" [placeholder]="taxIdPlaceholder()">
26
+ <mat-label>Tax ID type</mat-label>
27
+ <mat-select formControlName="taxIdType">
28
+ @for (t of options()?.taxIdTypes ?? []; track t.code) {
29
+ <mat-option [value]="t.code">{{ t.caption }}</mat-option>
30
+ }
31
+ </mat-select>
32
+ </mat-form-field>
33
+
34
+ <mat-form-field appearance="outline" class="full-width">
35
+ <mat-label>Tax ID</mat-label>
36
+ <input matInput formControlName="taxId">
28
37
  </mat-form-field>
29
- <p class="hint">{{ taxIdHint() }}</p>
38
+ <p class="hint">Shared with the tax authority for annual reporting</p>
30
39
 
31
40
  <mat-form-field appearance="outline" class="full-width">
32
41
  <mat-label>Billing address</mat-label>
@@ -1,39 +1,23 @@
1
- import { ChangeDetectionStrategy, Component, computed, effect, inject, input, linkedSignal, output } from '@angular/core';
2
- import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
1
+ import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core';
2
+ import { toSignal } from '@angular/core/rxjs-interop';
3
3
  import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
4
4
  import { MatButtonModule } from '@angular/material/button';
5
5
  import { MatCheckboxModule } from '@angular/material/checkbox';
6
6
  import { MatFormFieldModule } from '@angular/material/form-field';
7
7
  import { MatInputModule } from '@angular/material/input';
8
8
  import { MatSelectModule } from '@angular/material/select';
9
- import { BankInfoFormValue, CountryProfile } from '../../model/appdata';
9
+ import { forkJoin, map } from 'rxjs';
10
+ import { BankInfoFormValue } from '../../model/appdata';
11
+ import { Country } from '../../model/geo';
12
+ import { LabelService } from '../../service/label.service';
13
+ import { BackendService } from '../../service/rest_service';
10
14
 
11
15
  /**
12
- * Default country profiles bundled with sail. Covers CA / US / EU; downstream
13
- * apps can pass a different list via the `[countryProfiles]` input.
14
- *
15
- * Codes match basis constant_header `tax_id_type` seeded by keel:
16
- * S = SIN (CA), N = SSN (US), E = EIN (US business), V = VAT (EU), O = Other.
17
- */
18
- export const DEFAULT_COUNTRY_PROFILES: CountryProfile[] = [
19
- { code: 'CA', label: 'Canada', currency: 'CAD', taxIdType: 'S', taxIdLabel: 'SIN', taxIdPlaceholder: '123456789', taxIdHint: 'Social Insurance Number — shared with the tax authority for annual reporting' },
20
- { code: 'US', label: 'United States', currency: 'USD', taxIdType: 'N', taxIdLabel: 'SSN or EIN', taxIdPlaceholder: '123-45-6789', taxIdHint: 'SSN for an individual recipient, EIN for an incorporated recipient' },
21
- { code: 'DE', label: 'Germany (EU)', currency: 'EUR', taxIdType: 'V', taxIdLabel: 'VAT number', taxIdPlaceholder: 'DE123456789', taxIdHint: 'VAT registration number' },
22
- ];
23
-
24
- /**
25
- * Payout bank-info form.
26
- *
27
- * Captures the basis `user_bank_info` fields (country, currency, account
28
- * holder, tax id, billing address, agreement). Emits the form value via
29
- * `(submitted)` — first-time setup POSTs through the app's registration
30
- * service; CHANGES to an existing destination go through
31
- * PayoutService.replaceBankInfo (atomic version replacement), never a
32
- * generic CRUD edit.
33
- *
34
- * Ships no CSS — the consuming app styles the classes globally.
35
- *
36
- * Selector: <sail-payout-bank-info-form>
16
+ * Payout bank-info form over keel's `user_bank_info` fields. Countries come from the `country`
17
+ * table (its default currency is the payout currency) and tax-id types from the `tax_id_type`
18
+ * domain, so the caller's role needs `TABLE SELECT country`. Emits through `(submitted)`:
19
+ * first-time setup posts through the app's registration service, changes through
20
+ * `PayoutService.replaceBankInfo`, never generic CRUD. Ships no CSS.
37
21
  */
38
22
  @Component({
39
23
  selector: 'sail-payout-bank-info-form',
@@ -45,75 +29,47 @@ export const DEFAULT_COUNTRY_PROFILES: CountryProfile[] = [
45
29
  templateUrl: './bank_info_form.html',
46
30
  })
47
31
  export class PayoutBankInfoFormComponent {
48
- /** Step title rendered above the form. */
49
32
  readonly title = input('Payment and Tax');
50
- /** Optional explainer rendered below the title. */
51
33
  readonly intro = input('Tax + payout details. Bank account itself is set up with the payout provider in a separate step — only tax-reporting and dispute-correspondence fields are collected here.');
52
- /** Override the list of supported countries — keep the default for most apps. */
53
- readonly countryProfiles = input<CountryProfile[]>(DEFAULT_COUNTRY_PROFILES);
54
34
  /** Provider code persisted on user_bank_info.provider — must match the app's keel --payout_provider. */
55
35
  readonly provider = input.required<string>();
56
- /** Wording on the provider-agreement checkbox. Override to name the live provider. */
57
36
  readonly agreementLabel = input('I agree to the payout provider account agreement');
58
- /** Submit-button label — wizards usually use "Next >". */
59
37
  readonly submitLabel = input('Next >');
60
- /** When true, the back button renders alongside submit. */
61
38
  readonly showBack = input(true);
62
39
 
63
- /**
64
- * Emitted on submit as application-layer input. The consumer must use its
65
- * registration service for first-time setup or PayoutService.replaceBankInfo
66
- * for changes; never send this payload through generic CRUD.
67
- */
68
40
  readonly submitted = output<BankInfoFormValue>();
69
- /** Emitted when the user clicks Back. */
70
41
  readonly back = output<void>();
71
42
 
72
- private readonly fb = inject(FormBuilder);
43
+ private readonly backend = inject(BackendService);
44
+ private readonly labels = inject(LabelService);
73
45
 
74
- readonly form = this.fb.group({
46
+ readonly form = inject(FormBuilder).group({
75
47
  countryCode: ['', Validators.required],
48
+ taxIdType: ['', Validators.required],
76
49
  accountHolderName: ['', Validators.required],
77
50
  taxId: ['', Validators.required],
78
51
  billingAddress: ['', Validators.required],
79
52
  providerAgreement: [false, Validators.requiredTrue],
80
53
  });
81
54
 
82
- // linkedSignal, not a field initializer: the bound countryProfiles input
83
- // isn't available yet during construction.
84
- private readonly selectedCountry = linkedSignal<CountryProfile | undefined>(() => this.countryProfiles()[0]);
55
+ readonly options = toSignal(forkJoin({
56
+ countries: this.backend.list<Country>('country', undefined, { limit: 1000, orderBy: 'caption' }),
57
+ labels: this.labels.loadLabels('tax_id_type'),
58
+ }).pipe(map(({ countries }) => ({ countries, taxIdTypes: this.labels.options('tax_id_type') }))));
85
59
 
86
- readonly taxIdLabel = computed(() => this.selectedCountry()?.taxIdLabel ?? '');
87
- readonly taxIdPlaceholder = computed(() => this.selectedCountry()?.taxIdPlaceholder ?? '');
88
- readonly taxIdHint = computed(() => this.selectedCountry()?.taxIdHint ?? '');
89
-
90
- constructor() {
91
- // Keep the form's country in the bound profile list (never a phantom default).
92
- effect(() => {
93
- const profiles = this.countryProfiles();
94
- const current = this.form.controls.countryCode.value;
95
- if (!profiles.some((c) => c.code === current)) {
96
- this.form.controls.countryCode.setValue(profiles[0]?.code ?? '');
97
- }
98
- });
99
- this.form.controls.countryCode.valueChanges
100
- .pipe(takeUntilDestroyed())
101
- .subscribe((code) => {
102
- const profiles = this.countryProfiles();
103
- this.selectedCountry.set(profiles.find((c) => c.code === code) ?? profiles[0]);
104
- });
105
- }
60
+ readonly country = toSignal(this.form.controls.countryCode.valueChanges, { initialValue: '' });
61
+ readonly currency = computed(() => this.options()?.countries.find((c) => c.Id === this.country())?.Currency ?? '');
106
62
 
107
63
  submit() {
108
- const country = this.selectedCountry();
109
- if (this.form.invalid || !country) return;
110
- const v = this.form.value;
64
+ const currency = this.currency();
65
+ if (this.form.invalid || !currency) return;
66
+ const v = this.form.getRawValue();
111
67
  this.submitted.emit({
112
- countryCode: country.code,
113
- currency: country.currency,
68
+ countryCode: v.countryCode!,
69
+ currency,
114
70
  accountHolderName: v.accountHolderName!,
115
71
  billingAddress: v.billingAddress!,
116
- taxIdType: country.taxIdType,
72
+ taxIdType: v.taxIdType!,
117
73
  taxId: v.taxId!,
118
74
  provider: this.provider(),
119
75
  providerAgreement: v.providerAgreement!,
@@ -0,0 +1,85 @@
1
+ <div class="api-keys">
2
+ @if (minted(); as key) {
3
+ <section class="api-keys__minted" role="status" aria-label="New key">
4
+ <p>Copy this key now; it is not shown again.</p>
5
+ <code class="api-keys__secret">{{ key.api_key }}</code>
6
+ <button matButton type="button" (click)="minted.set(null)">I have stored it</button>
7
+ </section>
8
+ }
9
+
10
+ @if (canGenerate) {
11
+ <form class="api-keys__new" [formGroup]="form" (ngSubmit)="mint()">
12
+ <mat-form-field>
13
+ <mat-label>Key name</mat-label>
14
+ <input matInput formControlName="keyName" maxlength="100">
15
+ </mat-form-field>
16
+ <mat-form-field>
17
+ <mat-label>Allowed networks (optional)</mat-label>
18
+ <input matInput formControlName="allowedCidrs" maxlength="2000">
19
+ <mat-hint>Comma-separated addresses or CIDR ranges. Empty allows any address.</mat-hint>
20
+ </mat-form-field>
21
+ <mat-form-field>
22
+ <mat-label>Expires (optional)</mat-label>
23
+ <input matInput type="datetime-local" formControlName="expiresAt">
24
+ </mat-form-field>
25
+ @if (scopes().length) {
26
+ <fieldset class="api-keys__scopes">
27
+ <legend>Scopes</legend>
28
+ @for (scope of scopes(); track scope.code) {
29
+ <mat-checkbox [checked]="form.controls.scopes.value.includes(scope.code)"
30
+ (change)="toggleScope(scope.code, $event.checked)">{{ scope.caption }}</mat-checkbox>
31
+ }
32
+ </fieldset>
33
+ }
34
+ <button matButton="filled" type="submit" [disabled]="form.invalid || loading()">Create key</button>
35
+ </form>
36
+ }
37
+
38
+ @if (keys().length > 0) {
39
+ <table class="api-keys__table">
40
+ <thead>
41
+ <tr><th>Name</th><th>Prefix</th><th>Scopes</th><th>Networks</th><th>Created</th><th>Expires</th><th>Last used</th><th><span class="cdk-visually-hidden">Actions</span></th></tr>
42
+ </thead>
43
+ <tbody>
44
+ @for (key of keys(); track key.Id) {
45
+ <tr [class.api-keys__inactive]="!key.IsActive">
46
+ <td>{{ key.KeyName }}@if (!key.IsActive) { (inactive) }</td>
47
+ <td><code>{{ key.KeyPrefix }}…</code></td>
48
+ <td>{{ key.Scopes }}</td>
49
+ <td>
50
+ @if (restricting() === key.Id) {
51
+ <mat-form-field>
52
+ <mat-label>Allowed networks</mat-label>
53
+ <input matInput [formControl]="networks" maxlength="2000" (keydown.enter)="saveNetworks(key)">
54
+ </mat-form-field>
55
+ } @else {
56
+ {{ key.AllowedCidrs || 'any address' }}
57
+ }
58
+ </td>
59
+ <td>{{ key.CreatedAt | date: 'short' }}</td>
60
+ <td>{{ key.ExpiresAt ? (key.ExpiresAt | date: 'short') : 'never' }}</td>
61
+ <td>{{ key.LastUsedAt ? (key.LastUsedAt | date: 'short') : '' }}</td>
62
+ <td>
63
+ @if (restricting() === key.Id) {
64
+ <button matButton type="button" (click)="restricting.set(null)">Cancel</button>
65
+ <button matButton="filled" type="button" [disabled]="networks.invalid || loading()" (click)="saveNetworks(key)">Save</button>
66
+ } @else {
67
+ @if (key.IsActive && canRestrict) {
68
+ <button matButton type="button" [disabled]="loading()" (click)="editNetworks(key)">Restrict networks</button>
69
+ }
70
+ @if (key.IsActive && canRevoke) {
71
+ <button matButton type="button" [disabled]="loading()" (click)="revoke(key)">Revoke</button>
72
+ }
73
+ }
74
+ </td>
75
+ </tr>
76
+ }
77
+ </tbody>
78
+ </table>
79
+ } @else if (!loading()) {
80
+ <p class="empty-state">No API key yet.</p>
81
+ }
82
+
83
+ @if (errorMessage()) { <div class="auth-error" role="alert">{{ errorMessage() }}</div> }
84
+ @if (successMessage()) { <div class="auth-success" role="status">{{ successMessage() }}</div> }
85
+ </div>
@@ -0,0 +1,96 @@
1
+ import { DatePipe } from '@angular/common';
2
+ import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, inject, input, signal } from '@angular/core';
3
+ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
4
+ import { MatButtonModule } from '@angular/material/button';
5
+ import { MatCheckboxModule } from '@angular/material/checkbox';
6
+ import { MatFormFieldModule } from '@angular/material/form-field';
7
+ import { MatInputModule } from '@angular/material/input';
8
+ import { ApiKey, MintedApiKey } from '../../model/access';
9
+ import { AccessService } from '../../service/access.service';
10
+ import { LabelOption } from '../../service/label.service';
11
+ import { BaseAsync } from '../abstract/base_async';
12
+
13
+ /**
14
+ * The partner's API keys: mint one with scopes, allowed networks and an optional expiry (the
15
+ * plaintext shows once), restrict its networks later, or revoke it. The app passes the scopes it
16
+ * offers; keel's `ScopePolicy` decides and may supply them. Selector: <sail-api-keys>.
17
+ */
18
+ @Component({
19
+ selector: 'sail-api-keys',
20
+ templateUrl: './api_keys.html',
21
+ changeDetection: ChangeDetectionStrategy.OnPush,
22
+ encapsulation: ViewEncapsulation.None,
23
+ imports: [DatePipe, ReactiveFormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatInputModule],
24
+ })
25
+ export class ApiKeysComponent extends BaseAsync implements OnInit {
26
+ private readonly access = inject(AccessService);
27
+ private readonly fb = inject(FormBuilder).nonNullable;
28
+
29
+ /** Scopes offered for a new key, as code and caption. */
30
+ readonly scopes = input<LabelOption[]>([]);
31
+
32
+ readonly canGenerate = this.access.canApiKeyAction('generate');
33
+ readonly canRestrict = this.access.canApiKeyAction('restrict');
34
+ readonly canRevoke = this.access.canRevokeApiKey();
35
+ readonly keys = signal<ApiKey[]>([]);
36
+ readonly minted = signal<MintedApiKey | null>(null);
37
+ /** Id of the key whose networks are being edited. */
38
+ readonly restricting = signal<number | null>(null);
39
+ readonly form = this.fb.group({
40
+ keyName: ['', [Validators.required, Validators.maxLength(100)]],
41
+ allowedCidrs: ['', Validators.maxLength(2000)],
42
+ scopes: [[] as string[]],
43
+ expiresAt: [''],
44
+ });
45
+ readonly networks = this.fb.control('', Validators.maxLength(2000));
46
+
47
+ ngOnInit(): void {
48
+ this.load();
49
+ }
50
+
51
+ load(): void {
52
+ this.run(this.access.apiKeys(), (rows) => this.keys.set(rows), 'Failed to load API keys.');
53
+ }
54
+
55
+ toggleScope(code: string, checked: boolean): void {
56
+ const control = this.form.controls.scopes;
57
+ control.setValue(checked ? [...control.value, code] : control.value.filter((s) => s !== code));
58
+ }
59
+
60
+ mint(): void {
61
+ if (this.form.invalid) return;
62
+ const { keyName, scopes, allowedCidrs, expiresAt } = this.form.getRawValue();
63
+ this.run(this.access.mintApiKey({
64
+ key_name: keyName,
65
+ scopes: scopes.join(','),
66
+ allowed_cidrs: allowedCidrs,
67
+ expires_at: expiresAt ? new Date(expiresAt).toISOString() : undefined,
68
+ }), (key) => {
69
+ this.minted.set(key);
70
+ this.form.reset();
71
+ this.load();
72
+ }, 'Failed to create the key.');
73
+ }
74
+
75
+ editNetworks(key: ApiKey): void {
76
+ this.networks.setValue(key.AllowedCidrs ?? '');
77
+ this.restricting.set(key.Id);
78
+ }
79
+
80
+ saveNetworks(key: ApiKey): void {
81
+ if (this.networks.invalid) return;
82
+ this.run(this.access.restrictApiKey(key.Id, this.networks.value), () => {
83
+ this.restricting.set(null);
84
+ this.successMessage.set(`Networks of "${key.KeyName}" saved.`);
85
+ this.load();
86
+ }, 'Failed to save the networks.');
87
+ }
88
+
89
+ revoke(key: ApiKey): void {
90
+ if (!confirm(`Revoke key "${key.KeyName}" (${key.KeyPrefix}…)? Anything using it stops working.`)) return;
91
+ this.run(this.access.revokeApiKey(key.Id), () => {
92
+ this.successMessage.set(`Key "${key.KeyName}" revoked.`);
93
+ this.load();
94
+ }, 'Failed to revoke the key.');
95
+ }
96
+ }
@@ -0,0 +1,25 @@
1
+ <div class="connected-apps">
2
+ @if (grants().length > 0) {
3
+ <table class="connected-apps__table">
4
+ <thead>
5
+ <tr><th>App</th><th>Scopes</th><th>Connected</th><th>Last used</th><th><span class="cdk-visually-hidden">Actions</span></th></tr>
6
+ </thead>
7
+ <tbody>
8
+ @for (grant of grants(); track grant.clientId) {
9
+ <tr>
10
+ <td>{{ grant.clientName || grant.clientId }}</td>
11
+ <td>{{ grant.scopes.join(', ') }}</td>
12
+ <td>{{ grant.authorizedAt | date: 'short' }}</td>
13
+ <td>{{ grant.lastIssuedAt | date: 'short' }}</td>
14
+ <td><button matButton type="button" [disabled]="loading()" (click)="revoke(grant)">Disconnect</button></td>
15
+ </tr>
16
+ }
17
+ </tbody>
18
+ </table>
19
+ } @else if (!loading()) {
20
+ <p class="empty-state">No app is connected.</p>
21
+ }
22
+
23
+ @if (errorMessage()) { <div class="auth-error" role="alert">{{ errorMessage() }}</div> }
24
+ @if (successMessage()) { <div class="auth-success" role="status">{{ successMessage() }}</div> }
25
+ </div>