@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
@@ -0,0 +1,29 @@
1
+ import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
2
+ import { MAT_DIALOG_DATA, MatDialogModule } from "@angular/material/dialog";
3
+ import { MatButtonModule } from "@angular/material/button";
4
+
5
+ export interface ConfirmOptions {
6
+ title: string;
7
+ message?: string;
8
+ /** Label of the confirming button, e.g. "Delete record". */
9
+ action: string;
10
+ /** Styles the action with the global `.warn` class. */
11
+ destructive?: boolean;
12
+ }
13
+
14
+ /** Material confirmation opened by `PromptService`; closes with true only on the action. */
15
+ @Component({
16
+ selector: 'sail-confirm-dialog',
17
+ changeDetection: ChangeDetectionStrategy.OnPush,
18
+ imports: [MatDialogModule, MatButtonModule],
19
+ template: `
20
+ <h2 mat-dialog-title>{{ data.title }}</h2>
21
+ @if (data.message) { <mat-dialog-content><p>{{ data.message }}</p></mat-dialog-content> }
22
+ <mat-dialog-actions align="end">
23
+ <button matButton type="button" mat-dialog-close>Cancel</button>
24
+ <button matButton="filled" type="button" [class.warn]="data.destructive" [mat-dialog-close]="true">{{ data.action }}</button>
25
+ </mat-dialog-actions>`,
26
+ })
27
+ export class ConfirmDialog {
28
+ protected readonly data = inject<ConfirmOptions>(MAT_DIALOG_DATA);
29
+ }
@@ -0,0 +1,26 @@
1
+ import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
2
+ import { ValidationErrors } from "@angular/forms";
3
+ import { MatFormField } from "@angular/material/form-field";
4
+
5
+ /** Message for the enclosing field's first validation error: `<mat-error sailFieldError />`. */
6
+ @Component({
7
+ selector: 'mat-error[sailFieldError]',
8
+ // Default: control errors are not signals, so the message is re-read on every check.
9
+ changeDetection: ChangeDetectionStrategy.Default,
10
+ template: '{{ message }}',
11
+ })
12
+ export class FieldError {
13
+ private readonly formField = inject(MatFormField);
14
+
15
+ get message(): string {
16
+ const errors: ValidationErrors | null | undefined = this.formField._control?.ngControl?.errors;
17
+ if (!errors) return '';
18
+ if (errors['required']) return 'This field is required.';
19
+ if (errors['email']) return 'Enter a valid email address.';
20
+ if (errors['minlength']) return `Use at least ${errors['minlength'].requiredLength} characters.`;
21
+ if (errors['maxlength']) return `Use at most ${errors['maxlength'].requiredLength} characters.`;
22
+ if (errors['min']) return `Enter ${errors['min'].min} or more.`;
23
+ if (errors['max']) return `Enter ${errors['max'].max} or less.`;
24
+ return 'Check this value.';
25
+ }
26
+ }
@@ -7,10 +7,10 @@
7
7
  <mat-icon>search</mat-icon>
8
8
  </button>
9
9
  </mat-form-field>
