@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.
Files changed (61) hide show
  1. package/README.md +36 -23
  2. package/package.json +1 -1
  3. package/src/agent/component/approval_inbox.html +110 -0
  4. package/src/agent/component/approval_inbox.ts +129 -0
  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/decision_timeline.html +4 -4
  8. package/src/agent/component/decision_timeline.ts +2 -0
  9. package/src/agent/component/prompt_section_editor.html +4 -3
  10. package/src/agent/component/prompt_section_editor.ts +12 -5
  11. package/src/agent/component/release_sections.html +1 -1
  12. package/src/agent/component/release_sections.ts +2 -1
  13. package/src/agent/component/studio_page.html +303 -0
  14. package/src/agent/component/studio_page.ts +403 -0
  15. package/src/agent/component/studio_tab.ts +14 -0
  16. package/src/agent/component/turn_event_list.html +9 -0
  17. package/src/agent/component/turn_thread.html +27 -0
  18. package/src/agent/component/turn_thread.ts +83 -0
  19. package/src/agent/index.ts +8 -0
  20. package/src/agent/model/agent_studio.ts +185 -9
  21. package/src/agent/model/approval.ts +78 -0
  22. package/src/agent/model/conversation.ts +40 -1
  23. package/src/agent/model/decision.ts +14 -9
  24. package/src/agent/model/turn_event.ts +10 -0
  25. package/src/agent/service/agent_studio.service.ts +65 -1
  26. package/src/agent/service/approval.service.ts +38 -0
  27. package/src/agent/service/conversation.service.ts +10 -0
  28. package/src/agent/util/release_diff.ts +33 -0
  29. package/src/agent/util/studio_errors.ts +11 -0
  30. package/src/agent/util/turn_timeline.ts +7 -1
  31. package/src/component/agency/agency_earnings_summary.html +3 -3
  32. package/src/component/agency/agency_earnings_summary.ts +2 -2
  33. package/src/component/agency/agency_payout_history.html +1 -1
  34. package/src/component/agency/agency_payout_history.ts +2 -3
  35. package/src/component/agency/commission_ledger.html +1 -1
  36. package/src/component/agency/commission_ledger.ts +2 -3
  37. package/src/component/billing/plan_selector.ts +1 -1
  38. package/src/component/billing/price_selector.ts +5 -5
  39. package/src/component/login/account_deletion.html +17 -4
  40. package/src/component/login/account_deletion.ts +35 -28
  41. package/src/component/login/social_login.ts +6 -5
  42. package/src/component/payout/bank_info_form.html +15 -6
  43. package/src/component/payout/bank_info_form.ts +28 -72
  44. package/src/component/security/api_keys.html +78 -0
  45. package/src/component/security/api_keys.ts +90 -0
  46. package/src/component/security/connected_apps.html +25 -0
  47. package/src/component/security/connected_apps.ts +40 -0
  48. package/src/index.ts +8 -0
  49. package/src/model/access.ts +28 -0
  50. package/src/model/appdata.ts +41 -67
  51. package/src/model/auth.ts +17 -2
  52. package/src/model/geo.ts +12 -0
  53. package/src/service/access.service.ts +57 -0
  54. package/src/service/auth.service.ts +10 -4
  55. package/src/service/caption_pipe.ts +32 -0
  56. package/src/service/currency.service.ts +51 -0
  57. package/src/service/label.service.ts +5 -4
  58. package/src/service/money_pipe.ts +27 -0
  59. package/src/service/rest_service.ts +1 -1
  60. package/src/service/rest_url.ts +4 -0
  61. package/src/util/money.ts +18 -34
@@ -1,3 +1,4 @@
1
+ import { errorDetail } from '../../util/errors';
1
2
  import { AgentFieldError, AgentValidationProblem } from '../model/agent_studio';
2
3
 
3
4
  /**
@@ -59,3 +60,13 @@ export function retryAfterSeconds(err: unknown): number | undefined {
59
60
  const seconds = Number(raw);
60
61
  return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
61
62
  }
63
+
64
+ /** One message for a failed Studio call: the conflict cause, the field errors, or the detail. */
65
+ export function studioErrorMessage(err: unknown, fallback = 'Request failed.'): string {
66
+ const cause = studioConflictCause(err);
67
+ if (cause === 'revision') return 'This agent changed since you opened it. Reload and reapply your edits.';
68
+ if (cause === 'sealed') return 'A wider scope sealed this section, so narrower layers cannot change it.';
69
+ const fields = studioFieldErrors(err);
70
+ if (fields.length > 0) return fields.map((f) => `${f.field}: ${f.message}`).join('; ');
71
+ return errorDetail(err, fallback);
72
+ }
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Citation,
2
3
  EvidenceRef,
