@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.
- package/README.md +23 -16
- package/package.json +1 -1
- package/src/agent/component/approval_inbox.html +93 -22
- package/src/agent/component/approval_inbox.ts +199 -17
- package/src/agent/component/assistant_activity.html +28 -0
- package/src/agent/component/assistant_activity.ts +40 -0
- package/src/agent/component/studio_page.html +35 -1
- package/src/agent/component/studio_page.ts +67 -10
- package/src/agent/component/turn_thread.html +27 -0
- package/src/agent/component/turn_thread.ts +94 -0
- package/src/agent/index.ts +2 -0
- package/src/agent/model/agent_studio.ts +67 -1
- package/src/agent/model/approval.ts +45 -1
- package/src/agent/model/conversation.ts +35 -1
- package/src/agent/model/decision.ts +20 -8
- package/src/agent/service/agent_studio.service.ts +29 -0
- package/src/agent/service/approval.service.ts +24 -4
- package/src/agent/service/conversation.service.ts +10 -0
- package/src/component/agency/agency_accept.ts +2 -8
- package/src/component/agency/agency_earnings_summary.html +3 -3
- package/src/component/agency/agency_earnings_summary.ts +2 -2
- package/src/component/agency/agency_payout_history.html +1 -1
- package/src/component/agency/agency_payout_history.ts +2 -3
- package/src/component/agency/commission_ledger.html +1 -1
- package/src/component/agency/commission_ledger.ts +2 -3
- package/src/component/billing/plan_selector.ts +1 -1
- package/src/component/billing/price_selector.ts +5 -5
- package/src/component/oauth_connection/oauth_connection_panel.ts +2 -1
- package/src/component/payout/bank_info_form.html +15 -6
- package/src/component/payout/bank_info_form.ts +28 -72
- package/src/component/security/api_keys.html +85 -0
- package/src/component/security/api_keys.ts +96 -0
- package/src/component/security/connected_apps.html +25 -0
- package/src/component/security/connected_apps.ts +40 -0
- package/src/index.ts +7 -0
- package/src/model/access.ts +37 -0
- package/src/model/appdata.ts +41 -67
- package/src/model/geo.ts +12 -0
- package/src/service/access.service.ts +56 -0
- package/src/service/auth.service.ts +2 -1
- package/src/service/caption_pipe.ts +8 -2
- package/src/service/currency.service.ts +51 -0
- package/src/service/money_pipe.ts +27 -0
- package/src/service/rest_service.ts +1 -1
- package/src/service/rest_url.ts +3 -0
- package/src/util/errors.ts +5 -4
- 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 (
|
|
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">{{
|
|
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">{{
|
|
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">{{
|
|
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 {
|
|
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>{{
|
|
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 {
|
|
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>{{
|
|
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 {
|
|
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 {
|
|
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; //
|
|
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
|
|
20
|
-
*
|
|
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:
|
|
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
|
|
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
|
|
13
|
-
<mat-option [value]="c.
|
|
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
|
|
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>
|
|
27
|
-
<
|
|
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">
|
|
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,
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
43
|
+
private readonly backend = inject(BackendService);
|
|
44
|
+
private readonly labels = inject(LabelService);
|
|
73
45
|
|
|
74
|
-
readonly form =
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
87
|
-
readonly
|
|
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
|
|
109
|
-
if (this.form.invalid || !
|
|
110
|
-
const v = this.form.
|
|
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:
|
|
113
|
-
currency
|
|
68
|
+
countryCode: v.countryCode!,
|
|
69
|
+
currency,
|
|
114
70
|
accountHolderName: v.accountHolderName!,
|
|
115
71
|
billingAddress: v.billingAddress!,
|
|
116
|
-
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>
|