10
- } @else if (inputType === 'select' && isReadonly) {
10
+ } @else if (isReadonly && (inputType === 'select' || inputType === 'checkbox' || (isDateLike && !value()))) {
11
11
  <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
12
12
  <mat-label>{{ colCaption(field()) }}</mat-label>
13
- <input matInput [value]="displayCaption" readonly>
13
+ <input matInput [value]="readonlyText" readonly>
14
14
  </mat-form-field>
15
15
  } @else if (inputType === 'select') {
16
16
  <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
@@ -22,19 +22,14 @@
22
22
  </mat-select>
23
23
  </mat-form-field>
24
24
  } @else if (inputType === 'checkbox') {
25
- <mat-checkbox [checked]="value() === true || value() === '1'" (change)="onCheckboxChange($event);" [disabled]="isReadonly">{{ colCaption(field()) }}</mat-checkbox>
26
- } @else if (isReadonly && isDateLike && !value()) {
27
- <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
28
- <mat-label>{{ colCaption(field()) }}</mat-label>
29
- <input matInput value="" readonly>
30
- </mat-form-field>
25
+ <mat-checkbox [checked]="checked" (change)="onCheckboxChange($event);">{{ colCaption(field()) }}</mat-checkbox>
31
26
  } @else if (isTextArea) {
32
27
  <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
33
28
  <mat-label>{{ colCaption(field()) }}</mat-label>
34
29
  <textarea matInput
35
30
  [value]="inputValue"
36
31
  (input)="onInputChange($event);"
37
- [disabled]="isReadonly"
32
+ [readonly]="isReadonly"
38
33
  [required]="required"
39
34
  [attr.maxLength]="maxLength"
40
35
  [rows]="textAreaRows">
@@ -47,7 +42,7 @@
47
42
  [type]="inputType"
48
43
  [value]="inputValue"
49
44
  (input)="onInputChange($event);"
50
- [disabled]="isReadonly"
45
+ [readonly]="isReadonly"
51
46
  [required]="required"
52
47
  [attr.maxLength]="maxLength"
53
48
  [attr.step]="step">
@@ -140,6 +140,16 @@ export class DynamicField extends BaseTable {
140
140
  return this.displayValue(this.field(), this.value());
141
141
  }
142
142
 
143
+ get checked(): boolean {
144
+ return this.value() === true || this.value() === '1';
145
+ }
146
+
147
+ /** Text of a read-only select, boolean, or empty date; a checkbox has no native readonly state. */
148
+ get readonlyText(): string {
149
+ if (this.inputType === 'checkbox') return this.checked ? 'Yes' : 'No';
150
+ return this.inputType === 'select' ? this.displayCaption : '';
151
+ }
152
+
143
153
  onInputChange(event: Event) {
144
154
  const target = event.target as HTMLInputElement | HTMLTextAreaElement | null;
145
155
  if (target) this.valueChange.emit(this.coerce(target.value));
@@ -10,12 +10,14 @@
10
10
  <mat-form-field appearance="outline">
11
11
  <mat-label>Username</mat-label>
12
12
  <input matInput type="text" formControlName="username" autocomplete="username" required>
13
+ <mat-error sailFieldError></mat-error>
13
14
  </mat-form-field>
14
15
 
15
16
  @if (!forgotPassword()) {
16
17
  <mat-form-field appearance="outline">
17
18
  <mat-label>Current Password</mat-label>
18
19
  <input matInput type="password" formControlName="old_password" autocomplete="current-password" required>
20
+ <mat-error sailFieldError></mat-error>
19
21
  </mat-form-field>
20
22
 
21
23
  <mat-form-field appearance="outline">
@@ -30,6 +32,7 @@
30
32
  <mat-form-field appearance="outline">
31
33
  <mat-label>Confirm New Password</mat-label>
32
34
  <input matInput type="password" formControlName="confirm_password" autocomplete="new-password" required>
35
+ <mat-error sailFieldError></mat-error>
33
36
  </mat-form-field>
34
37
  }
35
38
 
@@ -43,7 +46,7 @@
43
46
  <div class="auth-error" role="alert">{{ errorMessage() }}</div>
44
47
  }
45
48
 
46
- <button matButton="elevated" class="primary" type="submit" [disabled]="!chpassForm.valid">
49
+ <button matButton="elevated" class="primary" type="submit">
47
50
  @if (forgotPassword()) {
48
51
  Send Confirmation Code
49
52
  } @else {
@@ -1,4 +1,6 @@
1
- import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, ViewEncapsulation } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, ViewEncapsulation, 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 { BaseAuthService } from '../../service/auth.service';
4
6
  import { Router, RouterLink } from '@angular/router';
@@ -16,7 +18,7 @@ import { passwordPolicyValidator, passwordPolicyHint } from '../../util/password
16
18
  changeDetection: ChangeDetectionStrategy.OnPush,
17
19
  encapsulation: ViewEncapsulation.None,
18
20
  imports: [
19
- ReactiveFormsModule,
21
+ FieldError, ReactiveFormsModule,
20
22
  MatButtonModule,
21
23
  MatFormFieldModule,
22
24
  MatInputModule,
@@ -25,6 +27,7 @@ import { passwordPolicyValidator, passwordPolicyHint } from '../../util/password
25
27
  ],
26
28
  })
