@nauticana/sail 1.1.9 → 1.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +4 -2
  2. package/package.json +5 -1
  3. package/src/component/abstract/base_async.ts +9 -11
  4. package/src/component/abstract/base_form.ts +6 -5
  5. package/src/component/abstract/base_table.ts +131 -58
  6. package/src/component/abstract/base_view.ts +43 -27
  7. package/src/component/account/profile_editor.ts +2 -2
  8. package/src/component/billing/payment_methods.ts +1 -2
  9. package/src/component/billing/price_selector.html +2 -1
  10. package/src/component/billing/price_selector.ts +28 -13
  11. package/src/component/billing/trial_banner.ts +7 -1
  12. package/src/component/billing/usage_meter.ts +2 -2
  13. package/src/component/dashboard/data_card.html +1 -1
  14. package/src/component/dashboard/entity_selector.ts +21 -23
  15. package/src/component/form/form_field.html +6 -5
  16. package/src/component/form/form_field.ts +39 -21
  17. package/src/component/form/form_record.html +3 -3
  18. package/src/component/form/form_record.ts +23 -8
  19. package/src/component/form/form_table.html +2 -2
  20. package/src/component/form/form_table.ts +6 -5
  21. package/src/component/login/account_deletion.ts +6 -6
  22. package/src/component/login/chpass_component.html +4 -4
  23. package/src/component/login/chpass_component.ts +0 -1
  24. package/src/component/login/confirm_chpass_component.html +4 -4
  25. package/src/component/login/confirm_chpass_component.ts +1 -4
  26. package/src/component/login/consent_gate.html +6 -6
  27. package/src/component/login/consent_gate.ts +17 -24
  28. package/src/component/login/login_component.html +7 -4
  29. package/src/component/login/login_component.ts +9 -5
  30. package/src/component/login/otp_input.html +13 -5
  31. package/src/component/login/otp_input.ts +34 -27
  32. package/src/component/login/register_component.html +31 -14
  33. package/src/component/login/register_component.ts +80 -89
  34. package/src/component/login/sms_consent.html +11 -0
  35. package/src/component/login/sms_consent.ts +38 -0
  36. package/src/component/login/social_login.html +2 -2
  37. package/src/component/login/social_login.ts +20 -13
  38. package/src/component/navigation/navigation.html +2 -2
  39. package/src/component/navigation/navigation.ts +2 -0
  40. package/src/component/oauth_connection/oauth_connection_panel.ts +24 -3
  41. package/src/component/payment/user_payment_methods.html +2 -2
  42. package/src/component/payment/user_payment_methods.ts +12 -23
  43. package/src/component/payout/bank_info_form.html +4 -4
  44. package/src/component/payout/bank_info_form.ts +22 -26
  45. package/src/component/payout/provider_onboarding.html +5 -5
  46. package/src/component/payout/provider_onboarding.ts +28 -48
  47. package/src/component/security/trusted_devices.html +1 -1
  48. package/src/component/security/trusted_devices.ts +0 -1
  49. package/src/component/security/twofactor_setup.html +8 -4
  50. package/src/component/security/twofactor_setup.ts +21 -3
  51. package/src/component/security/twofactor_verify.html +2 -1
  52. package/src/component/security/twofactor_verify.ts +2 -8
  53. package/src/component/table/table_detail.html +15 -14
  54. package/src/component/table/table_detail.ts +62 -41
  55. package/src/component/table/table_edit.html +15 -11
  56. package/src/component/table/table_edit.ts +94 -67
  57. package/src/component/table/table_list.html +45 -12
  58. package/src/component/table/table_list.ts +98 -26
  59. package/src/component/table/table_lookup.html +11 -8
  60. package/src/component/table/table_lookup.ts +45 -37
  61. package/src/component/table/table_report.html +2 -2
  62. package/src/component/table/table_report.ts +37 -31
  63. package/src/component/table/table_search.html +4 -4
  64. package/src/component/table/table_search.ts +36 -45
  65. package/src/config.ts +5 -1
  66. package/src/index.ts +2 -0
  67. package/src/model/appdata.ts +5 -9
  68. package/src/model/common.ts +27 -27
  69. package/src/model/decorator.ts +25 -21
  70. package/src/service/api-response.interceptor.ts +4 -1
  71. package/src/service/auth.interceptor.ts +27 -7
  72. package/src/service/auth.service.ts +188 -134
  73. package/src/service/label.service.ts +6 -5
  74. package/src/service/oauth_connection.service.ts +2 -2
  75. package/src/service/rest_service.ts +57 -13
  76. package/src/service/rest_url.ts +23 -7
  77. package/src/service/script_loader.ts +12 -2
  78. package/src/util/errors.ts +20 -0
  79. package/src/util/money.ts +19 -7
