@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
@@ -0,0 +1,90 @@
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 and allowed networks (the plaintext shows once),
15
+ * restrict its networks later, or revoke it. The app passes the scopes it offers; keel's
16
+ * `ScopePolicy` still decides. 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.required<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[], Validators.required],
43
+ });
44
+ readonly networks = this.fb.control('', Validators.maxLength(2000));
45
+
46
+ ngOnInit(): void {
47
+ this.load();
48
+ }
49
+
50
+ load(): void {
51
+ this.run(this.access.apiKeys(), (rows) => this.keys.set(rows), 'Failed to load API keys.');
52
+ }
53
+
54
+ toggleScope(code: string, checked: boolean): void {
55
+ const control = this.form.controls.scopes;
56
+ control.setValue(checked ? [...control.value, code] : control.value.filter((s) => s !== code));
57
+ }
58
+
59
+ mint(): void {
60
+ if (this.form.invalid) return;
61
+ const { keyName, scopes, allowedCidrs } = this.form.getRawValue();
62
+ this.run(this.access.mintApiKey(keyName, scopes, allowedCidrs), (key) => {
63
+ this.minted.set(key);
64
+ this.form.reset();
65
+ this.load();
66
+ }, 'Failed to create the key.');
67
+ }
68
+
69
+ editNetworks(key: ApiKey): void {
70
+ this.networks.setValue(key.AllowedCidrs ?? '');
71
+ this.restricting.set(key.Id);
72
+ }
73
+
74
+ saveNetworks(key: ApiKey): void {
75
+ if (this.networks.invalid) return;
76
+ this.run(this.access.restrictApiKey(key.Id, this.networks.value), () => {
77
+ this.restricting.set(null);
78
+ this.successMessage.set(`Networks of "${key.KeyName}" saved.`);
79
+ this.load();
80
+ }, 'Failed to save the networks.');
81
+ }
82
+
83
+ revoke(key: ApiKey): void {
84
+ if (!confirm(`Revoke key "${key.KeyName}" (${key.KeyPrefix}…)? Anything using it stops working.`)) return;
85
+ this.run(this.access.revokeApiKey(key.Id), () => {
86
+ this.successMessage.set(`Key "${key.KeyName}" revoked.`);
87
+ this.load();
88
+ }, 'Failed to revoke the key.');
89
+ }
90
+ }
@@ -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>
@@ -0,0 +1,40 @@
1
+ import { DatePipe } from '@angular/common';
2
+ import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, inject, signal } from '@angular/core';
3
+ import { MatButtonModule } from '@angular/material/button';
4
+ import { OAuthGrant } from '../../model/access';
5
+ import { AccessService } from '../../service/access.service';
6
+ import { BaseAsync } from '../abstract/base_async';
7
+
8
+ /**
9
+ * OAuth clients holding a live grant from the signed-in user, with disconnect; a disconnected
10
+ * client must authorize again. Selector: <sail-connected-apps>.
11
+ */
12
+ @Component({
13
+ selector: 'sail-connected-apps',
14
+ templateUrl: './connected_apps.html',
15
+ changeDetection: ChangeDetectionStrategy.OnPush,
16
+ encapsulation: ViewEncapsulation.None,
17
+ imports: [DatePipe, MatButtonModule],
18
+ })
19
+ export class ConnectedAppsComponent extends BaseAsync implements OnInit {
20
+ private readonly access = inject(AccessService);
21
+
22
+ readonly grants = signal<OAuthGrant[]>([]);
23
+
24
+ ngOnInit(): void {
25
+ this.load();
26
+ }
27
+
28
+ load(): void {
29
+ this.run(this.access.grants(), (rows) => this.grants.set(rows), 'Failed to load connected apps.');
30
+ }
31
+
32
+ revoke(grant: OAuthGrant): void {
33
+ const name = grant.clientName || grant.clientId;
34
+ if (!confirm(`Disconnect "${name}"? It will have to connect again.`)) return;
35
+ this.run(this.access.revokeGrant(grant.clientId), () => {
36
+ this.successMessage.set(`Disconnected ${name}.`);
37
+ this.load();
38
+ }, 'Failed to disconnect the app.');
39
+ }
40
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ export * from './model/oauth_connection';
7
7
  export * from './model/notification';
8
8
  export * from './model/document';
9
9
  export * from './model/agency';
10
+ export * from './model/geo';
11
+ export * from './model/access';
10
12
  export * from './model/decorator';
11
13
  export * from './component/abstract/base_table';
12
14
  export * from './component/dashboard/dashboard_shell';
@@ -53,6 +55,8 @@ export * from './component/security/twofactor_setup';
53
55
  export * from './component/security/twofactor_verify';
54
56
  export * from './component/security/trusted_devices';
55
57
  export * from './component/security/sessions';
58
+ export * from './component/security/api_keys';
59
+ export * from './component/security/connected_apps';
56
60
  export * from './component/billing/plan_selector';
57
61
  export * from './component/billing/price_selector';
58
62
  export * from './component/billing/checkout_button';
@@ -94,6 +98,10 @@ export * from './service/api-response.interceptor';
94
98
  export * from './service/rest_service';
95
99
  export * from './service/rest_url';
96
100
  export * from './service/label.service';
101
+ export * from './service/caption_pipe';
102
+ export * from './service/currency.service';
103
+ export * from './service/access.service';
104
+ export * from './service/money_pipe';
97
105
  export * from './service/base_rest.service';
98
106
  export * from './util/text';
99
107
  export * from './util/money';
@@ -0,0 +1,28 @@
1
+ /** A row of keel's `api_key` table as generic REST returns it; the hash never leaves keel. */
2
+ export interface ApiKey {
3
+ Id: number;
4
+ KeyName: string;
5
+ KeyPrefix: string;
6
+ Scopes: string; // CSV
7
+ IsActive: boolean;
8
+ CreatedAt: string;
9
+ ExpiresAt?: string;
10
+ LastUsedAt?: string;
11
+ UserId?: number;
12
+ AllowedCidrs?: string; // CSV of networks; absent allows any address
13
+ }
14
+
15
+ /** keel `api_key/generate` reply: the plaintext is shown once and never stored. */
16
+ export interface MintedApiKey {
17
+ api_key: string;
18
+ key_prefix: string;
19
+ }
20
+
21
+ /** An OAuth client the signed-in user authorized on keel's authorization server (`authserver.Grant`). */
22
+ export interface OAuthGrant {
23
+ clientId: string;
24
+ clientName: string;
25
+ scopes: string[];
26
+ authorizedAt: string;
27
+ lastIssuedAt: string;
28
+ }
@@ -252,47 +252,32 @@ export interface PartnerCreated {
252
252
  paymentUrl?: string;
253
253
  }
254
254
 
255
- // One purchasable offer for a plan — a (billing cycle × commitment term) row of
256
- // keel's `subscription_plan_price` table (v1.0.5). A plan sells several; the
257
- // customer picks one at checkout via `<sail-price-selector>`. Mirrors keel
258
- // `billing.PlanPrice`.
255
+ // One purchasable offer for a plan: a (billing cycle × commitment term) row of
256
+ // keel's `subscription_plan_price` table. Mirrors keel `billing.PlanPrice`.
259
257
  export interface PlanPrice {
260
258
  billingCycle: string; // PERIOD_TYPE code (D/W/M/A) — how often charged
261
259
  termCount: number; // commitment length, in termType units
262
260
  termType: string; // PERIOD_TYPE code — commitment/pricing unit
263
- amountMinor: number; // price per termType unit, in minor units (cents)
264
- amount: number; // same amount in major units (currency-exponent applied)
261
+ amountMinor: number; // price per termType unit, in minor units
265
262
  currency: string; // ISO 4217
263
+ exponent: number; // minor-unit decimals from keel's currency table
266
264
  // Provider price id (Stripe Price). Empty for free/unpriced offers; pass to
267
265
  // `<sail-checkout-button [priceId]>` / `createCheckout({ priceId })`.
268
266
  priceId?: string;
269
267
  }
270
268
 
271
- // PublicPlan — shape returned by GET /public/plans. Used on the registration
272
- // page to render the plan picker and to drive `<sail-checkout-button>` flows.
269
+ // Mirrors keel `billing.Plan` from GET /public/plans.
273
270
  export interface PublicPlan {
274
271
  id: string;
275
272
  caption: string;
276
- // NOTE: keel v1.0.7 REMOVED the flat `monthlyCost`/`annualCost` from the plan
277
- // JSON (both `/public/plans` and the billing catalog). Price lives per-offer
278
- // in `prices[]`; derive a display price with `fromPriceLabel(plan.prices)`.
279
- // Purchasable offers (keel v1.0.5 `subscription_plan_price`, nested under the
280
- // plan). The source of truth for checkout — feed a chosen offer's `priceId`
281
- // to `<sail-checkout-button>`. Returned by `/public/plans` as of keel v1.0.6
282
- // (so `<sail-price-selector>` works on the registration page too); absent on
283
- // pre-v1.0.6 backends.
284
- prices?: PlanPrice[];
285
- // @deprecated keel v1.0.5 moved per-offer prices into `prices[]`
286
- // (`subscription_plan.provider_price_id` was dropped). Use
287
- // `prices?.[…].priceId`. Kept optional for pre-v1.0.5 backends.
288
- priceId?: string;
289
- // keel `subscription_plan.activation_mode` (SUBSCRIPTION_ACTIVATION_MODE
290
- // dictionary, single char — e.g. 'P' paid, 'F' free, 'T' trial). Drives the
291
- // picker CTA copy ("Start trial" / "Subscribe" / …). Optional — pre-v1.0.3
292
- // backends omit it.
293
- activationMode?: string;
273
+ currency: string;
274
+ // Purchasable offers; derive a display price with `fromPriceLabel(plan.prices)`
275
+ // and feed the chosen offer's `priceId` to `<sail-checkout-button>`.
276
+ prices: PlanPrice[] | null;
277
+ // keel `subscription_plan.activation_mode` ('P' paid, 'F' free, 'T' trial); drives the picker CTA.
278
+ activationMode: string;
294
279
  // Length of the free trial in days when `activationMode === 'T'`.
295
- trialDays?: number;
280
+ trialDays: number;
296
281
  }
297
282
 
298
283
  // ConfirmRegisterResponse — shape returned by POST /public/register/confirm:
@@ -323,7 +308,7 @@ export type CheckoutMode = 'subscription' | 'payment' | 'setup';
323
308
  export interface CheckoutRequest {
324
309
  mode?: CheckoutMode; // default 'subscription'
325
310
  // Required for 'subscription' / 'payment'; must be empty for 'setup'.
326
- // Pass `PublicPlan.priceId` straight through.
311
+ // Pass the chosen `PlanPrice.priceId` straight through.
327
312
  priceId?: string;
328
313
  quantity?: number;
329
314
  email?: string;
@@ -401,33 +386,35 @@ export interface PageRequest {
401
386
  orderBy?: string;
402
387
  }
403
388
 
389
+ // Mirrors keel `billing.Subscription`; amounts are minor units at `exponent`.
404
390
  export interface Subscription {
405
- planId: string;
406
- status: string; // 'A' active, 'P' pending-payment, 'C' cancelled, 'T' trialing, 'X' past-due — caption via SUBSCRIPTION_STATUS dict
407
- begda: string;
408
- endda?: string;
409
- monthlyCost: number;
410
- currency: string;
411
- autoRenew: boolean;
412
- // PERIOD_TYPE code (D/W/M/A) the sub is charged on, copied from the chosen
413
- // offer (keel `billing_cycle`); absent on pre-v1.0.5 backends.
414
- billingCycle?: string;
415
- // ISO timestamp the trial ends (keel `trial_end`); empty/absent when not
416
- // trialing. Drives `<sail-trial-banner>`.
417
- trialEnd?: string;
418
- // Seat quantity on the active/trial sub (keel `seats`); absent for
419
- // non-seat plans. Drives `<sail-seat-selector>`.
420
- seats?: number;
421
- }
422
-
391
+ planId: string;
392
+ caption?: string; // plan display name
393
+ status: string; // 'A' active, 'P' pending-payment, 'C' cancelled, 'T' trialing, 'X' past-due — caption via SUBSCRIPTION_STATUS dict
394
+ begda: string;
395
+ endda?: string;
396
+ installmentMinor: number;
397
+ currency: string;
398
+ exponent: number;
399
+ autoRenew: boolean;
400
+ // PERIOD_TYPE code (D/W/M/A) the sub is charged on, copied from the chosen offer.
401
+ billingCycle?: string;
402
+ // ISO timestamp the trial ends; empty/absent when not trialing. Drives `<sail-trial-banner>`.
403
+ trialEnd?: string;
404
+ // Seat quantity on the active/trial sub; absent for non-seat plans. Drives `<sail-seat-selector>`.
405
+ seats?: number;
406
+ }
407
+
408
+ // Mirrors keel `billing.Invoice`; `status` is keel's one-character invoice status code.
423
409
  export interface Invoice {
424
- id: string;
425
- number: string;
426
- status: string; // 'paid' | 'open' | 'void' | ...
427
- total: number;
428
- currency: string;
429
- issuedAt: string;
430
- paidAt?: string;
410
+ id: string;
411
+ number: string;
412
+ status: string;
413
+ totalMinor: number;
414
+ currency: string;
415
+ exponent: number;
416
+ issuedAt: string;
417
+ paidAt?: string;
431
418
  }
432
419
 
433
420
  // ── Payout types ──
@@ -469,25 +456,12 @@ export interface BankInfoFormValue {
469
456
  currency: string;
470
457
  accountHolderName: string;
471
458
  billingAddress: string;
472
- taxIdType: string; // 'S' SIN, 'N' SSN, 'E' EIN, 'V' VAT, 'O' other
459
+ taxIdType: string; // keel `tax_id_type` code
473
460
  taxId: string;
474
461
  provider: string;
475
462
  providerAgreement: boolean;
476
463
  }
477
464
 
478
- // CountryProfile drives the country dropdown in the bank-info form.
479
- // Each profile bundles ISO codes + tax-id labelling so the form's
480
- // hint/placeholder text follows the country selection.
481
- export interface CountryProfile {
482
- code: string; // ISO 3166-1 alpha-2
483
- label: string;
484
- currency: string; // ISO 4217
485
- taxIdType: string; // matches basis constant_header 'tax_id_type'
486
- taxIdLabel: string;
487
- taxIdPlaceholder: string;
488
- taxIdHint: string;
489
- }
490
-
491
465
  // ── User payment methods ──
492
466
  // Consumer-side saved cards/wallets. Mirrors basis user_payment_method
493
467
  // one-to-one. Distinct from PaymentMethod (partner-scoped SaaS billing
package/src/model/auth.ts CHANGED
@@ -115,11 +115,19 @@ export interface UserProfile {
115
115
 
116
116
  // ── Re-authentication credentials ──
117
117
  // Required by Setup2FA, Disable2FA, DeleteAccount, LogoutEverywhere — server
118
- // rejects with 401 if neither `password` nor `twoFactorCode` is supplied.
119
- // Disable2FA additionally requires the current TOTP `code` alongside password.
118
+ // rejects with 401 unless one is supplied. `reauthCode` comes from
119
+ // sendReauthCode(). Disable2FA additionally requires the current TOTP `code`.
120
120
  export interface ReauthCredentials {
121
121
  password?: string;
122
122
  twoFactorCode?: string;
123
+ reauthCode?: string;
124
+ }
125
+
126
+ export type ReauthChannel = 'email' | 'phone';
127
+
128
+ export interface ReauthSendResponse {
129
+ channel: ReauthChannel;
130
+ resendCountdownSec: number;
123
131
  }
124
132
 
125
133
  // ── Social login (ID token flow) ──
@@ -144,6 +152,13 @@ export interface LoginResponseSocial {
144
152
  isNewUser: boolean;
145
153
  singleDeviceSession?: boolean;
146
154
  previousDeviceRevoked?: boolean;
155
+ // Client-side: the name Apple returns only on first authorization, never in the ID token.
156
+ socialName?: SocialName;
157
+ }
158
+
159
+ export interface SocialName {
160
+ firstName?: string;
161
+ lastName?: string;
147
162
  }
148
163
 
149
164
  // ── Push notification tokens ──
@@ -0,0 +1,12 @@
1
+ /** Rows of keel's `currency` and `country` tables as generic REST returns them. */
2
+ export interface Currency {
3
+ Code: string; // ISO 4217
4
+ Caption: string;
5
+ Exponent: number; // minor-unit decimals, 0–6
6
+ }
7
+
8
+ export interface Country {
9
+ Id: string; // ISO 3166-1 alpha-2
10
+ Caption: string;
11
+ Currency: string; // default ISO 4217 code
12
+ }
@@ -0,0 +1,57 @@
1
+ import { Injectable } from '@angular/core';
2
+ import { Observable, catchError, throwError } from 'rxjs';
3
+ import { ApiKey, MintedApiKey, OAuthGrant } from '../model/access';
4
+ import { TableAction } from '../model/appdata';
5
+ import { BackendService } from './rest_service';
6
+ import { RestURL } from './rest_url';
7
+
8
+ /** The signed-in user's API keys (keel `api_key` table and actions) and OAuth grants. */
9
+ @Injectable({ providedIn: 'root' })
10
+ export class AccessService extends BackendService {
11
+ apiKeys(): Observable<ApiKey[]> {
12
+ return this.list<ApiKey>('api_key', undefined, { orderBy: 'created_at desc' });
13
+ }
14
+
15
+ mintApiKey(keyName: string, scopes: string[], allowedCidrs: string): Observable<MintedApiKey> {
16
+ return this.apiKeyAction<MintedApiKey>('generate',
17
+ { key_name: keyName, scopes: scopes.join(','), allowed_cidrs: allowedCidrs });
18
+ }
19
+
20
+ restrictApiKey(id: number, allowedCidrs: string): Observable<void> {
21
+ return this.apiKeyAction<void>('restrict', { id, allowed_cidrs: allowedCidrs });
22
+ }
23
+
24
+ revokeApiKey(id: number): Observable<{ message: string }> {
25
+ return this.delete('api_key', { Id: String(id) });
26
+ }
27
+
28
+ grants(): Observable<OAuthGrant[]> {
29
+ return this.http.get<OAuthGrant[]>(this.url(RestURL.oauthGrantsURL))
30
+ .pipe(catchError((err) => this.handleError(err)));
31
+ }
32
+
33
+ revokeGrant(clientId: string): Observable<void> {
34
+ return this.http.post<void>(this.url(RestURL.oauthGrantRevokeURL), { clientId })
35
+ .pipe(catchError((err) => this.handleError(err)));
36
+ }
37
+
38
+ canApiKeyAction(name: string): boolean {
39
+ const action = this.findApiKeyAction(name);
40
+ return !!action && this.auth.canExecute(action.authorityObject, action.authorityCheck, 'api_key');
41
+ }
42
+
43
+ canRevokeApiKey(): boolean {
44
+ return this.auth.canDelete('api_key');
45
+ }
46
+
47
+ private apiKeyAction<T>(name: string, body: Record<string, unknown>): Observable<T> {
48
+ const action = this.findApiKeyAction(name);
49
+ return action
50
+ ? this.executeAction<T>(action.method, body)
51
+ : throwError(() => new Error(`sail: api_key/${name} is not available in backend metadata.`));
52
+ }
53
+
54
+ private findApiKeyAction(name: string): TableAction | undefined {
55
+ return (this.auth.getTableDefinition('api_key')?.Actions ?? []).find((action) => action.action === name);
56
+ }
57
+ }
@@ -7,7 +7,7 @@ import {
7
7
  LoginResponseSocial,
8
8
  OtpRequest, OtpResendRequest, OtpResponse, OtpVerifyRequest, OtpVerifyResponse,
9
9
  PushPlatform, PushRegisterRequest,
10
- ReauthCredentials,
10
+ ReauthChannel, ReauthCredentials, ReauthSendResponse,
11
11
  SignupConsent, SocialLoginRequest, SocialProvider,
12
12
  UserProfile,
13
13
  } from '../model/auth';
@@ -112,6 +112,7 @@ export abstract class BaseAuthService extends BaseRestService {
112
112
  protected get loginSocialUrl() { return this.url(RestURL.loginSocialURL); }
113
113
  protected get logoutEverywhereUrl() { return this.url(RestURL.logoutEverywhereURL); }
114
114
  protected get deleteAccountUrl() { return this.url(RestURL.deleteAccountURL); }
115
+ protected get reauthSendUrl() { return this.url(RestURL.reauthSendURL); }
115
116
  protected get pushRegisterUrl() { return this.url(RestURL.pushRegisterURL); }
116
117
  protected get pushRevokeUrl() { return this.url(RestURL.pushRevokeURL); }
117
118
  protected get profileUrl() { return this.url(RestURL.profileURL); }
@@ -625,9 +626,14 @@ export abstract class BaseAuthService extends BaseRestService {
625
626
 
626
627
  // ── Account lifecycle ──
627
628
 
629
+ /** Send a one-time code for ReauthCredentials.reauthCode; no channel prefers email. */
630
+ sendReauthCode(channel?: ReauthChannel): Observable<ReauthSendResponse> {
631
+ return this.http.post<ReauthSendResponse>(this.reauthSendUrl, { channel });
632
+ }
633
+
628
634
  /**
629
635
  * Soft-delete the caller's account. Requires recent re-authentication —
630
- * pass current `password` and/or `twoFactorCode`. Server anonymizes
636
+ * pass `password`, `twoFactorCode` or `reauthCode`. Server anonymizes
631
637
  * user_account, revokes all tokens / trusted devices / social links.
632
638
  * Clears local session on success.
633
639
  */
@@ -640,8 +646,8 @@ export abstract class BaseAuthService extends BaseRestService {
640
646
 
641
647
  /**
642
648
  * Revoke every active refresh token for this user (sign out everywhere).
643
- * Requires recent re-authentication — pass current `password` and/or
644
- * `twoFactorCode`.
649
+ * Requires recent re-authentication — pass `password`, `twoFactorCode`
650
+ * or `reauthCode`.
645
651
  */
646
652
  logoutEverywhere(reauth: ReauthCredentials): Observable<void> {
647
653
  return this.http.post<void>(this.logoutEverywhereUrl, reauth);
@@ -0,0 +1,32 @@
1
+ import { DestroyRef, Pipe, PipeTransform, inject, signal } from '@angular/core';
2
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
3
+ import { LabelService } from './label.service';
4
+
5
+ /**
6
+ * `{{ code | sailCaption:'constant_id' }}` — the backend caption of a constant code, the code
7
+ * itself until its domain loads or when the domain lacks it. Impure so it re-renders on load.
8
+ */
9
+ @Pipe({ name: 'sailCaption', pure: false })
10
+ export class CaptionPipe implements PipeTransform {
11
+ private readonly labels = inject(LabelService);
12
+ private readonly destroyRef = inject(DestroyRef);
13
+ private readonly requested = new Set<string>();
14
+ private readonly loaded = signal(0);
15
+
16
+ transform(code: string | null | undefined, constantId: string): string {
17
+ if (!code) return '';
18
+ this.loaded();
19
+ if (!this.requested.has(constantId)) {
20
+ this.requested.add(constantId);
21
+ // A cached domain emits synchronously: its caption is already current, and a signal
22
+ // write during render would throw NG0600.
23
+ let rendering = true;
24
+ this.labels.loadLabels(constantId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
25
+ next: () => { if (!rendering) this.loaded.update((n) => n + 1); },
26
+ error: () => this.requested.delete(constantId),
27
+ });
28
+ rendering = false;
29
+ }
30
+ return this.labels.getLabel(constantId, code);
31
+ }
32
+ }
@@ -0,0 +1,51 @@
1
+ import { Injectable, inject, signal } from '@angular/core';
2
+ import { Observable, map, of, shareReplay, tap } from 'rxjs';
3
+ import { Currency } from '../model/geo';
4
+ import { formatMinor } from '../util/money';
5
+ import { BackendService } from './rest_service';
6
+
7
+ /**
8
+ * keel's `currency` catalog, the only source of minor-unit exponents. Loaded once through
9
+ * generic REST, so the caller's role needs `TABLE SELECT currency`.
10
+ */
11
+ @Injectable({ providedIn: 'root' })
12
+ export class CurrencyService {
13
+ private readonly backend = inject(BackendService);
14
+ private readonly byCode = new Map<string, Currency>();
15
+ private pending?: Observable<void>;
16
+ readonly ready = signal(false);
17
+ readonly failed = signal(false);
18
+
19
+ /** Load the catalog; completed and concurrent calls share one request, a failed one is retried. */
20
+ load(): Observable<void> {
21
+ if (this.ready()) return of(undefined);
22
+ this.failed.set(false);
23
+ this.pending ??= this.backend.list<Currency>('currency', undefined, { limit: 1000 }).pipe(
24
+ tap({
25
+ next: (rows) => {
26
+ for (const row of rows) this.byCode.set(row.Code, row);
27
+ this.ready.set(true);
28
+ },
29
+ error: () => {
30
+ this.pending = undefined;
31
+ this.failed.set(true);
32
+ },
33
+ }),
34
+ map(() => undefined),
35
+ shareReplay({ bufferSize: 1, refCount: false }),
36
+ );
37
+ return this.pending;
38
+ }
39
+
40
+ /** Minor-unit exponent of a code; an unknown code is an error, never a guess. */
41
+ exponent(currency: string): number {
42
+ if (!this.ready()) throw new Error('sail: the currency catalog is not loaded.');
43
+ const row = this.byCode.get(currency);
44
+ if (!row) throw new Error(`sail: unknown currency "${currency}".`);
45
+ return row.Exponent;
46
+ }
47
+
48
+ format(minor: number, currency: string): string {
49
+ return formatMinor(minor, currency, this.exponent(currency));
50
+ }
51
+ }
@@ -36,9 +36,10 @@ export class LabelService {
36
36
  }
37
37
 
38
38
  /**
39
- * Load one backend-provided `constant_value` domain from ApplicationData.
40
- * Completed and concurrent requests are cached; a failed application-data
41
- * load is evicted so a later call can retry.
39
+ * Load one caption map from ApplicationData: a `constant_value` domain by its
40
+ * constant id, or a catalog table registered in `foreign_key_lookup` by its
41
+ * table name. Completed and concurrent requests are cached; a failed
42
+ * application-data load is evicted so a later call can retry.
42
43
  */
43
44
  loadLabels(constantId: string): Observable<void> {
44
45
  const domain = constantId.trim();
@@ -53,7 +54,7 @@ export class LabelService {
53
54
  tap({
54
55
  next: (data) => this.labels.set(
55
56
  domain,
56
- new Map(Object.entries(data.ConstantCache?.[domain] ?? {})),
57
+ new Map(Object.entries(data.ConstantCache?.[domain] ?? data.TableCache?.[domain] ?? {})),
57
58
  ),
58
59
  error: () => this.pendingLoads.delete(domain),
59
60
  }),