@nauticana/sail 1.1.9 → 1.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +4 -2
  2. package/package.json +5 -1
  3. package/src/component/abstract/base_async.ts +9 -11
  4. package/src/component/abstract/base_form.ts +6 -5
  5. package/src/component/abstract/base_table.ts +131 -58
  6. package/src/component/abstract/base_view.ts +43 -27
  7. package/src/component/account/profile_editor.ts +2 -2
  8. package/src/component/billing/payment_methods.ts +1 -2
  9. package/src/component/billing/price_selector.html +2 -1
  10. package/src/component/billing/price_selector.ts +28 -13
  11. package/src/component/billing/trial_banner.ts +7 -1
  12. package/src/component/billing/usage_meter.ts +2 -2
  13. package/src/component/dashboard/data_card.html +1 -1
  14. package/src/component/dashboard/entity_selector.ts +21 -23
  15. package/src/component/form/form_field.html +6 -5
  16. package/src/component/form/form_field.ts +39 -21
  17. package/src/component/form/form_record.html +3 -3
  18. package/src/component/form/form_record.ts +23 -8
  19. package/src/component/form/form_table.html +2 -2
  20. package/src/component/form/form_table.ts +6 -5
  21. package/src/component/login/account_deletion.ts +6 -6
  22. package/src/component/login/chpass_component.html +4 -4
  23. package/src/component/login/chpass_component.ts +0 -1
  24. package/src/component/login/confirm_chpass_component.html +4 -4
  25. package/src/component/login/confirm_chpass_component.ts +1 -4
  26. package/src/component/login/consent_gate.html +6 -6
  27. package/src/component/login/consent_gate.ts +17 -24
  28. package/src/component/login/login_component.html +7 -4
  29. package/src/component/login/login_component.ts +9 -5
  30. package/src/component/login/otp_input.html +13 -5
  31. package/src/component/login/otp_input.ts +34 -27
  32. package/src/component/login/register_component.html +31 -14
  33. package/src/component/login/register_component.ts +80 -89
  34. package/src/component/login/sms_consent.html +11 -0
  35. package/src/component/login/sms_consent.ts +38 -0
  36. package/src/component/login/social_login.html +2 -2
  37. package/src/component/login/social_login.ts +20 -13
  38. package/src/component/navigation/navigation.html +2 -2
  39. package/src/component/navigation/navigation.ts +2 -0
  40. package/src/component/oauth_connection/oauth_connection_panel.ts +24 -3
  41. package/src/component/payment/user_payment_methods.html +2 -2
  42. package/src/component/payment/user_payment_methods.ts +12 -23
  43. package/src/component/payout/bank_info_form.html +4 -4
  44. package/src/component/payout/bank_info_form.ts +22 -26
  45. package/src/component/payout/provider_onboarding.html +5 -5
  46. package/src/component/payout/provider_onboarding.ts +28 -48
  47. package/src/component/security/trusted_devices.html +1 -1
  48. package/src/component/security/trusted_devices.ts +0 -1
  49. package/src/component/security/twofactor_setup.html +8 -4
  50. package/src/component/security/twofactor_setup.ts +21 -3
  51. package/src/component/security/twofactor_verify.html +2 -1
  52. package/src/component/security/twofactor_verify.ts +2 -8
  53. package/src/component/table/table_detail.html +15 -14
  54. package/src/component/table/table_detail.ts +62 -41
  55. package/src/component/table/table_edit.html +15 -11
  56. package/src/component/table/table_edit.ts +94 -67
  57. package/src/component/table/table_list.html +45 -12
  58. package/src/component/table/table_list.ts +98 -26
  59. package/src/component/table/table_lookup.html +11 -8
  60. package/src/component/table/table_lookup.ts +45 -37
  61. package/src/component/table/table_report.html +2 -2
  62. package/src/component/table/table_report.ts +37 -31
  63. package/src/component/table/table_search.html +4 -4
  64. package/src/component/table/table_search.ts +36 -45
  65. package/src/config.ts +5 -1
  66. package/src/index.ts +2 -0
  67. package/src/model/appdata.ts +5 -9
  68. package/src/model/common.ts +27 -27
  69. package/src/model/decorator.ts +25 -21
  70. package/src/service/api-response.interceptor.ts +4 -1
  71. package/src/service/auth.interceptor.ts +27 -7
  72. package/src/service/auth.service.ts +188 -134
  73. package/src/service/label.service.ts +6 -5
  74. package/src/service/oauth_connection.service.ts +2 -2
  75. package/src/service/rest_service.ts +57 -13
  76. package/src/service/rest_url.ts +23 -7
  77. package/src/service/script_loader.ts +12 -2
  78. package/src/util/errors.ts +20 -0
  79. package/src/util/money.ts +19 -7