@@ -0,0 +1,38 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, inject, input } from '@angular/core';
2
+ import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
3
+
4
+ /**
5
+ * SmsConsent — the shared 10DLC / A2P SMS opt-in disclosure.
6
+ *
7
+ * Presentational only: renders the carrier-required consent language (recurring
8
+ * automated messages, frequency, rates, STOP/HELP, Terms + Privacy links) with
9
+ * the host app's brand and message purpose. Every SMS-sending app renders the
10
+ * same carrier-compliant copy — which must match the wording registered in its
11
+ * 10DLC campaign sample. Consent is *captured* server-side via keel's
12
+ * ConsentService (with the phone number); this component is only the disclosure
13
+ * shown at the point of opt-in. Styling is the consuming app's (sail ships no CSS).
14
+ */
15
+ @Component({
16
+ selector: 'sail-sms-consent',
17
+ templateUrl: './sms_consent.html',
18
+ changeDetection: ChangeDetectionStrategy.OnPush,
19
+ encapsulation: ViewEncapsulation.None,
20
+ })
21
+ export class SmsConsentComponent {
22
+ private readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, { optional: true }) ?? DEFAULT_CONFIG;
23
+
24
+ /** Brand name in the disclosure. Defaults to config.appName. */
25
+ readonly brand = input<string>('');
26
+ /** What the messages are — e.g. "one-time verification codes" or "verification codes and trip updates". */
27
+ readonly messagePurpose = input<string>('one-time verification codes');
28
+ /** The action label the disclosure references (the opt-in button's text). */
29
+ readonly action = input<string>('the button below');
30
+ /** Terms URL. Defaults to config.termsUrl. */
31
+ readonly termsUrl = input<string>('');
32
+ /** Privacy URL. Defaults to config.privacyPolicyUrl. */
33
+ readonly privacyUrl = input<string>('');
34
+
35
+ protected readonly brandName = computed(() => this.brand() || this.guiConfig.appName || 'us');
36
+ protected readonly terms = computed(() => this.termsUrl() || this.guiConfig.termsUrl || '');
37
+ protected readonly privacy = computed(() => this.privacyUrl() || this.guiConfig.privacyPolicyUrl || '');
38
+ }
@@ -1,8 +1,8 @@
1
1
  <div class="social-login">