27
29
  export class ChpassComponent extends BaseAsync {
30
+ private readonly host: HTMLElement = inject(ElementRef).nativeElement;
28
31
  private auth = inject(BaseAuthService);
29
32
  private fb = inject(FormBuilder);
30
33
  private router = inject(Router);
@@ -73,7 +76,7 @@ export class ChpassComponent extends BaseAsync {
73
76
  }
74
77
 
75
78
  changePassword(): void {
76
- if (this.chpassForm.invalid) return;
79
+ if (!submittable(this.chpassForm, this.host)) return;
77
80
 
78
81
  const { username, old_password, new_password, confirm_password } = this.chpassForm.value;
79
82
 
@@ -11,11 +11,13 @@
11
11
  <mat-form-field appearance="outline">
12
12
  <mat-label>Username</mat-label>
13
13
  <input matInput type="text" formControlName="username" autocomplete="username" required>
14
+ <mat-error sailFieldError></mat-error>
14
15
  </mat-form-field>
15
16
 
16
17
  <mat-form-field appearance="outline">
17
18
  <mat-label>Confirmation Code</mat-label>
18
19
  <input matInput type="text" formControlName="code" autocomplete="one-time-code" required>
20
+ <mat-error sailFieldError></mat-error>
19
21
  </mat-form-field>
20
22
 
21
23
  <mat-form-field appearance="outline">
@@ -30,13 +32,14 @@
30
32
  <mat-form-field appearance="outline">
31
33
  <mat-label>Confirm New Password</mat-label>
32
34
  <input matInput type="password" formControlName="confirm_password" autocomplete="new-password" required>
35
+ <mat-error sailFieldError></mat-error>
33
36
  </mat-form-field>
34
37
 
35
38
  @if (errorMessage()) {
36
39
  <div class="auth-error" role="alert">{{ errorMessage() }}</div>
37
40
  }
38
41
 
39
- <button matButton="elevated" class="primary" type="submit" [disabled]="!confirmForm.valid">
42
+ <button matButton="elevated" class="primary" type="submit">
40
43
  Confirm Password Change
41
44
  </button>
42
45
  </form>
@@ -1,4 +1,6 @@
1
- import { ChangeDetectionStrategy, Component, computed, effect, inject, OnInit, ViewEncapsulation } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, computed, effect, inject, OnInit, ViewEncapsulation, 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 { BaseAuthService } from '../../service/auth.service';
4
6
  import { ActivatedRoute, RouterLink } from '@angular/router';
@@ -15,7 +17,7 @@ import { passwordPolicyValidator, passwordPolicyHint } from '../../util/password
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,
@@ -23,6 +25,7 @@ import { passwordPolicyValidator, passwordPolicyHint } from '../../util/password
23
25
  ],
24
26
  })
