@nauticana/sail 1.1.8 → 1.1.10

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 +36 -3
  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/social_login.html +2 -2
  35. package/src/component/login/social_login.ts +20 -13
  36. package/src/component/navigation/navigation.html +2 -2
  37. package/src/component/navigation/navigation.ts +2 -0
  38. package/src/component/oauth_connection/oauth_connection_panel.html +68 -0
  39. package/src/component/oauth_connection/oauth_connection_panel.ts +252 -0
  40. package/src/component/payment/user_payment_methods.html +2 -2
  41. package/src/component/payment/user_payment_methods.ts +12 -23
  42. package/src/component/payout/bank_info_form.html +4 -4
  43. package/src/component/payout/bank_info_form.ts +22 -26
  44. package/src/component/payout/provider_onboarding.html +5 -5
  45. package/src/component/payout/provider_onboarding.ts +28 -48
  46. package/src/component/security/trusted_devices.html +1 -1
  47. package/src/component/security/trusted_devices.ts +0 -1
  48. package/src/component/security/twofactor_setup.html +8 -4
  49. package/src/component/security/twofactor_setup.ts +21 -3
  50. package/src/component/security/twofactor_verify.html +2 -1
  51. package/src/component/security/twofactor_verify.ts +2 -8
  52. package/src/component/table/table_detail.html +15 -14
  53. package/src/component/table/table_detail.ts +62 -41
  54. package/src/component/table/table_edit.html +15 -11
  55. package/src/component/table/table_edit.ts +94 -67
  56. package/src/component/table/table_list.html +45 -12
  57. package/src/component/table/table_list.ts +98 -26
  58. package/src/component/table/table_lookup.html +11 -8
  59. package/src/component/table/table_lookup.ts +45 -37
  60. package/src/component/table/table_report.html +2 -2
  61. package/src/component/table/table_report.ts +37 -31
  62. package/src/component/table/table_search.html +4 -4
  63. package/src/component/table/table_search.ts +36 -45
  64. package/src/config.ts +1 -1
  65. package/src/index.ts +4 -0
  66. package/src/model/appdata.ts +5 -9
  67. package/src/model/common.ts +27 -27
  68. package/src/model/decorator.ts +25 -21
  69. package/src/model/oauth_connection.ts +50 -0
  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 +56 -0
  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
@@ -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">
@@ -1,4 +1,4 @@
1
- import { ChangeDetectionStrategy, Component, inject, signal, ViewEncapsulation } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, effect, inject, input, signal, ViewEncapsulation } from '@angular/core';
2
2
  import { BaseAuthService } from '../../service/auth.service';
3
3
  import { MatButtonModule } from '@angular/material/button';
4
4
  import { MatFormFieldModule } from '@angular/material/form-field';
@@ -6,6 +6,7 @@ import { MatInputModule } from '@angular/material/input';
6
6
  import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
7
7
  import { RouterLink } from '@angular/router';
8
8
  import { BaseAsync } from '../abstract/base_async';
9
+ import { toDataURL } from 'qrcode';
9
10
 
