@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.
Files changed (51) hide show
  1. package/README.md +31 -7
  2. package/package.json +1 -1
  3. package/src/agent/component/studio_page.ts +35 -20
  4. package/src/component/abstract/base_form.ts +5 -0
  5. package/src/component/abstract/base_table.ts +23 -29
  6. package/src/component/abstract/base_view.ts +26 -17
  7. package/src/component/account/profile_editor.html +15 -8
  8. package/src/component/account/profile_editor.ts +12 -7
  9. package/src/component/agency/agency_clients.html +4 -1
  10. package/src/component/agency/agency_clients.ts +6 -3
  11. package/src/component/dialog/confirm_dialog.ts +29 -0
  12. package/src/component/form/field_error.ts +26 -0
  13. package/src/component/form/form_field.html +5 -10
  14. package/src/component/form/form_field.ts +10 -0
  15. package/src/component/login/chpass_component.html +4 -1
  16. package/src/component/login/chpass_component.ts +6 -3
  17. package/src/component/login/confirm_chpass_component.html +4 -1
  18. package/src/component/login/confirm_chpass_component.ts +6 -3
  19. package/src/component/login/confirm_register_component.html +3 -1
  20. package/src/component/login/confirm_register_component.ts +6 -3
  21. package/src/component/login/login_component.html +3 -1
  22. package/src/component/login/login_component.ts +6 -3
  23. package/src/component/login/register_component.html +14 -1
  24. package/src/component/login/register_component.ts +6 -3
  25. package/src/component/navigation/navigation.html +4 -1
  26. package/src/component/navigation/navigation.ts +24 -5
  27. package/src/component/oauth_connection/oauth_connection_panel.html +4 -0
  28. package/src/component/oauth_connection/oauth_connection_panel.ts +9 -5
  29. package/src/component/payout/bank_info_form.html +6 -2
  30. package/src/component/payout/bank_info_form.ts +6 -3
  31. package/src/component/security/api_keys.html +6 -2
  32. package/src/component/security/api_keys.ts +13 -7
  33. package/src/component/security/connected_apps.ts +7 -5
  34. package/src/component/security/sessions.ts +11 -9
  35. package/src/component/security/trusted_devices.html +2 -1
  36. package/src/component/security/trusted_devices.ts +18 -13
  37. package/src/component/security/twofactor_setup.html +7 -3
  38. package/src/component/security/twofactor_setup.ts +8 -5
  39. package/src/component/security/twofactor_verify.html +2 -1
  40. package/src/component/security/twofactor_verify.ts +6 -3
  41. package/src/component/table/table_detail.ts +1 -1
  42. package/src/component/table/table_edit.ts +27 -5
  43. package/src/component/table/table_list.html +21 -5
  44. package/src/component/table/table_list.ts +74 -67
  45. package/src/component/table/table_search.ts +2 -2
  46. package/src/index.ts +4 -0
  47. package/src/model/appdata.ts +3 -0
  48. package/src/service/auth.service.ts +30 -17
  49. package/src/service/billing.service.ts +8 -6
  50. package/src/service/prompt.service.ts +27 -0
  51. 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
  &lt; 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.invalid || !currency) return;
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]="form.invalid || loading()">Create key</button>
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]="networks.invalid || loading()" (click)="saveNetworks(key)">Save</button>
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.invalid) return;
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.invalid) return;
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
- if (!confirm(`Revoke key "${key.KeyName}" (${key.KeyPrefix}…)? Anything using it stops working.`)) return;
101
- this.run(this.access.revokeApiKey(key.Id), () => {
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
- if (!confirm(`Disconnect "${name}"? It will have to connect again.`)) return;
35
- this.run(this.access.revokeGrant(grant.clientId), () => {
36
- this.successMessage.set(`Disconnected ${name}.`);
37
- this.load();
38
- }, 'Failed to disconnect the app.');
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
- if (!confirm(`Sign out the session on ${session.userAgent || 'an unknown device'}?`)) return;
36
- this.run(
37
- this.auth.revokeSession(session.id),
38
- () => {
39
- this.loadSessions();
40
- this.successMessage.set('Session signed out.');
41
- },
42
- 'Failed to sign out the session.',
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]="!logoutForm.valid || loading()">
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
- if (!confirm(`Revoke trust for "${device.name}"?`)) return;
54
- this.run(
55
- this.auth.revokeTrustedDevice(device.id),
56
- () => {
57
- this.successMessage.set(`Device "${device.name}" revoked.`);
58
- this.loadDevices();
59
- },
60
- 'Failed to revoke device.',
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 confirm() alone.
71
+ * field instead of relying on a confirmation alone.
67
72
  */
68
73
  signOutEverywhere() {
69
- if (this.logoutForm.invalid) return;
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" [disabled]="!initForm.valid">Enable 2FA</button>
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" [disabled]="!verifyForm.valid">
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" [disabled]="!disableForm.valid">Disable 2FA</button>
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.invalid) return;
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.invalid) return;
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.invalid) return;
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" [disabled]="!verifyForm.valid">
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.invalid) return;
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
- alert('Save the record first before running this action.');
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
- /** Snapshot taken when the user enters edit mode; used for dirty-tracking on save. */
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
- if (confirm('Are you sure you want to delete this record?')) {
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
- <table>
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 (sortedRecords().length > 0) {
47
+ @if (records().length > 0) {
44
48
  <tbody>
45
- @for (record of sortedRecords(); track trackByRecord($index, record)) {
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 (records().length === 0 && displayedColumns().length > 0 && !viewError()) {
94
- <p class="empty-state">No records found.</p>
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
  }