25
27
  export class ConfirmChpassComponent extends BaseAsync implements OnInit {
28
+ private readonly host: HTMLElement = inject(ElementRef).nativeElement;
26
29
  private auth = inject(BaseAuthService);
27
30
  private fb = inject(FormBuilder);
28
31
  private route = inject(ActivatedRoute);
@@ -55,7 +58,7 @@ export class ConfirmChpassComponent extends BaseAsync implements OnInit {
55
58
  }
56
59
 
57
60
  confirm(): void {
58
- if (this.confirmForm.invalid) return;
61
+ if (!submittable(this.confirmForm, this.host)) return;
59
62
 
60
63
  const { username, code, new_password, confirm_password } = this.confirmForm.value;
61
64
 
@@ -11,11 +11,13 @@
11
11
  <mat-form-field appearance="outline">
12
12
  <mat-label>Email</mat-label>
13
13
  <input matInput type="email" formControlName="email" required>
14
+ <mat-error sailFieldError></mat-error>
14
15
  </mat-form-field>
15
16
 
16
17
  <mat-form-field appearance="outline">
17
18
  <mat-label>Confirmation Code</mat-label>
18
19
  <input matInput type="text" formControlName="code" required>
20
+ <mat-error sailFieldError></mat-error>
19
21
  </mat-form-field>
20
22
 
21
23
  @if (errorMessage()) {
@@ -26,7 +28,7 @@
26
28
  <div class="auth-success" role="status">{{ successMessage() }}</div>
27
29
  }
28
30
 
29
- <button matButton="elevated" class="primary" type="submit" [disabled]="!confirmForm.valid">
31
+ <button matButton="elevated" class="primary" type="submit">
30
32
  Confirm
31
33
  </button>
32
34
  </form>
@@ -1,4 +1,6 @@
1
- import { ChangeDetectionStrategy, Component, inject, OnInit, ViewEncapsulation } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, inject, OnInit, ViewEncapsulation, 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 { BaseAuthService } from '../../service/auth.service';
4
6
  import { ActivatedRoute, RouterLink } from '@angular/router';
@@ -14,7 +16,7 @@ import { BaseAsync } from '../abstract/base_async';
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 { BaseAsync } from '../abstract/base_async';
22
24
  ],
23
25
  })
24
26
  export class ConfirmRegisterComponent extends BaseAsync implements OnInit {
27
+ private readonly host: HTMLElement = inject(ElementRef).nativeElement;
25
28
  private auth = inject(BaseAuthService);
26
29
  private fb = inject(FormBuilder);
27
30
  private route = inject(ActivatedRoute);
@@ -40,7 +43,7 @@ export class ConfirmRegisterComponent extends BaseAsync implements OnInit {
40
43
  }
41
44
 
42
45
  confirm(): void {
43
- if (this.confirmForm.invalid) return;
46
+ if (!submittable(this.confirmForm, this.host)) return;
44
47
 
45
48
  const { email, code } = this.confirmForm.value;
46
49
 
@@ -30,15 +30,17 @@
30
30
  <mat-form-field appearance="outline">
31
31
  <mat-label>Username</mat-label>
32
32
  <input matInput type="text" formControlName="username" autocomplete="username" required>
33
+ <mat-error sailFieldError></mat-error>
33
34
  </mat-form-field>
34
35
  <mat-form-field appearance="outline">
35
36
  <mat-label>Password</mat-label>
36
37
  <input matInput type="password" formControlName="password" autocomplete="current-password" required>
38
+ <mat-error sailFieldError></mat-error>
37
39
  </mat-form-field>
38
40
  @if (errorMessage()) {
39
41
  <p class="auth-error" role="alert">{{ errorMessage() }}</p>
40
42
  }
41
- <button matButton="elevated" class="primary" type="submit" [disabled]="!loginForm.valid || loading()">
43
+ <button matButton="elevated" class="primary" type="submit" [disabled]="loading()">
42
44
  {{ loading() ? 'Signing in…' : 'Login' }}
43
45
  </button>
44
46
  </form>
@@ -1,4 +1,6 @@
1
- import { ChangeDetectionStrategy, Component, computed, inject, ViewEncapsulation } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, computed, inject, ViewEncapsulation, ElementRef } from '@angular/core';
2
+ import { submittable } from '../../util/forms';
3
+ import { FieldError } from '../form/field_error';
2
4
  import { BaseAsync } from '../abstract/base_async';
3
5
  import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
4
6
  import { BaseAuthService } from '../../service/auth.service';
@@ -17,7 +19,7 @@ import { SsoLoginComponent } from '../sso/sso_login';
17
19
  changeDetection: ChangeDetectionStrategy.OnPush,
18
20
  encapsulation: ViewEncapsulation.None,
19
21
  imports: [
20
- ReactiveFormsModule,
22
+ FieldError, ReactiveFormsModule,
21
23
  MatButtonModule,
22
24
  MatFormFieldModule,
23
25
  MatInputModule,
@@ -27,6 +29,7 @@ import { SsoLoginComponent } from '../sso/sso_login';
27
29
  ],
28
30
  })
