@nauticana/sail 1.1.11 → 1.1.13
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 -6
- package/package.json +1 -1
- package/src/component/login/chpass_component.html +4 -0
- package/src/component/login/chpass_component.ts +15 -3
- package/src/component/login/confirm_chpass_component.html +4 -0
- package/src/component/login/confirm_chpass_component.ts +15 -2
- package/src/component/login/login_component.html +8 -2
- package/src/component/payout/bank_info_form.ts +9 -3
- package/src/component/payout/provider_onboarding.ts +17 -5
- package/src/config.ts +7 -2
- package/src/index.ts +1 -0
- package/src/model/appdata.ts +10 -6
- package/src/service/auth.service.ts +17 -0
- package/src/service/payout.service.ts +27 -1
- package/src/service/rest_url.ts +2 -0
- package/src/util/password_policy.ts +43 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
4
4
|
|
|
5
5
|
> **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x** (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
|
|
6
6
|
|
|
7
|
-
**Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows.
|
|
7
|
+
**Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows. `passwordPolicyValidator` + `BaseAuthService.ensurePasswordPolicy()` — validate passwords against keel's policy (via `SailGuiConfig.passwordPolicyUrl`); see Configuration reference.
|
|
8
8
|
|
|
9
9
|
> **v1.1.10 is a breaking release.** The base classes migrated to signals (`records()`, `editableRecord()`, `isNew()` …), `login()`/`loginWithGoogle()` now return Observables, CRUD errors are typed `SailApiError`, `BaseView` split into read-only `BaseView` + `AbstractEditableView`, the decorators are renamed `IsSailString`/`IsSailNumeric`, and `configureRestUrls()` is finally safe to call from a subclass constructor. The complete old→new mapping is machine-readable in [`migration_guide.json`](migration_guide.json). Consumers must fully migrate before pushing to test or prod.
|
|
10
10
|
|
|
@@ -273,13 +273,24 @@ interface SailGuiConfig {
|
|
|
273
273
|
googleClientId?: string; // Google Identity Services client ID
|
|
274
274
|
appleServiceId?: string; // Apple Services ID
|
|
275
275
|
appleRedirectUri?: string; // Apple Sign-In redirect URI
|
|
276
|
-
privacyPolicyUrl?: string; // Linked from ConsentGateComponent
|
|
276
|
+
privacyPolicyUrl?: string; // Linked from ConsentGateComponent and the login footer
|
|
277
|
+
termsUrl?: string; // Linked from the SMS-consent disclosure and the login footer
|
|
277
278
|
defaultPolicyVersion?: string; // Content hash of the deployed policy
|
|
278
279
|
defaultPolicyLanguage?: string; // ISO 639-1 fallback language
|
|
279
280
|
accountDeletedRoute?: string; // Route after account deletion (default '/login/local')
|
|
281
|
+
passwordPolicyUrl?: string; // Public route serving keel's password policy (see below)
|
|
280
282
|
}
|
|
281
283
|
```
|
|
282
284
|
|
|
285
|
+
### Password-policy validation
|
|
286
|
+
|
|
287
|
+
Set `passwordPolicyUrl` to the app's public route for keel's `PublicHandler.GetPasswordPolicy`
|
|
288
|
+
(e.g. `'/public/v1/auth/password/policy'`). `BaseAuthService.ensurePasswordPolicy()` fetches it
|
|
289
|
+
once into the `passwordRules` signal, and `passwordPolicyValidator(() => auth.passwordRules())`
|
|
290
|
+
validates new-password inputs against the real policy before submit (`passwordPolicyHint(rules)`
|
|
291
|
+
builds the matching hint text). Built into `chpass` / `confirm-chpass`; reuse in app signup forms.
|
|
292
|
+
Omit the URL to skip client-side policy checks. The server stays the authoritative gate.
|
|
293
|
+
|
|
283
294
|
## Backend endpoints (keel v1.0)
|
|
284
295
|
|
|
285
296
|
| Endpoint | Purpose |
|
|
@@ -1128,8 +1139,8 @@ The v0.6 / v0.7 line introduced three additive feature groups against keel v0.7.
|
|
|
1128
1139
|
|
|
1129
1140
|
| From | Export | Purpose |
|
|
1130
1141
|
|---|---|---|
|
|
1131
|
-
| `@nauticana/sail` | `PayoutService` | keel/payout API client —
|
|
1132
|
-
| `@nauticana/sail` | `PayoutProviderOnboardingComponent` (`<sail-payout-provider-onboarding>`) | Drop-in onboarding step with reuse picker
|
|
1142
|
+
| `@nauticana/sail` | `PayoutService` | keel/payout API client — onboarding, reuse, status, bank-version replacement, beneficiary registration |
|
|
1143
|
+
| `@nauticana/sail` | `PayoutProviderOnboardingComponent` (`<sail-payout-provider-onboarding>`) | Drop-in onboarding step with reuse picker, hosted-KYC handoff, and asynchronous-confirmation output |
|
|
1133
1144
|
| `@nauticana/sail` | `PayoutBankInfoFormComponent` (`<sail-payout-bank-info-form>`) | Tax + payout details form (country / currency / tax ID / billing address / agreement) |
|
|
1134
1145
|
| `@nauticana/sail` | `UserPaymentMethodService` | Saved-card list / delete / set-default API client |
|
|
1135
1146
|
| `@nauticana/sail` | `UserPaymentMethodsComponent` (`<sail-user-payment-methods>`) | List of saved cards/wallets with set-default + delete |
|
|
@@ -1141,10 +1152,12 @@ Make sure your keel deployment exposes these — they're all under `/api/v1/`:
|
|
|
1141
1152
|
|
|
1142
1153
|
| Endpoint | Method | Purpose |
|
|
1143
1154
|
|---|---|---|
|
|
1144
|
-
| `/api/v1/payout/onboard/start` | POST |
|
|
1155
|
+
| `/api/v1/payout/onboard/start` | POST | Start provider onboarding; returns `{ url, externalAccountId, expiresAt }` (`url` is empty when confirmation continues asynchronously) |
|
|
1145
1156
|
| `/api/v1/payout/reusable` | POST | List provider accounts the user has on other partners |
|
|
1146
1157
|
| `/api/v1/payout/reusable/link` | POST | Copy a `providerAccountId` onto the active partner's `user_bank_info` row |
|
|
1147
1158
|
| `/api/v1/payout/status` | POST | `{ complete: true }` once the active partner's row has a `providerAccountId` |
|
|
1159
|
+
| `/api/v1/payout/bank/replace` | POST | Supersede the active bank-info version and insert its replacement atomically; the app must wire the handler's `SealTaxID` hook |
|
|
1160
|
+
| `/api/v1/payout/beneficiary/register` | POST | Create and link a provider beneficiary from provider-collected details |
|
|
1148
1161
|
| `/api/v1/payment-methods/set-default` | POST | Atomic multi-row UPDATE — sets one row as default, clears the rest |
|
|
1149
1162
|
| `/api/v1/{table}/{action_name}` | POST | Per-table custom actions resolved from `basis.table_action` |
|
|
1150
1163
|
|
|
@@ -1166,6 +1179,7 @@ Drop the two components into a wizard step. Routing between them stays in the co
|
|
|
1166
1179
|
<sail-payout-provider-onboarding
|
|
1167
1180
|
(linked)="step.set('done')"
|
|
1168
1181
|
(started)="step.set('waiting')"
|
|
1182
|
+
(pending)="step.set('awaiting-confirmation')"
|
|
1169
1183
|
(skipped)="step.set('done')"
|
|
1170
1184
|
(back)="step.set('bank')">
|
|
1171
1185
|
</sail-payout-provider-onboarding>
|
|
@@ -1173,7 +1187,9 @@ Drop the two components into a wizard step. Routing between them stays in the co
|
|
|
1173
1187
|
}
|
|
1174
1188
|
```
|
|
1175
1189
|
|
|
1176
|
-
`BankInfoFormValue`
|
|
1190
|
+
`BankInfoFormValue` is application-layer input, not a generic-CRUD row. In particular, its plaintext `taxId` must be sealed into the `tax_id_encrypted` column and must never be sent through the generic `/api/v1/user_bank_info` CRUD surface. First-time setup goes through the consuming app's registration service. Changes to an existing destination use `PayoutService.replaceBankInfo()`, which calls keel's atomic version-replacement endpoint; wire `PayoutHandler.SealTaxID` so that endpoint seals the plaintext before persistence.
|
|
1191
|
+
|
|
1192
|
+
`PayoutService.startOnboarding()` returns a `PayoutOnboardingSession`. When `url` is non-empty, the component emits `started` and navigates to the provider's hosted flow. When `url` is empty, as with Wise email recipients, it emits `pending` instead; consumers must show an awaiting-confirmation state while the recipient completes the provider's email flow. Airwallex apps can pass details collected by the embedded beneficiary component to `PayoutService.registerBeneficiary()`.
|
|
1177
1193
|
|
|
1178
1194
|
For apps that operate on a single keel partner, the reuse picker stays empty and only the "Start onboarding" CTA renders. Multi-partner apps get the picker for free.
|
|
1179
1195
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.13",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"scripts": {
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
<mat-form-field appearance="outline">
|
|
22
22
|
<mat-label>New Password</mat-label>
|
|
23
23
|
<input matInput type="password" formControlName="new_password" autocomplete="new-password" required>
|
|
24
|
+
@if (passwordHint(); as hint) { <mat-hint>{{ hint }}</mat-hint> }
|
|
25
|
+
@if (chpassForm.controls.new_password.hasError('passwordPolicy')) {
|
|
26
|
+
<mat-error>{{ chpassForm.controls.new_password.getError('passwordPolicy').message }}</mat-error>
|
|
27
|
+
}
|
|
24
28
|
</mat-form-field>
|
|
25
29
|
|
|
26
30
|
<mat-form-field appearance="outline">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, inject, signal, ViewEncapsulation } from '@angular/core';
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, ViewEncapsulation } from '@angular/core';
|
|
2
2
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
3
|
import { BaseAuthService } from '../../service/auth.service';
|
|
4
4
|
import { Router, RouterLink } from '@angular/router';
|
|
@@ -8,6 +8,7 @@ import { MatInputModule } from '@angular/material/input';
|
|
|
8
8
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
9
9
|
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
|
|
10
10
|
import { BaseAsync } from '../abstract/base_async';
|
|
11
|
+
import { passwordPolicyValidator, passwordPolicyHint } from '../../util/password_policy';
|
|
11
12
|
|
|
12
13
|
@Component({
|
|
13
14
|
selector: 'sail-chpass',
|
|
@@ -34,10 +35,21 @@ export class ChpassComponent extends BaseAsync {
|
|
|
34
35
|
chpassForm = this.fb.group({
|
|
35
36
|
username: ['', Validators.required],
|
|
36
37
|
old_password: ['', Validators.required],
|
|
37
|
-
new_password: ['', [Validators.required,
|
|
38
|
+
new_password: ['', [Validators.required, passwordPolicyValidator(() => this.auth.passwordRules())]],
|
|
38
39
|
confirm_password: ['', Validators.required],
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
protected readonly passwordHint = computed(() => passwordPolicyHint(this.auth.passwordRules()));
|
|
43
|
+
|
|
44
|
+
constructor() {
|
|
45
|
+
super();
|
|
46
|
+
this.auth.ensurePasswordPolicy();
|
|
47
|
+
effect(() => {
|
|
48
|
+
this.auth.passwordRules();
|
|
49
|
+
this.chpassForm.controls.new_password.updateValueAndValidity({ emitEvent: false });
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
toggleForgotPassword(checked: boolean) {
|
|
42
54
|
this.forgotPassword.set(checked);
|
|
43
55
|
const oldPw = this.chpassForm.get('old_password')!;
|
|
@@ -52,7 +64,7 @@ export class ChpassComponent extends BaseAsync {
|
|
|
52
64
|
confirmPw.setValue('');
|
|
53
65
|
} else {
|
|
54
66
|
oldPw.setValidators(Validators.required);
|
|
55
|
-
newPw.setValidators([Validators.required,
|
|
67
|
+
newPw.setValidators([Validators.required, passwordPolicyValidator(() => this.auth.passwordRules())]);
|
|
56
68
|
confirmPw.setValidators(Validators.required);
|
|
57
69
|
}
|
|
58
70
|
oldPw.updateValueAndValidity();
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
<mat-form-field appearance="outline">
|
|
22
22
|
<mat-label>New Password</mat-label>
|
|
23
23
|
<input matInput type="password" formControlName="new_password" autocomplete="new-password" required>
|
|
24
|
+
@if (passwordHint(); as hint) { <mat-hint>{{ hint }}</mat-hint> }
|
|
25
|
+
@if (confirmForm.controls.new_password.hasError('passwordPolicy')) {
|
|
26
|
+
<mat-error>{{ confirmForm.controls.new_password.getError('passwordPolicy').message }}</mat-error>
|
|
27
|
+
}
|
|
24
28
|
</mat-form-field>
|
|
25
29
|
|
|
26
30
|
<mat-form-field appearance="outline">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, inject, OnInit, ViewEncapsulation } from '@angular/core';
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, effect, inject, OnInit, ViewEncapsulation } from '@angular/core';
|
|
2
2
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
3
|
import { BaseAuthService } from '../../service/auth.service';
|
|
4
4
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|
@@ -7,6 +7,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
|
|
7
7
|
import { MatInputModule } from '@angular/material/input';
|
|
8
8
|
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
|
|
9
9
|
import { BaseAsync } from '../abstract/base_async';
|
|
10
|
+
import { passwordPolicyValidator, passwordPolicyHint } from '../../util/password_policy';
|
|
10
11
|
|
|
11
12
|
@Component({
|
|
12
13
|
selector: 'sail-confirm-chpass',
|
|
@@ -31,11 +32,23 @@ export class ConfirmChpassComponent extends BaseAsync implements OnInit {
|
|
|
31
32
|
confirmForm = this.fb.group({
|
|
32
33
|
username: ['', Validators.required],
|
|
33
34
|
code: ['', Validators.required],
|
|
34
|
-
new_password: ['', [Validators.required,
|
|
35
|
+
new_password: ['', [Validators.required, passwordPolicyValidator(() => this.auth.passwordRules())]],
|
|
35
36
|
confirm_password: ['', Validators.required],
|
|
36
37
|
});
|
|
37
38
|
|
|
39
|
+
protected readonly passwordHint = computed(() => passwordPolicyHint(this.auth.passwordRules()));
|
|
40
|
+
|
|
41
|
+
constructor() {
|
|
42
|
+
super();
|
|
43
|
+
// Re-validate the new-password field once the policy arrives.
|
|
44
|
+
effect(() => {
|
|
45
|
+
this.auth.passwordRules();
|
|
46
|
+
this.confirmForm.controls.new_password.updateValueAndValidity({ emitEvent: false });
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
ngOnInit() {
|
|
51
|
+
this.auth.ensurePasswordPolicy();
|
|
39
52
|
const username = this.route.snapshot.queryParamMap.get('username');
|
|
40
53
|
if (username) {
|
|
41
54
|
this.confirmForm.patchValue({ username });
|
|
@@ -38,11 +38,17 @@
|
|
|
38
38
|
<div class="auth-actions">
|
|
39
39
|
<a routerLink="/login/chpass">I forgot my password</a>
|
|
40
40
|
</div>
|
|
41
|
-
@if (guiConfig.loginFooterLinks
|
|
41
|
+
@if (guiConfig.loginFooterLinks?.length || guiConfig.termsUrl || guiConfig.privacyPolicyUrl) {
|
|
42
42
|
<div class="auth-footer">
|
|
43
|
-
@for (link of guiConfig.loginFooterLinks; track link.routerLink) {
|
|
43
|
+
@for (link of guiConfig.loginFooterLinks ?? []; track link.routerLink) {
|
|
44
44
|
<a [routerLink]="link.routerLink">{{ link.label }}</a>
|
|
45
45
|
}
|
|
46
|
+
@if (guiConfig.termsUrl) {
|
|
47
|
+
<a [href]="guiConfig.termsUrl" target="_blank" rel="noopener">Terms of Service</a>
|
|
48
|
+
}
|
|
49
|
+
@if (guiConfig.privacyPolicyUrl) {
|
|
50
|
+
<a [href]="guiConfig.privacyPolicyUrl" target="_blank" rel="noopener">Privacy Policy</a>
|
|
51
|
+
}
|
|
46
52
|
</div>
|
|
47
53
|
}
|
|
48
54
|
</div>
|
|
@@ -26,8 +26,10 @@ export const DEFAULT_COUNTRY_PROFILES: CountryProfile[] = [
|
|
|
26
26
|
*
|
|
27
27
|
* Captures the basis `user_bank_info` fields (country, currency, account
|
|
28
28
|
* holder, tax id, billing address, agreement). Emits the form value via
|
|
29
|
-
* `(submitted)` —
|
|
30
|
-
*
|
|
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.
|
|
31
33
|
*
|
|
32
34
|
* Ships no CSS — the consuming app styles the classes globally.
|
|
33
35
|
*
|
|
@@ -58,7 +60,11 @@ export class PayoutBankInfoFormComponent {
|
|
|
58
60
|
/** When true, the back button renders alongside submit. */
|
|
59
61
|
readonly showBack = input(true);
|
|
60
62
|
|
|
61
|
-
/**
|
|
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
|
+
*/
|
|
62
68
|
readonly submitted = output<BankInfoFormValue>();
|
|
63
69
|
/** Emitted when the user clicks Back. */
|
|
64
70
|
readonly back = output<void>();
|
|
@@ -14,12 +14,13 @@ import { ReusableAccount } from '../../model/appdata';
|
|
|
14
14
|
* two paths:
|
|
15
15
|
* 1. Reuse an existing provider account from another partner row
|
|
16
16
|
* (zero-KYC; calls /api/v1/payout/reusable/link).
|
|
17
|
-
* 2.
|
|
18
|
-
*
|
|
17
|
+
* 2. Start provider onboarding (/api/v1/payout/onboard/start), then
|
|
18
|
+
* either hand off to a hosted-KYC page or emit `pending` when the
|
|
19
|
+
* provider continues asynchronously without a hosted URL.
|
|
19
20
|
*
|
|
20
|
-
* Routing is consumer-owned via the `(linked)
|
|
21
|
-
* the component itself never calls Router.
|
|
22
|
-
* `skipLabel` inputs cover the common UX strings.
|
|
21
|
+
* Routing is consumer-owned via the `(linked)`, `(pending)`, and
|
|
22
|
+
* `(skipped)` outputs — the component itself never calls Router.
|
|
23
|
+
* Optional `title` and `skipLabel` inputs cover the common UX strings.
|
|
23
24
|
*
|
|
24
25
|
* Ships no CSS — the consuming app styles the classes globally.
|
|
25
26
|
*
|
|
@@ -47,6 +48,13 @@ export class PayoutProviderOnboardingComponent extends BaseAsync implements OnIn
|
|
|
47
48
|
readonly back = output<void>();
|
|
48
49
|
/** Emitted just before handing off to the hosted-KYC page. */
|
|
49
50
|
readonly started = output<void>();
|
|
51
|
+
/**
|
|
52
|
+
* Emitted when onboarding started but there is no hosted page to open
|
|
53
|
+
* (Wise email recipients: the account is linked and awaits the
|
|
54
|
+
* recipient's email confirmation). Consumers show a "linked, awaiting
|
|
55
|
+
* confirmation" state instead of navigating.
|
|
56
|
+
*/
|
|
57
|
+
readonly pending = output<void>();
|
|
50
58
|
|
|
51
59
|
private readonly payoutService = inject(PayoutService);
|
|
52
60
|
|
|
@@ -79,6 +87,10 @@ export class PayoutProviderOnboardingComponent extends BaseAsync implements OnIn
|
|
|
79
87
|
this.run(
|
|
80
88
|
this.payoutService.startOnboarding(),
|
|
81
89
|
(res) => {
|
|
90
|
+
if (!res.url) {
|
|
91
|
+
this.pending.emit();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
82
94
|
this.started.emit();
|
|
83
95
|
// Same-tab handoff: window.open from an async callback is popup-blocked
|
|
84
96
|
// (always on Safari), which used to leave users on a "waiting" screen
|
package/src/config.ts
CHANGED
|
@@ -53,9 +53,9 @@ export interface SailGuiConfig {
|
|
|
53
53
|
appleServiceId?: string;
|
|
54
54
|
/** Apple Sign-In redirect URI. Must match the Services ID configuration. */
|
|
55
55
|
appleRedirectUri?: string;
|
|
56
|
-
/** Public URL of your privacy policy.
|
|
56
|
+
/** Public URL of your privacy policy. Linked from ConsentGateComponent and the login footer. */
|
|
57
57
|
privacyPolicyUrl?: string;
|
|
58
|
-
/** Public URL of your terms of service.
|
|
58
|
+
/** Public URL of your terms of service. Linked from the SMS-consent disclosure and the login footer. */
|
|
59
59
|
termsUrl?: string;
|
|
60
60
|
/** Brand name shown in the SMS/10DLC consent disclosure (e.g. "Daxoom"). */
|
|
61
61
|
appName?: string;
|
|
@@ -74,6 +74,11 @@ export interface SailGuiConfig {
|
|
|
74
74
|
/** Hostnames a post-login `?return=` may redirect to (open-redirect allowlist),
|
|
75
75
|
* e.g. ['mcp.example.com']. Anything not listed is ignored. */
|
|
76
76
|
allowedReturnHosts?: string[];
|
|
77
|
+
|
|
78
|
+
/** Public route serving the password policy (keel PublicHandler.GetPasswordPolicy),
|
|
79
|
+
* e.g. '/public/v1/auth/password/policy'. When set, sail fetches it once and
|
|
80
|
+
* password inputs validate against it. Omitted → policy validation is skipped. */
|
|
81
|
+
passwordPolicyUrl?: string;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
export const SAIL_GUI_CONFIG = new InjectionToken<SailGuiConfig>('SailGuiConfig');
|
package/src/index.ts
CHANGED
package/src/model/appdata.ts
CHANGED
|
@@ -377,18 +377,22 @@ export interface ReusableAccount {
|
|
|
377
377
|
onboardedAt: string;
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
-
// Result of PayoutService.startOnboarding(). url is opened in a
|
|
381
|
-
// external browser
|
|
382
|
-
//
|
|
380
|
+
// Result of PayoutService.startOnboarding(). A non-empty url is opened in a
|
|
381
|
+
// webview / external browser. Some providers have no hosted flow (for example,
|
|
382
|
+
// Wise email recipients); in that case url and expiresAt are empty and
|
|
383
|
+
// PayoutProviderOnboardingComponent emits `pending` while confirmation
|
|
384
|
+
// continues asynchronously.
|
|
383
385
|
export interface PayoutOnboardingSession {
|
|
384
386
|
url: string;
|
|
385
387
|
externalAccountId: string;
|
|
386
388
|
expiresAt: string;
|
|
387
389
|
}
|
|
388
390
|
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
391
|
+
// Application-layer payload emitted by the bank-info form. It is not a
|
|
392
|
+
// generic-CRUD row: taxId must be sealed into basis.user_bank_info's
|
|
393
|
+
// tax_id_encrypted column. First-time setup goes through the consuming app's
|
|
394
|
+
// registration service; changes use PayoutService.replaceBankInfo so keel can
|
|
395
|
+
// atomically supersede the active destination version.
|
|
392
396
|
export interface BankInfoFormValue {
|
|
393
397
|
countryCode: string;
|
|
394
398
|
currency: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Injectable, WritableSignal, inject, signal } from '@angular/core';
|
|
2
2
|
import { HttpErrorResponse, HttpParams } from '@angular/common/http';
|
|
3
3
|
import { filter, map, mergeMap, take, tap } from 'rxjs/operators';
|
|
4
|
+
import { PasswordRules } from '../util/password_policy';
|
|
4
5
|
import { ApplicationData, ConfirmRegisterResponse, DictionaryPath, LoginResponse2FA, PartnerRegistration, RestReport, TableDefinition, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
|
|
5
6
|
import {
|
|
6
7
|
LoginResponseSocial,
|
|
@@ -94,6 +95,22 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
94
95
|
token: string | null = null;
|
|
95
96
|
readonly isLoggedIn = signal(false);
|
|
96
97
|
|
|
98
|
+
/** keel password policy for pre-submit validation; undefined until loaded
|
|
99
|
+
* (or when guiConfig.passwordPolicyUrl is unset). Fed via ensurePasswordPolicy(). */
|
|
100
|
+
readonly passwordRules = signal<PasswordRules | undefined>(undefined);
|
|
101
|
+
private passwordPolicyLoading = false;
|
|
102
|
+
|
|
103
|
+
/** Fetch the policy once (public route, safe pre-login). No-op if already
|
|
104
|
+
* loaded/loading or no URL configured. */
|
|
105
|
+
ensurePasswordPolicy(): void {
|
|
106
|
+
if (this.passwordRules() || this.passwordPolicyLoading || !this.guiConfig.passwordPolicyUrl) return;
|
|
107
|
+
this.passwordPolicyLoading = true;
|
|
108
|
+
this.http.get<PasswordRules>(this.url(this.guiConfig.passwordPolicyUrl)).subscribe({
|
|
109
|
+
next: (rules) => this.passwordRules.set(rules),
|
|
110
|
+
error: () => { this.passwordPolicyLoading = false; },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
97
114
|
/** Last appdata load failure (null when none). */
|
|
98
115
|
readonly appDataError = signal<unknown>(null);
|
|
99
116
|
|
|
@@ -3,6 +3,7 @@ import { Observable } from 'rxjs';
|
|
|
3
3
|
import { RestURL } from './rest_url';
|
|
4
4
|
import { BaseRestService } from './base_rest.service';
|
|
5
5
|
import {
|
|
6
|
+
BankInfoFormValue,
|
|
6
7
|
PayoutOnboardingSession,
|
|
7
8
|
ReusableAccount,
|
|
8
9
|
} from '../model/appdata';
|
|
@@ -20,7 +21,11 @@ import {
|
|
|
20
21
|
*/
|
|
21
22
|
@Injectable({ providedIn: 'root' })
|
|
22
23
|
export class PayoutService extends BaseRestService {
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Start provider onboarding. A non-empty URL is the hosted-KYC handoff;
|
|
26
|
+
* an empty URL means confirmation continues asynchronously (for example,
|
|
27
|
+
* a Wise recipient confirming from email).
|
|
28
|
+
*/
|
|
24
29
|
startOnboarding(): Observable<PayoutOnboardingSession> {
|
|
25
30
|
return this.http.post<PayoutOnboardingSession>(this.url(RestURL.payoutOnboardStartURL), {});
|
|
26
31
|
}
|
|
@@ -39,4 +44,25 @@ export class PayoutService extends BaseRestService {
|
|
|
39
44
|
status(): Observable<{ complete: boolean }> {
|
|
40
45
|
return this.http.post<{ complete: boolean }>(this.url(RestURL.payoutStatusURL), {});
|
|
41
46
|
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Replace the payout destination with a new bank-info version
|
|
50
|
+
* (atomic supersede + insert on the backend). Identity changes go
|
|
51
|
+
* through this, never through generic CRUD edits. `provider` and
|
|
52
|
+
* `providerAgreement` are server-owned and ignored here.
|
|
53
|
+
*/
|
|
54
|
+
replaceBankInfo(value: BankInfoFormValue): Observable<{ message: string }> {
|
|
55
|
+
const { countryCode, currency, accountHolderName, billingAddress, taxIdType, taxId } = value;
|
|
56
|
+
return this.http.post<{ message: string }>(this.url(RestURL.payoutBankReplaceURL),
|
|
57
|
+
{ countryCode, currency, accountHolderName, billingAddress, taxIdType, taxId });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Register a payout beneficiary from provider-collected details
|
|
62
|
+
* (Airwallex embedded beneficiary component). The payload passes
|
|
63
|
+
* through to the provider; the backend links the returned id.
|
|
64
|
+
*/
|
|
65
|
+
registerBeneficiary(beneficiary: unknown): Observable<{ beneficiaryId: string }> {
|
|
66
|
+
return this.http.post<{ beneficiaryId: string }>(this.url(RestURL.payoutBeneficiaryURL), { beneficiary });
|
|
67
|
+
}
|
|
42
68
|
}
|
package/src/service/rest_url.ts
CHANGED
|
@@ -53,6 +53,8 @@ export const RestURL = {
|
|
|
53
53
|
payoutReusableURL: '/api/v1/payout/reusable',
|
|
54
54
|
payoutReusableLinkURL: '/api/v1/payout/reusable/link',
|
|
55
55
|
payoutStatusURL: '/api/v1/payout/status',
|
|
56
|
+
payoutBankReplaceURL: '/api/v1/payout/bank/replace',
|
|
57
|
+
payoutBeneficiaryURL: '/api/v1/payout/beneficiary/register',
|
|
56
58
|
// User payment methods — list/delete go through keel's generic REST
|
|
57
59
|
// CRUD against the UserSpecific basis table; set-default is a
|
|
58
60
|
// TableAction (basis table_action row), mounted at the conventional
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
|
|
2
|
+
|
|
3
|
+
// keel PasswordPolicyView — the composition rules for pre-submit validation.
|
|
4
|
+
export interface PasswordRules {
|
|
5
|
+
minLength: number;
|
|
6
|
+
minUpper: number;
|
|
7
|
+
minLower: number;
|
|
8
|
+
minDigit: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type RulesSource = PasswordRules | (() => PasswordRules | undefined);
|
|
12
|
+
|
|
13
|
+
// Validator for keel's password policy. Empty is left to Validators.required;
|
|
14
|
+
// undefined rules (not loaded) skip the check. Char classes match keel's ASCII rules.
|
|
15
|
+
export function passwordPolicyValidator(source: RulesSource): ValidatorFn {
|
|
16
|
+
return (control: AbstractControl): ValidationErrors | null => {
|
|
17
|
+
const rules = typeof source === 'function' ? source() : source;
|
|
18
|
+
const value: string = control.value ?? '';
|
|
19
|
+
if (!rules || !value) return null;
|
|
20
|
+
let upper = 0, lower = 0, digit = 0;
|
|
21
|
+
for (const ch of value) {
|
|
22
|
+
if (ch >= 'A' && ch <= 'Z') upper++;
|
|
23
|
+
else if (ch >= 'a' && ch <= 'z') lower++;
|
|
24
|
+
else if (ch >= '0' && ch <= '9') digit++;
|
|
25
|
+
}
|
|
26
|
+
const unmet: string[] = [];
|
|
27
|
+
if (value.length < rules.minLength) unmet.push(`${rules.minLength} characters`);
|
|
28
|
+
if (upper < rules.minUpper) unmet.push('an uppercase letter');
|
|
29
|
+
if (lower < rules.minLower) unmet.push('a lowercase letter');
|
|
30
|
+
if (digit < rules.minDigit) unmet.push('a number');
|
|
31
|
+
return unmet.length ? { passwordPolicy: { message: `Password needs ${unmet.join(', ')}.` } } : null;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Human-readable requirement string built from the loaded rules ('' when unknown).
|
|
36
|
+
export function passwordPolicyHint(rules?: PasswordRules): string {
|
|
37
|
+
if (!rules) return '';
|
|
38
|
+
const parts = [`at least ${rules.minLength} characters`];
|
|
39
|
+
if (rules.minUpper > 0) parts.push('an uppercase letter');
|
|
40
|
+
if (rules.minLower > 0) parts.push('a lowercase letter');
|
|
41
|
+
if (rules.minDigit > 0) parts.push('a number');
|
|
42
|
+
return `Use ${parts.join(', ')}.`;
|
|
43
|
+
}
|