@nauticana/sail 0.5.0

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 (70) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +741 -0
  3. package/package.json +58 -0
  4. package/src/component/abstract/base_async.ts +49 -0
  5. package/src/component/abstract/base_form.ts +11 -0
  6. package/src/component/abstract/base_table.ts +320 -0
  7. package/src/component/abstract/base_view.ts +93 -0
  8. package/src/component/billing/checkout_button.html +10 -0
  9. package/src/component/billing/checkout_button.ts +57 -0
  10. package/src/component/billing/payment_methods.html +27 -0
  11. package/src/component/billing/payment_methods.ts +36 -0
  12. package/src/component/billing/plan_selector.html +24 -0
  13. package/src/component/billing/plan_selector.ts +29 -0
  14. package/src/component/form/form_field.html +54 -0
  15. package/src/component/form/form_field.ts +156 -0
  16. package/src/component/form/form_record.html +14 -0
  17. package/src/component/form/form_record.ts +56 -0
  18. package/src/component/form/form_table.html +19 -0
  19. package/src/component/form/form_table.ts +58 -0
  20. package/src/component/login/account_deletion.html +42 -0
  21. package/src/component/login/account_deletion.ts +79 -0
  22. package/src/component/login/chpass_component.html +58 -0
  23. package/src/component/login/chpass_component.ts +96 -0
  24. package/src/component/login/confirm_chpass_component.html +47 -0
  25. package/src/component/login/confirm_chpass_component.ts +70 -0
  26. package/src/component/login/confirm_register_component.html +37 -0
  27. package/src/component/login/confirm_register_component.ts +71 -0
  28. package/src/component/login/consent_gate.html +30 -0
  29. package/src/component/login/consent_gate.ts +109 -0
  30. package/src/component/login/login_component.html +43 -0
  31. package/src/component/login/login_component.ts +73 -0
  32. package/src/component/login/otp_input.html +38 -0
  33. package/src/component/login/otp_input.ts +104 -0
  34. package/src/component/login/register_component.html +132 -0
  35. package/src/component/login/register_component.ts +184 -0
  36. package/src/component/login/social_login.html +13 -0
  37. package/src/component/login/social_login.ts +120 -0
  38. package/src/component/navigation/navigation.html +49 -0
  39. package/src/component/navigation/navigation.ts +69 -0
  40. package/src/component/security/trusted_devices.html +76 -0
  41. package/src/component/security/trusted_devices.ts +92 -0
  42. package/src/component/security/twofactor_setup.html +75 -0
  43. package/src/component/security/twofactor_setup.ts +106 -0
  44. package/src/component/security/twofactor_verify.html +45 -0
  45. package/src/component/security/twofactor_verify.ts +90 -0
  46. package/src/component/table/table_detail.html +53 -0
  47. package/src/component/table/table_detail.ts +133 -0
  48. package/src/component/table/table_edit.html +58 -0
  49. package/src/component/table/table_edit.ts +163 -0
  50. package/src/component/table/table_list.html +41 -0
  51. package/src/component/table/table_list.ts +100 -0
  52. package/src/component/table/table_lookup.html +35 -0
  53. package/src/component/table/table_lookup.ts +88 -0
  54. package/src/component/table/table_search.html +17 -0
  55. package/src/component/table/table_search.ts +80 -0
  56. package/src/config.ts +67 -0
  57. package/src/index.ts +41 -0
  58. package/src/model/appdata.ts +266 -0
  59. package/src/model/auth.ts +134 -0
  60. package/src/model/common.ts +115 -0
  61. package/src/model/decorator.ts +74 -0
  62. package/src/service/api-response.interceptor.ts +26 -0
  63. package/src/service/auth.interceptor.ts +11 -0
  64. package/src/service/auth.service.ts +576 -0
  65. package/src/service/billing.service.ts +65 -0
  66. package/src/service/label.service.ts +18 -0
  67. package/src/service/rest_service.ts +94 -0
  68. package/src/service/rest_url.ts +42 -0
  69. package/src/service/script_loader.ts +31 -0
  70. package/tsconfig.json +16 -0