29
31
  export class LoginComponent extends BaseAsync {
32
+ private readonly host: HTMLElement = inject(ElementRef).nativeElement;
30
33
  protected readonly auth = inject(BaseAuthService);
31
34
  protected fb = inject(FormBuilder);
32
35
  protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
@@ -58,7 +61,7 @@ export class LoginComponent extends BaseAsync {
58
61
  });
59
62
 
60
63
  login(): void {
61
- if (!this.loginForm.valid) return;
64
+ if (!submittable(this.loginForm, this.host)) return;
62
65
  const { username, password } = this.loginForm.value;
63
66
  this.run(
64
67
  this.auth.login(username!, password!),
@@ -14,11 +14,13 @@
14
14
  <mat-form-field appearance="outline">
15
15
  <mat-label>First Name</mat-label>
16
16
  <input matInput type="text" formControlName="FirstName" autocomplete="given-name" required>
17
+ <mat-error sailFieldError></mat-error>
17
18
  </mat-form-field>
18
19
 
19
20
  <mat-form-field appearance="outline">
20
21
  <mat-label>Last Name</mat-label>
21
22
  <input matInput type="text" formControlName="LastName" autocomplete="family-name" required>
23
+ <mat-error sailFieldError></mat-error>
22
24
  </mat-form-field>
23
25
  </div>
24
26
 
@@ -33,6 +35,7 @@
33
35
  <mat-form-field appearance="outline">
34
36
  <mat-label>Username</mat-label>
35
37
  <input matInput type="text" formControlName="UserName" autocomplete="username" required>
38
+ <mat-error sailFieldError></mat-error>
36
39
  </mat-form-field>
37
40
 
38
41
  <div class="form-row">
@@ -47,6 +50,7 @@
47
50
  <mat-form-field appearance="outline">
48
51
  <mat-label>Confirm Password</mat-label>
49
52
  <input matInput type="password" formControlName="ConfirmPassword" autocomplete="new-password" required>
53
+ <mat-error sailFieldError></mat-error>
50
54
  </mat-form-field>
51
55
  </div>
52
56
  @if (registerForm.hasError('passwordMismatch') && registerForm.controls.ConfirmPassword.touched) {
@@ -67,6 +71,7 @@
67
71
  </mat-option>
68
72
  }
69
73
  </mat-select>
74
+ <mat-error sailFieldError></mat-error>
70
75
  </mat-form-field>
71
76
 
72
77
  <div class="auth-section-label">Business Information</div>
@@ -74,17 +79,20 @@
74
79
  <mat-form-field appearance="outline">
75
80
  <mat-label>Business/Company Name</mat-label>
76
81
  <input matInput type="text" formControlName="PartnerCaption" required>
82
+ <mat-error sailFieldError></mat-error>
77
83
  </mat-form-field>
78
84
 
79
85
  <div class="form-row">
80
86
  <mat-form-field appearance="outline">
81
87
  <mat-label>Phone</mat-label>
82
88
  <input matInput type="tel" formControlName="Phone" autocomplete="tel" required>
89
+ <mat-error sailFieldError></mat-error>
83
90
  </mat-form-field>
84
91
 
85
92
  <mat-form-field appearance="outline">
86
93
  <mat-label>Website URL</mat-label>
87
94
  <input matInput type="url" formControlName="DomainURL" autocomplete="url" required>
95
+ <mat-error sailFieldError></mat-error>
88
96
  </mat-form-field>
89
97
  </div>
90
98
 
@@ -93,17 +101,20 @@
93
101
  <mat-form-field appearance="outline">
94
102
  <mat-label>Address (Street and Number)</mat-label>
95
103
  <input matInput type="text" formControlName="Address" autocomplete="address-line1" required>
104
+ <mat-error sailFieldError></mat-error>
96
105
  </mat-form-field>
97
106
 
98
107
  <div class="form-row">
99
108
  <mat-form-field appearance="outline">
100
109
  <mat-label>City</mat-label>
101
110
  <input matInput type="text" formControlName="City" autocomplete="address-level2" required>
111
+ <mat-error sailFieldError></mat-error>
102
112
  </mat-form-field>
103
113
 
104
114
  <mat-form-field appearance="outline">
105
115
  <mat-label>State</mat-label>
106
116
  <input matInput type="text" formControlName="State" autocomplete="address-level1" required>
117
+ <mat-error sailFieldError></mat-error>
107
118
  </mat-form-field>
108
119
  </div>
109
120
 
@@ -111,11 +122,13 @@
111
122
  <mat-form-field appearance="outline">
112
123
  <mat-label>Zipcode</mat-label>
113
124
  <input matInput type="text" formControlName="Zipcode" autocomplete="postal-code" required>
125
+ <mat-error sailFieldError></mat-error>
114
126
  </mat-form-field>
115
127
 
116
128
  <mat-form-field appearance="outline">
117
129
  <mat-label>Country</mat-label>
118
130
  <input matInput type="text" formControlName="Country" autocomplete="country-name" required>
131
+ <mat-error sailFieldError></mat-error>
119
132
  </mat-form-field>
120
133
  </div>
121
134
 
@@ -140,7 +153,7 @@
140
153
  }
141
154
 
142
155
  <button matButton="elevated" class="primary" type="submit"
143
- [disabled]="!registerForm.valid || (geocodeEnabled && geocodeStatus() !== 'success')">
156
+ [disabled]="geocodeEnabled && geocodeStatus() !== 'success'">
144
157
  Create Account
145
158
  </button>
146
159
  </form>
@@ -1,4 +1,6 @@
1
- import { ChangeDetectionStrategy, Component, inject, DestroyRef, signal, ViewEncapsulation, OnInit } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, inject, DestroyRef, signal, ViewEncapsulation, OnInit, ElementRef } from '@angular/core';
2
+ import { submittable } from '../../util/forms';
3
+ import { FieldError } from '../form/field_error';
2
4
  import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