3
4
  TurnApprovalEvent,
4
5
  TurnEffectEvent,
@@ -31,6 +32,7 @@ export type TurnTimelineItem =
31
32
  | { kind: 'evidence'; refs: EvidenceRef[] }
32
33
  | { kind: 'progress'; progress: TurnProgressEvent }
33
34
  | { kind: 'extension'; extension: TurnExtensionEvent }
35
+ | { kind: 'citations'; citations: Citation[] }
34
36
  | { kind: 'result' };
35
37
 
36
38
  function errorClassOf(tool: TurnToolEvent): string | undefined {
@@ -62,7 +64,8 @@ export function buildTurnTimeline(events: readonly unknown[]): TurnTimelineItem[
62
64
  };
63
65
 
64
66
  const applyApproval = (approval: TurnApprovalEvent | undefined, state: ToolCallApproval): void => {
65
- if (!approval) return;
67
+ // A turn-level suspension carries an empty approval; turn status shows it, not a tool row.
68
+ if (!approval?.call_id) return;
66
69
  const call = callFor(approval);
67
70
  call.approval = state;
68
71
  call.decision = approval.decision;
@@ -106,6 +109,9 @@ export function buildTurnTimeline(events: readonly unknown[]): TurnTimelineItem[
106
109
  case 'extension':
107
110
  if (event.extension) items.push({ kind: 'extension', extension: event.extension });
108
111
  break;
112
+ case 'citations':
113
+ if (event.citations?.length) items.push({ kind: 'citations', citations: event.citations });
114
+ break;
109
115
  case 'result':
110
116
  items.push({ kind: 'result' });
111
117
  break;
@@ -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
  };
@@ -13,10 +13,23 @@
13
13
  <input matInput formControlName="confirm" autocomplete="off" required>
14
14
  </mat-form-field>
15
15
 
16
- <mat-form-field appearance="outline">
17
- <mat-label>Confirm your password</mat-label>
18
- <input matInput type="password" formControlName="password" autocomplete="current-password" required>
19
- </mat-form-field>
16
+ @if (method() === 'password') {
17
+ <mat-form-field appearance="outline">
18
+ <mat-label>Confirm your password</mat-label>
19
+ <input matInput type="password" formControlName="credential" autocomplete="current-password" required>
20
+ </mat-form-field>
21
+ } @else {
22
+ <button matButton type="button" [disabled]="loading()" (click)="sendCode()">
23
+ {{ codeSent() ? 'Resend code' : 'Send me a code' }}
24
+ </button>
25
+ <mat-form-field appearance="outline">
26
+ <mat-label>One-time code</mat-label>
27
+ <input matInput formControlName="credential" inputmode="numeric" autocomplete="one-time-code" required>
28
+ </mat-form-field>
29
+ }
30
+ <button matButton type="button" (click)="toggleMethod()">
31
+ {{ method() === 'password' ? 'Use a one-time code instead' : 'Use my password instead' }}
32
+ </button>
20
33
 
21
34
  <mat-form-field appearance="outline">
22
35
  <mat-label>Reason (optional)</mat-label>
@@ -1,7 +1,7 @@
1
1
  import {
2
- ChangeDetectionStrategy, Component, ViewEncapsulation, computed, inject, input, signal,
2
+ ChangeDetectionStrategy, Component, ViewEncapsulation, computed, inject, input, linkedSignal, signal,
3
3
  } from '@angular/core';
4
- import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
4
+ import { toSignal } from '@angular/core/rxjs-interop';
5
5
  import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
6
6
  import { MatButtonModule } from '@angular/material/button';
7
7
  import { MatFormFieldModule } from '@angular/material/form-field';
@@ -10,18 +10,17 @@ import { Router, RouterLink } from '@angular/router';
10
10
  import { BaseAsync } from '../abstract/base_async';
11
11
  import { BaseAuthService } from '../../service/auth.service';
12
12
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
13
+ import { ReauthCredentials } from '../../model/auth';
13
14
 
14
15
  /**
15
16
  * AccountDeletionComponent — App Store / Play Store compliance surface.
16
17
  *
17
- * Typed-confirmation UX (standard anti-fat-finger pattern): user must type
18
- * the literal `confirmationText` (default "DELETE") before the destructive
19
- * button enables. Optional `reason` textarea forwarded to the backend's
20
- * DELETE /api/user/account for the audit trail.
21
- *
22
- * On success: local session cleared by BaseAuthService.deleteAccount; this
23
- * component then navigates to `config.accountDeletedRoute` (default
24
- * `/login/local`).
18
+ * The destructive button enables once the user types `confirmationText`
19
+ * (default "DELETE") and re-authenticates with a password or, for accounts
20
+ * without one, a one-time code (`reauthMethod`, switchable in the form).
21
+ * The optional reason is forwarded to DELETE /api/user/account for audit.
22
+ * On success the session is cleared and the router goes to
23
+ * `config.accountDeletedRoute` (default `/login/local`).
25
24
  */
26
25
  @Component({
27
26
  selector: 'sail-account-deletion',
@@ -44,37 +43,45 @@ export class AccountDeletionComponent extends BaseAsync {
44
43
  inject(SAIL_GUI_CONFIG, { optional: true }) ?? DEFAULT_CONFIG;
45
44
 
46
45
  readonly confirmationText = input('DELETE');
46
+ readonly reauthMethod = input<'password' | 'code'>('password');
47
+ readonly method = linkedSignal(() => this.reauthMethod());
48
+ readonly codeSent = signal(false);
47
49
 
48
50
  readonly form = this.fb.nonNullable.group({
49
- confirm: ['', Validators.required],
50
- password: ['', Validators.required], // re-auth gate required by keel
51
- reason: [''],
51
+ confirm: ['', Validators.required],
52
+ credential: ['', Validators.required],
53
+ reason: [''],
52
54
  });
53
55
 
54
- readonly typedValue = signal('');
55
- private readonly passwordValue = signal('');
56
+ private readonly confirmValue = toSignal(this.form.controls.confirm.valueChanges, { initialValue: '' });
57
+ private readonly credentialValue = toSignal(this.form.controls.credential.valueChanges, { initialValue: '' });
56
58
  readonly canSubmit = computed(() =>
57
- this.typedValue() === this.confirmationText() &&
58
- !!this.passwordValue() &&
59
+ this.confirmValue() === this.confirmationText() &&
60
+ !!this.credentialValue() &&
59
61
  !this.loading(),
60
62
  );
61
63
 
62
- constructor() {
63
- super();
64
- this.form.controls.confirm.valueChanges
65
- .pipe(takeUntilDestroyed())
66
- .subscribe((v) => this.typedValue.set(v ?? ''));
67
- this.form.controls.password.valueChanges
68
- .pipe(takeUntilDestroyed())
69
- .subscribe((v) => this.passwordValue.set(v ?? ''));
64
+ toggleMethod() {
65
+ this.method.update((m) => m === 'password' ? 'code' : 'password');
66
+ this.codeSent.set(false);
67
+ this.form.controls.credential.reset();
68
+ this.clearMessages();
69
+ }
70
+
71
+ sendCode() {
72
+ this.run(this.auth.sendReauthCode(), (res) => {
73
+ this.codeSent.set(true);
74
+ this.successMessage.set(`A code was sent to your ${res.channel}.`);
75
+ }, 'Failed to send a code. Please try again.');
70
76
  }
71
77
 
72
78
  onDelete() {
73
79
  if (!this.canSubmit()) return;
74
- const password = this.form.controls.password.value;
75
- const reason = this.form.controls.reason.value?.trim() || undefined;
80
+ const credential = this.form.controls.credential.value;
81
+ const reauth: ReauthCredentials = this.method() === 'code' ? { reauthCode: credential } : { password: credential };
82
+ const reason = this.form.controls.reason.value.trim() || undefined;
76
83
  this.run(
77
- this.auth.deleteAccount({ password }, reason),
84
+ this.auth.deleteAccount(reauth, reason),
78
85
  () => this.router.navigateByUrl(this.guiConfig.accountDeletedRoute ?? '/login/local'),
79
86
  'Failed to delete account. Please try again.',
80
87
  );
@@ -7,7 +7,7 @@ import { BaseAuthService } from '../../service/auth.service';
7
7
  import { loadScript } from '../../service/script_loader';
8
8
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
9
9
  import {
10
- ConsentState, LoginResponseSocial, SignupConsent, SocialProvider,
10
+ ConsentState, LoginResponseSocial, SignupConsent, SocialName, SocialProvider,
11
11
  } from '../../model/auth';
12
12
  import { firstValueFrom } from 'rxjs';
13
13
 
@@ -22,7 +22,8 @@ const APPLE_URL = 'https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/
22
22
  * buttons using each provider's official SDK.
23
23
  *
24
24
  * Flow: click → SDK returns ID token → BaseAuthService.loginSocial(provider,
25
- * idToken, signupConsent) → emits loginSuccess / loginError.
25
+ * idToken, signupConsent) → emits loginSuccess / loginError. Apple's
26
+ * first-authorization name rides along as loginSuccess.socialName.
26
27
  *
27
28
  * Requires `googleClientId` / `appleServiceId` in SailGuiConfig; providers
28
29
  * without config are hidden (with a console.warn for diagnostics).
@@ -104,17 +105,17 @@ export class SocialLoginComponent extends BaseAsync implements OnInit {
104
105
  try {
105
106
  const res = await AppleID.auth.signIn();
106
107
  const token = res?.authorization?.id_token;
107
- if (token) this.onIdToken('apple', token);
108
+ if (token) this.onIdToken('apple', token, res.user?.name);
108
109
  } catch (err: unknown) {
109
110
  this.loginError.emit(err instanceof Error ? err : new Error(String(err)));
110
111
  }
111
112
  }
112
113
 
113
- private onIdToken(provider: SocialProvider, idToken: string) {
114
+ private onIdToken(provider: SocialProvider, idToken: string, socialName?: SocialName) {
114
115
  const fallback = `Sign-in with ${provider} failed.`;
115
116
  this.run(
116
117
  this.auth.loginSocial(provider, idToken, this.buildSignupConsent()),
117
- (resp) => this.loginSuccess.emit(resp),
118
+ (resp) => this.loginSuccess.emit(socialName ? { ...resp, socialName } : resp),
118
119
  fallback,
119
120
  (err) => this.loginError.emit(this.toError(err, fallback)),
120
121
  );
@@ -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,78 @@
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
+ <fieldset class="api-keys__scopes">
22
+ <legend>Scopes</legend>
23
+ @for (scope of scopes(); track scope.code) {
24
+ <mat-checkbox [checked]="form.controls.scopes.value.includes(scope.code)"
25
+ (change)="toggleScope(scope.code, $event.checked)">{{ scope.caption }}</mat-checkbox>
26
+ }
27
+ </fieldset>
28
+ <button matButton="filled" type="submit" [disabled]="form.invalid || loading()">Create key</button>
29
+ </form>
30
+ }
31
+
32
+ @if (keys().length > 0) {
33
+ <table class="api-keys__table">
34
+ <thead>
35
+ <tr><th>Name</th><th>Prefix</th><th>Scopes</th><th>Networks</th><th>Created</th><th>Last used</th><th><span class="cdk-visually-hidden">Actions</span></th></tr>
36
+ </thead>
37
+ <tbody>
38
+ @for (key of keys(); track key.Id) {
39
+ <tr [class.api-keys__inactive]="!key.IsActive">
40
+ <td>{{ key.KeyName }}@if (!key.IsActive) { (inactive) }</td>
41
+ <td><code>{{ key.KeyPrefix }}…</code></td>
42
+ <td>{{ key.Scopes }}</td>
43
+ <td>
44
+ @if (restricting() === key.Id) {
45
+ <mat-form-field>
46
+ <mat-label>Allowed networks</mat-label>
47
+ <input matInput [formControl]="networks" maxlength="2000" (keydown.enter)="saveNetworks(key)">
48
+ </mat-form-field>
49
+ } @else {
50
+ {{ key.AllowedCidrs || 'any address' }}
51
+ }
52
+ </td>
53
+ <td>{{ key.CreatedAt | date: 'short' }}</td>
54
+ <td>{{ key.LastUsedAt ? (key.LastUsedAt | date: 'short') : '' }}</td>
55
+ <td>
56
+ @if (restricting() === key.Id) {
57
+ <button matButton type="button" (click)="restricting.set(null)">Cancel</button>
58
+ <button matButton="filled" type="button" [disabled]="networks.invalid || loading()" (click)="saveNetworks(key)">Save</button>
59
+ } @else {
60
+ @if (key.IsActive && canRestrict) {
61
+ <button matButton type="button" [disabled]="loading()" (click)="editNetworks(key)">Restrict networks</button>
62
+ }
63
+ @if (key.IsActive && canRevoke) {
64
+ <button matButton type="button" [disabled]="loading()" (click)="revoke(key)">Revoke</button>
65
+ }
66
+ }
67
+ </td>
68
+ </tr>
69
+ }
70
+ </tbody>
71
+ </table>
72
+ } @else if (!loading()) {
73
+ <p class="empty-state">No API key yet.</p>
74
+ }
75
+
76
+ @if (errorMessage()) { <div class="auth-error" role="alert">{{ errorMessage() }}</div> }
77
+ @if (successMessage()) { <div class="auth-success" role="status">{{ successMessage() }}</div> }
78
+ </div>