2
- @if (googleEnabled) {
2
+ @if (googleEnabled()) {
3
3
  <div #googleBtn class="social-google"></div>
4
4
  }
5
- @if (appleEnabled) {
5
+ @if (appleEnabled()) {
6
6
  <button type="button" class="social-apple-btn" (click)="onAppleClick()" [disabled]="loading()">
7
7
  Sign in with Apple
8
8
  </button>
@@ -1,6 +1,6 @@
1
1
  import {
2
- ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild, ViewEncapsulation,
3
- inject, input, output,
2
+ ChangeDetectionStrategy, Component, computed, ElementRef, OnInit, ViewEncapsulation,
3
+ inject, input, output, viewChild,
4
4
  } from '@angular/core';
5
5
  import { BaseAsync } from '../abstract/base_async';
6
6
  import { BaseAuthService } from '../../service/auth.service';
@@ -45,15 +45,14 @@ export class SocialLoginComponent extends BaseAsync implements OnInit {
45
45
  readonly loginSuccess = output<LoginResponseSocial>();
46
46
  readonly loginError = output<Error>();
47
47
 
48
- @ViewChild('googleBtn', { static: false }) googleBtn?: ElementRef<HTMLDivElement>;
48
+ private readonly googleBtn = viewChild<ElementRef<HTMLDivElement>>('googleBtn');
49
49
 
50
- protected googleEnabled = false;
51
- protected appleEnabled = false;
50
+ protected readonly googleEnabled = computed(() =>
51
+ this.providers().includes('google') && !!this.guiConfig.googleClientId);
52
+ protected readonly appleEnabled = computed(() =>
53
+ this.providers().includes('apple') && !!this.guiConfig.appleServiceId);
52
54
 
53
55
  ngOnInit() {
54
- this.googleEnabled = this.providers().includes('google') && !!this.guiConfig.googleClientId;
55
- this.appleEnabled = this.providers().includes('apple') && !!this.guiConfig.appleServiceId;
56
-
57
56
  if (this.providers().includes('google') && !this.guiConfig.googleClientId) {
58
57
  console.warn('SocialLoginComponent: googleClientId missing from SailGuiConfig — Google button hidden');
59
58
  }
@@ -61,8 +60,15 @@ export class SocialLoginComponent extends BaseAsync implements OnInit {
61
60
  console.warn('SocialLoginComponent: appleServiceId missing from SailGuiConfig — Apple button hidden');
62
61
  }
63
62
 
64
- if (this.googleEnabled) this.initGoogle();
65
- if (this.appleEnabled) this.initApple();
63
+ // SDK load failures (CSP, ad-blockers) must surface, not leave a blank area.
64
+ if (this.googleEnabled()) this.initGoogle().catch((err) => this.onInitError('Google', err));
65
+ if (this.appleEnabled()) this.initApple().catch((err) => this.onInitError('Apple', err));
66
+ }
67
+
68
+ private onInitError(provider: string, err: unknown) {
69
+ console.error(`${provider} sign-in init failed:`, err);
70
+ this.errorMessage.set(`${provider} sign-in is unavailable right now.`);
71
+ this.loginError.emit(err instanceof Error ? err : new Error(String(err)));
66
72
  }
67
73
 
68
74
  private async initGoogle() {
@@ -75,8 +81,9 @@ export class SocialLoginComponent extends BaseAsync implements OnInit {
75
81
  });
76
82
  // Render into the template anchor once the view is ready.
77
83
  queueMicrotask(() => {
78
- if (this.googleBtn?.nativeElement) {
79
- google.accounts.id.renderButton(this.googleBtn.nativeElement, { theme: 'outline', size: 'large' });
84
+ const anchor = this.googleBtn()?.nativeElement;
85
+ if (anchor) {
86
+ google.accounts.id.renderButton(anchor, { theme: 'outline', size: 'large' });
80
87
  }
81
88
  });
82
89
  }
@@ -98,7 +105,7 @@ export class SocialLoginComponent extends BaseAsync implements OnInit {
98
105
  const res = await AppleID.auth.signIn();
99
106
  const token = res?.authorization?.id_token;
100
107
  if (token) this.onIdToken('apple', token);
101
- } catch (err: any) {
108
+ } catch (err: unknown) {
102
109
  this.loginError.emit(err instanceof Error ? err : new Error(String(err)));
103
110
  }
104
111
  }
@@ -9,7 +9,7 @@
9
9
  [attr.aria-label]="guiConfig.appTitle ? guiConfig.appTitle + ' home' : 'Home'">
10
10
  @if (guiConfig.appLogoUrl) {
11
11
  <img class="sail-navigation-logo"
12
- [src]="guiConfig.appLogoUrl"
12
+ [ngSrc]="guiConfig.appLogoUrl"
13
13
  width="40"
14
14
  height="40"
15
15
  alt="">
@@ -44,7 +44,7 @@
44
44
  <mat-toolbar class="primary">
45
45
  @if (isHandset()) {
46
46
  <button type="button" aria-label="Toggle sidenav" matIconButton (click)="sidenav.toggle()">
47
- <mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
47
+ <mat-icon>menu</mat-icon>
48
48
  </button>
49
49
  }
50
50
  <ng-content select="[toolbar-title]"></ng-content>
@@ -1,4 +1,5 @@
1
1
  import { ChangeDetectionStrategy, Component, computed, inject, ViewEncapsulation } from "@angular/core";
2
+ import { NgOptimizedImage } from "@angular/common";
2
3
  import { toSignal } from "@angular/core/rxjs-interop";
3
4
  import { RouterLink, RouterOutlet } from "@angular/router";
4
5
  import { MatSidenavModule } from '@angular/material/sidenav';
@@ -24,6 +25,7 @@ import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
24
25
  MatListModule,
25
26
  MatIconModule,
26
27
  MatButtonModule,
28
+ NgOptimizedImage,
27
29
  RouterOutlet,
28
30
  RouterLink,
29
31
  MatExpansionModule,
@@ -1,4 +1,4 @@
1
- import { ChangeDetectionStrategy, Component, OnInit, computed, effect, inject, input, output, signal } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, computed, effect, inject, input, output, signal } from '@angular/core';
2
2
  import { NgOptimizedImage } from '@angular/common';
3
3
  import { Subscription } from 'rxjs';
4
4
  import { MatButtonModule } from '@angular/material/button';
@@ -40,6 +40,8 @@ export class OAuthConnectionPanelComponent implements OnInit {
40
40
  private readonly fieldValues = signal<Record<string, Record<string, string>>>({});
41
41
  private readonly pendingSuccess = signal<Set<string>>(new Set());
42
42
  private reloadSub?: Subscription;
43
+ private successRetries = 0;
44
+ private retryTimer: ReturnType<typeof setTimeout> | null = null;
43
45
 
44
46
  // The list is already scoped to entityId(), so a per-provider match is correct.
45
47
  readonly views = computed<OAuthConnectionView[]>(() => {
@@ -54,6 +56,10 @@ export class OAuthConnectionPanelComponent implements OnInit {
54
56
  constructor() {
55
57
  // Reload on init and whenever the entity scope changes.
56
58
  effect(() => this.reload(this.entityId()));
59
+ inject(DestroyRef).onDestroy(() => {
60
+ this.reloadSub?.unsubscribe();
61
+ if (this.retryTimer) clearTimeout(this.retryTimer);
62
+ });
57
63
  }
58
64
 
59
65
  // After an OAuth round-trip keel redirects back with ?<provider>=success. Record
@@ -91,13 +97,28 @@ export class OAuthConnectionPanelComponent implements OnInit {
91
97
  });
92
98
  }
93
99
 
94
- // Emit `connected` only for a returned provider whose row is actually present now.
100
+ // Emit `connected` only for a returned provider whose row is actually present.
101
+ // Rows can lag the redirect (webhook/commit); retry twice before giving up loudly.
95
102
  private confirmSuccess(): void {
96
103
  const pending = this.pendingSuccess();
97
104
  if (!pending.size) return;
98
105
  const present = new Set(this.connections().map((c) => c.Provider));
106
+ const missing = [...pending].filter((code) => !present.has(code));
99
107
  for (const code of pending) if (present.has(code)) this.connected.emit(code);
100
- this.pendingSuccess.set(new Set());
108
+ if (!missing.length) {
109
+ this.pendingSuccess.set(new Set());
110
+ this.successRetries = 0;
111
+ return;
112
+ }
113
+ this.pendingSuccess.set(new Set(missing));
114
+ if (this.successRetries < 2) {
115
+ this.successRetries++;
116
+ this.retryTimer = setTimeout(() => this.reload(this.entityId()), 1500 * this.successRetries);
117
+ } else {
118
+ this.pendingSuccess.set(new Set());
119
+ this.successRetries = 0;
120
+ this.error.set('The connection completed but is not visible yet — refresh to check.');
121
+ }
101
122
  }
102
123
 
103
124
  isIconUrl(icon?: string): boolean {
@@ -40,7 +40,7 @@
40
40
  </mat-card>
41
41
  }
42
42
 
43
- @if (errorMsg()) {
44
- <p class="user-payment-methods__error">{{ errorMsg() }}</p>
43
+ @if (errorMessage()) {
44
+ <p class="user-payment-methods__error" role="alert">{{ errorMessage() }}</p>
45
45
  }
46
46
  </div>
@@ -16,6 +16,8 @@ import { UserPaymentMethod } from '../../model/appdata';
16
16
  * the consumer routes to its own SetupIntent flow (sail does not bundle
17
17
  * a SetupIntent UI today — providers vary too much).
18
18
  *
19
+ * Ships no CSS — the consuming app styles the classes globally.
20
+ *
19
21
  * Selector: <sail-user-payment-methods>
20
22
  */
21
23
  @Component({
@@ -24,20 +26,6 @@ import { UserPaymentMethod } from '../../model/appdata';
24
26
  encapsulation: ViewEncapsulation.None,
25
27
  imports: [MatButtonModule, MatCardModule, MatIconModule],
26
28
  templateUrl: './user_payment_methods.html',
27
- styles: `
28
- .user-payment-methods { display: flex; flex-direction: column; gap: 12px; }
29
- .user-payment-methods__title { margin: 0; }
30
- .user-payment-methods__add { align-self: flex-start; }
31
- .user-payment-methods__empty { color: var(--mat-app-on-surface-variant, #555); }
32
- .user-payment-methods__item { background: var(--mat-app-surface, #fff); }
33
- .user-payment-methods__row { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
34
- .user-payment-methods__info { display: flex; align-items: center; gap: 8px; }
35
- .user-payment-methods__brand { font-weight: 600; }
36
- .user-payment-methods__last4 { color: var(--mat-app-on-surface-variant, #555); }
37
- .user-payment-methods__default { color: var(--mat-app-primary, #1976d2); margin-left: 8px; font-size: 12px; font-weight: 600; }
38
- .user-payment-methods__actions { display: flex; align-items: center; gap: 4px; }
39
- .user-payment-methods__error { color: var(--mat-app-error, #b00020); font-size: 13px; margin: 0; }
40
- `,
41
29
  })
42
30
  export class UserPaymentMethodsComponent extends BaseAsync implements OnInit {
43
31
  readonly title = input('Payment Methods');
@@ -55,7 +43,6 @@ export class UserPaymentMethodsComponent extends BaseAsync implements OnInit {
55
43
  private readonly service = inject(UserPaymentMethodService);
56
44
 
57
45
  readonly methods = signal<UserPaymentMethod[]>([]);
58
- readonly errorMsg = signal<string>('');
59
46
 
60
47
  ngOnInit(): void {
61
48
  this.load();
@@ -77,25 +64,27 @@ export class UserPaymentMethodsComponent extends BaseAsync implements OnInit {
77
64
  }
78
65
 
79
66
  setDefault(m: UserPaymentMethod): void {
80
- this.service.setDefault(m.Id).subscribe({
81
- next: () => {
67
+ this.run(
68
+ this.service.setDefault(m.Id),
69
+ () => {
82
70
  this.methods.update((list) =>
83
71
  list.map((row) => ({ ...row, IsDefault: row.Id === m.Id })),
84
72
  );
85
73
  this.defaultChanged.emit(m);
86
74
  },
87
- error: (err) => this.errorMsg.set(err?.error?.detail ?? 'Failed to set default'),
88
- });
75
+ 'Failed to set default.',
76
+ );
89
77
  }
90
78
 
91
79
  remove(m: UserPaymentMethod): void {
92
- this.service.delete(m.Id).subscribe({
93
- next: () => {
80
+ this.run(
81
+ this.service.delete(m.Id),
82
+ () => {
94
83
  this.methods.update((list) => list.filter((row) => row.Id !== m.Id));
95
84
  this.deleted.emit(m);
96
85
  },
97
- error: (err) => this.errorMsg.set(err?.error?.detail ?? 'Failed to delete'),
98
- });
86
+ 'Failed to delete.',
87
+ );
99
88
  }
100
89
 
101
90
  iconFor(m: UserPaymentMethod): string {
@@ -7,7 +7,7 @@
7
7
 
8
8
  <form [formGroup]="form" (ngSubmit)="submit()">
9
9
  <mat-form-field appearance="outline" class="full-width">
10
- <mat-label>Country *</mat-label>
10
+ <mat-label>Country</mat-label>
11
11
  <mat-select formControlName="countryCode">
12
12
  @for (c of countryProfiles(); track c.code) {
13
13
  <mat-option [value]="c.code">{{ c.label }} ({{ c.currency }})</mat-option>
@@ -17,19 +17,19 @@
17
17
  <p class="hint">Sets your tax ID format + payout currency</p>
18
18
 
19
19
  <mat-form-field appearance="outline" class="full-width">
20
- <mat-label>Account holder name *</mat-label>
20
+ <mat-label>Account holder name</mat-label>
21
21
  <input matInput formControlName="accountHolderName">
22
22
  </mat-form-field>
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>{{ taxIdLabel() }} *</mat-label>
26
+ <mat-label>{{ taxIdLabel() }}</mat-label>
27
27
  <input matInput formControlName="taxId" [placeholder]="taxIdPlaceholder()">
28
28
  </mat-form-field>
29
29
  <p class="hint">{{ taxIdHint() }}</p>
30
30
 
31
31
  <mat-form-field appearance="outline" class="full-width">
32
- <mat-label>Billing address *</mat-label>
32
+ <mat-label>Billing address</mat-label>
33
33
  <input matInput formControlName="billingAddress">
34
34
  </mat-form-field>
35
35
  <p class="hint">Business or home address — used for tax correspondence</p>
@@ -1,4 +1,4 @@
1
- import { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, computed, effect, inject, input, linkedSignal, output } from '@angular/core';
2
2
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
3
3
  import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
4
4
  import { MatButtonModule } from '@angular/material/button';
@@ -29,8 +29,7 @@ export const DEFAULT_COUNTRY_PROFILES: CountryProfile[] = [
29
29
  * `(submitted)` — the consumer decides whether to POST directly to
30
30
  * /api/v1/user_bank_info or pipe through its own registration service.
31
31
  *
32
- * `[provider]` defaults to "AW" (Airwallex) to match keel/payout's
33
- * default --payout_provider flag. Override per deployment if needed.
32
+ * Ships no CSS — the consuming app styles the classes globally.
34
33
  *
35
34
  * Selector: <sail-payout-bank-info-form>
36
35
  */
@@ -42,18 +41,6 @@ export const DEFAULT_COUNTRY_PROFILES: CountryProfile[] = [
42
41
  MatFormFieldModule, MatInputModule, MatSelectModule,
43
42
  ],
44
43
  templateUrl: './bank_info_form.html',
45
- styles: `
46
- .bank-info-form { display: flex; flex-direction: column; gap: 4px; padding: 0 24px 32px; }
47
- .bank-info-form__title { margin: 0 0 16px; }
48
- .bank-info-form__intro { font-size: 14px; color: var(--mat-app-on-surface-variant, #555); margin: 0 0 16px; }
49
- .full-width { width: 100%; }
50
- .hint { font-size: 12px; color: var(--mat-app-on-surface-variant, #555); margin: -4px 0 8px; }
51
- .provider-note { font-size: 13px; color: var(--mat-app-on-surface-variant, #555); background: rgba(0,0,0,0.04); padding: 12px; border-radius: 4px; margin: 12px 0; }
52
- .agreement { margin: 12px 0 16px; font-size: 14px; }
53
- .nav-buttons { display: flex; gap: 12px; margin-top: 8px; }
54
- .pill-btn { border-radius: 24px; height: 48px; font-size: 15px; font-weight: 600; flex: 1; }
55
- .primary-btn { background: var(--mat-app-primary, #1976d2); color: white; }
56
- `,
57
44
  })
58
45
  export class PayoutBankInfoFormComponent {
59
46
  /** Step title rendered above the form. */
@@ -62,8 +49,8 @@ export class PayoutBankInfoFormComponent {
62
49
  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.');
63
50
  /** Override the list of supported countries — keep the default for most apps. */
64
51
  readonly countryProfiles = input<CountryProfile[]>(DEFAULT_COUNTRY_PROFILES);
65
- /** Provider code persisted on user_bank_info.provider. Default AW (Airwallex) matches keel's default --payout_provider. */
66
- readonly provider = input('AW');
52
+ /** Provider code persisted on user_bank_info.provider — must match the app's keel --payout_provider. */
53
+ readonly provider = input.required<string>();
67
54
  /** Wording on the provider-agreement checkbox. Override to name the live provider. */
68
55
  readonly agreementLabel = input('I agree to the payout provider account agreement');
69
56
  /** Submit-button label — wizards usually use "Next >". */
@@ -79,33 +66,42 @@ export class PayoutBankInfoFormComponent {
79
66
  private readonly fb = inject(FormBuilder);
80
67
 
81
68
  readonly form = this.fb.group({
82
- countryCode: ['CA', Validators.required],
69
+ countryCode: ['', Validators.required],
83
70
  accountHolderName: ['', Validators.required],
84
71
  taxId: ['', Validators.required],
85
72
  billingAddress: ['', Validators.required],
86
73
  providerAgreement: [false, Validators.requiredTrue],
87
74
  });
88
75
 
89
- private readonly selectedCountry = signal<CountryProfile>(this.countryProfiles()[0]);
76
+ // linkedSignal, not a field initializer: the bound countryProfiles input
77
+ // isn't available yet during construction.
78
+ private readonly selectedCountry = linkedSignal<CountryProfile | undefined>(() => this.countryProfiles()[0]);
90
79
 
91
- readonly taxIdLabel = computed(() => this.selectedCountry().taxIdLabel);
92
- readonly taxIdPlaceholder = computed(() => this.selectedCountry().taxIdPlaceholder);
93
- readonly taxIdHint = computed(() => this.selectedCountry().taxIdHint);
80
+ readonly taxIdLabel = computed(() => this.selectedCountry()?.taxIdLabel ?? '');
81
+ readonly taxIdPlaceholder = computed(() => this.selectedCountry()?.taxIdPlaceholder ?? '');
82
+ readonly taxIdHint = computed(() => this.selectedCountry()?.taxIdHint ?? '');
94
83
 
95
84
  constructor() {
85
+ // Keep the form's country in the bound profile list (never a phantom default).
86
+ effect(() => {
87
+ const profiles = this.countryProfiles();
88
+ const current = this.form.controls.countryCode.value;
89
+ if (!profiles.some((c) => c.code === current)) {
90
+ this.form.controls.countryCode.setValue(profiles[0]?.code ?? '');
91
+ }
92
+ });
96
93
  this.form.controls.countryCode.valueChanges
97
94
  .pipe(takeUntilDestroyed())
98
95
  .subscribe((code) => {
99
96
  const profiles = this.countryProfiles();
100
- const profile = profiles.find((c) => c.code === code) ?? profiles[0];
101
- this.selectedCountry.set(profile);
97
+ this.selectedCountry.set(profiles.find((c) => c.code === code) ?? profiles[0]);
102
98
  });
103
99
  }
104
100
 
105
101
  submit() {
106
- if (this.form.invalid) return;
107
- const v = this.form.value;
108
102
  const country = this.selectedCountry();
103
+ if (this.form.invalid || !country) return;
104
+ const v = this.form.value;
109
105
  this.submitted.emit({
110
106
  countryCode: country.code,
111
107
  currency: country.currency,
@@ -18,7 +18,7 @@
18
18
  {{ reusable().length === 1 ? 'another partner' : 'other partners' }}.
19
19
  You can reuse it here (no KYC redo) if the currency matches.
20
20
  </p>
21
- <mat-radio-group [value]="selectedAccountId()" (change)="selectAccount($any($event.value))">
21
+ <mat-radio-group [value]="selectedAccountId()" (change)="onAccountChange($event)">
22
22
  @for (acc of reusable(); track acc.providerAccountId) {
23
23
  <mat-radio-button [value]="acc.providerAccountId" class="reuse-option">
24
24
  <strong>{{ acc.partnerCaption }}</strong>
@@ -27,7 +27,7 @@
27
27
  }
28
28
  </mat-radio-group>
29
29
  <button matButton="filled" class="payout-onboarding__btn payout-onboarding__btn--primary"
30
- [disabled]="!selectedAccountId() || busy()"
30
+ [disabled]="!selectedAccountId() || loading()"
31
31
  (click)="linkExisting()">
32
32
  Use this account
33
33
  </button>
@@ -45,11 +45,11 @@
45
45
  for bank routing details and government ID. The process
46
46
  usually takes a few minutes.
47
47
  </p>
48
- @if (errorMsg()) {
49
- <p class="payout-onboarding__error">{{ errorMsg() }}</p>
48
+ @if (errorMessage()) {
49
+ <p class="payout-onboarding__error">{{ errorMessage() }}</p>
50
50
  }
51
51
  <button matButton="filled" class="payout-onboarding__btn payout-onboarding__btn--primary"
52
- [disabled]="busy()"
52
+ [disabled]="loading()"
53
53
  (click)="startProviderKyc()">
54
54
  <mat-icon>open_in_new</mat-icon>
55
55
  Start onboarding
@@ -1,8 +1,9 @@
1
1
  import { ChangeDetectionStrategy, Component, OnInit, inject, input, output, signal } from '@angular/core';
2
+ import { BaseAsync } from '../abstract/base_async';
2
3
  import { MatButtonModule } from '@angular/material/button';
3
4
  import { MatCardModule } from '@angular/material/card';
4
5
  import { MatIconModule } from '@angular/material/icon';
5
- import { MatRadioModule } from '@angular/material/radio';
6
+ import { MatRadioChange, MatRadioModule } from '@angular/material/radio';
6
7
  import { PayoutService } from '../../service/payout.service';
7
8
  import { ReusableAccount } from '../../model/appdata';
8
9
 
@@ -13,13 +14,15 @@ import { ReusableAccount } from '../../model/appdata';
13
14
  * two paths:
14
15
  * 1. Reuse an existing provider account from another partner row
15
16
  * (zero-KYC; calls /api/v1/payout/reusable/link).
16
- * 2. Launch the provider's hosted-KYC page in a new tab
17
+ * 2. Hand off to the provider's hosted-KYC page
17
18
  * (/api/v1/payout/onboard/start).
18
19
  *
19
20
  * Routing is consumer-owned via the `(linked)` / `(skipped)` outputs —
20
21
  * the component itself never calls Router. Optional `title` and
21
22
  * `skipLabel` inputs cover the common UX strings.
22
23
  *
24
+ * Ships no CSS — the consuming app styles the classes globally.
25
+ *
23
26
  * Selector: <sail-payout-provider-onboarding>
24
27
  */
25
28
  @Component({
@@ -27,20 +30,8 @@ import { ReusableAccount } from '../../model/appdata';
27
30
  changeDetection: ChangeDetectionStrategy.OnPush,
28
31
  imports: [MatButtonModule, MatCardModule, MatIconModule, MatRadioModule],
29
32
  templateUrl: './provider_onboarding.html',
30
- styles: `
31
- .payout-onboarding { display: flex; flex-direction: column; gap: 16px; padding: 0 24px 32px; }
32
- .payout-onboarding__title { margin: 0; }
33
- .payout-onboarding__intro { font-size: 14px; color: var(--mat-app-on-surface-variant, #555); margin: 0; }
34
- .reuse-card { background: var(--mat-app-surface-variant, #f5f5f5); }
35
- .reuse-option { display: block; margin: 8px 0; }
36
- .hint { font-size: 13px; color: var(--mat-app-on-surface-variant, #555); margin: 0 0 12px; }
37
- .payout-onboarding__error { color: var(--mat-app-error, #b00020); font-size: 13px; margin: 0 0 12px; }
38
- .payout-onboarding__nav { display: flex; gap: 12px; margin-top: 8px; }
39
- .payout-onboarding__btn { border-radius: 24px; height: 48px; font-size: 15px; font-weight: 600; flex: 1; }
40
- .payout-onboarding__btn--primary { background: var(--mat-app-primary, #1976d2); color: white; }
41
- `,
42
33
  })
43
- export class PayoutProviderOnboardingComponent implements OnInit {
34
+ export class PayoutProviderOnboardingComponent extends BaseAsync implements OnInit {
44
35
  /** Step title — defaults match the most common payout-step framing. */
45
36
  readonly title = input('Bank Account Setup');
46
37
  /** Skip-button label — covers wizards where skipping returns to the parent flow. */
@@ -54,58 +45,47 @@ export class PayoutProviderOnboardingComponent implements OnInit {
54
45
  readonly skipped = output<void>();
55
46
  /** Emitted when the user clicks Back. */
56
47
  readonly back = output<void>();
57
- /** Emitted after the hosted page is opened in a new tab. Consumers can route away or show a "waiting on provider" screen. */
48
+ /** Emitted just before handing off to the hosted-KYC page. */
58
49
  readonly started = output<void>();
59
50
 
60
51
  private readonly payoutService = inject(PayoutService);
61
52
 
62
53
  readonly reusable = signal<ReusableAccount[]>([]);
63
54
  readonly selectedAccountId = signal<string>('');
64
- readonly busy = signal<boolean>(false);
65
- readonly errorMsg = signal<string>('');
66
55
 
67
56
  ngOnInit() {
68
- this.payoutService.listReusable().subscribe({
69
- next: ({ accounts }) => this.reusable.set(accounts ?? []),
70
- error: () => this.reusable.set([]),
71
- });
57
+ this.run(
58
+ this.payoutService.listReusable(),
59
+ ({ accounts }) => this.reusable.set(accounts ?? []),
60
+ 'Could not check for reusable accounts.',
61
+ );
72
62
  }
73
63
 
74
- selectAccount(id: string) {
75
- this.selectedAccountId.set(id);
64
+ onAccountChange(event: MatRadioChange) {
65
+ this.selectedAccountId.set(String(event.value));
76
66
  }
77
67
 
78
68
  linkExisting() {
79
69
  const id = this.selectedAccountId();
80
70
  if (!id) return;
81
- this.busy.set(true);
82
- this.payoutService.linkReusable(id).subscribe({
83
- next: () => {
84
- this.busy.set(false);
85
- this.linked.emit();
86
- },
87
- error: (err) => {
88
- this.errorMsg.set(err?.error?.detail ?? 'Failed to link existing account');
89
- this.busy.set(false);
90
- },
91
- });
71
+ this.run(
72
+ this.payoutService.linkReusable(id),
73
+ () => this.linked.emit(),
74
+ 'Failed to link existing account',
75
+ );
92
76
  }
93
77
 
94
78
  startProviderKyc() {
95
- this.busy.set(true);
96
- this.payoutService.startOnboarding().subscribe({
97
- next: (res) => {
98
- this.busy.set(false);
99
- // Open in the platform browser. Mobile webview wrappers
100
- // intercept this and pop the system browser so the provider
101
- // page works in its expected environment.
102
- window.open(res.url, '_blank');
79
+ this.run(
80
+ this.payoutService.startOnboarding(),
81
+ (res) => {
103
82
  this.started.emit();
83
+ // Same-tab handoff: window.open from an async callback is popup-blocked
84
+ // (always on Safari), which used to leave users on a "waiting" screen
85
+ // with nothing open. Mobile webview wrappers intercept the navigation.
86
+ window.location.assign(res.url);
104
87
  },
105
- error: (err) => {
106
- this.errorMsg.set(err?.error?.detail ?? 'Failed to start onboarding');
107
- this.busy.set(false);
108
- },
109
- });
88
+ 'Failed to start onboarding',
89
+ );
110
90
  }
111
91
  }
@@ -28,7 +28,7 @@
28
28
  <td>{{ formatDate(device.last_used_at) }}</td>
29
29
  <td>{{ formatDate(device.created_at) }}</td>
30
30
  <td>
31
- <button matIconButton (click)="revokeDevice(device)" title="Revoke">
31
+ <button matIconButton (click)="revokeDevice(device)" title="Revoke" aria-label="Revoke device">
32
32
  <mat-icon>delete</mat-icon>
33
33
  </button>
34
34
  </td>
@@ -71,7 +71,6 @@ export class TrustedDevicesComponent extends BaseAsync implements OnInit {
71
71
  this.run(
72
72
  this.auth.logoutEverywhere({ password }),
73
73
  () => {
74
- this.successMessage.set('Signed out of all devices.');
75
74
  this.logoutForm.reset();
76
75
  this.logoutOpen.set(false);
77
76
  this.auth.logout();
@@ -15,10 +15,14 @@
15
15
  }
16
16
  @case ('qr') {
17
17
  <p class="auth-instructions">Scan this QR code with your authenticator app (e.g., Google Authenticator, Authy).</p>
18
- <div class="twofactor-qr">
19
- <img [src]="qrUri()" alt="2FA QR Code" width="200" height="200">
20
- </div>
21
- <p class="auth-instructions">Or enter this secret manually: <code>{{ secret() }}</code></p>
18
+ @if (qrDataUrl()) {
19
+ <div class="twofactor-qr">
20
+ <img [src]="qrDataUrl()" alt="2FA QR Code" width="200" height="200">
21
+ </div>
22
+ <p class="auth-instructions">Or enter this secret manually: <code>{{ secret() }}</code></p>
23
+ } @else {
24
+ <p class="auth-instructions">Enter this secret in your authenticator app: <code>{{ secret() }}</code></p>
25
+ }
22
26
 
23
27
  @if (backupCodes().length > 0) {
24
28
  <div class="twofactor-backup">