3
5
  import { AbstractControl, FormBuilder, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';
4
6
  import { Router, RouterLink } from '@angular/router';
@@ -30,7 +32,7 @@ interface GeocodingResponse {
30
32
  changeDetection: ChangeDetectionStrategy.OnPush,
31
33
  encapsulation: ViewEncapsulation.None,
32
34
  imports: [
33
- ReactiveFormsModule,
35
+ FieldError, ReactiveFormsModule,
34
36
  RouterLink,
35
37
  MatButtonModule,
36
38
  MatFormFieldModule,
@@ -40,6 +42,7 @@ interface GeocodingResponse {
40
42
  ],
41
43
  })
42
44
  export class RegisterComponent implements OnInit {
45
+ private readonly host: HTMLElement = inject(ElementRef).nativeElement;
43
46
  private auth = inject(BaseAuthService);
44
47
  private billing = inject(BillingService);
45
48
  private fb = inject(FormBuilder);
@@ -155,7 +158,7 @@ export class RegisterComponent implements OnInit {
155
158
  }
156
159
 
157
160
  register() {
158
- if (!this.registerForm.valid) return;
161
+ if (!submittable(this.registerForm, this.host)) return;
159
162
  this.registerError.set('');
160
163
  const formValue = this.registerForm.getRawValue();
161
164
  const partnerReg: PartnerRegistration = {
@@ -1,3 +1,4 @@
1
+ <a class="sail-skip-link" href="#sail-main" (click)="focusMain($event)">Skip to content</a>
1
2
  <mat-sidenav-container class="sidenav-container">
2
3
  <mat-sidenav #sidenav class="sidenav"
3
4
  [attr.role]="isHandset() ? 'dialog' : 'navigation'"
@@ -61,6 +62,8 @@
61
62
  }
62
63
  }
63
64
  </mat-toolbar>
64
- <router-outlet></router-outlet>
65
+ <main #main id="sail-main" class="sail-main" tabindex="-1">
66
+ <router-outlet></router-outlet>
67
+ </main>
65
68
  </mat-sidenav-content>
66
69
  </mat-sidenav-container>
@@ -1,15 +1,15 @@
1
- import { ChangeDetectionStrategy, Component, computed, inject, ViewEncapsulation } from "@angular/core";
1
+ import { ChangeDetectionStrategy, Component, computed, ElementRef, inject, viewChild, ViewEncapsulation } from "@angular/core";
2
2
  import { NgOptimizedImage } from "@angular/common";
3
- import { toSignal } from "@angular/core/rxjs-interop";
4
- import { RouterLink, RouterOutlet } from "@angular/router";
5
- import { MatSidenavModule } from '@angular/material/sidenav';
3
+ import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
4
+ import { NavigationEnd, Router, RouterLink, RouterOutlet } from "@angular/router";
5
+ import { MatSidenav, MatSidenavModule } from '@angular/material/sidenav';
6
6
  import { MatToolbarModule } from '@angular/material/toolbar';
7
7
  import { MatListModule } from '@angular/material/list';
8
8
  import { MatIconModule } from '@angular/material/icon';
9
9
  import { MatButtonModule } from '@angular/material/button';
10
10
  import { MatExpansionModule } from '@angular/material/expansion';
11
11
  import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
12
- import { map } from 'rxjs/operators';
12
+ import { filter, map, pairwise } from 'rxjs/operators';
13
13
  import { ApplicationMenu, ApplicationMenuItem } from "../../model/common";
14
14
  import { BaseAuthService } from "../../service/auth.service";
15
15
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
@@ -35,6 +35,8 @@ export class Navigation {
35
35
  private readonly authService = inject(BaseAuthService);
36
36
  private readonly breakpointObserver = inject(BreakpointObserver);
37
37
  protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
38
+ private readonly main = viewChild.required<ElementRef<HTMLElement>>('main');
39
+ private readonly sidenav = viewChild.required(MatSidenav);
38
40
 
39
41
  // Restore the stored JWT before the menu stream fires so `canAccess()` checks
40
42
  // resolve against the right user. Done at field-init time (runs in injection
@@ -61,6 +63,23 @@ export class Navigation {
61
63
  readonly isLoggedIn = this.authService.isLoggedIn;
62
64
  protected readonly accountLink = this.guiConfig.accountLink;
63
65
 
66
+ constructor() {
67
+ // Focus the page after a path change; the initial load and query-only updates keep focus,
68
+ // as does an open handset menu.
69
+ inject(Router).events.pipe(
70
+ filter((e): e is NavigationEnd => e instanceof NavigationEnd),
71
+ map((e) => e.urlAfterRedirects.split(/[?#]/)[0]),
72
+ pairwise(),
73
+ filter(([before, after]) => before !== after && !(this.isHandset() && this.sidenav().opened)),
74
+ takeUntilDestroyed(),
75
+ ).subscribe(() => this.main().nativeElement.focus());
76
+ }
77
+
78
+ focusMain(event: Event) {
79
+ event.preventDefault();
80
+ this.main().nativeElement.focus();
81
+ }
82
+
64
83
  logout() {
65
84
  this.authService.logout();
66
85
  }
@@ -1,6 +1,7 @@
1
1
  @if (error()) {
2
2
  <p class="connection-panel__error" role="alert">{{ error() }}</p>
3
3
  }
4
+ <p class="connection-panel__notice" role="status">{{ notice() }}</p>
4
5
 
5
6
  @if (loading()) {
6
7
  <p>Loading connections…</p>
@@ -28,6 +29,9 @@
28
29
  @if (v.provider.description) {
29
30
  <p class="connection-card__desc">{{ v.provider.description }}</p>
30
31
  }
32
+ @if (v.connection?.LastChecked) {
33
+ <p class="connection-card__checked">Last checked {{ v.connection?.LastChecked | date: 'medium' }}</p>
34
+ }
31
35
 
32
36
  <div class="connection-card__actions">
33
37
  @if (v.status === 'disconnected') {
@@ -1,5 +1,5 @@
1
1
  import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, computed, effect, inject, input, output, signal } from '@angular/core';
2
- import { NgOptimizedImage } from '@angular/common';
2
+ import { DatePipe, NgOptimizedImage } from '@angular/common';
3
3
  import { Subscription } from 'rxjs';
4
4
  import { MatButtonModule } from '@angular/material/button';
5
5
  import { MatCardModule } from '@angular/material/card';
@@ -22,7 +22,7 @@ import { OAuthConnectionStatus, OAuthConnectionView, PartnerCredential, OAuthPro
22
22
  @Component({
23
23
  selector: 'sail-oauth-connection-panel',
24
24
  changeDetection: ChangeDetectionStrategy.OnPush,
25
- imports: [MatButtonModule, MatCardModule, MatIconModule, MatFormFieldModule, MatInputModule, NgOptimizedImage],
25
+ imports: [DatePipe, MatButtonModule, MatCardModule, MatIconModule, MatFormFieldModule, MatInputModule, NgOptimizedImage],
26
26
  templateUrl: './oauth_connection_panel.html',
27
27
  })
28
28
  export class OAuthConnectionPanelComponent implements OnInit {
@@ -37,6 +37,7 @@ export class OAuthConnectionPanelComponent implements OnInit {
37
37
  readonly connections = signal<PartnerCredential[]>([]);
38
38
  readonly loading = signal(true);
39
39
  readonly error = signal('');
40
+ readonly notice = signal(''); // last passing test
40
41
  readonly expanded = signal(''); // provider code whose input form is open
41
42
  readonly busy = signal(''); // provider code with an action in flight
42
43
  private readonly fieldValues = signal<Record<string, Record<string, string>>>({});
@@ -98,7 +99,8 @@ export class OAuthConnectionPanelComponent implements OnInit {
98
99
  return this.msg(e, 'Could not complete the connection.');
99
100
  }
100
101
 
101
- private reload(entityId: number): void {
102
+ private reload(entityId: number, keepNotice = false): void {
103
+ if (!keepNotice) this.notice.set('');
102
104
  this.reloadSub?.unsubscribe(); // cancel any in-flight load so a slow old entity can't overwrite the new one
103
105
  this.loading.set(true);
104
106
  this.expanded.set('');
@@ -229,13 +231,15 @@ export class OAuthConnectionPanelComponent implements OnInit {
229
231
  if (!v.connection) return;
230
232
  this.busy.set(v.provider.code);
231
233
  this.error.set('');
234
+ this.notice.set('');
232
235
  this.svc.testConnection(v.provider.code, this.entityId()).subscribe({
233
236
  // keel returns HTTP 200 with {status:'error', message} for a failed test —
234
237
  // surface the message, then reload to reflect the status it just recorded.
235
238
  next: (res) => {
236
239
  this.busy.set('');
237
- if (res.status !== 'ok') this.error.set(res.message || 'Connection test failed.');
238
- this.reload(this.entityId());
240
+ if (res.status === 'ok') this.notice.set(`${v.provider.name} connection works.`);
241
+ else this.error.set(res.message || 'Connection test failed.');
242
+ this.reload(this.entityId(), true);
239
243
  },
240
244
  error: (e) => {
241
245
  this.busy.set('');