@@ -7,8 +7,7 @@ import { PaymentMethod } from '../../model/appdata';
7
7
 
8
8
  /**
9
9
  * Lists the partner's saved payment methods. Inherits loading/error state
10
- * from BaseAsyncView. The "Manage in Stripe" button is a placeholder — pass
11
- * `[portalUrl]` to show it; otherwise the button is hidden.
10
+ * from BaseAsync. Pair with `<sail-portal-button>` for provider-side management.
12
11
  */
13
12
  @Component({
14
13
  selector: 'sail-payment-methods',
@@ -1,9 +1,10 @@
1
- <div class="price-selector" role="radiogroup" aria-label="Billing options">
1
+ <div class="price-selector" role="radiogroup" aria-label="Billing options" (keydown)="onKeydown($event)">
2
2
  @for (offer of offers(); track offer.key) {
3
3
  <button class="price-offer" type="button"
4
4
  role="radio"
5
5
  [class.price-selected]="offer.key === selected()"
6
6
  [attr.aria-checked]="offer.key === selected()"
7
+ [attr.tabindex]="tabIndexFor(offer, $index)"
7
8
  (click)="onSelect(offer)">
8
9
  <span class="price-amount">{{ offer.amountText }}</span>
9
10
  <span class="price-cycle">/ {{ offer.cycleText }}</span>
@@ -1,6 +1,6 @@
1
1
  import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input, output } from '@angular/core';
2
2
  import { PlanPrice } from '../../model/appdata';
3
- import { formatCurrency } from '../../util/money';
3
+ import { formatCurrency, PERIOD_TYPE_INFO } from '../../util/money';
4
4
 
5
5
  /** Per-offer view model for accessible, currency-formatted rendering. */
6
6
  interface OfferRow {
@@ -27,14 +27,6 @@ interface OfferRow {
27
27
  encapsulation: ViewEncapsulation.None,
28
28
  })
29
29
  export class PriceSelectorComponent {
30
- private static readonly DEFAULT_CYCLE: Record<string, string> = {
31
- D: 'Daily',
32
- W: 'Weekly',
33
- M: 'Monthly',
34
- Q: 'Quarterly',
35
- A: 'Annual',
36
- };
37
-
38
30
  readonly prices = input<PlanPrice[]>([]);
39
31
  /** Selected offer key — a `priceId`, or the composite key when priceId is empty. */
40
32
  readonly selected = input<string | undefined>(undefined);
@@ -45,7 +37,9 @@ export class PriceSelectorComponent {
45
37
  readonly offers = computed<OfferRow[]>(() => {
46
38
  const labels = this.cycleLabels();
47
39
  const cycleLabel = (code: string) =>
48
- labels[code] ?? PriceSelectorComponent.DEFAULT_CYCLE[code] ?? code;
40
+ labels[code] ?? PERIOD_TYPE_INFO[code]?.label ?? code;
41
+ const termNoun = (code: string) =>
42
+ PERIOD_TYPE_INFO[code]?.noun ?? cycleLabel(code).toLowerCase();
49
43
  return this.prices().map((p) => {
50
44
  const singleCycle = p.termCount === 1 && p.termType === p.billingCycle;
51
45
  return {
@@ -53,9 +47,7 @@ export class PriceSelectorComponent {
53
47
  price: p,
54
48
  amountText: formatCurrency(p.amount, p.currency),
55
49
  cycleText: cycleLabel(p.billingCycle),
56
- termText: singleCycle
57
- ? ''
58
- : `${p.termCount} ${cycleLabel(p.termType)}${p.termCount > 1 ? 's' : ''} commitment`,
50
+ termText: singleCycle ? '' : `${p.termCount}-${termNoun(p.termType)} commitment`,
59
51
  };
60
52
  });
61
53
  });
@@ -63,4 +55,27 @@ export class PriceSelectorComponent {
63
55
  onSelect(offer: OfferRow): void {
64
56
  this.selectionChange.emit(offer.price);
65
57
  }
58
+
59
+ /** Roving-radio tabindex: the selected offer (or the first) is the tab stop. */
60
+ tabIndexFor(offer: OfferRow, index: number): number {
61
+ const sel = this.selected();
62
+ if (sel) return offer.key === sel ? 0 : -1;
63
+ return index === 0 ? 0 : -1;
64
+ }
65
+
66
+ /** Arrow-key navigation required by the radio pattern (WCAG 4.1.2). */
67
+ onKeydown(event: KeyboardEvent): void {
68
+ const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown';
69
+ const backward = event.key === 'ArrowLeft' || event.key === 'ArrowUp';
70
+ if (!forward && !backward) return;
71
+ event.preventDefault();
72
+ const group = event.currentTarget as HTMLElement;
73
+ const buttons = Array.from(group.querySelectorAll<HTMLButtonElement>('[role="radio"]'));
74
+ if (!buttons.length) return;
75
+ const current = buttons.indexOf(event.target as HTMLButtonElement);
76
+ const next = (current + (forward ? 1 : -1) + buttons.length) % buttons.length;
77
+ buttons[next].focus();
78
+ const offer = this.offers()[next];
79
+ if (offer) this.onSelect(offer);
80
+ }
66
81
  }
@@ -1,4 +1,6 @@
1
1
  import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input } from '@angular/core';
2
+ import { toSignal } from '@angular/core/rxjs-interop';
3
+ import { map, timer } from 'rxjs';
2
4
 
3
5
  /**
4
6
  * "Trial ends in N days" banner, bound to `Subscription.trialEnd`. Renders
@@ -15,12 +17,16 @@ export class TrialBannerComponent {
15
17
  /** ISO timestamp the trial ends (Subscription.trialEnd). */
16
18
  readonly trialEnd = input<string | undefined>(undefined);
17
19
 
20
+ // Hourly clock tick — a computed reading Date.now() directly would freeze
21
+ // at mount time and show a stale day count across midnight.
22
+ private readonly now = toSignal(timer(0, 3_600_000).pipe(map(() => Date.now())), { requireSync: true });
23
+
18
24
  readonly daysLeft = computed<number>(() => {
19
25
  const raw = this.trialEnd();
20
26
  if (!raw) return 0;
21
27
  const end = Date.parse(raw);
22
28
  if (Number.isNaN(end)) return 0;
23
- return Math.ceil((end - Date.now()) / 86_400_000);
29
+ return Math.ceil((end - this.now()) / 86_400_000);
24
30
  });
25
31
 
26
32
  readonly message = computed<string>(() => {
@@ -34,9 +34,9 @@ export class UsageMeterComponent {
34
34
  return {
35
35
  resource: m.resource,
36
36
  hasBar,
37
- percent: hasBar ? Math.min(100, Math.round((m.used / m.limit) * 100)) : 0,
37
+ percent: hasBar ? Math.min(100, Math.max(0, Math.round((m.used / m.limit) * 100))) : 0,
38
38
  ariaMax: m.limit,
39
- ariaNow: hasBar ? Math.min(m.used, m.limit) : m.used,
39
+ ariaNow: hasBar ? Math.min(Math.max(0, m.used), m.limit) : m.used,
40
40
  valueText: `${m.used} / ${m.limit < 0 ? 'Unlimited' : m.limit}`,
41
41
  };
42
42
  }),
@@ -3,7 +3,7 @@
3
3
 
4
4
  @switch (state()) {
5
5
  @case ('loading') {
6
- <div class="data-card-loading"><mat-spinner diameter="28"></mat-spinner></div>
6
+ <div class="data-card-loading"><mat-spinner diameter="28" [attr.aria-label]="(heading() || 'Card') + ' loading'"></mat-spinner></div>
7
7
  }
8
8
  @case ('ready') {
9
9
  <p class="sr-only">{{ description() }}</p>
@@ -1,4 +1,5 @@
1
- import { ChangeDetectionStrategy, Component, ViewEncapsulation, effect, input, output, signal } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, input, linkedSignal } from '@angular/core';
2
+ import { outputFromObservable, toObservable } from '@angular/core/rxjs-interop';
2
3
  import { MatFormFieldModule } from '@angular/material/form-field';
3
4
  import { MatSelectModule } from '@angular/material/select';
4
5
  import { EntityOption } from '../../model/dashboard';
@@ -19,32 +20,29 @@ export class EntitySelectorComponent {
19
20
  readonly entities = input<EntityOption[]>([]);
20
21
  readonly selected = input<string | undefined>(undefined);
21
22
  readonly label = input<string>('Business');
22
- readonly selectionChange = output<string | undefined>();
23
23
 
24
- readonly value = signal<string | undefined>(undefined);
24
+ /**
25
+ * Re-resolves only when the INPUTS change (a user pick via `.set()` sticks —
26
+ * an unchanged `[selected]` binding can never snap the selection back):
27
+ * a newly-changed valid `selected` wins, else the current pick if still
28
+ * valid, else the first active entity, else undefined (host clears stale data).
29
+ */
30
+ readonly value = linkedSignal<{ opts: EntityOption[]; sel?: string }, string | undefined>({
31
+ source: () => ({ opts: this.entities(), sel: this.selected() }),
32
+ computation: (src, prev) => {
33
+ const isActive = (id?: string) => !!id && src.opts.some((e) => e.id === id && e.active !== false);
34
+ const selChanged = src.sel !== prev?.source.sel;
35
+ if (selChanged && isActive(src.sel)) return src.sel;
36
+ if (isActive(prev?.value)) return prev?.value;
37
+ if (isActive(src.sel)) return src.sel;
38
+ return src.opts.find((e) => e.active !== false)?.id;
39
+ },
40
+ });
25
41
 
26
- constructor() {
27
- // Explicit selection wins; else keep the current pick if still valid; else the
28
- // first active entity. "Valid" means present AND active, so a scope that goes
29
- // inactive (or vanishes) is dropped. Emit on every change, including a clear
30
- // (undefined) when no active option remains, so the host stops showing stale data.
31
- effect(() => {
32
- const opts = this.entities();
33
- const isActive = (id?: string) => !!id && opts.some((e) => e.id === id && e.active !== false);
34
- const cur = this.value();
35
- const resolved =
36
- isActive(this.selected()) ? this.selected()
37
- : isActive(cur) ? cur
38
- : opts.find((e) => e.active !== false)?.id;
39
- if (resolved !== cur) {
40
- this.value.set(resolved);
41
- this.selectionChange.emit(resolved);
42
- }
43
- });
44
- }
42
+ // Single emission path — signals already dedupe unchanged values.
43
+ readonly selectionChange = outputFromObservable(toObservable(this.value));
45
44
 
46
45
  onChange(id: string): void {
47
46
  this.value.set(id);
48
- this.selectionChange.emit(id);
49
47
  }
50
48
  }
@@ -1,8 +1,9 @@
1
1
  @if (isLookupTableSearch()) {
2
2
  <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
3
3
  <mat-label>{{ colCaption(field()) }}</mat-label>
4
- <input matInput [value]="isReadonly ? displayCaption : value()" [required]="required" [readonly]="isReadonly">
5
- <button matIconButton matSuffix (click)="openLookup()" [disabled]="isReadonly">
4
+ <input matInput [value]="isReadonly ? displayCaption : inputValue" [required]="required" readonly>
5
+ <button matIconButton matSuffix type="button" (click)="openLookup()" [disabled]="isReadonly"
6
+ [attr.aria-label]="'Look up ' + colCaption(field())">
6
7
  <mat-icon>search</mat-icon>
7
8
  </button>
8
9
  </mat-form-field>
@@ -14,7 +15,7 @@
14
15
  } @else if (inputType === 'select') {
15
16
  <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
16
17
  <mat-label>{{ colCaption(field()) }}</mat-label>
17
- <mat-select [value]="value()" (selectionChange)="onSelectChange($event);">
18
+ <mat-select [value]="value()" [required]="required" (selectionChange)="onSelectChange($event);">
18
19
  @for (option of options; track option.Value) {
19
20
  <mat-option [value]="option.Value">{{option.Caption}}</mat-option>
20
21
  }
@@ -31,7 +32,7 @@
31
32
  <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
32
33
  <mat-label>{{ colCaption(field()) }}</mat-label>
33
34
  <textarea matInput
34
- [value]="value()"
35
+ [value]="inputValue"
35
36
  (input)="onInputChange($event);"
36
37
  [disabled]="isReadonly"
37
38
  [required]="required"
@@ -44,7 +45,7 @@
44
45
  <mat-label>{{ colCaption(field()) }}</mat-label>
45
46
  <input matInput
46
47
  [type]="inputType"
47
- [value]="value()"
48
+ [value]="inputValue"
48
49
  (input)="onInputChange($event);"
49
50
  [disabled]="isReadonly"
50
51
  [required]="required"
@@ -1,11 +1,11 @@
1
1
  import { ChangeDetectionStrategy, Component, inject, input, linkedSignal, output, ViewEncapsulation } from "@angular/core";
2
2
  import { MatButtonModule } from "@angular/material/button";
3
- import { MatCheckboxModule } from "@angular/material/checkbox";
3
+ import { MatCheckboxChange, MatCheckboxModule } from "@angular/material/checkbox";
4
4
  import { MatDialog } from "@angular/material/dialog";
5
5
  import { MatFormFieldModule } from "@angular/material/form-field";
6
6
  import { MatIconModule } from "@angular/material/icon";
7
7
  import { MatInputModule } from "@angular/material/input";
8
- import { MatSelectModule } from "@angular/material/select";
8
+ import { MatSelectChange, MatSelectModule } from "@angular/material/select";
9
9
  import { BaseTable } from "../abstract/base_table";
10
10
  import { ConstantValue, LookupStyle } from "../../model/common";
11
11
  import { TableLookup } from "../table/table_lookup";
@@ -32,9 +32,9 @@ import { TableLookup } from "../table/table_lookup";
32
32
  })
33
33
  export class DynamicField extends BaseTable {
34
34
  readonly tableNameInput = input('', { alias: 'tableName' });
35
- readonly value = input<any>(undefined);
36
- readonly valueChange = output<any>();
37
- readonly recordUpdate = output<{[key: string]: any}>();
35
+ readonly value = input<unknown>(undefined);
36
+ readonly valueChange = output<unknown>();
37
+ readonly recordUpdate = output<{[key: string]: unknown}>();
38
38
  readonly field = input('');
39
39
  readonly label = input('');
40
40
  readonly readonlyField = input(false, {alias: 'readonly'});
@@ -103,9 +103,17 @@ export class DynamicField extends BaseTable {
103
103
  return !!(this.col && this.col.LookupTable && this.col.LookupStyle === LookupStyle.Search);
104
104
  }
105
105
 
106
- get maxLength() {
107
- if (!this.col) return 0;
108
- return this.col.Size;
106
+ /** null when metadata is missing/zero — [attr.maxLength] then omits the
107
+ * attribute (maxlength="0" would block all typing). */
108
+ get maxLength(): number | null {
109
+ const size = this.col?.Size ?? 0;
110
+ return size > 0 ? size : null;
111
+ }
112
+
113
+ /** Input-safe string form of the bound value. */
114
+ get inputValue(): string {
115
+ const v = this.value();
116
+ return v === null || v === undefined ? '' : String(v);
109
117
  }
110
118
 
111
119
  get scale() {
@@ -132,27 +140,37 @@ export class DynamicField extends BaseTable {
132
140
  return this.displayValue(this.field(), this.value());
133
141
  }
134
142
 
135
- onInputChange(event: any) {
136
- if (event && event.target) this.valueChange.emit(event.target.value);
143
+ onInputChange(event: Event) {
144
+ const target = event.target as HTMLInputElement | HTMLTextAreaElement | null;
145
+ if (target) this.valueChange.emit(this.coerce(target.value));
137
146
  }
138
147
 
139
- onSelectChange(event: any) {
140
- if (event) this.valueChange.emit(event.value);
148
+ /** Numeric columns emit numbers, not the input's raw string. */
149
+ private coerce(raw: string): unknown {
150
+ if (raw !== '' && this.isNumber(this.field())) {
151
+ const n = Number(raw);
152
+ if (!isNaN(n)) return n;
153
+ }
154
+ return raw;
141
155
  }
142
156
 
143
- onCheckboxChange(event: any) {
144
- if (event) this.valueChange.emit(event.checked);
157
+ onSelectChange(event: MatSelectChange) {
158
+ this.valueChange.emit(event.value);
145
159
  }
146
160
 
147
- updateForeignKeys(parentRecord: any) {
161
+ onCheckboxChange(event: MatCheckboxChange) {
162
+ this.valueChange.emit(event.checked);
163
+ }
164
+
165
+ updateForeignKeys(parentRecord: Record<string, unknown>) {
148
166
  if (!this.col || !this.col.LookupTable) return;
149
167
  const fk = this.getForeignKeyConfig(this.col.LookupTable);
150
- const updates: {[key: string]: any} = {};
151
- if (fk && fk.fk) {
152
- fk.fk.Columns.forEach((column, index) => {
153
- updates[column.PascalName] = parentRecord[fk.parent.Keys[index].PascalName];
154
- });
155
- }
168
+ // No FK config → nothing to map; emitting would wipe the current value.
169
+ if (!fk?.fk || fk.fk.Columns.length !== fk.parent.Keys.length) return;
170
+ const updates: {[key: string]: unknown} = {};
171
+ fk.fk.Columns.forEach((column, index) => {
172
+ updates[column.PascalName] = parentRecord[fk.parent.Keys[index].PascalName];
173
+ });
156
174
  this.recordUpdate.emit(updates);
157
175
  this.valueChange.emit(updates[this.field()]);
158
176
  }
@@ -1,10 +1,10 @@
1
1
  <div class="form-container">
2
2
  @for (key of computedFields(); track key) {
3
- @if (!isArray(record()[key])) {
3
+ @if (!isArray(working()[key])) {
4
4
  <sail-dynamic-field
5
5
  [tableName]="tableName()"
6
- [value]="record()[key]"
7
- (valueChange)="record()[key]=$event"
6
+ [value]="working()[key]"
7
+ (valueChange)="onFieldChange(key, $event)"
8
8
  (recordUpdate)="onRecordUpdate($event)"
9
9
  [field]="key"
10
10
  [readonly]="isReadOnly()(key)">
@@ -1,4 +1,4 @@
1
- import { ChangeDetectionStrategy, Component, computed, inject, input, ViewEncapsulation } from "@angular/core";
1
+ import { ChangeDetectionStrategy, Component, computed, inject, input, linkedSignal, output, ViewEncapsulation } from "@angular/core";
2
2
  import { DynamicField } from "./form_field";
3
3
  import { BaseAuthService } from "../../service/auth.service";
4
4
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
@@ -13,10 +13,15 @@ import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
13
13
  export class RecordForm {
14
14
  private readonly auth = inject(BaseAuthService);
15
15
  private readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
16
- readonly record = input<{[key: string]: any}>({});
16
+ readonly record = input<{[key: string]: unknown}>({});
17
17
  readonly tableName = input('');
18
18
  readonly fields = input<string[]>([]);
19
19
  readonly isReadOnly = input<(key: string) => boolean>(() => false);
20
+ /** Full updated record on every field edit. The input object is never mutated. */
21
+ readonly recordChange = output<{[key: string]: unknown}>();
22
+
23
+ /** Local working copy — resets whenever the parent binds a new record object. */
24
+ readonly working = linkedSignal(() => ({...this.record()}));
20
25
 
21
26
  private get hiddenFields(): Set<string> {
22
27
  const global = this.guiConfig.hiddenFields;
@@ -31,12 +36,12 @@ export class RecordForm {
31
36
  return f.filter(key => !hidden.has(key));
32
37
  }
33
38
  const tableDef = this.auth.getTableDefinition(this.tableName());
34
- const recordKeys = new Set(Object.keys(this.record()));
39
+ const record = this.working();
40
+ const recordKeys = new Set(Object.keys(record));
35
41
  const editableTimestamps = new Set(
36
42
  this.guiConfig.tableOverrides?.[this.tableName()]?.editableTimestamps ?? []
37
43
  );
38
44
  if (tableDef && tableDef.Columns && tableDef.Columns.length > 0) {
39
- const record = this.record();
40
45
  return tableDef.Columns
41
46
  .filter(col => !(col.HasDefault && col.DataType === 'timestamp'
42
47
  && !editableTimestamps.has(col.PascalName)))
@@ -55,15 +60,25 @@ export class RecordForm {
55
60
  return [...recordKeys].filter(key => !hidden.has(key));
56
61
  });
57
62
 
58
- isArray(value: any): boolean {
63
+ isArray(value: unknown): boolean {
59
64
  return Array.isArray(value);
60
65
  }
61
66
 
62
- private isEmpty(value: any): boolean {
67
+ private isEmpty(value: unknown): boolean {
63
68
  return value === null || value === undefined || value === '';
64
69
  }
65
70
 
66
- onRecordUpdate(updates: {[key: string]: any}) {
67
- Object.assign(this.record(), updates);
71
+ onFieldChange(key: string, value: unknown) {
72
+ this.applyUpdates({[key]: value});
73
+ }
74
+
75
+ onRecordUpdate(updates: {[key: string]: unknown}) {
76
+ this.applyUpdates(updates);
77
+ }
78
+
79
+ private applyUpdates(updates: {[key: string]: unknown}) {
80
+ const updated = {...this.working(), ...updates};
81
+ this.working.set(updated);
82
+ this.recordChange.emit(updated);
68
83
  }
69
84
  }
@@ -1,6 +1,6 @@
1
1
  <h1 mat-dialog-title class="table-form-title">
2
2
  <span class="table-form-title-text">
3
- @if (isNew) {
3
+ @if (isNew()) {
4
4
  New {{ getCaption() }} Record
5
5
  } @else {
6
6
  {{ getCaption() }} Details
@@ -11,7 +11,7 @@
11
11
  </button>
12
12
  </h1>
13
13
  <div mat-dialog-content>
14
- <sail-record-form [tableName]="tableName()" [record]="editableRecord" [isReadOnly]="isReadOnlyBound"></sail-record-form>
14
+ <sail-record-form [tableName]="tableName()" [record]="editableRecord()" (recordChange)="editableRecord.set($event)" [isReadOnly]="isReadOnlyFn"></sail-record-form>
15
15
  </div>
16
16
  <div mat-dialog-actions align="end">
17
17
  <button matButton="elevated" class="primary" type="submit" (click)="onSave()">Save</button>
@@ -1,4 +1,4 @@
1
- import { ChangeDetectionStrategy, Component, inject, ViewEncapsulation } from "@angular/core";
1
+ import { ChangeDetectionStrategy, Component, inject, signal, ViewEncapsulation } from "@angular/core";
2
2
  import { MAT_DIALOG_DATA, MatDialogActions, MatDialogContent, MatDialogRef, MatDialogTitle } from "@angular/material/dialog";
3
3
  import { BaseForm } from "../abstract/base_form";
4
4
  import { RecordForm } from "./form_record";
@@ -6,7 +6,7 @@ import { MatButtonModule } from "@angular/material/button";
6
6
  import { MatIconModule } from "@angular/material/icon";
7
7
 
8
8
  interface TableFormDialogData {
9
- record: {[key: string]: any};
9
+ record: {[key: string]: unknown};
10
10
  tableName: string;
11
11
  isNew?: boolean;
12
12
  readOnlyColumns?: string[];
@@ -28,6 +28,7 @@ interface TableFormDialogData {
28
28
  ]
29
29
  })
30
30
  export class TableForm extends BaseForm {
31
+ override readonly tableName = signal('');
31
32
  dialogRef = inject(MatDialogRef<TableForm>);
32
33
  data = inject<TableFormDialogData>(MAT_DIALOG_DATA);
33
34
 
@@ -35,9 +36,9 @@ export class TableForm extends BaseForm {
35
36
 
36
37
  constructor() {
37
38
  super();
38
- this.editableRecord = {...this.data.record};
39
+ this.editableRecord.set({...this.data.record});
39
40
  this.tableName.set(this.data.tableName);
40
- this.isNew = !!this.data.isNew;
41
+ this.isNew.set(!!this.data.isNew);
41
42
  this.readOnlyColumns = this.data.readOnlyColumns ?? [];
42
43
  }
43
44
 
@@ -47,7 +48,7 @@ export class TableForm extends BaseForm {
47
48
  }
48
49
 
49
50
  onSave(): void {
50
- const result = {...this.editableRecord};
51
+ const result = {...this.editableRecord()};
51
52
  this.readyToSave(result);
52
53
  this.dialogRef.close(result);
53
54
  }
@@ -52,9 +52,10 @@ export class AccountDeletionComponent extends BaseAsync {
52
52
  });
53
53
 
54
54
  readonly typedValue = signal('');
55
+ private readonly passwordValue = signal('');
55
56
  readonly canSubmit = computed(() =>
56
57
  this.typedValue() === this.confirmationText() &&
57
- !!this.form.controls.password.value &&
58
+ !!this.passwordValue() &&
58
59
  !this.loading(),
59
60
  );
60
61
 
@@ -63,6 +64,9 @@ export class AccountDeletionComponent extends BaseAsync {
63
64
  this.form.controls.confirm.valueChanges
64
65
  .pipe(takeUntilDestroyed())
65
66
  .subscribe((v) => this.typedValue.set(v ?? ''));
67
+ this.form.controls.password.valueChanges
68
+ .pipe(takeUntilDestroyed())
69
+ .subscribe((v) => this.passwordValue.set(v ?? ''));
66
70
  }
67
71
 
68
72
  onDelete() {
@@ -71,11 +75,7 @@ export class AccountDeletionComponent extends BaseAsync {
71
75
  const reason = this.form.controls.reason.value?.trim() || undefined;
72
76
  this.run(
73
77
  this.auth.deleteAccount({ password }, reason),
74
- () => {
75
- this.successMessage.set('Account deleted.');
76
- const route = this.guiConfig.accountDeletedRoute ?? '/login/local';
77
- this.router.navigateByUrl(route);
78
- },
78
+ () => this.router.navigateByUrl(this.guiConfig.accountDeletedRoute ?? '/login/local'),
79
79
  'Failed to delete account. Please try again.',
80
80
  );
81
81
  }
@@ -9,23 +9,23 @@
9
9
  <form [formGroup]="chpassForm" (ngSubmit)="changePassword()" class="auth-form">
10
10
  <mat-form-field appearance="outline">
11
11
  <mat-label>Username</mat-label>
12
- <input matInput type="text" formControlName="username" required>
12
+ <input matInput type="text" formControlName="username" autocomplete="username" required>
13
13
  </mat-form-field>
14
14
 
15
15
  @if (!forgotPassword()) {
16
16
  <mat-form-field appearance="outline">
17
17
  <mat-label>Current Password</mat-label>
18
- <input matInput type="password" formControlName="old_password" required>
18
+ <input matInput type="password" formControlName="old_password" autocomplete="current-password" required>
19
19
  </mat-form-field>
20
20
 
21
21
  <mat-form-field appearance="outline">
22
22
  <mat-label>New Password</mat-label>
23
- <input matInput type="password" formControlName="new_password" required>
23
+ <input matInput type="password" formControlName="new_password" autocomplete="new-password" required>
24
24
  </mat-form-field>
25
25
 
26
26
  <mat-form-field appearance="outline">
27
27
  <mat-label>Confirm New Password</mat-label>
28
- <input matInput type="password" formControlName="confirm_password" required>
28
+ <input matInput type="password" formControlName="confirm_password" autocomplete="new-password" required>
29
29
  </mat-form-field>
30
30
  }
31
31
 
@@ -79,7 +79,6 @@ export class ChpassComponent extends BaseAsync {
79
79
  request,
80
80
  () => {
81
81
  if (isForgot) {
82
- this.successMessage.set('A confirmation code has been sent to your email.');
83
82
  this.router.navigate(['/confirm/password'], { queryParams: { username } });
84
83
  } else {
85
84
  this.successMessage.set('Password changed successfully.');
@@ -10,22 +10,22 @@
10
10
  <form [formGroup]="confirmForm" (ngSubmit)="confirm()" class="auth-form">
11
11
  <mat-form-field appearance="outline">
12
12
  <mat-label>Username</mat-label>
13
- <input matInput type="text" formControlName="username" required>
13
+ <input matInput type="text" formControlName="username" autocomplete="username" required>
14
14
  </mat-form-field>
15
15
 
16
16
  <mat-form-field appearance="outline">
17
17
  <mat-label>Confirmation Code</mat-label>
18
- <input matInput type="text" formControlName="code" required>
18
+ <input matInput type="text" formControlName="code" autocomplete="one-time-code" required>
19
19
  </mat-form-field>
20
20
 
21
21
  <mat-form-field appearance="outline">
22
22
  <mat-label>New Password</mat-label>
23
- <input matInput type="password" formControlName="new_password" required>
23
+ <input matInput type="password" formControlName="new_password" autocomplete="new-password" required>
24
24
  </mat-form-field>
25
25
 
26
26
  <mat-form-field appearance="outline">
27
27
  <mat-label>Confirm New Password</mat-label>
28
- <input matInput type="password" formControlName="confirm_password" required>
28
+ <input matInput type="password" formControlName="confirm_password" autocomplete="new-password" required>
29
29
  </mat-form-field>
30
30
 
31
31
  @if (errorMessage()) {
@@ -54,10 +54,7 @@ export class ConfirmChpassComponent extends BaseAsync implements OnInit {
54
54
 
55
55
  this.run(
56
56
  this.auth.confirmChpass(username!, code!, new_password!),
57
- () => {
58
- this.successMessage.set('Password changed successfully. Redirecting to login...');
59
- this.router.navigate(['/login/local']);
60
- },
57
+ () => this.router.navigate(['/login/local']),
61
58
  'Confirmation failed. Please try again.',
62
59
  );
63
60
  }
@@ -1,21 +1,21 @@
1
1
  <form [formGroup]="form" class="consent-form" role="group" aria-label="Consent required to continue">
2
- <label class="consent-row">
2
+ <div class="consent-row">
3
3
  <mat-checkbox [formControlName]="ConsentType.PRIVACY_POLICY" required>
4
4
  I have read and agree to the
5
5
  <a [attr.href]="effectivePrivacyUrl()" target="_blank" rel="noopener noreferrer">Privacy Policy</a>
6
6
  and Terms of Service.
7
7
  </mat-checkbox>
8
- </label>
8
+ </div>
9
9
 
10
- <label class="consent-row">
10
+ <div class="consent-row">
11
11
  <mat-checkbox [formControlName]="ConsentType.CROSS_BORDER" required>
12
12
  I consent to my personal information being processed outside my region
13
13
  as described in the Privacy Policy.
14
14
  </mat-checkbox>
15
- </label>
15
+ </div>
16
16
 
17
17
  @for (opt of optionalConsents(); track opt.id) {
18
- <label class="consent-row">
18
+ <div class="consent-row">
19
19
  <mat-checkbox [formControlName]="opt.id" [required]="opt.required ?? false">
20
20
  {{ opt.label }}
21
21
  @if (opt.linkUrl && opt.linkText) {
@@ -25,6 +25,6 @@
25
25
  <small class="consent-hint">{{ opt.hint }}</small>
26
26
  }
27
27
  </mat-checkbox>
28
- </label>
28
+ </div>
29
29
  }
30
30
  </form>