@@ -0,0 +1,24 @@
1
+ <div class="plan-selector">
2
+ @for (plan of plans(); track plan.id) {
3
+ <div class="plan-card" [class.plan-selected]="plan.id === selected()">
4
+ <h3 class="plan-caption">{{ plan.caption }}</h3>
5
+ <div class="plan-price">
6
+ <span class="plan-amount">{{ plan.monthlyCost }}</span>
7
+ <span class="plan-period">/ month</span>
8
+ </div>
9
+ @if (featuresFor(plan.id).length > 0) {
10
+ <ul class="plan-features">
11
+ @for (feature of featuresFor(plan.id); track feature) {
12
+ <li>{{ feature }}</li>
13
+ }
14
+ </ul>
15
+ }
16
+ <button mat-raised-button class="primary" type="button" (click)="onSelect(plan.id)">
17
+ @if (plan.id === selected()) { Selected } @else { Choose }
18
+ </button>
19
+ </div>
20
+ }
21
+ @if (plans().length === 0) {
22
+ <p class="empty-state">No plans available.</p>
23
+ }
24
+ </div>
@@ -0,0 +1,29 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, input, output } from '@angular/core';
2
+ import { MatButtonModule } from '@angular/material/button';
3
+ import { PublicPlan } from '../../model/appdata';
4
+
5
+ /**
6
+ * Plan picker. Pure presentational component — stateless, no service calls.
7
+ * The caller provides the plans and handles the selection event.
8
+ */
9
+ @Component({
10
+ selector: 'app-plan-selector',
11
+ templateUrl: './plan_selector.html',
12
+ changeDetection: ChangeDetectionStrategy.OnPush,
13
+ encapsulation: ViewEncapsulation.None,
14
+ imports: [MatButtonModule],
15
+ })
16
+ export class PlanSelectorComponent {
17
+ readonly plans = input<PublicPlan[]>([]);
18
+ readonly selected = input<string | undefined>(undefined);
19
+ readonly features = input<{[planId: string]: string[]}>({});
20
+ readonly selectionChange = output<string>();
21
+
22
+ onSelect(planId: string): void {
23
+ this.selectionChange.emit(planId);
24
+ }
25
+
26
+ featuresFor(planId: string): string[] {
27
+ return this.features()[planId] ?? [];
28
+ }
29
+ }
@@ -0,0 +1,54 @@
1
+ @if (isLookupTableSearch()) {
2
+ <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
3
+ <mat-label>{{ colCaption(field()) }}</mat-label>
4
+ <input matInput [value]="readonlyField() ? displayCaption : value()" [required]="required" [readonly]="readonlyField()">
5
+ <button mat-icon-button matSuffix (click)="openLookup()" [disabled]="readonlyField()">
6
+ <mat-icon>search</mat-icon>
7
+ </button>
8
+ </mat-form-field>
9
+ } @else if (inputType === 'select' && readonlyField()) {
10
+ <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
11
+ <mat-label>{{ colCaption(field()) }}</mat-label>
12
+ <input matInput [value]="displayCaption" readonly>
13
+ </mat-form-field>
14
+ } @else if (inputType === 'select') {
15
+ <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
16
+ <mat-label>{{ colCaption(field()) }}</mat-label>
17
+ <mat-select [value]="value()" (selectionChange)="onSelectChange($event);">
18
+ @for (option of options; track option.Value) {
19
+ <mat-option [value]="option.Value">{{option.Caption}}</mat-option>
20
+ }
21
+ </mat-select>
22
+ </mat-form-field>
23
+ } @else if (inputType === 'checkbox') {
24
+ <mat-checkbox [checked]="value() === true || value() === '1'" (change)="onCheckboxChange($event);" [disabled]="readonlyField()">{{ colCaption(field()) }}</mat-checkbox>
25
+ } @else if (readonlyField() && isDateLike && !value()) {
26
+ <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
27
+ <mat-label>{{ colCaption(field()) }}</mat-label>
28
+ <input matInput value="" readonly>
29
+ </mat-form-field>
30
+ } @else if (isTextArea) {
31
+ <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
32
+ <mat-label>{{ colCaption(field()) }}</mat-label>
33
+ <textarea matInput
34
+ [value]="value()"
35
+ (input)="onInputChange($event);"
36
+ [disabled]="readonlyField()"
37
+ [required]="required"
38
+ [attr.maxLength]="maxLength"
39
+ [rows]="textAreaRows">
40
+ </textarea>
41
+ </mat-form-field>
42
+ } @else {
43
+ <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
44
+ <mat-label>{{ colCaption(field()) }}</mat-label>
45
+ <input matInput
46
+ [type]="inputType"
47
+ [value]="value()"
48
+ (input)="onInputChange($event);"
49
+ [disabled]="readonlyField()"
50
+ [required]="required"
51
+ [attr.maxLength]="maxLength"
52
+ [attr.step]="step">
53
+ </mat-form-field>
54
+ }
@@ -0,0 +1,156 @@
1
+ import { ChangeDetectionStrategy, Component, inject, Input, input, output, ViewEncapsulation } from "@angular/core";
2
+ import { MatButtonModule } from "@angular/material/button";
3
+ import { MatCheckboxModule } from "@angular/material/checkbox";
4
+ import { MatDialog } from "@angular/material/dialog";
5
+ import { MatFormFieldModule } from "@angular/material/form-field";
6
+ import { MatIconModule } from "@angular/material/icon";
7
+ import { MatInputModule } from "@angular/material/input";
8
+ import { MatSelectModule } from "@angular/material/select";
9
+ import { BaseTable } from "../abstract/base_table";
10
+ import { ConstantValue } from "../../model/common";
11
+ import { TableLookup } from "../table/table_lookup";
12
+
13
+ @Component({
14
+ selector: 'dynamic-field',
15
+ templateUrl: './form_field.html',
16
+ changeDetection: ChangeDetectionStrategy.OnPush,
17
+ encapsulation: ViewEncapsulation.None,
18
+ host: {
19
+ '[class.field-wide]': 'isTextArea',
20
+ '[class.field-editable]': '!readonlyField()',
21
+ '[class.field-readonly]': 'readonlyField()',
22
+ '[style.max-width.px]': 'fieldMaxWidth',
23
+ },
24
+ imports: [
25
+ MatButtonModule,
26
+ MatCheckboxModule,
27
+ MatFormFieldModule,
28
+ MatIconModule,
29
+ MatInputModule,
30
+ MatSelectModule,
31
+ ]
32
+ })
33
+ export class DynamicField extends BaseTable {
34
+ // @Input() required — signal input not possible due to BaseTable inheritance chain
35
+ @Input() override tableName = '';
36
+ readonly value = input<any>(undefined);
37
+ readonly valueChange = output<any>();
38
+ readonly recordUpdate = output<{[key: string]: any}>();
39
+ readonly field = input('');
40
+ readonly label = input('');
41
+ readonly readonlyField = input(false, {alias: 'readonly'});
42
+ readonly subscriptSizing = input<'fixed' | 'dynamic'>('dynamic');
43
+ readonly appearance = input<'fill' | 'outline'>('outline');
44
+
45
+ private readonly dialog = inject(MatDialog);
46
+
47
+ get col() {
48
+ return this.getColumn(this.field());
49
+ }
50
+
51
+ get inputType() {
52
+ if (!this.col || !this.col.InputType || this.col.LookupStyle === 'S') return 'text';
53
+ return this.col.InputType;
54
+ }
55
+
56
+ get isTextArea(): boolean {
57
+ if (!this.col) return false;
58
+ return this.col.Size > 80;
59
+ }
60
+
61
+ get isDateLike(): boolean {
62
+ if (!this.col) return false;
63
+ return this.col.InputType === 'date'
64
+ || this.col.InputType === 'datetime'
65
+ || this.col.InputType === 'datetime-local';
66
+ }
67
+
68
+ /**
69
+ * Max width (px) for the field, derived from column Size.
70
+ * `null` = no cap (field fills its container — used for text areas / long fields).
71
+ */
72
+ get fieldMaxWidth(): number | null {
73
+ if (this.isTextArea) return null;
74
+ const size = this.col?.Size ?? 0;
75
+ if (size === 0) return 240;
76
+ if (size <= 10) return 180;
77
+ if (size <= 30) return 260;
78
+ if (size <= 80) return 400;
79
+ return 520;
80
+ }
81
+
82
+ get textAreaRows(): number {
83
+ if (!this.col) return 3;
84
+ if (this.col.Size > 500) return 4;
85
+ return 2;
86
+ }
87
+
88
+ isLookupTableSearch(): boolean {
89
+ return !!(this.col && this.col.LookupTable && this.col.LookupStyle === 'S');
90
+ }
91
+
92
+ get maxLength() {
93
+ if (!this.col) return 0;
94
+ return this.col.Size;
95
+ }
96
+
97
+ get scale() {
98
+ if (!this.col) return 0;
99
+ return this.col.Scale;
100
+ }
101
+
102
+ get required() {
103
+ if (!this.col) return false;
104
+ return this.col.Required;
105
+ }
106
+
107
+ get options(): ConstantValue[] {
108
+ if (!this.col) return [];
109
+ return this.getOptions(this.field()) ?? [];
110
+ }
111
+
112
+ get step() {
113
+ if (!this.col) return 0;
114
+ return this.col.Step;
115
+ }
116
+
117
+ get displayCaption(): string {
118
+ return this.displayValue(this.field(), this.value());
119
+ }
120
+
121
+ onInputChange(event: any) {
122
+ if (event && event.target) this.valueChange.emit(event.target.value);
123
+ }
124
+
125
+ onSelectChange(event: any) {
126
+ if (event) this.valueChange.emit(event.value);
127
+ }
128
+
129
+ onCheckboxChange(event: any) {
130
+ if (event) this.valueChange.emit(event.checked);
131
+ }
132
+
133
+ updateForeignKeys(parentRecord: any) {
134
+ if (!this.col || !this.col.LookupTable) return;
135
+ const fk = this.getForeignKeyConfig(this.col.LookupTable);
136
+ const updates: {[key: string]: any} = {};
137
+ if (fk && fk.fk) {
138
+ fk.fk.Columns.forEach((column, index) => {
139
+ updates[column.PascalName] = parentRecord[fk.parent.Keys[index].PascalName];
140
+ });
141
+ }
142
+ this.recordUpdate.emit(updates);
143
+ this.valueChange.emit(updates[this.field()]);
144
+ }
145
+
146
+ openLookup() {
147
+ if (!this.isLookupTableSearch()) return;
148
+ const dialogRef = this.dialog.open(TableLookup, {
149
+ width: this.dialogWidth,
150
+ data: {tableName: this.col?.LookupTable},
151
+ });
152
+ dialogRef.afterClosed().subscribe((result) => {
153
+ if (result) this.updateForeignKeys(result);
154
+ });
155
+ }
156
+ }
@@ -0,0 +1,14 @@
1
+ <div class="form-container">
2
+ @for (key of computedFields(); track key) {
3
+ @if (!isArray(record()[key])) {
4
+ <dynamic-field
5
+ [tableName]="tableName()"
6
+ [value]="record()[key]"
7
+ (valueChange)="record()[key]=$event"
8
+ (recordUpdate)="onRecordUpdate($event)"
9
+ [field]="key"
10
+ [readonly]="isReadOnly()(key)">
11
+ </dynamic-field>
12
+ }
13
+ }
14
+ </div>
@@ -0,0 +1,56 @@
1
+ import { ChangeDetectionStrategy, Component, computed, inject, input, ViewEncapsulation } from "@angular/core";
2
+ import { DynamicField } from "./form_field";
3
+ import { BaseAuthService } from "../../service/auth.service";
4
+ import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
5
+
6
+ @Component({
7
+ selector: 'record-form',
8
+ templateUrl: './form_record.html',
9
+ changeDetection: ChangeDetectionStrategy.OnPush,
10
+ encapsulation: ViewEncapsulation.None,
11
+ imports: [DynamicField],
12
+ })
13
+ export class RecordForm {
14
+ private readonly auth = inject(BaseAuthService);
15
+ private readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
16
+ readonly record = input<{[key: string]: any}>({});
17
+ readonly tableName = input('');
18
+ readonly fields = input<string[]>([]);
19
+ readonly isReadOnly = input<(key: string) => boolean>(() => false);
20
+
21
+ private get hiddenFields(): Set<string> {
22
+ const global = this.guiConfig.hiddenFields;
23
+ const override = this.guiConfig.tableOverrides?.[this.tableName()]?.hiddenFields ?? [];
24
+ return new Set([...global, ...override]);
25
+ }
26
+
27
+ readonly computedFields = computed(() => {
28
+ const f = this.fields();
29
+ const hidden = this.hiddenFields;
30
+ if (f.length > 0) {
31
+ return f.filter(key => !hidden.has(key));
32
+ }
33
+ const tableDef = this.auth.getTableDefinition(this.tableName());
34
+ const recordKeys = new Set(Object.keys(this.record()));
35
+ const editableTimestamps = new Set(
36
+ this.guiConfig.tableOverrides?.[this.tableName()]?.editableTimestamps ?? []
37
+ );
38
+ if (tableDef && tableDef.Columns && tableDef.Columns.length > 0) {
39
+ return tableDef.Columns
40
+ .filter(col => !(col.HasDefault && col.DataType === 'timestamp'
41
+ && !editableTimestamps.has(col.PascalName)))
42
+ .sort((a, b) => a.Order - b.Order)
43
+ .map(col => col.PascalName)
44
+ .filter(key => !hidden.has(key) && recordKeys.has(key));
45
+ }
46
+ return [...recordKeys].filter(key => !hidden.has(key));
47
+ });
48
+
49
+ isArray(value: any): boolean {
50
+ return Array.isArray(value);
51
+ }
52
+
53
+ onRecordUpdate(updates: {[key: string]: any}) {
54
+ Object.assign(this.record(), updates);
55
+ }
56
+ }
@@ -0,0 +1,19 @@
1
+ <h1 mat-dialog-title class="table-form-title">
2
+ <span class="table-form-title-text">
3
+ @if (isNew) {
4
+ New {{ getCaption() }} Record
5
+ } @else {
6
+ {{ getCaption() }} Details
7
+ }
8
+ </span>
9
+ <button mat-icon-button type="button" (click)="onCancel()" aria-label="Close dialog" class="table-form-close">
10
+ <mat-icon>close</mat-icon>
11
+ </button>
12
+ </h1>
13
+ <div mat-dialog-content>
14
+ <record-form [tableName]="tableName" [record]="editableRecord" [isReadOnly]="isReadOnlyBound"></record-form>
15
+ </div>
16
+ <div mat-dialog-actions align="end">
17
+ <button mat-raised-button class="primary" type="submit" (click)="onSave()">Save</button>
18
+ <button mat-button type="button" (click)="onCancel()">Cancel</button>
19
+ </div>
@@ -0,0 +1,58 @@
1
+ import { ChangeDetectionStrategy, Component, inject, ViewEncapsulation } from "@angular/core";
2
+ import { MAT_DIALOG_DATA, MatDialogActions, MatDialogContent, MatDialogRef, MatDialogTitle } from "@angular/material/dialog";
3
+ import { BaseForm } from "../abstract/base_form";
4
+ import { RecordForm } from "./form_record";
5
+ import { MatButtonModule } from "@angular/material/button";
6
+ import { MatIconModule } from "@angular/material/icon";
7
+
8
+ interface TableFormDialogData {
9
+ record: {[key: string]: any};
10
+ tableName: string;
11
+ isNew?: boolean;
12
+ readOnlyColumns?: string[];
13
+ attributeName?: string;
14
+ }
15
+
16
+ @Component({
17
+ selector: 'table-form',
18
+ changeDetection: ChangeDetectionStrategy.OnPush,
19
+ encapsulation: ViewEncapsulation.None,
20
+ templateUrl: './form_table.html',
21
+ imports: [
22
+ MatButtonModule,
23
+ MatDialogActions,
24
+ MatDialogContent,
25
+ MatDialogTitle,
26
+ MatIconModule,
27
+ RecordForm,
28
+ ]
29
+ })
30
+ export class TableForm extends BaseForm {
31
+ dialogRef = inject(MatDialogRef<TableForm>);
32
+ data = inject<TableFormDialogData>(MAT_DIALOG_DATA);
33
+
34
+ readOnlyColumns: string[] = [];
35
+
36
+ constructor() {
37
+ super();
38
+ this.editableRecord = {...this.data.record};
39
+ this.tableName = this.data.tableName;
40
+ this.isNew = !!this.data.isNew;
41
+ this.readOnlyColumns = this.data.readOnlyColumns ?? [];
42
+ }
43
+
44
+ override isReadOnly(fieldName: string): boolean {
45
+ if (this.readOnlyColumns.includes(fieldName)) return true;
46
+ return super.isReadOnly(fieldName);
47
+ }
48
+
49
+ onSave(): void {
50
+ const result = {...this.editableRecord};
51
+ this.readyToSave(result);
52
+ this.dialogRef.close(result);
53
+ }
54
+
55
+ onCancel(): void {
56
+ this.dialogRef.close();
57
+ }
58
+ }
@@ -0,0 +1,42 @@
1
+ <div class="auth-container">
2
+ <div class="auth-card">
3
+ <h2 class="auth-title">Delete account</h2>
4
+ <p class="auth-instructions">
5
+ This action cannot be undone. Your account will be anonymized and all
6
+ active sessions will be signed out. To confirm, type
7
+ <strong>{{ confirmationText() }}</strong> below.
8
+ </p>
9
+
10
+ <form [formGroup]="form" class="auth-form" (ngSubmit)="onDelete()">
11
+ <mat-form-field appearance="outline">
12
+ <mat-label>Type {{ confirmationText() }} to confirm</mat-label>
13
+ <input matInput formControlName="confirm" autocomplete="off" required>
14
+ </mat-form-field>
15
+
16
+ <mat-form-field appearance="outline">
17
+ <mat-label>Confirm your password</mat-label>
18
+ <input matInput type="password" formControlName="password" autocomplete="current-password" required>
19
+ </mat-form-field>
20
+
21
+ <mat-form-field appearance="outline">
22
+ <mat-label>Reason (optional)</mat-label>
23
+ <textarea matInput rows="3" formControlName="reason"></textarea>
24
+ </mat-form-field>
25
+
26
+ <button mat-raised-button class="warn" type="submit" [disabled]="!canSubmit()">
27
+ @if (loading()) { Deleting… } @else { Permanently delete account }
28
+ </button>
29
+
30
+ @if (errorMessage()) {
31
+ <div class="auth-error" role="alert">{{ errorMessage() }}</div>
32
+ }
33
+ @if (successMessage()) {
34
+ <div class="auth-success" role="status">{{ successMessage() }}</div>
35
+ }
36
+ </form>
37
+
38
+ <div class="auth-actions">
39
+ <a routerLink="/dashboard">Cancel</a>
40
+ </div>
41
+ </div>
42
+ </div>
@@ -0,0 +1,79 @@
1
+ import {
2
+ ChangeDetectionStrategy, Component, ViewEncapsulation, computed, inject, input, signal,
3
+ } from '@angular/core';
4
+ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
5
+ import { MatButtonModule } from '@angular/material/button';
6
+ import { MatFormFieldModule } from '@angular/material/form-field';
7
+ import { MatInputModule } from '@angular/material/input';
8
+ import { Router, RouterLink } from '@angular/router';
9
+ import { BaseAsync } from '../abstract/base_async';
10
+ import { BaseAuthService } from '../../service/auth.service';
11
+ import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
12
+
13
+ /**
14
+ * AccountDeletionComponent — App Store / Play Store compliance surface.
15
+ *
16
+ * Typed-confirmation UX (standard anti-fat-finger pattern): user must type
17
+ * the literal `confirmationText` (default "DELETE") before the destructive
18
+ * button enables. Optional `reason` textarea forwarded to the backend's
19
+ * DELETE /api/user/account for the audit trail.
20
+ *
21
+ * On success: local session cleared by BaseAuthService.deleteAccount; this
22
+ * component then navigates to `config.accountDeletedRoute` (default
23
+ * `/login/local`).
24
+ */
25
+ @Component({
26
+ selector: 'app-account-deletion',
27
+ templateUrl: './account_deletion.html',
28
+ changeDetection: ChangeDetectionStrategy.OnPush,
29
+ encapsulation: ViewEncapsulation.None,
30
+ imports: [
31
+ ReactiveFormsModule,
32
+ MatButtonModule,
33
+ MatFormFieldModule,
34
+ MatInputModule,
35
+ RouterLink,
36
+ ],
37
+ })
38
+ export class AccountDeletionComponent extends BaseAsync {
39
+ private readonly auth = inject(BaseAuthService);
40
+ private readonly fb = inject(FormBuilder);
41
+ private readonly router = inject(Router);
42
+ protected readonly guiConfig: SailGuiConfig =
43
+ inject(SAIL_GUI_CONFIG, { optional: true }) ?? DEFAULT_CONFIG;
44
+
45
+ readonly confirmationText = input('DELETE');
46
+
47
+ readonly form = this.fb.nonNullable.group({
48
+ confirm: ['', Validators.required],
49
+ password: ['', Validators.required], // re-auth gate required by keel
50
+ reason: [''],
51
+ });
52
+
53
+ readonly typedValue = signal('');
54
+ readonly canSubmit = computed(() =>
55
+ this.typedValue() === this.confirmationText() &&
56
+ !!this.form.controls.password.value &&
57
+ !this.loading(),
58
+ );
59
+
60
+ constructor() {
61
+ super();
62
+ this.form.controls.confirm.valueChanges.subscribe((v) => this.typedValue.set(v ?? ''));
63
+ }
64
+
65
+ onDelete() {
66
+ if (!this.canSubmit()) return;
67
+ const password = this.form.controls.password.value;
68
+ const reason = this.form.controls.reason.value?.trim() || undefined;
69
+ this.run(
70
+ this.auth.deleteAccount({ password }, reason),
71
+ () => {
72
+ this.successMessage.set('Account deleted.');
73
+ const route = this.guiConfig.accountDeletedRoute ?? '/login/local';
74
+ this.router.navigateByUrl(route);
75
+ },
76
+ 'Failed to delete account. Please try again.',
77
+ );
78
+ }
79
+ }
@@ -0,0 +1,58 @@
1
+ <div class="auth-container">
2
+ @if (guiConfig.appTitle) {
3
+ <div class="auth-header">
4
+ <h1 class="auth-app-title">{{ guiConfig.appTitle }}</h1>
5
+ </div>
6
+ }
7
+ <div class="auth-card">
8
+ <h2 class="auth-title">Change Password</h2>
9
+ <form [formGroup]="chpassForm" (ngSubmit)="changePassword()" class="auth-form">
10
+ <mat-form-field appearance="outline">
11
+ <mat-label>Username</mat-label>
12
+ <input matInput type="text" formControlName="username" required>
13
+ </mat-form-field>
14
+
15
+ @if (!forgotPassword()) {
16
+ <mat-form-field appearance="outline">
17
+ <mat-label>Current Password</mat-label>
18
+ <input matInput type="password" formControlName="old_password" required>
19
+ </mat-form-field>
20
+
21
+ <mat-form-field appearance="outline">
22
+ <mat-label>New Password</mat-label>
23
+ <input matInput type="password" formControlName="new_password" required>
24
+ </mat-form-field>
25
+
26
+ <mat-form-field appearance="outline">
27
+ <mat-label>Confirm New Password</mat-label>
28
+ <input matInput type="password" formControlName="confirm_password" required>
29
+ </mat-form-field>
30
+ }
31
+
32
+ <div class="auth-checkbox">
33
+ <mat-checkbox (change)="toggleForgotPassword($event.checked)">
34
+ I don't remember my old password, send me a confirmation code in email
35
+ </mat-checkbox>
36
+ </div>
37
+
38
+ @if (errorMessage()) {
39
+ <div class="auth-error" role="alert">{{ errorMessage() }}</div>
40
+ }
41
+
42
+ @if (successMessage()) {
43
+ <div class="auth-success" role="status">{{ successMessage() }}</div>
44
+ }
45
+
46
+ <button mat-raised-button class="primary" type="submit" [disabled]="!chpassForm.valid">
47
+ @if (forgotPassword()) {
48
+ Send Confirmation Code
49
+ } @else {
50
+ Change Password
51
+ }
52
+ </button>
53
+ </form>
54
+ <div class="auth-actions">
55
+ <a routerLink="/login/local">Back to Login</a>
56
+ </div>
57
+ </div>
58
+ </div>