10
11
  @Component({
11
12
  selector: 'sail-twofactor-setup',
@@ -24,8 +25,13 @@ export class TwoFactorSetupComponent extends BaseAsync {
24
25
  private readonly auth = inject(BaseAuthService);
25
26
  private readonly fb = inject(FormBuilder);
26
27
 
28
+ /** keel has no 2FA-status endpoint — the app tells us whether 2FA is already
29
+ * on so returning users land on the manage/disable view, not a fresh setup
30
+ * (which would rotate their seed and revoke all refresh tokens). */
31
+ readonly twoFactorEnabled = input(false);
32
+
27
33
  readonly step = signal<'init' | 'qr' | 'verify' | 'done'>('init');
28
- readonly qrUri = signal('');
34
+ readonly qrDataUrl = signal('');
29
35
  readonly secret = signal('');
30
36
  readonly backupCodes = signal<string[]>([]);
31
37
  readonly showCodes = signal(false);
@@ -46,13 +52,25 @@ export class TwoFactorSetupComponent extends BaseAsync {
46
52
  code: ['', [Validators.required, Validators.minLength(6), Validators.maxLength(6)]],
47
53
  });
48
54
 
55
+ constructor() {
56
+ super();
57
+ // Only flips between the two idle states; never yanks a user out of a flow.
58
+ effect(() => {
59
+ const enabled = this.twoFactorEnabled();
60
+ this.step.update((s) => (s === 'init' || s === 'done') ? (enabled ? 'done' : 'init') : s);
61
+ });
62
+ }
63
+
49
64
  startSetup() {
50
65
  if (this.initForm.invalid) return;
51
66
  const password = this.initForm.value.password!;
52
67
  this.run(
53
68
  this.auth.setup2FA({ password }),
54
69
  (res) => {
55
- this.qrUri.set(res.qrUri);
70
+ // keel returns an otpauth:// provisioning URI, not an image — render it.
71
+ toDataURL(res.qrUri, { width: 200, margin: 1 })
72
+ .then((url) => this.qrDataUrl.set(url))
73
+ .catch(() => this.qrDataUrl.set(''));
56
74
  this.secret.set(res.secret);
57
75
  this.backupCodes.set(res.backupCodes);
58
76
  this.step.set('qr');
@@ -36,7 +36,8 @@
36
36
  </button>
37
37
  </form>
38
38
  <div class="auth-actions">
39
- <a tabindex="0" role="button" (click)="toggleBackupCode()" (keydown.enter)="toggleBackupCode()">
39
+ <a tabindex="0" role="button" (click)="toggleBackupCode()" (keydown.enter)="toggleBackupCode()"
40
+ (keydown.space)="$event.preventDefault(); toggleBackupCode()">
40
41
  {{ useBackupCode() ? 'Use authenticator code' : 'Use a backup code instead' }}
41
42
  </a>
42
43
  <a routerLink="/login/local">Back to Login</a>
@@ -46,17 +46,11 @@ export class TwoFactorVerifyComponent extends BaseAsync {
46
46
  const { code, trustDevice } = this.verifyForm.value;
47
47
 
48
48
  if (this.useBackupCode()) {
49
+ // verifyBackupCode completes the login inside BaseAuthService.
49
50
  this.run(
50
51
  this.auth.verifyBackupCode(code!),
51
52
  (res) => {
52
- if (res.valid && res.token) {
53
- localStorage.removeItem('loginToken');
54
- this.auth.token = res.token;
55
- this.auth.isLoggedIn.set(true);
56
- localStorage.setItem('jwt', res.token);
57
- this.auth.loadAppData();
58
- this.auth.initRoutes();
59
- } else {
53
+ if (!res.valid || !res.token) {
60
54
  this.errorMessage.set('Invalid backup code.');
61
55
  }
62
56
  },
@@ -4,7 +4,7 @@
4
4
  @if (canCreate()) {
5
5
  <button matButton="elevated" class="accent" (click)="addRecord()">New Record</button>
6
6
  }
7
- @for (action of getActions(false); track action.action) {
7
+ @for (action of tableActions(); track action.action) {
8
8
  @if (canExecuteAction(action)) {
9
9
  <button matButton="outlined" type="button"
10
10
  [title]="action.caption"
@@ -18,17 +18,17 @@
18
18
  <table class="mat-table">
19
19
  <thead>
20
20
  <tr>
21
- @for (col of displayedColumns; track col) {
22
- <th>{{ colCaption(col) }}</th>
21
+ @for (col of displayedColumns(); track col) {
22
+ <th scope="col">{{ colCaption(col) }}</th>
23
23
  }
24
- <th>Actions</th>
24
+ <th scope="col">Actions</th>
25
25
  </tr>
26
26
  </thead>
27
27
  <tbody>
28
- @for (record of records; track $index) {
28
+ @for (record of visibleRecords(); track trackByRecord($index, record)) {
29
29
  <tr [class.deleted-record]="isDeleted(record)" [class.updated-record]="isUpdated(record)" [class.new-record]="isInserted(record)">
30
- @if (record == editingRecord) {
31
- @for (col of displayedColumns; track col) {
30
+ @if (record === editingRecord()) {
31
+ @for (col of displayedColumns(); track col) {
32
32
  <td>
33
33
  @if (isReadOnly(col)) {
34
34
  {{ record[col] }}
@@ -38,27 +38,28 @@
38
38
  </td>
39
39
  }
40
40
  <td>
41
- <button matIconButton (click)="saveRow()" [title]="isNew ? 'Add' : 'Update'"><mat-icon>{{ isNew ? 'add' : 'check' }}</mat-icon></button>
42
- <button matIconButton (click)="cancelRow(record)" title="Cancel"><mat-icon>cancel</mat-icon></button>
41
+ <button matIconButton (click)="saveRow()" [title]="isNew() ? 'Add' : 'Update'" [attr.aria-label]="isNew() ? 'Add' : 'Update'"><mat-icon>{{ isNew() ? 'add' : 'check' }}</mat-icon></button>
42
+ <button matIconButton (click)="cancelRow(record)" title="Cancel" aria-label="Cancel"><mat-icon>cancel</mat-icon></button>
43
43
  </td>
44
44
  } @else {
45
- @for (col of displayedColumns; track col) {
45
+ @for (col of displayedColumns(); track col) {
46
46
  <td>{{ displayValue(col, record[col]) }}</td>
47
47
  }
48
48
  <td>
49
49
  @if (canUpdate()) {
50
- <button matIconButton (click)="editRow(record)" title="Edit"><mat-icon>edit</mat-icon></button>
50
+ <button matIconButton (click)="editRow(record)" title="Edit" aria-label="Edit"><mat-icon>edit</mat-icon></button>
51
51
  }
52
52
  @if (isDeleted(record)) {
53
- <button matIconButton (click)="unDeleteRow(record)" title="Undelete"><mat-icon>restore_from_trash</mat-icon></button>
53
+ <button matIconButton (click)="unDeleteRow(record)" title="Undelete" aria-label="Undelete"><mat-icon>restore_from_trash</mat-icon></button>
54
54
  } @else if (canDelete()) {
55
- <button matIconButton (click)="deleteRow(record)" title="Delete"><mat-icon>delete</mat-icon></button>
55
+ <button matIconButton (click)="deleteRow(record)" title="Delete" aria-label="Delete"><mat-icon>delete</mat-icon></button>
56
56
  }
57
57
  @if (!isInserted(record)) {
58
- @for (action of getActions(true); track action.action) {
58
+ @for (action of recordActions(); track action.action) {
59
59
  @if (canExecuteAction(action)) {
60
60
  <button matIconButton type="button"
61
61
  [title]="action.caption"
62
+ [attr.aria-label]="action.caption"
62
63
  (click)="executeAction(action, record)">
63
64
  <mat-icon>{{ action.icon || 'play_arrow' }}</mat-icon>
64
65
  </button>