@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.
- package/README.md +36 -23
- package/package.json +1 -1
- package/src/agent/component/approval_inbox.html +110 -0
- package/src/agent/component/approval_inbox.ts +129 -0
- package/src/agent/component/assistant_activity.html +28 -0
- package/src/agent/component/assistant_activity.ts +40 -0
- package/src/agent/component/decision_timeline.html +4 -4
- package/src/agent/component/decision_timeline.ts +2 -0
- package/src/agent/component/prompt_section_editor.html +4 -3
- package/src/agent/component/prompt_section_editor.ts +12 -5
- package/src/agent/component/release_sections.html +1 -1
- package/src/agent/component/release_sections.ts +2 -1
- package/src/agent/component/studio_page.html +303 -0
- package/src/agent/component/studio_page.ts +403 -0
- package/src/agent/component/studio_tab.ts +14 -0
- package/src/agent/component/turn_event_list.html +9 -0
- package/src/agent/component/turn_thread.html +27 -0
- package/src/agent/component/turn_thread.ts +83 -0
- package/src/agent/index.ts +8 -0
- package/src/agent/model/agent_studio.ts +185 -9
- package/src/agent/model/approval.ts +78 -0
- package/src/agent/model/conversation.ts +40 -1
- package/src/agent/model/decision.ts +14 -9
- package/src/agent/model/turn_event.ts +10 -0
- package/src/agent/service/agent_studio.service.ts +65 -1
- package/src/agent/service/approval.service.ts +38 -0
- package/src/agent/service/conversation.service.ts +10 -0
- package/src/agent/util/release_diff.ts +33 -0
- package/src/agent/util/studio_errors.ts +11 -0
- package/src/agent/util/turn_timeline.ts +7 -1
- 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/login/account_deletion.html +17 -4
- package/src/component/login/account_deletion.ts +35 -28
- package/src/component/login/social_login.ts +6 -5
- 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 +78 -0
- package/src/component/security/api_keys.ts +90 -0
- package/src/component/security/connected_apps.html +25 -0
- package/src/component/security/connected_apps.ts +40 -0
- package/src/index.ts +8 -0
- package/src/model/access.ts +28 -0
- package/src/model/appdata.ts +41 -67
- package/src/model/auth.ts +17 -2
- package/src/model/geo.ts +12 -0
- package/src/service/access.service.ts +57 -0
- package/src/service/auth.service.ts +10 -4
- package/src/service/caption_pipe.ts +32 -0
- package/src/service/currency.service.ts +51 -0
- package/src/service/label.service.ts +5 -4
- package/src/service/money_pipe.ts +27 -0
- package/src/service/rest_service.ts +1 -1
- package/src/service/rest_url.ts +4 -0
- 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
|
-
|
|
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">{{
|
|
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
|
};
|
|
@@ -13,10 +13,23 @@
|
|
|
13
13
|
<input matInput formControlName="confirm" autocomplete="off" required>
|
|
14
14
|
</mat-form-field>
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
<mat-
|
|
18
|
-
|
|
19
|
-
|
|
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 {
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* DELETE /api/user/account for
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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:
|
|
50
|
-
|
|
51
|
-
reason:
|
|
51
|
+
confirm: ['', Validators.required],
|
|
52
|
+
credential: ['', Validators.required],
|
|
53
|
+
reason: [''],
|
|
52
54
|
});
|
|
53
55
|
|
|
54
|
-
readonly
|
|
55
|
-
private readonly
|
|
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.
|
|
58
|
-
!!this.
|
|
59
|
+
this.confirmValue() === this.confirmationText() &&
|
|
60
|
+
!!this.credentialValue() &&
|
|
59
61
|
!this.loading(),
|
|
60
62
|
);
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
this.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
75
|
-
const
|
|
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(
|
|
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
|
|
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,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>
|