@nauticana/sail 1.1.34 → 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 +22 -15
- package/package.json +1 -1
- package/src/agent/component/approval_inbox.html +49 -2
- package/src/agent/component/approval_inbox.ts +54 -16
- 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 +83 -0
- package/src/agent/index.ts +2 -0
- package/src/agent/model/agent_studio.ts +63 -0
- package/src/agent/model/approval.ts +37 -1
- package/src/agent/model/conversation.ts +31 -0
- package/src/agent/model/decision.ts +12 -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_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/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 +7 -0
- package/src/model/access.ts +28 -0
- package/src/model/appdata.ts +41 -67
- package/src/model/geo.ts +12 -0
- package/src/service/access.service.ts +57 -0
- 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/money.ts +18 -34
|
@@ -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>
|
|
@@ -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';
|
|
@@ -95,6 +99,9 @@ export * from './service/rest_service';
|
|
|
95
99
|
export * from './service/rest_url';
|
|
96
100
|
export * from './service/label.service';
|
|
97
101
|
export * from './service/caption_pipe';
|
|
102
|
+
export * from './service/currency.service';
|
|
103
|
+
export * from './service/access.service';
|
|
104
|
+
export * from './service/money_pipe';
|
|
98
105
|
export * from './service/base_rest.service';
|
|
99
106
|
export * from './util/text';
|
|
100
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
|
+
}
|
package/src/model/appdata.ts
CHANGED
|
@@ -252,47 +252,32 @@ export interface PartnerCreated {
|
|
|
252
252
|
paymentUrl?: string;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
// One purchasable offer for a plan
|
|
256
|
-
// keel's `subscription_plan_price` table
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
//
|
|
281
|
-
|
|
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
|
|
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 `
|
|
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:
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
// trialing. Drives `<sail-trial-banner>`.
|
|
417
|
-
trialEnd?:
|
|
418
|
-
// Seat quantity on the active/trial sub
|
|
419
|
-
|
|
420
|
-
|
|
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:
|
|
425
|
-
number:
|
|
426
|
-
status:
|
|
427
|
-
|
|
428
|
-
currency:
|
|
429
|
-
|
|
430
|
-
|
|
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; //
|
|
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/geo.ts
ADDED
|
@@ -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
|
+
}
|