@nauticana/sail 1.1.43 → 1.1.45
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 +31 -7
- package/package.json +1 -1
- package/src/agent/component/studio_page.ts +35 -20
- package/src/component/abstract/base_form.ts +5 -0
- package/src/component/abstract/base_table.ts +23 -29
- package/src/component/abstract/base_view.ts +26 -17
- package/src/component/account/profile_editor.html +15 -8
- package/src/component/account/profile_editor.ts +12 -7
- package/src/component/agency/agency_clients.html +4 -1
- package/src/component/agency/agency_clients.ts +6 -3
- package/src/component/dialog/confirm_dialog.ts +29 -0
- package/src/component/form/field_error.ts +26 -0
- package/src/component/form/form_field.html +5 -10
- package/src/component/form/form_field.ts +10 -0
- package/src/component/login/chpass_component.html +4 -1
- package/src/component/login/chpass_component.ts +6 -3
- package/src/component/login/confirm_chpass_component.html +4 -1
- package/src/component/login/confirm_chpass_component.ts +6 -3
- package/src/component/login/confirm_register_component.html +3 -1
- package/src/component/login/confirm_register_component.ts +6 -3
- package/src/component/login/login_component.html +3 -1
- package/src/component/login/login_component.ts +6 -3
- package/src/component/login/register_component.html +14 -1
- package/src/component/login/register_component.ts +6 -3
- package/src/component/navigation/navigation.html +4 -1
- package/src/component/navigation/navigation.ts +24 -5
- package/src/component/oauth_connection/oauth_connection_panel.html +4 -0
- package/src/component/oauth_connection/oauth_connection_panel.ts +9 -5
- package/src/component/payout/bank_info_form.html +6 -2
- package/src/component/payout/bank_info_form.ts +6 -3
- package/src/component/security/api_keys.html +6 -2
- package/src/component/security/api_keys.ts +13 -7
- package/src/component/security/connected_apps.ts +7 -5
- package/src/component/security/sessions.ts +11 -9
- package/src/component/security/trusted_devices.html +2 -1
- package/src/component/security/trusted_devices.ts +18 -13
- package/src/component/security/twofactor_setup.html +7 -3
- package/src/component/security/twofactor_setup.ts +8 -5
- package/src/component/security/twofactor_verify.html +2 -1
- package/src/component/security/twofactor_verify.ts +6 -3
- package/src/component/table/table_detail.ts +1 -1
- package/src/component/table/table_edit.ts +27 -5
- package/src/component/table/table_list.html +21 -5
- package/src/component/table/table_list.ts +74 -67
- package/src/component/table/table_search.ts +2 -2
- package/src/index.ts +4 -0
- package/src/model/appdata.ts +3 -0
- package/src/service/auth.service.ts +30 -17
- package/src/service/billing.service.ts +8 -6
- package/src/service/prompt.service.ts +27 -0
- package/src/util/forms.ts +12 -0
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
<mat-option [value]="c.Id">{{ c.Caption }} ({{ c.Currency }})</mat-option>
|
|
14
14
|
}
|
|
15
15
|
</mat-select>
|
|
16
|
+
<mat-error sailFieldError></mat-error>
|
|
16
17
|
</mat-form-field>
|
|
17
18
|
<p class="hint">Sets your payout currency</p>
|
|
18
19
|
|
|
19
20
|
<mat-form-field appearance="outline" class="full-width">
|
|
20
21
|
<mat-label>Account holder name</mat-label>
|
|
21
22
|
<input matInput formControlName="accountHolderName">
|
|
23
|
+
<mat-error sailFieldError></mat-error>
|
|
22
24
|
</mat-form-field>
|
|
23
25
|
<p class="hint">Legal name on the bank account (must match government ID)</p>
|
|
24
26
|
|
|
@@ -29,17 +31,20 @@
|
|
|
29
31
|
<mat-option [value]="t.code">{{ t.caption }}</mat-option>
|
|
30
32
|
}
|
|
31
33
|
</mat-select>
|
|
34
|
+
<mat-error sailFieldError></mat-error>
|
|
32
35
|
</mat-form-field>
|
|
33
36
|
|
|
34
37
|
<mat-form-field appearance="outline" class="full-width">
|
|
35
38
|
<mat-label>Tax ID</mat-label>
|
|
36
39
|
<input matInput formControlName="taxId">
|
|
40
|
+
<mat-error sailFieldError></mat-error>
|
|
37
41
|
</mat-form-field>
|
|
38
42
|
<p class="hint">Shared with the tax authority for annual reporting</p>
|
|
39
43
|
|
|
40
44
|
<mat-form-field appearance="outline" class="full-width">
|
|
41
45
|
<mat-label>Billing address</mat-label>
|
|
42
46
|
<input matInput formControlName="billingAddress">
|
|
47
|
+
<mat-error sailFieldError></mat-error>
|
|
43
48
|
</mat-form-field>
|
|
44
49
|
<p class="hint">Business or home address — used for tax correspondence</p>
|
|
45
50
|
|
|
@@ -59,8 +64,7 @@
|
|
|
59
64
|
< Back
|
|
60
65
|
</button>
|
|
61
66
|
}
|
|
62
|
-
<button matButton="filled" class="pill-btn primary-btn" type="submit"
|
|
63
|
-
[disabled]="form.invalid">
|
|
67
|
+
<button matButton="filled" class="pill-btn primary-btn" type="submit">
|
|
64
68
|
{{ submitLabel() }}
|
|
65
69
|
</button>
|
|
66
70
|
</div>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core';
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, inject, input, output, ElementRef } from '@angular/core';
|
|
2
|
+
import { submittable } from '../../util/forms';
|
|
3
|
+
import { FieldError } from '../form/field_error';
|
|
2
4
|
import { toSignal } from '@angular/core/rxjs-interop';
|
|
3
5
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
4
6
|
import { MatButtonModule } from '@angular/material/button';
|
|
@@ -23,7 +25,7 @@ import { BackendService } from '../../service/rest_service';
|
|
|
23
25
|
selector: 'sail-payout-bank-info-form',
|
|
24
26
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
25
27
|
imports: [
|
|
26
|
-
ReactiveFormsModule, MatButtonModule, MatCheckboxModule,
|
|
28
|
+
FieldError, ReactiveFormsModule, MatButtonModule, MatCheckboxModule,
|
|
27
29
|
MatFormFieldModule, MatInputModule, MatSelectModule,
|
|
28
30
|
],
|
|
29
31
|
templateUrl: './bank_info_form.html',
|
|
@@ -40,6 +42,7 @@ export class PayoutBankInfoFormComponent {
|
|
|
40
42
|
readonly submitted = output<BankInfoFormValue>();
|
|
41
43
|
readonly back = output<void>();
|
|
42
44
|
|
|
45
|
+
private readonly host: HTMLElement = inject(ElementRef).nativeElement;
|
|
43
46
|
private readonly backend = inject(BackendService);
|
|
44
47
|
private readonly labels = inject(LabelService);
|
|
45
48
|
|
|
@@ -62,7 +65,7 @@ export class PayoutBankInfoFormComponent {
|
|
|
62
65
|
|
|
63
66
|
submit() {
|
|
64
67
|
const currency = this.currency();
|
|
65
|
-
if (this.form.
|
|
68
|
+
if (!submittable(this.form, this.host) || !currency) return;
|
|
66
69
|
const v = this.form.getRawValue();
|
|
67
70
|
this.submitted.emit({
|
|
68
71
|
countryCode: v.countryCode!,
|
|
@@ -12,15 +12,18 @@
|
|
|
12
12
|
<mat-form-field>
|
|
13
13
|
<mat-label>Key name</mat-label>
|
|
14
14
|
<input matInput formControlName="keyName" maxlength="100">
|
|
15
|
+
<mat-error sailFieldError></mat-error>
|
|
15
16
|
</mat-form-field>
|
|
16
17
|
<mat-form-field>
|
|
17
18
|
<mat-label>Allowed networks (optional)</mat-label>
|
|
18
19
|
<input matInput formControlName="allowedCidrs" maxlength="2000">
|
|
19
20
|
<mat-hint>Comma-separated addresses or CIDR ranges. Empty allows any address.</mat-hint>
|
|
21
|
+
<mat-error sailFieldError></mat-error>
|
|
20
22
|
</mat-form-field>
|
|
21
23
|
<mat-form-field>
|
|
22
24
|
<mat-label>Expires (optional)</mat-label>
|
|
23
25
|
<input matInput type="datetime-local" formControlName="expiresAt">
|
|
26
|
+
<mat-error sailFieldError></mat-error>
|
|
24
27
|
</mat-form-field>
|
|
25
28
|
@if (offered().length) {
|
|
26
29
|
<fieldset class="api-keys__scopes">
|
|
@@ -31,7 +34,7 @@
|
|
|
31
34
|
}
|
|
32
35
|
</fieldset>
|
|
33
36
|
}
|
|
34
|
-
<button matButton="filled" type="submit" [disabled]="
|
|
37
|
+
<button matButton="filled" type="submit" [disabled]="loading()">Create key</button>
|
|
35
38
|
</form>
|
|
36
39
|
}
|
|
37
40
|
|
|
@@ -51,6 +54,7 @@
|
|
|
51
54
|
<mat-form-field>
|
|
52
55
|
<mat-label>Allowed networks</mat-label>
|
|
53
56
|
<input matInput [formControl]="networks" maxlength="2000" (keydown.enter)="saveNetworks(key)">
|
|
57
|
+
<mat-error sailFieldError></mat-error>
|
|
54
58
|
</mat-form-field>
|
|
55
59
|
} @else {
|
|
56
60
|
{{ key.AllowedCidrs || 'any address' }}
|
|
@@ -62,7 +66,7 @@
|
|
|
62
66
|
<td>
|
|
63
67
|
@if (restricting() === key.Id) {
|
|
64
68
|
<button matButton type="button" (click)="restricting.set(null)">Cancel</button>
|
|
65
|
-
<button matButton="filled" type="button" [disabled]="
|
|
69
|
+
<button matButton="filled" type="button" [disabled]="loading()" (click)="saveNetworks(key)">Save</button>
|
|
66
70
|
} @else {
|
|
67
71
|
@if (key.IsActive && canRestrict) {
|
|
68
72
|
<button matButton type="button" [disabled]="loading()" (click)="editNetworks(key)">Restrict networks</button>
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { DatePipe } from '@angular/common';
|
|
2
|
-
import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, computed, inject, input, signal } from '@angular/core';
|
|
2
|
+
import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, computed, inject, input, signal, ElementRef } from '@angular/core';
|
|
3
|
+
import { submittable } from '../../util/forms';
|
|
4
|
+
import { FieldError } from '../form/field_error';
|
|
3
5
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
4
6
|
import { MatButtonModule } from '@angular/material/button';
|
|
5
7
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
@@ -10,6 +12,7 @@ import { ApiKey, MintedApiKey } from '../../model/access';
|
|
|
10
12
|
import { AccessService } from '../../service/access.service';
|
|
11
13
|
import { LabelOption, LabelService } from '../../service/label.service';
|
|
12
14
|
import { BaseAsync } from '../abstract/base_async';
|
|
15
|
+
import { PromptService } from '../../service/prompt.service';
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* The partner's API keys: mint one with scopes, allowed networks and an optional expiry (the
|
|
@@ -21,10 +24,12 @@ import { BaseAsync } from '../abstract/base_async';
|
|
|
21
24
|
templateUrl: './api_keys.html',
|
|
22
25
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
23
26
|
encapsulation: ViewEncapsulation.None,
|
|
24
|
-
imports: [DatePipe, ReactiveFormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatInputModule],
|
|
27
|
+
imports: [FieldError, DatePipe, ReactiveFormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatInputModule],
|
|
25
28
|
})
|
|
26
29
|
export class ApiKeysComponent extends BaseAsync implements OnInit {
|
|
30
|
+
private readonly host: HTMLElement = inject(ElementRef).nativeElement;
|
|
27
31
|
private readonly access = inject(AccessService);
|
|
32
|
+
private readonly prompt = inject(PromptService);
|
|
28
33
|
private readonly labels = inject(LabelService);
|
|
29
34
|
private readonly fb = inject(FormBuilder).nonNullable;
|
|
30
35
|
|
|
@@ -68,7 +73,7 @@ export class ApiKeysComponent extends BaseAsync implements OnInit {
|
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
mint(): void {
|
|
71
|
-
if (this.form.
|
|
76
|
+
if (!submittable(this.form, this.host)) return;
|
|
72
77
|
const { keyName, scopes, allowedCidrs, expiresAt } = this.form.getRawValue();
|
|
73
78
|
this.run(this.access.mintApiKey({
|
|
74
79
|
key_name: keyName,
|
|
@@ -88,7 +93,7 @@ export class ApiKeysComponent extends BaseAsync implements OnInit {
|
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
saveNetworks(key: ApiKey): void {
|
|
91
|
-
if (this.networks.
|
|
96
|
+
if (!submittable(this.networks, this.host)) return;
|
|
92
97
|
this.run(this.access.restrictApiKey(key.Id, this.networks.value), () => {
|
|
93
98
|
this.restricting.set(null);
|
|
94
99
|
this.successMessage.set(`Networks of "${key.KeyName}" saved.`);
|
|
@@ -97,10 +102,11 @@ export class ApiKeysComponent extends BaseAsync implements OnInit {
|
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
revoke(key: ApiKey): void {
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
this.prompt.ask({
|
|
106
|
+
title: `Revoke key "${key.KeyName}"?`, message: `Anything using ${key.KeyPrefix}… stops working.`, action: 'Revoke key', destructive: true,
|
|
107
|
+
}, () => this.run(this.access.revokeApiKey(key.Id), () => {
|
|
102
108
|
this.successMessage.set(`Key "${key.KeyName}" revoked.`);
|
|
103
109
|
this.load();
|
|
104
|
-
}, 'Failed to revoke the key.');
|
|
110
|
+
}, 'Failed to revoke the key.'));
|
|
105
111
|
}
|
|
106
112
|
}
|
|
@@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
|
|
|
4
4
|
import { OAuthGrant } from '../../model/access';
|
|
5
5
|
import { AccessService } from '../../service/access.service';
|
|
6
6
|
import { BaseAsync } from '../abstract/base_async';
|
|
7
|
+
import { PromptService } from '../../service/prompt.service';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* OAuth clients holding a live grant from the signed-in user, with disconnect; a disconnected
|
|
@@ -18,6 +19,7 @@ import { BaseAsync } from '../abstract/base_async';
|
|
|
18
19
|
})
|
|
19
20
|
export class ConnectedAppsComponent extends BaseAsync implements OnInit {
|
|
20
21
|
private readonly access = inject(AccessService);
|
|
22
|
+
private readonly prompt = inject(PromptService);
|
|
21
23
|
|
|
22
24
|
readonly grants = signal<OAuthGrant[]>([]);
|
|
23
25
|
|
|
@@ -31,10 +33,10 @@ export class ConnectedAppsComponent extends BaseAsync implements OnInit {
|
|
|
31
33
|
|
|
32
34
|
revoke(grant: OAuthGrant): void {
|
|
33
35
|
const name = grant.clientName || grant.clientId;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
this.prompt.ask({ title: `Disconnect "${name}"?`, message: 'It will have to connect again.', action: 'Disconnect app', destructive: true },
|
|
37
|
+
() => this.run(this.access.revokeGrant(grant.clientId), () => {
|
|
38
|
+
this.successMessage.set(`Disconnected ${name}.`);
|
|
39
|
+
this.load();
|
|
40
|
+
}, 'Failed to disconnect the app.'));
|
|
39
41
|
}
|
|
40
42
|
}
|
|
@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, inject,
|
|
|
2
2
|
import { DatePipe } from '@angular/common';
|
|
3
3
|
import { MatButtonModule } from '@angular/material/button';
|
|
4
4
|
import { BaseAsync } from '../abstract/base_async';
|
|
5
|
+
import { PromptService } from '../../service/prompt.service';
|
|
5
6
|
import { BaseAuthService } from '../../service/auth.service';
|
|
6
7
|
import { ActiveSession } from '../../model/appdata';
|
|
7
8
|
|
|
@@ -14,6 +15,7 @@ import { ActiveSession } from '../../model/appdata';
|
|
|
14
15
|
})
|
|
15
16
|
export class SessionsComponent extends BaseAsync implements OnInit {
|
|
16
17
|
private readonly auth = inject(BaseAuthService);
|
|
18
|
+
private readonly prompt = inject(PromptService);
|
|
17
19
|
|
|
18
20
|
readonly sessions = signal<ActiveSession[]>([]);
|
|
19
21
|
private readonly methods = new Map(
|
|
@@ -32,14 +34,14 @@ export class SessionsComponent extends BaseAsync implements OnInit {
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
revoke(session: ActiveSession) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
this.prompt.ask({ title: `Sign out the session on ${session.userAgent || 'an unknown device'}?`, action: 'Sign out session', destructive: true }, () =>
|
|
38
|
+
this.run(
|
|
39
|
+
this.auth.revokeSession(session.id),
|
|
40
|
+
() => {
|
|
41
|
+
this.loadSessions();
|
|
42
|
+
this.successMessage.set('Session signed out.');
|
|
43
|
+
},
|
|
44
|
+
'Failed to sign out the session.',
|
|
45
|
+
));
|
|
44
46
|
}
|
|
45
47
|
}
|
|
@@ -53,9 +53,10 @@
|
|
|
53
53
|
<mat-form-field appearance="outline">
|
|
54
54
|
<mat-label>Password</mat-label>
|
|
55
55
|
<input matInput type="password" formControlName="password" autocomplete="current-password" required>
|
|
56
|
+
<mat-error sailFieldError></mat-error>
|
|
56
57
|
</mat-form-field>
|
|
57
58
|
<div class="auth-actions">
|
|
58
|
-
<button matButton="elevated" class="warn" type="submit" [disabled]="
|
|
59
|
+
<button matButton="elevated" class="warn" type="submit" [disabled]="loading()">
|
|
59
60
|
Sign out everywhere
|
|
60
61
|
</button>
|
|
61
62
|
<button matButton type="button" (click)="logoutOpen.set(false)">Cancel</button>
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, inject, signal, input } from '@angular/core';
|
|
1
|
+
import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, inject, signal, input, ElementRef } from '@angular/core';
|
|
2
|
+
import { submittable } from '../../util/forms';
|
|
3
|
+
import { FieldError } from '../form/field_error';
|
|
2
4
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
5
|
import { BaseAsync } from '../abstract/base_async';
|
|
6
|
+
import { PromptService } from '../../service/prompt.service';
|
|
4
7
|
import { BaseAuthService } from '../../service/auth.service';
|
|
5
8
|
import { TrustedDevice } from '../../model/appdata';
|
|
6
9
|
import { MatButtonModule } from '@angular/material/button';
|
|
@@ -15,7 +18,7 @@ import { RouterLink } from '@angular/router';
|
|
|
15
18
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
19
|
encapsulation: ViewEncapsulation.None,
|
|
17
20
|
imports: [
|
|
18
|
-
MatButtonModule,
|
|
21
|
+
FieldError, MatButtonModule,
|
|
19
22
|
MatFormFieldModule,
|
|
20
23
|
MatInputModule,
|
|
21
24
|
MatIconModule,
|
|
@@ -24,7 +27,9 @@ import { RouterLink } from '@angular/router';
|
|
|
24
27
|
],
|
|
25
28
|
})
|
|
26
29
|
export class TrustedDevicesComponent extends BaseAsync implements OnInit {
|
|
30
|
+
private readonly host: HTMLElement = inject(ElementRef).nativeElement;
|
|
27
31
|
private readonly auth = inject(BaseAuthService);
|
|
32
|
+
private readonly prompt = inject(PromptService);
|
|
28
33
|
private readonly fb = inject(FormBuilder);
|
|
29
34
|
|
|
30
35
|
readonly devices = signal<TrustedDevice[]>([]);
|
|
@@ -50,23 +55,23 @@ export class TrustedDevicesComponent extends BaseAsync implements OnInit {
|
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
revokeDevice(device: TrustedDevice) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
this.prompt.ask({ title: `Revoke trust for "${device.name}"?`, action: 'Revoke device', destructive: true }, () =>
|
|
59
|
+
this.run(
|
|
60
|
+
this.auth.revokeTrustedDevice(device.id),
|
|
61
|
+
() => {
|
|
62
|
+
this.successMessage.set(`Device "${device.name}" revoked.`);
|
|
63
|
+
this.loadDevices();
|
|
64
|
+
},
|
|
65
|
+
'Failed to revoke device.',
|
|
66
|
+
));
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
/**
|
|
65
70
|
* logout-everywhere requires a re-auth gate, so we reveal a password
|
|
66
|
-
* field instead of relying on
|
|
71
|
+
* field instead of relying on a confirmation alone.
|
|
67
72
|
*/
|
|
68
73
|
signOutEverywhere() {
|
|
69
|
-
if (this.logoutForm.
|
|
74
|
+
if (!submittable(this.logoutForm, this.host)) return;
|
|
70
75
|
const password = this.logoutForm.value.password!;
|
|
71
76
|
this.run(
|
|
72
77
|
this.auth.logoutEverywhere({ password }),
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
<mat-form-field appearance="outline">
|
|
10
10
|
<mat-label>Confirm your password</mat-label>
|
|
11
11
|
<input matInput type="password" formControlName="password" autocomplete="current-password" required>
|
|
12
|
+
<mat-error sailFieldError></mat-error>
|
|
12
13
|
</mat-form-field>
|
|
13
|
-
<button matButton="elevated" class="primary" type="submit"
|
|
14
|
+
<button matButton="elevated" class="primary" type="submit">Enable 2FA</button>
|
|
14
15
|
</form>
|
|
15
16
|
}
|
|
16
17
|
@case ('qr') {
|
|
@@ -50,9 +51,10 @@
|
|
|
50
51
|
<mat-form-field appearance="outline">
|
|
51
52
|
<mat-label>Verification Code</mat-label>
|
|
52
53
|
<input matInput type="text" formControlName="code" maxlength="6" autocomplete="one-time-code" required>
|
|
54
|
+
<mat-error sailFieldError></mat-error>
|
|
53
55
|
</mat-form-field>
|
|
54
56
|
|
|
55
|
-
<button matButton="elevated" class="primary" type="submit"
|
|
57
|
+
<button matButton="elevated" class="primary" type="submit">
|
|
56
58
|
Verify and Enable
|
|
57
59
|
</button>
|
|
58
60
|
</form>
|
|
@@ -64,12 +66,14 @@
|
|
|
64
66
|
<mat-form-field appearance="outline">
|
|
65
67
|
<mat-label>Password</mat-label>
|
|
66
68
|
<input matInput type="password" formControlName="password" autocomplete="current-password" required>
|
|
69
|
+
<mat-error sailFieldError></mat-error>
|
|
67
70
|
</mat-form-field>
|
|
68
71
|
<mat-form-field appearance="outline">
|
|
69
72
|
<mat-label>Current 6-digit code</mat-label>
|
|
70
73
|
<input matInput type="text" formControlName="code" maxlength="6" autocomplete="one-time-code" required>
|
|
74
|
+
<mat-error sailFieldError></mat-error>
|
|
71
75
|
</mat-form-field>
|
|
72
|
-
<button matButton class="warn" type="submit"
|
|
76
|
+
<button matButton class="warn" type="submit">Disable 2FA</button>
|
|
73
77
|
</form>
|
|
74
78
|
}
|
|
75
79
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, effect, inject, input, signal, ViewEncapsulation } from '@angular/core';
|
|
1
|
+
import { ChangeDetectionStrategy, Component, effect, inject, input, signal, ViewEncapsulation, ElementRef } from '@angular/core';
|
|
2
|
+
import { submittable } from '../../util/forms';
|
|
3
|
+
import { FieldError } from '../form/field_error';
|
|
2
4
|
import { BaseAuthService } from '../../service/auth.service';
|
|
3
5
|
import { MatButtonModule } from '@angular/material/button';
|
|
4
6
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
@@ -14,7 +16,7 @@ import { toDataURL } from 'qrcode';
|
|
|
14
16
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
15
17
|
encapsulation: ViewEncapsulation.None,
|
|
16
18
|
imports: [
|
|
17
|
-
ReactiveFormsModule,
|
|
19
|
+
FieldError, ReactiveFormsModule,
|
|
18
20
|
MatButtonModule,
|
|
19
21
|
MatFormFieldModule,
|
|
20
22
|
MatInputModule,
|
|
@@ -22,6 +24,7 @@ import { toDataURL } from 'qrcode';
|
|
|
22
24
|
],
|
|
23
25
|
})
|
|
24
26
|
export class TwoFactorSetupComponent extends BaseAsync {
|
|
27
|
+
private readonly host: HTMLElement = inject(ElementRef).nativeElement;
|
|
25
28
|
private readonly auth = inject(BaseAuthService);
|
|
26
29
|
private readonly fb = inject(FormBuilder);
|
|
27
30
|
|
|
@@ -62,7 +65,7 @@ export class TwoFactorSetupComponent extends BaseAsync {
|
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
startSetup() {
|
|
65
|
-
if (this.initForm.
|
|
68
|
+
if (!submittable(this.initForm, this.host)) return;
|
|
66
69
|
const password = this.initForm.value.password!;
|
|
67
70
|
this.run(
|
|
68
71
|
this.auth.setup2FA({ password }),
|
|
@@ -97,7 +100,7 @@ export class TwoFactorSetupComponent extends BaseAsync {
|
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
confirmSetup() {
|
|
100
|
-
if (this.verifyForm.
|
|
103
|
+
if (!submittable(this.verifyForm, this.host)) return;
|
|
101
104
|
const { code } = this.verifyForm.value;
|
|
102
105
|
this.run(
|
|
103
106
|
this.auth.verify2FA({ code: code! }),
|
|
@@ -114,7 +117,7 @@ export class TwoFactorSetupComponent extends BaseAsync {
|
|
|
114
117
|
}
|
|
115
118
|
|
|
116
119
|
disable2FA() {
|
|
117
|
-
if (this.disableForm.
|
|
120
|
+
if (!submittable(this.disableForm, this.host)) return;
|
|
118
121
|
const { password, code } = this.disableForm.value;
|
|
119
122
|
this.run(
|
|
120
123
|
this.auth.disable2FA(password!, code!),
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
<mat-form-field appearance="outline">
|
|
20
20
|
<mat-label>{{ useBackupCode() ? 'Backup Code' : 'Verification Code' }}</mat-label>
|
|
21
21
|
<input matInput type="text" formControlName="code" autocomplete="one-time-code" required>
|
|
22
|
+
<mat-error sailFieldError></mat-error>
|
|
22
23
|
</mat-form-field>
|
|
23
24
|
|
|
24
25
|
@if (!useBackupCode() && !emailed) {
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
<div class="auth-error" role="alert">{{ errorMessage() }}</div>
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
<button matButton="elevated" class="primary" type="submit"
|
|
37
|
+
<button matButton="elevated" class="primary" type="submit">
|
|
37
38
|
Verify
|
|
38
39
|
</button>
|
|
39
40
|
</form>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, inject, signal, ViewEncapsulation } from '@angular/core';
|
|
1
|
+
import { ChangeDetectionStrategy, Component, inject, signal, ViewEncapsulation, ElementRef } from '@angular/core';
|
|
2
|
+
import { submittable } from '../../util/forms';
|
|
3
|
+
import { FieldError } from '../form/field_error';
|
|
2
4
|
import { BaseAuthService } from '../../service/auth.service';
|
|
3
5
|
import { MatButtonModule } from '@angular/material/button';
|
|
4
6
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
@@ -15,7 +17,7 @@ import { BaseAsync } from '../abstract/base_async';
|
|
|
15
17
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
18
|
encapsulation: ViewEncapsulation.None,
|
|
17
19
|
imports: [
|
|
18
|
-
ReactiveFormsModule,
|
|
20
|
+
FieldError, ReactiveFormsModule,
|
|
19
21
|
MatButtonModule,
|
|
20
22
|
MatFormFieldModule,
|
|
21
23
|
MatInputModule,
|
|
@@ -24,6 +26,7 @@ import { BaseAsync } from '../abstract/base_async';
|
|
|
24
26
|
],
|
|
25
27
|
})
|
|
26
28
|
export class TwoFactorVerifyComponent extends BaseAsync {
|
|
29
|
+
private readonly host: HTMLElement = inject(ElementRef).nativeElement;
|
|
27
30
|
private readonly auth = inject(BaseAuthService);
|
|
28
31
|
private readonly fb = inject(FormBuilder);
|
|
29
32
|
protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
@@ -43,7 +46,7 @@ export class TwoFactorVerifyComponent extends BaseAsync {
|
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
verify() {
|
|
46
|
-
if (this.verifyForm.
|
|
49
|
+
if (!submittable(this.verifyForm, this.host)) return;
|
|
47
50
|
const { code, trustDevice } = this.verifyForm.value;
|
|
48
51
|
|
|
49
52
|
if (this.useBackupCode()) {
|
|
@@ -69,7 +69,7 @@ export class TableDetail extends BaseForm {
|
|
|
69
69
|
*/
|
|
70
70
|
protected override beforeExecuteAction(action: TableAction, record?: Record<string, unknown>): boolean {
|
|
71
71
|
if (action.recordSpecific && record && record[this.config.opField] === OpCode.Insert) {
|
|
72
|
-
|
|
72
|
+
this.reportError('Save the record first before running this action.');
|
|
73
73
|
return false;
|
|
74
74
|
}
|
|
75
75
|
return true;
|
|
@@ -48,7 +48,7 @@ export class TableEdit extends BaseForm implements OnInit {
|
|
|
48
48
|
(this.cacheService.getTableDefinition(this.tableName())?.Children ?? []).map((child) => child.PascalName));
|
|
49
49
|
|
|
50
50
|
protected get isDialog(): boolean { return !!this.dialogRef; }
|
|
51
|
-
/**
|
|
51
|
+
/** Scalar values when editing began; children carry their own op codes. */
|
|
52
52
|
private originalSnapshot: Record<string, unknown> | null = null;
|
|
53
53
|
|
|
54
54
|
ngOnInit(): void {
|
|
@@ -61,6 +61,7 @@ export class TableEdit extends BaseForm implements OnInit {
|
|
|
61
61
|
}
|
|
62
62
|
if (this.isNew()) {
|
|
63
63
|
this.setEditableRecord(Object.assign(this.emptyRecord(), this.record()));
|
|
64
|
+
this.originalSnapshot = { ...this.editableRecord() };
|
|
64
65
|
} else {
|
|
65
66
|
this.fetchFullRecord();
|
|
66
67
|
}
|
|
@@ -119,18 +120,29 @@ export class TableEdit extends BaseForm implements OnInit {
|
|
|
119
120
|
return super.isReadOnly(fieldName);
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
override hasUnsavedChanges(): boolean {
|
|
124
|
+
if (this.isReadOnlyMode() || !this.originalSnapshot) return false;
|
|
125
|
+
const op = this.config.opField;
|
|
126
|
+
return this.isRecordDirty(this.editableRecord(), this.originalSnapshot)
|
|
127
|
+
|| this.tabFields().some((key) => this.childRecords(key).some((row) =>
|
|
128
|
+
row[op] === OpCode.Insert || row[op] === OpCode.Update || row[op] === OpCode.Delete));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
protected override reportError(message: string): void {
|
|
132
|
+
this.saveError.set(message);
|
|
133
|
+
}
|
|
134
|
+
|
|
122
135
|
onEdit() {
|
|
123
136
|
this.isReadOnlyMode.set(false);
|
|
124
137
|
this.isNew.set(false);
|
|
125
|
-
// Snapshot scalar fields only; children arrays are dirty-tracked per row.
|
|
126
138
|
this.originalSnapshot = { ...this.editableRecord() };
|
|
127
139
|
}
|
|
128
140
|
|
|
129
141
|
onNew() {
|
|
130
142
|
this.isReadOnlyMode.set(false);
|
|
131
143
|
this.isNew.set(true);
|
|
132
|
-
this.originalSnapshot = null;
|
|
133
144
|
this.setEditableRecord(this.emptyRecord());
|
|
145
|
+
this.originalSnapshot = { ...this.editableRecord() };
|
|
134
146
|
}
|
|
135
147
|
|
|
136
148
|
onSave() {
|
|
@@ -170,7 +182,7 @@ export class TableEdit extends BaseForm implements OnInit {
|
|
|
170
182
|
}
|
|
171
183
|
|
|
172
184
|
onDelete() {
|
|
173
|
-
|
|
185
|
+
this.prompt.ask({ title: `Delete this ${this.getCaption()} record?`, action: 'Delete record', destructive: true }, () => {
|
|
174
186
|
const result = {...this.editableRecord()};
|
|
175
187
|
result[this.config.opField] = OpCode.Delete;
|
|
176
188
|
this.backendService.post(this.apiName(), result).subscribe({
|
|
@@ -183,10 +195,20 @@ export class TableEdit extends BaseForm implements OnInit {
|
|
|
183
195
|
this.saveError.set(errorDetail(err, 'Delete failed.'));
|
|
184
196
|
},
|
|
185
197
|
});
|
|
186
|
-
}
|
|
198
|
+
});
|
|
187
199
|
}
|
|
188
200
|
|
|
189
201
|
onCancel() {
|
|
202
|
+
if (!this.hasUnsavedChanges()) {
|
|
203
|
+
this.discard();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
this.prompt.ask({ title: 'Discard your changes?', message: 'Your edits to this record are not saved.', action: 'Discard changes', destructive: true },
|
|
207
|
+
() => this.discard());
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private discard() {
|
|
211
|
+
this.originalSnapshot = null;
|
|
190
212
|
if (this.dialogRef) {
|
|
191
213
|
this.dialogRef.close();
|
|
192
214
|
return;
|
|
@@ -20,7 +20,11 @@
|
|
|
20
20
|
<p class="table-list__error" role="alert">{{ viewError() }}</p>
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
@if (loading()) {
|
|
24
|
+
<mat-progress-bar mode="indeterminate" [attr.aria-label]="'Loading ' + getCaption()"></mat-progress-bar>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
<table [attr.aria-busy]="loading()">
|
|
24
28
|
<thead>
|
|
25
29
|
<tr>
|
|
26
30
|
@for (column of displayedColumns(); track column) {
|
|
@@ -40,9 +44,9 @@
|
|
|
40
44
|
}
|
|
41
45
|
</tr>
|
|
42
46
|
</thead>
|
|
43
|
-
@if (
|
|
47
|
+
@if (records().length > 0) {
|
|
44
48
|
<tbody>
|
|
45
|
-
@for (record of
|
|
49
|
+
@for (record of records(); track trackByRecord($index, record)) {
|
|
46
50
|
<tr>
|
|
47
51
|
@for (column of displayedColumns(); track column) {
|
|
48
52
|
<td>
|
|
@@ -90,6 +94,18 @@
|
|
|
90
94
|
</mat-paginator>
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
@if (
|
|
94
|
-
<
|
|
97
|
+
@if (!loading() && records().length === 0 && !viewError()) {
|
|
98
|
+
<div class="empty-state" role="status">
|
|
99
|
+
@if (activeFilters().length > 0) {
|
|
100
|
+
<p>No {{ getCaption() }} match your search.</p>
|
|
101
|
+
<dl class="empty-state__filters">
|
|
102
|
+
@for (filter of activeFilters(); track $index) {
|
|
103
|
+
<dt>{{ filter.label }}</dt><dd>{{ filter.value }}</dd>
|
|
104
|
+
}
|
|
105
|
+
</dl>
|
|
106
|
+
<button matButton="outlined" type="button" (click)="clearSearch()">Clear search</button>
|
|
107
|
+
} @else {
|
|
108
|
+
<p>{{ emptyMessage() || 'No ' + getCaption() + ' yet.' }}</p>
|
|
109
|
+
}
|
|
110
|
+
</div>
|
|
95
111
|
}
|