@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.
- package/README.md +4 -2
- package/package.json +5 -1
- package/src/component/abstract/base_async.ts +9 -11
- package/src/component/abstract/base_form.ts +6 -5
- package/src/component/abstract/base_table.ts +131 -58
- package/src/component/abstract/base_view.ts +43 -27
- package/src/component/account/profile_editor.ts +2 -2
- package/src/component/billing/payment_methods.ts +1 -2
- package/src/component/billing/price_selector.html +2 -1
- package/src/component/billing/price_selector.ts +28 -13
- package/src/component/billing/trial_banner.ts +7 -1
- package/src/component/billing/usage_meter.ts +2 -2
- package/src/component/dashboard/data_card.html +1 -1
- package/src/component/dashboard/entity_selector.ts +21 -23
- package/src/component/form/form_field.html +6 -5
- package/src/component/form/form_field.ts +39 -21
- package/src/component/form/form_record.html +3 -3
- package/src/component/form/form_record.ts +23 -8
- package/src/component/form/form_table.html +2 -2
- package/src/component/form/form_table.ts +6 -5
- package/src/component/login/account_deletion.ts +6 -6
- package/src/component/login/chpass_component.html +4 -4
- package/src/component/login/chpass_component.ts +0 -1
- package/src/component/login/confirm_chpass_component.html +4 -4
- package/src/component/login/confirm_chpass_component.ts +1 -4
- package/src/component/login/consent_gate.html +6 -6
- package/src/component/login/consent_gate.ts +17 -24
- package/src/component/login/login_component.html +7 -4
- package/src/component/login/login_component.ts +9 -5
- package/src/component/login/otp_input.html +13 -5
- package/src/component/login/otp_input.ts +34 -27
- package/src/component/login/register_component.html +31 -14
- package/src/component/login/register_component.ts +80 -89
- package/src/component/login/sms_consent.html +11 -0
- package/src/component/login/sms_consent.ts +38 -0
- package/src/component/login/social_login.html +2 -2
- package/src/component/login/social_login.ts +20 -13
- package/src/component/navigation/navigation.html +2 -2
- package/src/component/navigation/navigation.ts +2 -0
- package/src/component/oauth_connection/oauth_connection_panel.ts +24 -3
- package/src/component/payment/user_payment_methods.html +2 -2
- package/src/component/payment/user_payment_methods.ts +12 -23
- package/src/component/payout/bank_info_form.html +4 -4
- package/src/component/payout/bank_info_form.ts +22 -26
- package/src/component/payout/provider_onboarding.html +5 -5
- package/src/component/payout/provider_onboarding.ts +28 -48
- package/src/component/security/trusted_devices.html +1 -1
- package/src/component/security/trusted_devices.ts +0 -1
- package/src/component/security/twofactor_setup.html +8 -4
- package/src/component/security/twofactor_setup.ts +21 -3
- package/src/component/security/twofactor_verify.html +2 -1
- package/src/component/security/twofactor_verify.ts +2 -8
- package/src/component/table/table_detail.html +15 -14
- package/src/component/table/table_detail.ts +62 -41
- package/src/component/table/table_edit.html +15 -11
- package/src/component/table/table_edit.ts +94 -67
- package/src/component/table/table_list.html +45 -12
- package/src/component/table/table_list.ts +98 -26
- package/src/component/table/table_lookup.html +11 -8
- package/src/component/table/table_lookup.ts +45 -37
- package/src/component/table/table_report.html +2 -2
- package/src/component/table/table_report.ts +37 -31
- package/src/component/table/table_search.html +4 -4
- package/src/component/table/table_search.ts +36 -45
- package/src/config.ts +5 -1
- package/src/index.ts +2 -0
- package/src/model/appdata.ts +5 -9
- package/src/model/common.ts +27 -27
- package/src/model/decorator.ts +25 -21
- package/src/service/api-response.interceptor.ts +4 -1
- package/src/service/auth.interceptor.ts +27 -7
- package/src/service/auth.service.ts +188 -134
- package/src/service/label.service.ts +6 -5
- package/src/service/oauth_connection.service.ts +2 -2
- package/src/service/rest_service.ts +57 -13
- package/src/service/rest_url.ts +23 -7
- package/src/service/script_loader.ts +12 -2
- package/src/util/errors.ts +20 -0
- 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
|
|
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] ??
|
|
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 -
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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 :
|
|
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]="
|
|
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]="
|
|
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<
|
|
36
|
-
readonly valueChange = output<
|
|
37
|
-
readonly recordUpdate = output<{[key: string]:
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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:
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
144
|
-
|
|
157
|
+
onSelectChange(event: MatSelectChange) {
|
|
158
|
+
this.valueChange.emit(event.value);
|
|
145
159
|
}
|
|
146
160
|
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
if (fk
|
|
152
|
-
|
|
153
|
-
|
|
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(
|
|
3
|
+
@if (!isArray(working()[key])) {
|
|
4
4
|
<sail-dynamic-field
|
|
5
5
|
[tableName]="tableName()"
|
|
6
|
-
[value]="
|
|
7
|
-
(valueChange)="
|
|
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]:
|
|
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
|
|
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:
|
|
63
|
+
isArray(value: unknown): boolean {
|
|
59
64
|
return Array.isArray(value);
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
private isEmpty(value:
|
|
67
|
+
private isEmpty(value: unknown): boolean {
|
|
63
68
|
return value === null || value === undefined || value === '';
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
|
|
67
|
-
|
|
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]="
|
|
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]:
|
|
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
|
|
39
|
+
this.editableRecord.set({...this.data.record});
|
|
39
40
|
this.tableName.set(this.data.tableName);
|
|
40
|
-
this.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.
|
|
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
|
-
<
|
|
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
|
-
</
|
|
8
|
+
</div>
|
|
9
9
|
|
|
10
|
-
<
|
|
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
|
-
</
|
|
15
|
+
</div>
|
|
16
16
|
|
|
17
17
|
@for (opt of optionalConsents(); track opt.id) {
|
|
18
|
-
<
|
|
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
|
-
</
|
|
28
|
+
</div>
|
|
29
29
|
}
|
|
30
30
|
</form>
|