@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,76 @@
1
+ <div class="auth-container">
2
+ <div class="auth-card">
3
+ <h2 class="auth-title">Trusted Devices</h2>
4
+ <p class="auth-instructions">Devices that can skip two-factor authentication for 30 days.</p>
5
+
6
+ @if (singleDeviceSession()) {
7
+ <div class="single-device-banner" role="status">
8
+ This account is in single-device mode — signing in on another device will sign you out here.
9
+ </div>
10
+ }
11
+
12
+ @if (devices().length > 0) {
13
+ <table class="trusted-devices-table">
14
+ <thead>
15
+ <tr>
16
+ <th>Device</th>
17
+ <th>Last Used</th>
18
+ <th>Trusted Since</th>
19
+ <th>Actions</th>
20
+ </tr>
21
+ </thead>
22
+ <tbody>
23
+ @for (device of devices(); track device.id) {
24
+ <tr>
25
+ <td>
26
+ {{ device.name }}
27
+ @if (isCurrentDevice(device)) {
28
+ <span class="current-device-badge">(this device)</span>
29
+ }
30
+ </td>
31
+ <td>{{ formatDate(device.last_used_at) }}</td>
32
+ <td>{{ formatDate(device.created_at) }}</td>
33
+ <td>
34
+ <button mat-icon-button (click)="revokeDevice(device)" title="Revoke">
35
+ <mat-icon>delete</mat-icon>
36
+ </button>
37
+ </td>
38
+ </tr>
39
+ }
40
+ </tbody>
41
+ </table>
42
+ } @else {
43
+ <p class="empty-state">No trusted devices.</p>
44
+ }
45
+
46
+ @if (errorMessage()) {
47
+ <div class="auth-error" role="alert">{{ errorMessage() }}</div>
48
+ }
49
+ @if (successMessage()) {
50
+ <div class="auth-success" role="status">{{ successMessage() }}</div>
51
+ }
52
+
53
+ @if (logoutOpen()) {
54
+ <form [formGroup]="logoutForm" class="auth-form" (ngSubmit)="signOutEverywhere()">
55
+ <p class="auth-instructions">Confirm your password to sign out of every device.</p>
56
+ <mat-form-field appearance="outline">
57
+ <mat-label>Password</mat-label>
58
+ <input matInput type="password" formControlName="password" autocomplete="current-password" required>
59
+ </mat-form-field>
60
+ <div class="auth-actions">
61
+ <button mat-raised-button class="warn" type="submit" [disabled]="!logoutForm.valid || loading()">
62
+ Sign out everywhere
63
+ </button>
64
+ <button mat-button type="button" (click)="logoutOpen.set(false)">Cancel</button>
65
+ </div>
66
+ </form>
67
+ } @else {
68
+ <div class="auth-actions">
69
+ <button mat-button class="warn" type="button" (click)="logoutOpen.set(true)" [disabled]="loading()">
70
+ Sign out of all devices
71
+ </button>
72
+ <a routerLink="/dashboard">Back to Dashboard</a>
73
+ </div>
74
+ }
75
+ </div>
76
+ </div>
@@ -0,0 +1,92 @@
1
+ import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, inject, signal, input } from '@angular/core';
2
+ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
3
+ import { BaseAsync } from '../abstract/base_async';
4
+ import { BaseAuthService } from '../../service/auth.service';
5
+ import { TrustedDevice } from '../../model/appdata';
6
+ import { MatButtonModule } from '@angular/material/button';
7
+ import { MatFormFieldModule } from '@angular/material/form-field';
8
+ import { MatInputModule } from '@angular/material/input';
9
+ import { MatIconModule } from '@angular/material/icon';
10
+ import { RouterLink } from '@angular/router';
11
+
12
+ @Component({
13
+ selector: 'app-trusted-devices',
14
+ templateUrl: './trusted_devices.html',
15
+ changeDetection: ChangeDetectionStrategy.OnPush,
16
+ encapsulation: ViewEncapsulation.None,
17
+ imports: [
18
+ MatButtonModule,
19
+ MatFormFieldModule,
20
+ MatInputModule,
21
+ MatIconModule,
22
+ ReactiveFormsModule,
23
+ RouterLink,
24
+ ],
25
+ })
26
+ export class TrustedDevicesComponent extends BaseAsync implements OnInit {
27
+ private readonly auth = inject(BaseAuthService);
28
+ private readonly fb = inject(FormBuilder);
29
+
30
+ readonly devices = signal<TrustedDevice[]>([]);
31
+ /** When true, show the "single-device mode" banner (single-device session policy). */
32
+ readonly singleDeviceSession = input(false);
33
+
34
+ /** Inline form revealed when the user clicks "Sign out of all devices". */
35
+ readonly logoutOpen = signal(false);
36
+ readonly logoutForm = this.fb.group({
37
+ password: ['', Validators.required],
38
+ });
39
+
40
+ ngOnInit() {
41
+ this.loadDevices();
42
+ }
43
+
44
+ loadDevices() {
45
+ this.run(
46
+ this.auth.getTrustedDevices(),
47
+ (devices) => this.devices.set(devices),
48
+ 'Failed to load devices.',
49
+ );
50
+ }
51
+
52
+ revokeDevice(device: TrustedDevice) {
53
+ if (!confirm(`Revoke trust for "${device.name}"?`)) return;
54
+ this.run(
55
+ this.auth.revokeTrustedDevice(device.id),
56
+ () => {
57
+ this.successMessage.set(`Device "${device.name}" revoked.`);
58
+ this.loadDevices();
59
+ },
60
+ 'Failed to revoke device.',
61
+ );
62
+ }
63
+
64
+ /**
65
+ * logout-everywhere requires a re-auth gate, so we reveal a password
66
+ * field instead of relying on confirm() alone.
67
+ */
68
+ signOutEverywhere() {
69
+ if (this.logoutForm.invalid) return;
70
+ const password = this.logoutForm.value.password!;
71
+ this.run(
72
+ this.auth.logoutEverywhere({ password }),
73
+ () => {
74
+ this.successMessage.set('Signed out of all devices.');
75
+ this.logoutForm.reset();
76
+ this.logoutOpen.set(false);
77
+ this.auth.logout();
78
+ },
79
+ 'Failed to sign out of all devices.',
80
+ );
81
+ }
82
+
83
+ isCurrentDevice(device: TrustedDevice): boolean {
84
+ const fp = localStorage.getItem('deviceFingerprint');
85
+ return !!fp && device.fingerprint === fp;
86
+ }
87
+
88
+ formatDate(dateStr: string): string {
89
+ if (!dateStr) return '';
90
+ return dateStr.slice(0, 16).replace('T', ' ');
91
+ }
92
+ }
@@ -0,0 +1,75 @@
1
+ <div class="auth-container">
2
+ <div class="auth-card">
3
+ <h2 class="auth-title">Two-Factor Authentication</h2>
4
+
5
+ @switch (step()) {
6
+ @case ('init') {
7
+ <p class="auth-instructions">Add an extra layer of security to your account with TOTP-based two-factor authentication.</p>
8
+ <form [formGroup]="initForm" (ngSubmit)="startSetup()" class="auth-form">
9
+ <mat-form-field appearance="outline">
10
+ <mat-label>Confirm your password</mat-label>
11
+ <input matInput type="password" formControlName="password" autocomplete="current-password" required>
12
+ </mat-form-field>
13
+ <button mat-raised-button class="primary" type="submit" [disabled]="!initForm.valid">Enable 2FA</button>
14
+ </form>
15
+ }
16
+ @case ('qr') {
17
+ <p class="auth-instructions">Scan this QR code with your authenticator app (e.g., Google Authenticator, Authy).</p>
18
+ <div class="twofactor-qr">
19
+ <img [src]="qrUri()" alt="2FA QR Code" width="200" height="200">
20
+ </div>
21
+ <p class="auth-instructions">Or enter this secret manually: <code>{{ secret() }}</code></p>
22
+
23
+ @if (backupCodes().length > 0) {
24
+ <div class="twofactor-backup">
25
+ <h3>Backup Codes</h3>
26
+ <p class="auth-instructions">Save these codes in a safe place. Each code can only be used once.</p>
27
+ <ul class="backup-code-list">
28
+ @for (code of backupCodes(); track code) {
29
+ <li><code>{{ code }}</code></li>
30
+ }
31
+ </ul>
32
+ </div>
33
+ }
34
+
35
+ <button mat-raised-button class="primary" (click)="proceedToVerify()">I've saved my backup codes</button>
36
+ }
37
+ @case ('verify') {
38
+ <p class="auth-instructions">Enter the 6-digit code from your authenticator app to confirm setup.</p>
39
+ <form [formGroup]="verifyForm" (ngSubmit)="confirmSetup()" class="auth-form">
40
+ <mat-form-field appearance="outline">
41
+ <mat-label>Verification Code</mat-label>
42
+ <input matInput type="text" formControlName="code" maxlength="6" autocomplete="one-time-code" required>
43
+ </mat-form-field>
44
+
45
+ <button mat-raised-button class="primary" type="submit" [disabled]="!verifyForm.valid">
46
+ Verify and Enable
47
+ </button>
48
+ </form>
49
+ }
50
+ @case ('done') {
51
+ <div class="auth-success" role="status">{{ successMessage() }}</div>
52
+ <form [formGroup]="disableForm" (ngSubmit)="disable2FA()" class="auth-form">
53
+ <p class="auth-instructions">To disable 2FA, confirm your password and a current 2FA code.</p>
54
+ <mat-form-field appearance="outline">
55
+ <mat-label>Password</mat-label>
56
+ <input matInput type="password" formControlName="password" autocomplete="current-password" required>
57
+ </mat-form-field>
58
+ <mat-form-field appearance="outline">
59
+ <mat-label>Current 6-digit code</mat-label>
60
+ <input matInput type="text" formControlName="code" maxlength="6" autocomplete="one-time-code" required>
61
+ </mat-form-field>
62
+ <button mat-button class="warn" type="submit" [disabled]="!disableForm.valid">Disable 2FA</button>
63
+ </form>
64
+ }
65
+ }
66
+
67
+ @if (errorMessage()) {
68
+ <div class="auth-error" role="alert">{{ errorMessage() }}</div>
69
+ }
70
+
71
+ <div class="auth-actions">
72
+ <a routerLink="/dashboard">Back to Dashboard</a>
73
+ </div>
74
+ </div>
75
+ </div>
@@ -0,0 +1,106 @@
1
+ import { ChangeDetectionStrategy, Component, inject, signal, ViewEncapsulation } from '@angular/core';
2
+ import { BaseAuthService } from '../../service/auth.service';
3
+ import { MatButtonModule } from '@angular/material/button';
4
+ import { MatFormFieldModule } from '@angular/material/form-field';
5
+ import { MatInputModule } from '@angular/material/input';
6
+ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
7
+ import { RouterLink } from '@angular/router';
8
+
9
+ @Component({
10
+ selector: 'app-twofactor-setup',
11
+ templateUrl: './twofactor_setup.html',
12
+ changeDetection: ChangeDetectionStrategy.OnPush,
13
+ encapsulation: ViewEncapsulation.None,
14
+ imports: [
15
+ ReactiveFormsModule,
16
+ MatButtonModule,
17
+ MatFormFieldModule,
18
+ MatInputModule,
19
+ RouterLink,
20
+ ],
21
+ })
22
+ export class TwoFactorSetupComponent {
23
+ private readonly auth = inject(BaseAuthService);
24
+ private readonly fb = inject(FormBuilder);
25
+
26
+ readonly step = signal<'init' | 'qr' | 'verify' | 'done'>('init');
27
+ readonly qrUri = signal('');
28
+ readonly secret = signal('');
29
+ readonly backupCodes = signal<string[]>([]);
30
+ readonly errorMessage = signal('');
31
+ readonly successMessage = signal('');
32
+
33
+ // setup2FA requires the user's current password (or current TOTP code
34
+ // if 2FA already enabled — pass twoFactorCode instead). Disable additionally
35
+ // requires both.
36
+ readonly initForm = this.fb.group({
37
+ password: ['', Validators.required],
38
+ });
39
+
40
+ readonly verifyForm = this.fb.group({
41
+ code: ['', [Validators.required, Validators.minLength(6), Validators.maxLength(6)]],
42
+ });
43
+
44
+ readonly disableForm = this.fb.group({
45
+ password: ['', Validators.required],
46
+ code: ['', [Validators.required, Validators.minLength(6), Validators.maxLength(6)]],
47
+ });
48
+
49
+ startSetup() {
50
+ if (this.initForm.invalid) return;
51
+ this.errorMessage.set('');
52
+ const password = this.initForm.value.password!;
53
+ this.auth.setup2FA({ password }).subscribe({
54
+ next: (res) => {
55
+ this.qrUri.set(res.qrUri);
56
+ this.secret.set(res.secret);
57
+ this.backupCodes.set(res.backupCodes);
58
+ this.step.set('qr');
59
+ this.initForm.reset();
60
+ },
61
+ error: (err) => {
62
+ this.errorMessage.set(err.error?.message ?? '2FA setup failed.');
63
+ },
64
+ });
65
+ }
66
+
67
+ proceedToVerify() {
68
+ this.step.set('verify');
69
+ }
70
+
71
+ confirmSetup() {
72
+ if (this.verifyForm.invalid) return;
73
+ this.errorMessage.set('');
74
+
75
+ const { code } = this.verifyForm.value;
76
+ this.auth.verify2FA({ code: code! }).subscribe({
77
+ next: (res) => {
78
+ if (res.valid) {
79
+ this.successMessage.set('Two-factor authentication enabled.');
80
+ this.step.set('done');
81
+ } else {
82
+ this.errorMessage.set('Invalid code. Please try again.');
83
+ }
84
+ },
85
+ error: (err) => {
86
+ this.errorMessage.set(err.error?.message ?? 'Verification failed.');
87
+ },
88
+ });
89
+ }
90
+
91
+ disable2FA() {
92
+ if (this.disableForm.invalid) return;
93
+ this.errorMessage.set('');
94
+ const { password, code } = this.disableForm.value;
95
+ this.auth.disable2FA(password!, code!).subscribe({
96
+ next: () => {
97
+ this.successMessage.set('Two-factor authentication disabled.');
98
+ this.step.set('init');
99
+ this.disableForm.reset();
100
+ },
101
+ error: (err) => {
102
+ this.errorMessage.set(err.error?.message ?? 'Failed to disable 2FA.');
103
+ },
104
+ });
105
+ }
106
+ }
@@ -0,0 +1,45 @@
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">Two-Factor Verification</h2>
9
+ <p class="auth-instructions">
10
+ @if (useBackupCode()) {
11
+ Enter one of your backup codes.
12
+ } @else {
13
+ Enter the 6-digit code from your authenticator app.
14
+ }
15
+ </p>
16
+ <form [formGroup]="verifyForm" (ngSubmit)="verify()" class="auth-form">
17
+ <mat-form-field appearance="outline">
18
+ <mat-label>{{ useBackupCode() ? 'Backup Code' : 'Verification Code' }}</mat-label>
19
+ <input matInput type="text" formControlName="code" autocomplete="one-time-code" required>
20
+ </mat-form-field>
21
+
22
+ @if (!useBackupCode()) {
23
+ <div class="auth-checkbox">
24
+ <mat-checkbox formControlName="trustDevice">
25
+ Trust this device for 30 days
26
+ </mat-checkbox>
27
+ </div>
28
+ }
29
+
30
+ @if (errorMessage()) {
31
+ <div class="auth-error" role="alert">{{ errorMessage() }}</div>
32
+ }
33
+
34
+ <button mat-raised-button class="primary" type="submit" [disabled]="!verifyForm.valid">
35
+ Verify
36
+ </button>
37
+ </form>
38
+ <div class="auth-actions">
39
+ <a tabindex="0" role="button" (click)="toggleBackupCode()" (keydown.enter)="toggleBackupCode()">
40
+ {{ useBackupCode() ? 'Use authenticator code' : 'Use a backup code instead' }}
41
+ </a>
42
+ <a routerLink="/login/local">Back to Login</a>
43
+ </div>
44
+ </div>
45
+ </div>
@@ -0,0 +1,90 @@
1
+ import { ChangeDetectionStrategy, Component, inject, signal, ViewEncapsulation } from '@angular/core';
2
+ import { BaseAuthService } from '../../service/auth.service';
3
+ import { MatButtonModule } from '@angular/material/button';
4
+ import { MatFormFieldModule } from '@angular/material/form-field';
5
+ import { MatInputModule } from '@angular/material/input';
6
+ import { MatCheckboxModule } from '@angular/material/checkbox';
7
+ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
8
+ import { RouterLink } from '@angular/router';
9
+ import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
10
+
11
+ @Component({
12
+ selector: 'app-twofactor-verify',
13
+ templateUrl: './twofactor_verify.html',
14
+ changeDetection: ChangeDetectionStrategy.OnPush,
15
+ encapsulation: ViewEncapsulation.None,
16
+ imports: [
17
+ ReactiveFormsModule,
18
+ MatButtonModule,
19
+ MatFormFieldModule,
20
+ MatInputModule,
21
+ MatCheckboxModule,
22
+ RouterLink,
23
+ ],
24
+ })
25
+ export class TwoFactorVerifyComponent {
26
+ private readonly auth = inject(BaseAuthService);
27
+ private readonly fb = inject(FormBuilder);
28
+ protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
29
+
30
+ readonly useBackupCode = signal(false);
31
+ readonly errorMessage = signal('');
32
+
33
+ readonly verifyForm = this.fb.group({
34
+ code: ['', [Validators.required, Validators.minLength(6)]],
35
+ trustDevice: [false],
36
+ });
37
+
38
+ toggleBackupCode() {
39
+ this.useBackupCode.update(v => !v);
40
+ this.verifyForm.controls.code.reset();
41
+ this.errorMessage.set('');
42
+ }
43
+
44
+ verify() {
45
+ if (this.verifyForm.invalid) return;
46
+ this.errorMessage.set('');
47
+
48
+ const { code, trustDevice } = this.verifyForm.value;
49
+
50
+ if (this.useBackupCode()) {
51
+ this.auth.verifyBackupCode(code!).subscribe({
52
+ next: (res) => {
53
+ if (res.valid && res.token) {
54
+ localStorage.removeItem('loginToken');
55
+ this.auth.token = res.token;
56
+ this.auth.isLoggedIn.set(true);
57
+ localStorage.setItem('jwt', res.token);
58
+ this.auth.loadAppData();
59
+ this.auth.initRoutes();
60
+ } else {
61
+ this.errorMessage.set('Invalid backup code.');
62
+ }
63
+ },
64
+ error: (err) => {
65
+ this.errorMessage.set(err.error?.message ?? 'Backup code verification failed.');
66
+ },
67
+ });
68
+ } else {
69
+ this.auth.verify2FALogin(
70
+ code!,
71
+ trustDevice ?? false,
72
+ this.getDeviceFingerprint(),
73
+ this.getDeviceName(),
74
+ );
75
+ }
76
+ }
77
+
78
+ private getDeviceFingerprint(): string {
79
+ let fp = localStorage.getItem('deviceFingerprint');
80
+ if (!fp) {
81
+ fp = crypto.randomUUID();
82
+ localStorage.setItem('deviceFingerprint', fp);
83
+ }
84
+ return fp;
85
+ }
86
+
87
+ private getDeviceName(): string {
88
+ return navigator.userAgent.substring(0, 80);
89
+ }
90
+ }
@@ -0,0 +1,53 @@
1
+ <section class="child-table">
2
+ <div class="child-table-header">
3
+ <h3>{{ getCaption() }}</h3>
4
+ @if (canCreate()) {
5
+ <button mat-raised-button class="accent" (click)="addRecord()">New Record</button>
6
+ }
7
+ </div>
8
+ <table class="mat-table">
9
+ <thead>
10
+ <tr>
11
+ @for (col of displayedColumns; track col) {
12
+ <th>{{ colCaption(col) }}</th>
13
+ }
14
+ <th>Actions</th>
15
+ </tr>
16
+ </thead>
17
+ <tbody>
18
+ @for (record of records; track $index) {
19
+ <tr [class.deleted-record]="record[config.opField] === 'D'" [class.updated-record]="record[config.opField] === 'U'" [class.new-record]="record[config.opField] === 'I'">
20
+ @if (record == editingRecord) {
21
+ @for (col of displayedColumns; track col) {
22
+ <td>
23
+ @if (isReadOnly(col)) {
24
+ {{ record[col] }}
25
+ } @else {
26
+ <dynamic-field [tableName]="tableName" [value]="record[col]" [field]="col" (valueChange)="record[col]=$event" subscriptSizing="dynamic"></dynamic-field>
27
+ }
28
+ </td>
29
+ }
30
+ <td>
31
+ <button mat-icon-button (click)="saveRow()" title="Save"><mat-icon>save</mat-icon></button>
32
+ <button mat-icon-button (click)="cancelRow(record)" title="Cancel"><mat-icon>cancel</mat-icon></button>
33
+ </td>
34
+ } @else {
35
+ @for (col of displayedColumns; track col) {
36
+ <td>{{ displayValue(col, record[col]) }}</td>
37
+ }
38
+ <td>
39
+ @if (canUpdate()) {
40
+ <button mat-icon-button (click)="editRow(record)" title="Edit"><mat-icon>edit</mat-icon></button>
41
+ }
42
+ @if (record[config.opField] === 'D') {
43
+ <button mat-icon-button (click)="unDeleteRow(record)" title="Undelete"><mat-icon>restore_from_trash</mat-icon></button>
44
+ } @else if (canDelete()) {
45
+ <button mat-icon-button (click)="deleteRow(record)" title="Delete"><mat-icon>delete</mat-icon></button>
46
+ }
47
+ </td>
48
+ }
49
+ </tr>
50
+ }
51
+ </tbody>
52
+ </table>
53
+ </section>
@@ -0,0 +1,133 @@
1
+ import { ChangeDetectionStrategy, Component, inject, Input, input, OnInit, ViewEncapsulation } from "@angular/core";
2
+ import { DynamicField } from "../form/form_field";
3
+ import { MatButtonModule } from "@angular/material/button";
4
+ import { BaseForm } from "../abstract/base_form";
5
+ import { MatDialog } from "@angular/material/dialog";
6
+ import { TableForm } from "../form/form_table";
7
+ import { MatIconModule } from "@angular/material/icon";
8
+
9
+ @Component({
10
+ selector: 'table-detail',
11
+ changeDetection: ChangeDetectionStrategy.OnPush,
12
+ encapsulation: ViewEncapsulation.None,
13
+ templateUrl: './table_detail.html',
14
+ imports: [
15
+ DynamicField,
16
+ MatButtonModule,
17
+ MatIconModule,
18
+ ],
19
+ })
20
+ export class TableDetail extends BaseForm implements OnInit {
21
+ @Input() override tableName = '';
22
+ @Input() records: any[] = [];
23
+ readonly parentTableName = input('');
24
+ readonly parentRecord = input<any>({});
25
+ /** When true, the parent view is in read-only mode — disable all child row editing. */
26
+ readonly parentReadOnly = input(false);
27
+
28
+ editingRecord: any = null;
29
+ originalRecord: any = null;
30
+ displayedColumns: string[] = [];
31
+ fkColumns: string[] = [];
32
+
33
+ private readonly dialog = inject(MatDialog);
34
+
35
+ override canCreate() { return !this.parentReadOnly() && super.canCreate(); }
36
+ override canUpdate() { return !this.parentReadOnly() && super.canUpdate(); }
37
+ override canDelete() { return !this.parentReadOnly() && super.canDelete(); }
38
+
39
+ ngOnInit(): void {
40
+ this.displayedColumns = this.getDisplayedColumns(this.records);
41
+ const fkCfg = this.getForeignKeyConfig(this.parentTableName());
42
+ if (fkCfg && fkCfg.fk && fkCfg.fk.Columns) {
43
+ for (const col of fkCfg.fk.Columns) {
44
+ this.fkColumns.push(col.PascalName);
45
+ }
46
+ }
47
+ }
48
+
49
+ override isReadOnly(fieldName: string): boolean {
50
+ return (this.fkColumns.includes(fieldName) || super.isReadOnly(fieldName));
51
+ }
52
+
53
+ openEditDialog(record: any, isNew: boolean, readOnlyColumns: string[]) {
54
+ const dialogRef = this.dialog.open(TableForm, {
55
+ width: this.dialogWidth,
56
+ disableClose: true,
57
+ data: this.getDialogData(record, isNew, readOnlyColumns),
58
+ });
59
+ dialogRef.afterClosed().subscribe((result) => {
60
+ if (result) {
61
+ if (isNew) {
62
+ this.records.push(result);
63
+ } else {
64
+ Object.assign(record, result);
65
+ }
66
+ }
67
+ });
68
+ }
69
+
70
+ addRecord() {
71
+ const newRecord = this.emptyRecord();
72
+ newRecord[this.config.opField] = 'I';
73
+ this.initializeForeignKeys(newRecord, this.parentTableName(), this.parentRecord());
74
+
75
+ if (this.displayedColumns.length > 6) {
76
+ this.openEditDialog(newRecord, true, this.fkColumns);
77
+ } else {
78
+ this.records.push(newRecord);
79
+ this.isNew = true;
80
+ this.originalRecord = null;
81
+ this.editingRecord = newRecord;
82
+ }
83
+ }
84
+
85
+ editRow(record: any) {
86
+ if (this.displayedColumns.length > 6) {
87
+ this.openEditDialog(record, false, this.fkColumns);
88
+ } else {
89
+ this.editingRecord = record;
90
+ this.isNew = record[this.config.opField] === 'I';
91
+ this.originalRecord = {...record};
92
+ this.formatRecordTimeStamp(record);
93
+ }
94
+ }
95
+
96
+ saveRow() {
97
+ this.readyToSave(this.editingRecord);
98
+ this.editingRecord = null;
99
+ this.originalRecord = null;
100
+ this.isNew = false;
101
+ }
102
+
103
+ cancelRow(record: any) {
104
+ if (this.originalRecord) {
105
+ Object.assign(record, this.originalRecord);
106
+ } else {
107
+ const index = this.records.indexOf(record);
108
+ if (index > -1) {
109
+ this.records.splice(index, 1);
110
+ }
111
+ }
112
+ this.editingRecord = null;
113
+ this.originalRecord = null;
114
+ this.isNew = false;
115
+ }
116
+
117
+ deleteRow(record: any) {
118
+ if (record[this.config.opField] === 'I') {
119
+ const index = this.records.indexOf(record);
120
+ if (index > -1) {
121
+ this.records.splice(index, 1);
122
+ }
123
+ } else {
124
+ record[this.config.opField] = 'D';
125
+ }
126
+ }
127
+
128
+ unDeleteRow(record: any) {
129
+ if (record[this.config.opField] === 'D') {
130
+ record[this.config.opField] = 'U';
131
+ }
132
+ }
133
+ }