@nauticana/sail 0.7.0 → 0.8.1
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 +419 -15
- package/package.json +2 -2
- package/src/component/abstract/base_async.ts +8 -2
- package/src/component/abstract/base_table.ts +108 -49
- package/src/component/abstract/base_view.ts +32 -45
- package/src/component/billing/checkout_button.html +1 -1
- package/src/component/billing/checkout_button.ts +1 -1
- package/src/component/billing/payment_methods.ts +1 -1
- package/src/component/billing/plan_selector.html +1 -1
- package/src/component/billing/plan_selector.ts +1 -1
- package/src/component/form/form_field.html +1 -1
- package/src/component/form/form_field.ts +8 -7
- package/src/component/form/form_record.html +2 -2
- package/src/component/form/form_record.ts +1 -1
- package/src/component/form/form_table.html +4 -4
- package/src/component/form/form_table.ts +2 -2
- package/src/component/login/account_deletion.html +1 -1
- package/src/component/login/account_deletion.ts +5 -2
- package/src/component/login/chpass_component.html +1 -1
- package/src/component/login/chpass_component.ts +11 -16
- package/src/component/login/confirm_chpass_component.html +1 -1
- package/src/component/login/confirm_chpass_component.ts +9 -15
- package/src/component/login/confirm_register_component.html +1 -1
- package/src/component/login/confirm_register_component.ts +9 -15
- package/src/component/login/consent_gate.ts +1 -1
- package/src/component/login/login_component.html +2 -2
- package/src/component/login/login_component.ts +1 -1
- package/src/component/login/otp_input.html +3 -3
- package/src/component/login/otp_input.ts +1 -1
- package/src/component/login/register_component.html +1 -1
- package/src/component/login/register_component.ts +1 -1
- package/src/component/login/social_login.ts +1 -1
- package/src/component/navigation/navigation.html +3 -3
- package/src/component/navigation/navigation.ts +10 -11
- package/src/component/payment/user_payment_methods.html +46 -0
- package/src/component/payment/user_payment_methods.ts +10 -58
- package/src/component/payout/bank_info_form.html +59 -0
- package/src/component/payout/bank_info_form.ts +21 -77
- package/src/component/payout/provider_onboarding.html +70 -0
- package/src/component/payout/provider_onboarding.ts +9 -80
- package/src/component/security/trusted_devices.html +4 -4
- package/src/component/security/trusted_devices.ts +1 -1
- package/src/component/security/twofactor_setup.html +4 -4
- package/src/component/security/twofactor_setup.ts +18 -26
- package/src/component/security/twofactor_verify.html +1 -1
- package/src/component/security/twofactor_verify.ts +10 -12
- package/src/component/table/table_detail.html +12 -12
- package/src/component/table/table_detail.ts +28 -33
- package/src/component/table/table_edit.html +20 -10
- package/src/component/table/table_edit.ts +29 -18
- package/src/component/table/table_list.html +5 -5
- package/src/component/table/table_list.ts +24 -48
- package/src/component/table/table_lookup.html +4 -4
- package/src/component/table/table_lookup.ts +11 -12
- package/src/component/table/table_report.html +1 -1
- package/src/component/table/table_report.ts +11 -14
- package/src/component/table/table_search.html +5 -5
- package/src/component/table/table_search.ts +31 -42
- package/src/index.ts +2 -0
- package/src/model/appdata.ts +2 -2
- package/src/model/common.ts +39 -1
- package/src/service/auth.service.ts +26 -26
- package/src/service/base_rest.service.ts +20 -0
- package/src/service/billing.service.ts +3 -9
- package/src/service/payout.service.ts +3 -9
- package/src/service/rest_service.ts +5 -5
- package/src/service/user_payment_method.service.ts +4 -8
- package/src/util/text.ts +19 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component,
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core';
|
|
2
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
2
3
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
4
|
import { MatButtonModule } from '@angular/material/button';
|
|
4
5
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
@@ -40,67 +41,7 @@ export const DEFAULT_COUNTRY_PROFILES: CountryProfile[] = [
|
|
|
40
41
|
ReactiveFormsModule, MatButtonModule, MatCheckboxModule,
|
|
41
42
|
MatFormFieldModule, MatInputModule, MatSelectModule,
|
|
42
43
|
],
|
|
43
|
-
|
|
44
|
-
<div class="bank-info-form">
|
|
45
|
-
<h2 class="bank-info-form__title">{{ title }}</h2>
|
|
46
|
-
|
|
47
|
-
@if (intro) {
|
|
48
|
-
<p class="bank-info-form__intro">{{ intro }}</p>
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
<form [formGroup]="form" (ngSubmit)="submit()">
|
|
52
|
-
<mat-form-field appearance="outline" class="full-width">
|
|
53
|
-
<mat-label>Country *</mat-label>
|
|
54
|
-
<mat-select formControlName="countryCode">
|
|
55
|
-
@for (c of countryProfiles; track c.code) {
|
|
56
|
-
<mat-option [value]="c.code">{{ c.label }} ({{ c.currency }})</mat-option>
|
|
57
|
-
}
|
|
58
|
-
</mat-select>
|
|
59
|
-
</mat-form-field>
|
|
60
|
-
<p class="hint">Sets your tax ID format + payout currency</p>
|
|
61
|
-
|
|
62
|
-
<mat-form-field appearance="outline" class="full-width">
|
|
63
|
-
<mat-label>Account holder name *</mat-label>
|
|
64
|
-
<input matInput formControlName="accountHolderName">
|
|
65
|
-
</mat-form-field>
|
|
66
|
-
<p class="hint">Legal name on the bank account (must match government ID)</p>
|
|
67
|
-
|
|
68
|
-
<mat-form-field appearance="outline" class="full-width">
|
|
69
|
-
<mat-label>{{ taxIdLabel() }} *</mat-label>
|
|
70
|
-
<input matInput formControlName="taxId" [placeholder]="taxIdPlaceholder()">
|
|
71
|
-
</mat-form-field>
|
|
72
|
-
<p class="hint">{{ taxIdHint() }}</p>
|
|
73
|
-
|
|
74
|
-
<mat-form-field appearance="outline" class="full-width">
|
|
75
|
-
<mat-label>Billing address *</mat-label>
|
|
76
|
-
<input matInput formControlName="billingAddress">
|
|
77
|
-
</mat-form-field>
|
|
78
|
-
<p class="hint">Business or home address — used for tax correspondence</p>
|
|
79
|
-
|
|
80
|
-
<p class="provider-note">
|
|
81
|
-
Bank routing details (account number + institution / ABA /
|
|
82
|
-
IBAN) are collected by the payout provider in the next step;
|
|
83
|
-
this application does not store them.
|
|
84
|
-
</p>
|
|
85
|
-
|
|
86
|
-
<mat-checkbox formControlName="providerAgreement" class="agreement">
|
|
87
|
-
{{ agreementLabel }}
|
|
88
|
-
</mat-checkbox>
|
|
89
|
-
|
|
90
|
-
<div class="nav-buttons">
|
|
91
|
-
@if (showBack) {
|
|
92
|
-
<button mat-stroked-button class="pill-btn" type="button" (click)="back.emit()">
|
|
93
|
-
< Back
|
|
94
|
-
</button>
|
|
95
|
-
}
|
|
96
|
-
<button mat-flat-button class="pill-btn primary-btn" type="submit"
|
|
97
|
-
[disabled]="form.invalid">
|
|
98
|
-
{{ submitLabel }}
|
|
99
|
-
</button>
|
|
100
|
-
</div>
|
|
101
|
-
</form>
|
|
102
|
-
</div>
|
|
103
|
-
`,
|
|
44
|
+
templateUrl: './bank_info_form.html',
|
|
104
45
|
styles: `
|
|
105
46
|
.bank-info-form { display: flex; flex-direction: column; gap: 4px; padding: 0 24px 32px; }
|
|
106
47
|
.bank-info-form__title { margin: 0 0 16px; }
|
|
@@ -116,24 +57,24 @@ export const DEFAULT_COUNTRY_PROFILES: CountryProfile[] = [
|
|
|
116
57
|
})
|
|
117
58
|
export class PayoutBankInfoFormComponent {
|
|
118
59
|
/** Step title rendered above the form. */
|
|
119
|
-
|
|
60
|
+
readonly title = input('Payment and Tax');
|
|
120
61
|
/** Optional explainer rendered below the title. */
|
|
121
|
-
|
|
62
|
+
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.');
|
|
122
63
|
/** Override the list of supported countries — keep the default for most apps. */
|
|
123
|
-
|
|
64
|
+
readonly countryProfiles = input<CountryProfile[]>(DEFAULT_COUNTRY_PROFILES);
|
|
124
65
|
/** Provider code persisted on user_bank_info.provider. Default AW (Airwallex) matches keel's default --payout_provider. */
|
|
125
|
-
|
|
66
|
+
readonly provider = input('AW');
|
|
126
67
|
/** Wording on the provider-agreement checkbox. Override to name the live provider. */
|
|
127
|
-
|
|
68
|
+
readonly agreementLabel = input('I agree to the payout provider account agreement');
|
|
128
69
|
/** Submit-button label — wizards usually use "Next >". */
|
|
129
|
-
|
|
70
|
+
readonly submitLabel = input('Next >');
|
|
130
71
|
/** When true, the back button renders alongside submit. */
|
|
131
|
-
|
|
72
|
+
readonly showBack = input(true);
|
|
132
73
|
|
|
133
74
|
/** Emitted on submit; payload maps 1:1 to basis user_bank_info columns. */
|
|
134
|
-
|
|
75
|
+
readonly submitted = output<BankInfoFormValue>();
|
|
135
76
|
/** Emitted when the user clicks Back. */
|
|
136
|
-
|
|
77
|
+
readonly back = output<void>();
|
|
137
78
|
|
|
138
79
|
private readonly fb = inject(FormBuilder);
|
|
139
80
|
|
|
@@ -145,17 +86,20 @@ export class PayoutBankInfoFormComponent {
|
|
|
145
86
|
providerAgreement: [false, Validators.requiredTrue],
|
|
146
87
|
});
|
|
147
88
|
|
|
148
|
-
private readonly selectedCountry = signal<CountryProfile>(this.countryProfiles[0]);
|
|
89
|
+
private readonly selectedCountry = signal<CountryProfile>(this.countryProfiles()[0]);
|
|
149
90
|
|
|
150
91
|
readonly taxIdLabel = computed(() => this.selectedCountry().taxIdLabel);
|
|
151
92
|
readonly taxIdPlaceholder = computed(() => this.selectedCountry().taxIdPlaceholder);
|
|
152
93
|
readonly taxIdHint = computed(() => this.selectedCountry().taxIdHint);
|
|
153
94
|
|
|
154
95
|
constructor() {
|
|
155
|
-
this.form.controls.countryCode.valueChanges
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
96
|
+
this.form.controls.countryCode.valueChanges
|
|
97
|
+
.pipe(takeUntilDestroyed())
|
|
98
|
+
.subscribe((code) => {
|
|
99
|
+
const profiles = this.countryProfiles();
|
|
100
|
+
const profile = profiles.find((c) => c.code === code) ?? profiles[0];
|
|
101
|
+
this.selectedCountry.set(profile);
|
|
102
|
+
});
|
|
159
103
|
}
|
|
160
104
|
|
|
161
105
|
submit() {
|
|
@@ -169,7 +113,7 @@ export class PayoutBankInfoFormComponent {
|
|
|
169
113
|
billingAddress: v.billingAddress!,
|
|
170
114
|
taxIdType: country.taxIdType,
|
|
171
115
|
taxId: v.taxId!,
|
|
172
|
-
provider: this.provider,
|
|
116
|
+
provider: this.provider(),
|
|
173
117
|
providerAgreement: v.providerAgreement!,
|
|
174
118
|
});
|
|
175
119
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<div class="payout-onboarding">
|
|
2
|
+
<h2 class="payout-onboarding__title">{{ title() }}</h2>
|
|
3
|
+
|
|
4
|
+
<p class="payout-onboarding__intro">
|
|
5
|
+
Bank routing details are collected by the payout provider, not
|
|
6
|
+
this application. After this step the provider will run KYC and
|
|
7
|
+
notify us when the account is ready for payouts.
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
@if (reusable().length > 0) {
|
|
11
|
+
<mat-card class="reuse-card">
|
|
12
|
+
<mat-card-header>
|
|
13
|
+
<mat-card-title>Reuse an existing account</mat-card-title>
|
|
14
|
+
</mat-card-header>
|
|
15
|
+
<mat-card-content>
|
|
16
|
+
<p class="hint">
|
|
17
|
+
You already have a payout account on
|
|
18
|
+
{{ reusable().length === 1 ? 'another partner' : 'other partners' }}.
|
|
19
|
+
You can reuse it here (no KYC redo) if the currency matches.
|
|
20
|
+
</p>
|
|
21
|
+
<mat-radio-group [value]="selectedAccountId()" (change)="selectAccount($any($event.value))">
|
|
22
|
+
@for (acc of reusable(); track acc.providerAccountId) {
|
|
23
|
+
<mat-radio-button [value]="acc.providerAccountId" class="reuse-option">
|
|
24
|
+
<strong>{{ acc.partnerCaption }}</strong>
|
|
25
|
+
— {{ acc.countryCode }} / {{ acc.currency }} ({{ acc.provider }})
|
|
26
|
+
</mat-radio-button>
|
|
27
|
+
}
|
|
28
|
+
</mat-radio-group>
|
|
29
|
+
<button matButton="filled" class="payout-onboarding__btn payout-onboarding__btn--primary"
|
|
30
|
+
[disabled]="!selectedAccountId() || busy()"
|
|
31
|
+
(click)="linkExisting()">
|
|
32
|
+
Use this account
|
|
33
|
+
</button>
|
|
34
|
+
</mat-card-content>
|
|
35
|
+
</mat-card>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
<mat-card>
|
|
39
|
+
<mat-card-header>
|
|
40
|
+
<mat-card-title>Set up a new account</mat-card-title>
|
|
41
|
+
</mat-card-header>
|
|
42
|
+
<mat-card-content>
|
|
43
|
+
<p class="hint">
|
|
44
|
+
Opens the provider's secure onboarding page. You'll be asked
|
|
45
|
+
for bank routing details and government ID. The process
|
|
46
|
+
usually takes a few minutes.
|
|
47
|
+
</p>
|
|
48
|
+
@if (errorMsg()) {
|
|
49
|
+
<p class="payout-onboarding__error">{{ errorMsg() }}</p>
|
|
50
|
+
}
|
|
51
|
+
<button matButton="filled" class="payout-onboarding__btn payout-onboarding__btn--primary"
|
|
52
|
+
[disabled]="busy()"
|
|
53
|
+
(click)="startProviderKyc()">
|
|
54
|
+
<mat-icon>open_in_new</mat-icon>
|
|
55
|
+
Start onboarding
|
|
56
|
+
</button>
|
|
57
|
+
</mat-card-content>
|
|
58
|
+
</mat-card>
|
|
59
|
+
|
|
60
|
+
<div class="payout-onboarding__nav">
|
|
61
|
+
@if (showBack()) {
|
|
62
|
+
<button matButton="outlined" class="payout-onboarding__btn" type="button" (click)="back.emit()">
|
|
63
|
+
< Back
|
|
64
|
+
</button>
|
|
65
|
+
}
|
|
66
|
+
<button matButton="outlined" class="payout-onboarding__btn" type="button" (click)="skipped.emit()">
|
|
67
|
+
{{ skipLabel() }}
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component,
|
|
1
|
+
import { ChangeDetectionStrategy, Component, OnInit, inject, input, output, signal } from '@angular/core';
|
|
2
2
|
import { MatButtonModule } from '@angular/material/button';
|
|
3
3
|
import { MatCardModule } from '@angular/material/card';
|
|
4
4
|
import { MatIconModule } from '@angular/material/icon';
|
|
@@ -26,78 +26,7 @@ import { ReusableAccount } from '../../model/appdata';
|
|
|
26
26
|
selector: 'sail-payout-provider-onboarding',
|
|
27
27
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
28
28
|
imports: [MatButtonModule, MatCardModule, MatIconModule, MatRadioModule],
|
|
29
|
-
|
|
30
|
-
<div class="payout-onboarding">
|
|
31
|
-
<h2 class="payout-onboarding__title">{{ title }}</h2>
|
|
32
|
-
|
|
33
|
-
<p class="payout-onboarding__intro">
|
|
34
|
-
Bank routing details are collected by the payout provider, not
|
|
35
|
-
this application. After this step the provider will run KYC and
|
|
36
|
-
notify us when the account is ready for payouts.
|
|
37
|
-
</p>
|
|
38
|
-
|
|
39
|
-
@if (reusable().length > 0) {
|
|
40
|
-
<mat-card class="reuse-card">
|
|
41
|
-
<mat-card-header>
|
|
42
|
-
<mat-card-title>Reuse an existing account</mat-card-title>
|
|
43
|
-
</mat-card-header>
|
|
44
|
-
<mat-card-content>
|
|
45
|
-
<p class="hint">
|
|
46
|
-
You already have a payout account on
|
|
47
|
-
{{ reusable().length === 1 ? 'another partner' : 'other partners' }}.
|
|
48
|
-
You can reuse it here (no KYC redo) if the currency matches.
|
|
49
|
-
</p>
|
|
50
|
-
<mat-radio-group [value]="selectedAccountId()" (change)="selectAccount($any($event.value))">
|
|
51
|
-
@for (acc of reusable(); track acc.providerAccountId) {
|
|
52
|
-
<mat-radio-button [value]="acc.providerAccountId" class="reuse-option">
|
|
53
|
-
<strong>{{ acc.partnerCaption }}</strong>
|
|
54
|
-
— {{ acc.countryCode }} / {{ acc.currency }} ({{ acc.provider }})
|
|
55
|
-
</mat-radio-button>
|
|
56
|
-
}
|
|
57
|
-
</mat-radio-group>
|
|
58
|
-
<button mat-flat-button class="payout-onboarding__btn payout-onboarding__btn--primary"
|
|
59
|
-
[disabled]="!selectedAccountId() || busy()"
|
|
60
|
-
(click)="linkExisting()">
|
|
61
|
-
Use this account
|
|
62
|
-
</button>
|
|
63
|
-
</mat-card-content>
|
|
64
|
-
</mat-card>
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
<mat-card>
|
|
68
|
-
<mat-card-header>
|
|
69
|
-
<mat-card-title>Set up a new account</mat-card-title>
|
|
70
|
-
</mat-card-header>
|
|
71
|
-
<mat-card-content>
|
|
72
|
-
<p class="hint">
|
|
73
|
-
Opens the provider's secure onboarding page. You'll be asked
|
|
74
|
-
for bank routing details and government ID. The process
|
|
75
|
-
usually takes a few minutes.
|
|
76
|
-
</p>
|
|
77
|
-
@if (errorMsg()) {
|
|
78
|
-
<p class="payout-onboarding__error">{{ errorMsg() }}</p>
|
|
79
|
-
}
|
|
80
|
-
<button mat-flat-button class="payout-onboarding__btn payout-onboarding__btn--primary"
|
|
81
|
-
[disabled]="busy()"
|
|
82
|
-
(click)="startProviderKyc()">
|
|
83
|
-
<mat-icon>open_in_new</mat-icon>
|
|
84
|
-
Start onboarding
|
|
85
|
-
</button>
|
|
86
|
-
</mat-card-content>
|
|
87
|
-
</mat-card>
|
|
88
|
-
|
|
89
|
-
<div class="payout-onboarding__nav">
|
|
90
|
-
@if (showBack) {
|
|
91
|
-
<button mat-stroked-button class="payout-onboarding__btn" type="button" (click)="back.emit()">
|
|
92
|
-
< Back
|
|
93
|
-
</button>
|
|
94
|
-
}
|
|
95
|
-
<button mat-stroked-button class="payout-onboarding__btn" type="button" (click)="skipped.emit()">
|
|
96
|
-
{{ skipLabel }}
|
|
97
|
-
</button>
|
|
98
|
-
</div>
|
|
99
|
-
</div>
|
|
100
|
-
`,
|
|
29
|
+
templateUrl: './provider_onboarding.html',
|
|
101
30
|
styles: `
|
|
102
31
|
.payout-onboarding { display: flex; flex-direction: column; gap: 16px; padding: 0 24px 32px; }
|
|
103
32
|
.payout-onboarding__title { margin: 0; }
|
|
@@ -113,20 +42,20 @@ import { ReusableAccount } from '../../model/appdata';
|
|
|
113
42
|
})
|
|
114
43
|
export class PayoutProviderOnboardingComponent implements OnInit {
|
|
115
44
|
/** Step title — defaults match the most common payout-step framing. */
|
|
116
|
-
|
|
45
|
+
readonly title = input('Bank Account Setup');
|
|
117
46
|
/** Skip-button label — covers wizards where skipping returns to the parent flow. */
|
|
118
|
-
|
|
47
|
+
readonly skipLabel = input("I'll do this later");
|
|
119
48
|
/** When true, the back button renders alongside skip. */
|
|
120
|
-
|
|
49
|
+
readonly showBack = input(true);
|
|
121
50
|
|
|
122
51
|
/** Emitted after a successful reuse-link operation. */
|
|
123
|
-
|
|
52
|
+
readonly linked = output<void>();
|
|
124
53
|
/** Emitted when the user dismisses the step (skip). */
|
|
125
|
-
|
|
54
|
+
readonly skipped = output<void>();
|
|
126
55
|
/** Emitted when the user clicks Back. */
|
|
127
|
-
|
|
56
|
+
readonly back = output<void>();
|
|
128
57
|
/** Emitted after the hosted page is opened in a new tab. Consumers can route away or show a "waiting on provider" screen. */
|
|
129
|
-
|
|
58
|
+
readonly started = output<void>();
|
|
130
59
|
|
|
131
60
|
private readonly payoutService = inject(PayoutService);
|
|
132
61
|
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
<td>{{ formatDate(device.last_used_at) }}</td>
|
|
32
32
|
<td>{{ formatDate(device.created_at) }}</td>
|
|
33
33
|
<td>
|
|
34
|
-
<button
|
|
34
|
+
<button matIconButton (click)="revokeDevice(device)" title="Revoke">
|
|
35
35
|
<mat-icon>delete</mat-icon>
|
|
36
36
|
</button>
|
|
37
37
|
</td>
|
|
@@ -58,15 +58,15 @@
|
|
|
58
58
|
<input matInput type="password" formControlName="password" autocomplete="current-password" required>
|
|
59
59
|
</mat-form-field>
|
|
60
60
|
<div class="auth-actions">
|
|
61
|
-
<button
|
|
61
|
+
<button matButton="elevated" class="warn" type="submit" [disabled]="!logoutForm.valid || loading()">
|
|
62
62
|
Sign out everywhere
|
|
63
63
|
</button>
|
|
64
|
-
<button
|
|
64
|
+
<button matButton type="button" (click)="logoutOpen.set(false)">Cancel</button>
|
|
65
65
|
</div>
|
|
66
66
|
</form>
|
|
67
67
|
} @else {
|
|
68
68
|
<div class="auth-actions">
|
|
69
|
-
<button
|
|
69
|
+
<button matButton class="warn" type="button" (click)="logoutOpen.set(true)" [disabled]="loading()">
|
|
70
70
|
Sign out of all devices
|
|
71
71
|
</button>
|
|
72
72
|
<a routerLink="/dashboard">Back to Dashboard</a>
|
|
@@ -10,7 +10,7 @@ import { MatIconModule } from '@angular/material/icon';
|
|
|
10
10
|
import { RouterLink } from '@angular/router';
|
|
11
11
|
|
|
12
12
|
@Component({
|
|
13
|
-
selector: '
|
|
13
|
+
selector: 'sail-trusted-devices',
|
|
14
14
|
templateUrl: './trusted_devices.html',
|
|
15
15
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
16
|
encapsulation: ViewEncapsulation.None,
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<mat-label>Confirm your password</mat-label>
|
|
11
11
|
<input matInput type="password" formControlName="password" autocomplete="current-password" required>
|
|
12
12
|
</mat-form-field>
|
|
13
|
-
<button
|
|
13
|
+
<button matButton="elevated" class="primary" type="submit" [disabled]="!initForm.valid">Enable 2FA</button>
|
|
14
14
|
</form>
|
|
15
15
|
}
|
|
16
16
|
@case ('qr') {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
</div>
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
<button
|
|
35
|
+
<button matButton="elevated" class="primary" (click)="proceedToVerify()">I've saved my backup codes</button>
|
|
36
36
|
}
|
|
37
37
|
@case ('verify') {
|
|
38
38
|
<p class="auth-instructions">Enter the 6-digit code from your authenticator app to confirm setup.</p>
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
<input matInput type="text" formControlName="code" maxlength="6" autocomplete="one-time-code" required>
|
|
43
43
|
</mat-form-field>
|
|
44
44
|
|
|
45
|
-
<button
|
|
45
|
+
<button matButton="elevated" class="primary" type="submit" [disabled]="!verifyForm.valid">
|
|
46
46
|
Verify and Enable
|
|
47
47
|
</button>
|
|
48
48
|
</form>
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
<mat-label>Current 6-digit code</mat-label>
|
|
60
60
|
<input matInput type="text" formControlName="code" maxlength="6" autocomplete="one-time-code" required>
|
|
61
61
|
</mat-form-field>
|
|
62
|
-
<button
|
|
62
|
+
<button matButton class="warn" type="submit" [disabled]="!disableForm.valid">Disable 2FA</button>
|
|
63
63
|
</form>
|
|
64
64
|
}
|
|
65
65
|
}
|
|
@@ -5,9 +5,10 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
|
|
5
5
|
import { MatInputModule } from '@angular/material/input';
|
|
6
6
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
7
7
|
import { RouterLink } from '@angular/router';
|
|
8
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
8
9
|
|
|
9
10
|
@Component({
|
|
10
|
-
selector: '
|
|
11
|
+
selector: 'sail-twofactor-setup',
|
|
11
12
|
templateUrl: './twofactor_setup.html',
|
|
12
13
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
13
14
|
encapsulation: ViewEncapsulation.None,
|
|
@@ -19,7 +20,7 @@ import { RouterLink } from '@angular/router';
|
|
|
19
20
|
RouterLink,
|
|
20
21
|
],
|
|
21
22
|
})
|
|
22
|
-
export class TwoFactorSetupComponent {
|
|
23
|
+
export class TwoFactorSetupComponent extends BaseAsync {
|
|
23
24
|
private readonly auth = inject(BaseAuthService);
|
|
24
25
|
private readonly fb = inject(FormBuilder);
|
|
25
26
|
|
|
@@ -27,8 +28,6 @@ export class TwoFactorSetupComponent {
|
|
|
27
28
|
readonly qrUri = signal('');
|
|
28
29
|
readonly secret = signal('');
|
|
29
30
|
readonly backupCodes = signal<string[]>([]);
|
|
30
|
-
readonly errorMessage = signal('');
|
|
31
|
-
readonly successMessage = signal('');
|
|
32
31
|
|
|
33
32
|
// setup2FA requires the user's current password (or current TOTP code
|
|
34
33
|
// if 2FA already enabled — pass twoFactorCode instead). Disable additionally
|
|
@@ -48,20 +47,18 @@ export class TwoFactorSetupComponent {
|
|
|
48
47
|
|
|
49
48
|
startSetup() {
|
|
50
49
|
if (this.initForm.invalid) return;
|
|
51
|
-
this.errorMessage.set('');
|
|
52
50
|
const password = this.initForm.value.password!;
|
|
53
|
-
this.
|
|
54
|
-
|
|
51
|
+
this.run(
|
|
52
|
+
this.auth.setup2FA({ password }),
|
|
53
|
+
(res) => {
|
|
55
54
|
this.qrUri.set(res.qrUri);
|
|
56
55
|
this.secret.set(res.secret);
|
|
57
56
|
this.backupCodes.set(res.backupCodes);
|
|
58
57
|
this.step.set('qr');
|
|
59
58
|
this.initForm.reset();
|
|
60
59
|
},
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
},
|
|
64
|
-
});
|
|
60
|
+
'2FA setup failed.',
|
|
61
|
+
);
|
|
65
62
|
}
|
|
66
63
|
|
|
67
64
|
proceedToVerify() {
|
|
@@ -70,11 +67,10 @@ export class TwoFactorSetupComponent {
|
|
|
70
67
|
|
|
71
68
|
confirmSetup() {
|
|
72
69
|
if (this.verifyForm.invalid) return;
|
|
73
|
-
this.errorMessage.set('');
|
|
74
|
-
|
|
75
70
|
const { code } = this.verifyForm.value;
|
|
76
|
-
this.
|
|
77
|
-
|
|
71
|
+
this.run(
|
|
72
|
+
this.auth.verify2FA({ code: code! }),
|
|
73
|
+
(res) => {
|
|
78
74
|
if (res.valid) {
|
|
79
75
|
this.successMessage.set('Two-factor authentication enabled.');
|
|
80
76
|
this.step.set('done');
|
|
@@ -82,25 +78,21 @@ export class TwoFactorSetupComponent {
|
|
|
82
78
|
this.errorMessage.set('Invalid code. Please try again.');
|
|
83
79
|
}
|
|
84
80
|
},
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
},
|
|
88
|
-
});
|
|
81
|
+
'Verification failed.',
|
|
82
|
+
);
|
|
89
83
|
}
|
|
90
84
|
|
|
91
85
|
disable2FA() {
|
|
92
86
|
if (this.disableForm.invalid) return;
|
|
93
|
-
this.errorMessage.set('');
|
|
94
87
|
const { password, code } = this.disableForm.value;
|
|
95
|
-
this.
|
|
96
|
-
|
|
88
|
+
this.run(
|
|
89
|
+
this.auth.disable2FA(password!, code!),
|
|
90
|
+
() => {
|
|
97
91
|
this.successMessage.set('Two-factor authentication disabled.');
|
|
98
92
|
this.step.set('init');
|
|
99
93
|
this.disableForm.reset();
|
|
100
94
|
},
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
},
|
|
104
|
-
});
|
|
95
|
+
'Failed to disable 2FA.',
|
|
96
|
+
);
|
|
105
97
|
}
|
|
106
98
|
}
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
<div class="auth-error" role="alert">{{ errorMessage() }}</div>
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
<button
|
|
34
|
+
<button matButton="elevated" class="primary" type="submit" [disabled]="!verifyForm.valid">
|
|
35
35
|
Verify
|
|
36
36
|
</button>
|
|
37
37
|
</form>
|
|
@@ -7,9 +7,10 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
|
7
7
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
8
8
|
import { RouterLink } from '@angular/router';
|
|
9
9
|
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
|
|
10
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
10
11
|
|
|
11
12
|
@Component({
|
|
12
|
-
selector: '
|
|
13
|
+
selector: 'sail-twofactor-verify',
|
|
13
14
|
templateUrl: './twofactor_verify.html',
|
|
14
15
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
15
16
|
encapsulation: ViewEncapsulation.None,
|
|
@@ -22,13 +23,12 @@ import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
|
|
|
22
23
|
RouterLink,
|
|
23
24
|
],
|
|
24
25
|
})
|
|
25
|
-
export class TwoFactorVerifyComponent {
|
|
26
|
+
export class TwoFactorVerifyComponent extends BaseAsync {
|
|
26
27
|
private readonly auth = inject(BaseAuthService);
|
|
27
28
|
private readonly fb = inject(FormBuilder);
|
|
28
29
|
protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
29
30
|
|
|
30
31
|
readonly useBackupCode = signal(false);
|
|
31
|
-
readonly errorMessage = signal('');
|
|
32
32
|
|
|
33
33
|
readonly verifyForm = this.fb.group({
|
|
34
34
|
code: ['', [Validators.required, Validators.minLength(6)]],
|
|
@@ -38,18 +38,17 @@ export class TwoFactorVerifyComponent {
|
|
|
38
38
|
toggleBackupCode() {
|
|
39
39
|
this.useBackupCode.update(v => !v);
|
|
40
40
|
this.verifyForm.controls.code.reset();
|
|
41
|
-
this.
|
|
41
|
+
this.clearMessages();
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
verify() {
|
|
45
45
|
if (this.verifyForm.invalid) return;
|
|
46
|
-
this.errorMessage.set('');
|
|
47
|
-
|
|
48
46
|
const { code, trustDevice } = this.verifyForm.value;
|
|
49
47
|
|
|
50
48
|
if (this.useBackupCode()) {
|
|
51
|
-
this.
|
|
52
|
-
|
|
49
|
+
this.run(
|
|
50
|
+
this.auth.verifyBackupCode(code!),
|
|
51
|
+
(res) => {
|
|
53
52
|
if (res.valid && res.token) {
|
|
54
53
|
localStorage.removeItem('loginToken');
|
|
55
54
|
this.auth.token = res.token;
|
|
@@ -61,11 +60,10 @@ export class TwoFactorVerifyComponent {
|
|
|
61
60
|
this.errorMessage.set('Invalid backup code.');
|
|
62
61
|
}
|
|
63
62
|
},
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
},
|
|
67
|
-
});
|
|
63
|
+
'Backup code verification failed.',
|
|
64
|
+
);
|
|
68
65
|
} else {
|
|
66
|
+
this.clearMessages();
|
|
69
67
|
this.auth.verify2FALogin(
|
|
70
68
|
code!,
|
|
71
69
|
trustDevice ?? false,
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
<div class="child-table-header">
|
|
3
3
|
<h3>{{ getCaption() }}</h3>
|
|
4
4
|
@if (canCreate()) {
|
|
5
|
-
<button
|
|
5
|
+
<button matButton="elevated" class="accent" (click)="addRecord()">New Record</button>
|
|
6
6
|
}
|
|
7
7
|
@for (action of getActions(false); track action.action) {
|
|
8
8
|
@if (canExecuteAction(action)) {
|
|
9
|
-
<button
|
|
9
|
+
<button matButton="outlined" type="button"
|
|
10
10
|
[title]="action.caption"
|
|
11
11
|
(click)="executeAction(action)">
|
|
12
12
|
@if (action.icon) { <mat-icon>{{ action.icon }}</mat-icon> }
|
|
@@ -26,20 +26,20 @@
|
|
|
26
26
|
</thead>
|
|
27
27
|
<tbody>
|
|
28
28
|
@for (record of records; track $index) {
|
|
29
|
-
<tr [class.deleted-record]="record
|
|
29
|
+
<tr [class.deleted-record]="isDeleted(record)" [class.updated-record]="isUpdated(record)" [class.new-record]="isInserted(record)">
|
|
30
30
|
@if (record == editingRecord) {
|
|
31
31
|
@for (col of displayedColumns; track col) {
|
|
32
32
|
<td>
|
|
33
33
|
@if (isReadOnly(col)) {
|
|
34
34
|
{{ record[col] }}
|
|
35
35
|
} @else {
|
|
36
|
-
<dynamic-field [tableName]="tableName" [value]="record[col]" [field]="col" (valueChange)="record[col]=$event" subscriptSizing="dynamic"></dynamic-field>
|
|
36
|
+
<sail-dynamic-field [tableName]="tableName()" [value]="record[col]" [field]="col" (valueChange)="record[col]=$event" subscriptSizing="dynamic"></sail-dynamic-field>
|
|
37
37
|
}
|
|
38
38
|
</td>
|
|
39
39
|
}
|
|
40
40
|
<td>
|
|
41
|
-
<button
|
|
42
|
-
<button
|
|
41
|
+
<button matIconButton (click)="saveRow()" title="Save"><mat-icon>save</mat-icon></button>
|
|
42
|
+
<button matIconButton (click)="cancelRow(record)" title="Cancel"><mat-icon>cancel</mat-icon></button>
|
|
43
43
|
</td>
|
|
44
44
|
} @else {
|
|
45
45
|
@for (col of displayedColumns; track col) {
|
|
@@ -47,17 +47,17 @@
|
|
|
47
47
|
}
|
|
48
48
|
<td>
|
|
49
49
|
@if (canUpdate()) {
|
|
50
|
-
<button
|
|
50
|
+
<button matIconButton (click)="editRow(record)" title="Edit"><mat-icon>edit</mat-icon></button>
|
|
51
51
|
}
|
|
52
|
-
@if (record
|
|
53
|
-
<button
|
|
52
|
+
@if (isDeleted(record)) {
|
|
53
|
+
<button matIconButton (click)="unDeleteRow(record)" title="Undelete"><mat-icon>restore_from_trash</mat-icon></button>
|
|
54
54
|
} @else if (canDelete()) {
|
|
55
|
-
<button
|
|
55
|
+
<button matIconButton (click)="deleteRow(record)" title="Delete"><mat-icon>delete</mat-icon></button>
|
|
56
56
|
}
|
|
57
|
-
@if (record
|
|
57
|
+
@if (!isInserted(record)) {
|
|
58
58
|
@for (action of getActions(true); track action.action) {
|
|
59
59
|
@if (canExecuteAction(action)) {
|
|
60
|
-
<button
|
|
60
|
+
<button matIconButton type="button"
|
|
61
61
|
[title]="action.caption"
|
|
62
62
|
(click)="executeAction(action, record)">
|
|
63
63
|
<mat-icon>{{ action.icon || 'play_arrow' }}</mat-icon>
|