@nauticana/sail 1.0.9 → 1.1.1
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 +6 -1
- package/package.json +1 -1
- package/src/component/abstract/base_table.ts +14 -7
- package/src/component/account/my_account.html +17 -0
- package/src/component/account/my_account.ts +15 -0
- package/src/component/account/profile_editor.html +50 -0
- package/src/component/account/profile_editor.ts +101 -0
- package/src/component/form/form_record.ts +6 -5
- package/src/component/navigation/navigation.html +3 -0
- package/src/component/navigation/navigation.ts +1 -0
- package/src/component/security/twofactor_setup.html +12 -6
- package/src/component/security/twofactor_setup.ts +13 -0
- package/src/component/table/reveal_dialog.html +16 -0
- package/src/component/table/reveal_dialog.ts +22 -0
- package/src/config.ts +2 -0
- package/src/index.ts +2 -0
- package/src/model/appdata.ts +1 -1
- package/src/model/auth.ts +10 -0
- package/src/service/auth.service.ts +34 -0
- package/src/service/rest_url.ts +9 -0
package/README.md
CHANGED
|
@@ -13,11 +13,12 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
13
13
|
| **Navigation** | `Navigation` (sidenav + toolbar with menu, responsive) |
|
|
14
14
|
| **Login** | `LoginComponent`, `RegisterComponent`, `ChpassComponent`, `ConfirmRegisterComponent`, `ConfirmChpassComponent` |
|
|
15
15
|
| **Security** | `TwoFactorSetupComponent`, `TwoFactorVerifyComponent`, `TrustedDevicesComponent`, `AccountDeletionComponent` |
|
|
16
|
+
| **Account** | `MyAccountComponent` (self-service hub), `ProfileEditorComponent` (name/locale immediate; email/phone verify-before-apply) |
|
|
16
17
|
| **Auth** | `ConsentGateComponent`, `OtpInputComponent`, `SocialLoginComponent` |
|
|
17
18
|
| **Billing** | `PlanSelectorComponent`, `PriceSelectorComponent`, `CheckoutButtonComponent`, `PaymentMethodsComponent`, `PortalButtonComponent`, `UsageMeterComponent`, `StatusChipComponent`, `TrialBannerComponent`, `SeatSelectorComponent`, `DunningBannerComponent` |
|
|
18
19
|
| **Payout** | `PayoutProviderOnboardingComponent`, `PayoutBankInfoFormComponent` |
|
|
19
20
|
| **Payments / SCA** | `UserPaymentMethodsComponent`, `ScaConfirmComponent`, `ScaConfirmer` (port), `SCA_CONFIRMER` (token) |
|
|
20
|
-
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere), `BillingService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
21
|
+
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
21
22
|
| **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `BaseAsync`, `BaseRestService` |
|
|
22
23
|
| **Config** | `SAIL_GUI_CONFIG`, `SailGuiConfig`, `configureRestUrls()` |
|
|
23
24
|
| **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, 2FA types, etc. |
|
|
@@ -121,6 +122,10 @@ bootstrapApplication(App, {
|
|
|
121
122
|
{ label: 'Login', routerLink: '/login/local' },
|
|
122
123
|
{ label: 'Register', routerLink: '/login/register' },
|
|
123
124
|
],
|
|
125
|
+
// Shown in the toolbar before Logout when logged in. Mount the matching
|
|
126
|
+
// authenticated route to MyAccountComponent (its links expect
|
|
127
|
+
// /account/2fa, /account/devices, /account/delete, /login/chpass).
|
|
128
|
+
accountLink: { label: 'My Account', routerLink: '/account' },
|
|
124
129
|
loginFooterLinks: [
|
|
125
130
|
{ label: 'Sign in with Google', routerLink: '/login/google' },
|
|
126
131
|
],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"scripts": {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { inject, signal, WritableSignal } from "@angular/core";
|
|
1
|
+
import { inject, signal, WritableSignal, Injector } from "@angular/core";
|
|
2
2
|
import { ForeignKey, TableAction, TableColumn, TableDefinition } from "../../model/appdata";
|
|
3
3
|
import { ConstantValue, LookupStyle, OpCode } from "../../model/common";
|
|
4
4
|
import { BaseAuthService } from "../../service/auth.service";
|
|
5
5
|
import { BackendService } from "../../service/rest_service";
|
|
6
6
|
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
|
|
7
7
|
import { titleCase } from "../../util/text";
|
|
8
|
+
import { MatDialog } from "@angular/material/dialog";
|
|
9
|
+
import { RevealDialog } from "../table/reveal_dialog";
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
/** CRUD verb used by `requireAuth()` to compose the "Missing authorization" alert. */
|
|
@@ -14,6 +16,8 @@ export type AuthVerb = 'create' | 'read' | 'update' | 'delete' | 'view';
|
|
|
14
16
|
export abstract class BaseTable {
|
|
15
17
|
protected readonly cacheService = inject(BaseAuthService);
|
|
16
18
|
protected readonly backendService = inject(BackendService);
|
|
19
|
+
// Reveal dialogs are rare — resolve MatDialog lazily so every BaseTable subclass doesn't eagerly depend on it.
|
|
20
|
+
readonly #injector = inject(Injector);
|
|
17
21
|
protected readonly config: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
18
22
|
protected readonly dialogWidth: string = '400px';
|
|
19
23
|
readonly tableName: WritableSignal<string> = signal('');
|
|
@@ -114,15 +118,18 @@ export abstract class BaseTable {
|
|
|
114
118
|
if (!this.beforeExecuteAction(action, record)) return;
|
|
115
119
|
if (action.confirmMessage && !confirm(action.confirmMessage)) return;
|
|
116
120
|
const body = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
|
|
117
|
-
this.backendService.executeAction<
|
|
121
|
+
this.backendService.executeAction<Record<string, unknown>>(action.method, body).subscribe({
|
|
118
122
|
next: (resp) => {
|
|
119
|
-
|
|
120
|
-
// (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
window.location.href = resp.url;
|
|
123
|
+
const url = resp?.['url'];
|
|
124
|
+
// R = redirect: POST then follow the returned {url} (hosted checkout / portal).
|
|
125
|
+
if (action.kind === 'R' && typeof url === 'string') {
|
|
126
|
+
window.location.href = url;
|
|
124
127
|
return;
|
|
125
128
|
}
|
|
129
|
+
// V = reveal: show the returned secret once (e.g. a freshly minted key).
|
|
130
|
+
if (action.kind === 'V') {
|
|
131
|
+
this.#injector.get(MatDialog).open(RevealDialog, { data: resp ?? {}, width: '480px' });
|
|
132
|
+
}
|
|
126
133
|
this.onActionSuccess(action, record);
|
|
127
134
|
},
|
|
128
135
|
error: (err) => {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<div class="auth-container">
|
|
2
|
+
<div class="auth-card">
|
|
3
|
+
<h2 class="auth-title">My Account</h2>
|
|
4
|
+
|
|
5
|
+
<sail-profile-editor></sail-profile-editor>
|
|
6
|
+
|
|
7
|
+
<section class="account-section">
|
|
8
|
+
<h3>Security</h3>
|
|
9
|
+
<nav class="account-links">
|
|
10
|
+
<a matButton routerLink="/login/chpass">Change password</a>
|
|
11
|
+
<a matButton routerLink="/account/2fa">Two-factor authentication</a>
|
|
12
|
+
<a matButton routerLink="/account/devices">Trusted devices</a>
|
|
13
|
+
<a matButton class="warn" routerLink="/account/delete">Delete account</a>
|
|
14
|
+
</nav>
|
|
15
|
+
</section>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
|
2
|
+
import { RouterLink } from '@angular/router';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { ProfileEditorComponent } from './profile_editor';
|
|
5
|
+
|
|
6
|
+
// "My Account" hub: the profile editor plus links to the existing self-service
|
|
7
|
+
// flows (password, 2FA, trusted devices, account deletion) at their
|
|
8
|
+
// conventional sail routes.
|
|
9
|
+
@Component({
|
|
10
|
+
selector: 'sail-my-account',
|
|
11
|
+
templateUrl: './my_account.html',
|
|
12
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
13
|
+
imports: [RouterLink, MatButtonModule, ProfileEditorComponent],
|
|
14
|
+
})
|
|
15
|
+
export class MyAccountComponent {}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<section class="account-section">
|
|
2
|
+
<h3>Profile</h3>
|
|
3
|
+
<form [formGroup]="profileForm" (ngSubmit)="saveProfile()" class="auth-form">
|
|
4
|
+
<mat-form-field appearance="outline">
|
|
5
|
+
<mat-label>First name</mat-label>
|
|
6
|
+
<input matInput formControlName="firstName" required>
|
|
7
|
+
</mat-form-field>
|
|
8
|
+
<mat-form-field appearance="outline">
|
|
9
|
+
<mat-label>Last name</mat-label>
|
|
10
|
+
<input matInput formControlName="lastName">
|
|
11
|
+
</mat-form-field>
|
|
12
|
+
<mat-form-field appearance="outline">
|
|
13
|
+
<mat-label>Locale</mat-label>
|
|
14
|
+
<input matInput formControlName="locale" placeholder="en">
|
|
15
|
+
</mat-form-field>
|
|
16
|
+
<button matButton="elevated" class="primary" type="submit" [disabled]="!profileForm.valid || loading()">Save</button>
|
|
17
|
+
</form>
|
|
18
|
+
@if (profileMsg()) { <div class="auth-success" role="status">{{ profileMsg() }}</div> }
|
|
19
|
+
@if (profileErr()) { <div class="auth-error" role="alert">{{ profileErr() }}</div> }
|
|
20
|
+
</section>
|
|
21
|
+
|
|
22
|
+
<section class="account-section">
|
|
23
|
+
<h3>Email</h3>
|
|
24
|
+
@if (emailPhase() === 'idle') {
|
|
25
|
+
<p>{{ email() || 'Not set' }}</p>
|
|
26
|
+
<button matButton (click)="startEmailChange()">Change email</button>
|
|
27
|
+
} @else if (emailPhase() === 'enter') {
|
|
28
|
+
<mat-form-field appearance="outline">
|
|
29
|
+
<mat-label>New email</mat-label>
|
|
30
|
+
<input matInput type="email" [formControl]="newEmail" required>
|
|
31
|
+
</mat-form-field>
|
|
32
|
+
<button matButton="elevated" class="primary" (click)="sendEmailCode()" [disabled]="newEmail.invalid || loading()">Send code</button>
|
|
33
|
+
<button matButton (click)="cancelEmailChange()">Cancel</button>
|
|
34
|
+
} @else {
|
|
35
|
+
@if (emailMsg()) { <p>{{ emailMsg() }}</p> }
|
|
36
|
+
<mat-form-field appearance="outline">
|
|
37
|
+
<mat-label>Confirmation code</mat-label>
|
|
38
|
+
<input matInput [formControl]="emailCode" inputmode="numeric" required>
|
|
39
|
+
</mat-form-field>
|
|
40
|
+
<button matButton="elevated" class="primary" (click)="confirmEmailChange()" [disabled]="emailCode.invalid || loading()">Confirm</button>
|
|
41
|
+
<button matButton (click)="cancelEmailChange()">Cancel</button>
|
|
42
|
+
}
|
|
43
|
+
@if (emailErr()) { <div class="auth-error" role="alert">{{ emailErr() }}</div> }
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<section class="account-section">
|
|
47
|
+
<h3>Phone</h3>
|
|
48
|
+
<p>{{ phone() || 'Not set' }}</p>
|
|
49
|
+
@if (!phoneChangeEnabled) { <p class="auth-instructions">Phone change is coming soon.</p> }
|
|
50
|
+
</section>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
|
|
2
|
+
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
5
|
+
import { MatInputModule } from '@angular/material/input';
|
|
6
|
+
import { BaseAuthService } from '../../service/auth.service';
|
|
7
|
+
import { UserProfile } from '../../model/auth';
|
|
8
|
+
|
|
9
|
+
// Self-service profile editor: name/locale save immediately; email change is
|
|
10
|
+
// verify-before-apply (code to the new address). Phone change is gated until
|
|
11
|
+
// the backend has an SMS provider.
|
|
12
|
+
@Component({
|
|
13
|
+
selector: 'sail-profile-editor',
|
|
14
|
+
templateUrl: './profile_editor.html',
|
|
15
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
|
+
imports: [ReactiveFormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
|
|
17
|
+
})
|
|
18
|
+
export class ProfileEditorComponent implements OnInit {
|
|
19
|
+
private readonly auth = inject(BaseAuthService);
|
|
20
|
+
private readonly fb = inject(FormBuilder);
|
|
21
|
+
|
|
22
|
+
// Flip true once the backend wires an SMS provider for phone codes.
|
|
23
|
+
readonly phoneChangeEnabled = false;
|
|
24
|
+
|
|
25
|
+
readonly loading = signal(false);
|
|
26
|
+
readonly profileMsg = signal('');
|
|
27
|
+
readonly profileErr = signal('');
|
|
28
|
+
readonly email = signal('');
|
|
29
|
+
readonly phone = signal('');
|
|
30
|
+
|
|
31
|
+
readonly profileForm = this.fb.group({
|
|
32
|
+
firstName: ['', Validators.required],
|
|
33
|
+
lastName: [''],
|
|
34
|
+
locale: [''],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
readonly emailPhase = signal<'idle' | 'enter' | 'code'>('idle');
|
|
38
|
+
readonly emailMsg = signal('');
|
|
39
|
+
readonly emailErr = signal('');
|
|
40
|
+
readonly newEmail = new FormControl('', [Validators.required, Validators.email]);
|
|
41
|
+
readonly emailCode = new FormControl('', [Validators.required]);
|
|
42
|
+
|
|
43
|
+
ngOnInit(): void {
|
|
44
|
+
this.auth.getProfile().subscribe({
|
|
45
|
+
next: (p: UserProfile) => {
|
|
46
|
+
this.profileForm.patchValue({ firstName: p.firstName, lastName: p.lastName, locale: p.language });
|
|
47
|
+
this.email.set(p.email ?? '');
|
|
48
|
+
this.phone.set(p.phoneNumber ?? '');
|
|
49
|
+
},
|
|
50
|
+
error: () => this.profileErr.set('Could not load your profile.'),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
saveProfile(): void {
|
|
55
|
+
if (this.profileForm.invalid) return;
|
|
56
|
+
const v = this.profileForm.getRawValue();
|
|
57
|
+
this.loading.set(true);
|
|
58
|
+
this.profileMsg.set('');
|
|
59
|
+
this.profileErr.set('');
|
|
60
|
+
this.auth.updateProfile(v.firstName!, v.lastName ?? '', v.locale ?? '').subscribe({
|
|
61
|
+
next: () => { this.loading.set(false); this.profileMsg.set('Profile saved.'); },
|
|
62
|
+
error: (e) => { this.loading.set(false); this.profileErr.set(this.msg(e, 'Could not save profile.')); },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
startEmailChange(): void {
|
|
67
|
+
this.emailPhase.set('enter');
|
|
68
|
+
this.emailMsg.set('');
|
|
69
|
+
this.emailErr.set('');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
cancelEmailChange(): void {
|
|
73
|
+
this.emailPhase.set('idle');
|
|
74
|
+
this.newEmail.reset();
|
|
75
|
+
this.emailCode.reset();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
sendEmailCode(): void {
|
|
79
|
+
if (this.newEmail.invalid) return;
|
|
80
|
+
this.loading.set(true);
|
|
81
|
+
this.emailErr.set('');
|
|
82
|
+
this.auth.requestEmailChange(this.newEmail.value!).subscribe({
|
|
83
|
+
next: () => { this.loading.set(false); this.emailPhase.set('code'); this.emailMsg.set('We sent a code to your new email.'); },
|
|
84
|
+
error: (e) => { this.loading.set(false); this.emailErr.set(this.msg(e, 'Could not send a code.')); },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
confirmEmailChange(): void {
|
|
89
|
+
if (this.emailCode.invalid) return;
|
|
90
|
+
this.loading.set(true);
|
|
91
|
+
this.emailErr.set('');
|
|
92
|
+
this.auth.confirmEmailChange(this.newEmail.value!, Number(this.emailCode.value)).subscribe({
|
|
93
|
+
next: () => { this.loading.set(false); this.email.set(this.newEmail.value!); this.cancelEmailChange(); this.profileMsg.set('Email updated.'); },
|
|
94
|
+
error: (e) => { this.loading.set(false); this.emailErr.set(this.msg(e, 'Invalid or expired code.')); },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private msg(e: { error?: { detail?: string; message?: string } }, fallback: string): string {
|
|
99
|
+
return e?.error?.detail || e?.error?.message || fallback;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -40,11 +40,12 @@ export class RecordForm {
|
|
|
40
40
|
return tableDef.Columns
|
|
41
41
|
.filter(col => !(col.HasDefault && col.DataType === 'timestamp'
|
|
42
42
|
&& !editableTimestamps.has(col.PascalName)))
|
|
43
|
-
// column_display_attribute: 'H' is never shown; '
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
43
|
+
// column_display_attribute: 'H' is never shown; 'S' (secret) is
|
|
44
|
+
// also never shown and is stripped from reads server-side; 'R'/'U'
|
|
45
|
+
// (read-only and keel-set audit stamp) show display-only but hide
|
|
46
|
+
// when empty — so a new record's audit fields disappear and on edit
|
|
47
|
+
// only populated ones show.
|
|
48
|
+
.filter(col => col.DisplayMode !== 'H' && col.DisplayMode !== 'S')
|
|
48
49
|
.filter(col => !((col.DisplayMode === 'R' || col.DisplayMode === 'U')
|
|
49
50
|
&& this.isEmpty(record[col.PascalName])))
|
|
50
51
|
.sort((a, b) => a.Order - b.Order)
|
|
@@ -37,6 +37,9 @@
|
|
|
37
37
|
<span class="toolbar-spacer"></span>
|
|
38
38
|
<ng-content select="[toolbar-actions]"></ng-content>
|
|
39
39
|
@if (isLoggedIn()) {
|
|
40
|
+
@if (accountLink) {
|
|
41
|
+
<a matButton [routerLink]="accountLink.routerLink">{{ accountLink.label }}</a>
|
|
42
|
+
}
|
|
40
43
|
<button matButton (click)="logout()">Logout</button>
|
|
41
44
|
} @else if (guiConfig.publicRouteLinks) {
|
|
42
45
|
@for (link of guiConfig.publicRouteLinks; track link.routerLink) {
|
|
@@ -23,12 +23,18 @@
|
|
|
23
23
|
@if (backupCodes().length > 0) {
|
|
24
24
|
<div class="twofactor-backup">
|
|
25
25
|
<h3>Backup Codes</h3>
|
|
26
|
-
<p class="auth-instructions">
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
<p class="auth-instructions">Download these codes and keep them safe — they're shown only once, each works once, and can't be retrieved later (only regenerated).</p>
|
|
27
|
+
<div class="auth-actions">
|
|
28
|
+
<button matButton="elevated" class="primary" type="button" (click)="downloadBackupCodes()">Download codes</button>
|
|
29
|
+
<button matButton type="button" (click)="showCodes.set(!showCodes())">{{ showCodes() ? 'Hide codes' : 'Show codes' }}</button>
|
|
30
|
+
</div>
|
|
31
|
+
@if (showCodes()) {
|
|
32
|
+
<ul class="backup-code-list">
|
|
33
|
+
@for (code of backupCodes(); track code) {
|
|
34
|
+
<li><code>{{ code }}</code></li>
|
|
35
|
+
}
|
|
36
|
+
</ul>
|
|
37
|
+
}
|
|
32
38
|
</div>
|
|
33
39
|
}
|
|
34
40
|
|
|
@@ -28,6 +28,7 @@ export class TwoFactorSetupComponent extends BaseAsync {
|
|
|
28
28
|
readonly qrUri = signal('');
|
|
29
29
|
readonly secret = signal('');
|
|
30
30
|
readonly backupCodes = signal<string[]>([]);
|
|
31
|
+
readonly showCodes = signal(false);
|
|
31
32
|
|
|
32
33
|
// setup2FA requires the user's current password (or current TOTP code
|
|
33
34
|
// if 2FA already enabled — pass twoFactorCode instead). Disable additionally
|
|
@@ -65,6 +66,18 @@ export class TwoFactorSetupComponent extends BaseAsync {
|
|
|
65
66
|
this.step.set('verify');
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
// Backup codes are shown once at setup. Offer a download (they're hashed
|
|
70
|
+
// server-side, so they can never be re-displayed later — only regenerated).
|
|
71
|
+
downloadBackupCodes() {
|
|
72
|
+
const blob = new Blob([this.backupCodes().join('\n') + '\n'], { type: 'text/plain' });
|
|
73
|
+
const url = URL.createObjectURL(blob);
|
|
74
|
+
const a = document.createElement('a');
|
|
75
|
+
a.href = url;
|
|
76
|
+
a.download = 'backup-codes.txt';
|
|
77
|
+
a.click();
|
|
78
|
+
URL.revokeObjectURL(url);
|
|
79
|
+
}
|
|
80
|
+
|
|
68
81
|
confirmSetup() {
|
|
69
82
|
if (this.verifyForm.invalid) return;
|
|
70
83
|
const { code } = this.verifyForm.value;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<h2 mat-dialog-title>Copy now</h2>
|
|
2
|
+
<mat-dialog-content>
|
|
3
|
+
<p>This won't be shown again.</p>
|
|
4
|
+
@for (entry of entries; track entry[0]) {
|
|
5
|
+
<p style="margin:12px 0 2px; font-size:12px; opacity:0.7;">{{ entry[0] }}</p>
|
|
6
|
+
<div style="display:flex; align-items:center; gap:8px;">
|
|
7
|
+
<code style="flex:1; word-break:break-all;">{{ entry[1] }}</code>
|
|
8
|
+
<button matIconButton (click)="copy(entry[1])" [attr.aria-label]="'Copy ' + entry[0]">
|
|
9
|
+
<mat-icon>content_copy</mat-icon>
|
|
10
|
+
</button>
|
|
11
|
+
</div>
|
|
12
|
+
}
|
|
13
|
+
</mat-dialog-content>
|
|
14
|
+
<mat-dialog-actions align="end">
|
|
15
|
+
<button matButton mat-dialog-close>Done</button>
|
|
16
|
+
</mat-dialog-actions>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
|
|
2
|
+
import { MAT_DIALOG_DATA, MatDialogModule } from "@angular/material/dialog";
|
|
3
|
+
import { MatButtonModule } from "@angular/material/button";
|
|
4
|
+
import { MatIconModule } from "@angular/material/icon";
|
|
5
|
+
import { Clipboard } from "@angular/cdk/clipboard";
|
|
6
|
+
|
|
7
|
+
/** Shows the one-time secret a kind:'V' reveal action returned (e.g. a minted API key), with copy buttons. */
|
|
8
|
+
@Component({
|
|
9
|
+
selector: "sail-reveal-dialog",
|
|
10
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
11
|
+
imports: [MatDialogModule, MatButtonModule, MatIconModule],
|
|
12
|
+
templateUrl: "./reveal_dialog.html",
|
|
13
|
+
})
|
|
14
|
+
export class RevealDialog {
|
|
15
|
+
private readonly clipboard = inject(Clipboard);
|
|
16
|
+
readonly entries = Object.entries(inject<Record<string, unknown>>(MAT_DIALOG_DATA))
|
|
17
|
+
.filter(([, v]) => typeof v === "string" && v.length > 0) as [string, string][];
|
|
18
|
+
|
|
19
|
+
copy(value: string): void {
|
|
20
|
+
this.clipboard.copy(value);
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface SailGuiConfig {
|
|
|
37
37
|
loginFooterLinks?: RouteLink[];
|
|
38
38
|
/** Links shown in navigation toolbar when not logged in (e.g., Login, Register) */
|
|
39
39
|
publicRouteLinks?: RouteLink[];
|
|
40
|
+
/** Link shown in the toolbar before Logout when logged in, e.g. {label:'My Account', routerLink:'/account'}. Omitted → not shown. */
|
|
41
|
+
accountLink?: RouteLink;
|
|
40
42
|
/** Map RestUri (or prefix pattern like 'analytic/*') to a custom component */
|
|
41
43
|
menuItemRouteOverrides?: { [restUriPattern: string]: Type<unknown> };
|
|
42
44
|
/** Per-table overrides for hiding fields / seeding defaults. Keyed by table PascalName. */
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,8 @@ export * from './component/login/consent_gate';
|
|
|
26
26
|
export * from './component/login/otp_input';
|
|
27
27
|
export * from './component/login/social_login';
|
|
28
28
|
export * from './component/login/account_deletion';
|
|
29
|
+
export * from './component/account/profile_editor';
|
|
30
|
+
export * from './component/account/my_account';
|
|
29
31
|
export * from './component/security/twofactor_setup';
|
|
30
32
|
export * from './component/security/twofactor_verify';
|
|
31
33
|
export * from './component/security/trusted_devices';
|
package/src/model/appdata.ts
CHANGED
|
@@ -83,7 +83,7 @@ export interface TableAction {
|
|
|
83
83
|
confirmMessage?: string;
|
|
84
84
|
authorityObject: string; // uppercased table_name — for canExecute()
|
|
85
85
|
authorityCheck: string; // uppercased action_name — for canExecute()
|
|
86
|
-
kind?: '
|
|
86
|
+
kind?: 'P' | 'R' | 'V'; // P post (default) / R redirect (follow {url}) / V reveal (show returned secret once)
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
export interface ForeignKey {
|
package/src/model/auth.ts
CHANGED
|
@@ -102,6 +102,16 @@ export interface OtpResendRequest {
|
|
|
102
102
|
purpose?: OtpPurpose;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// Current user's editable profile (GET profileURL). language is the locale.
|
|
106
|
+
export interface UserProfile {
|
|
107
|
+
firstName: string;
|
|
108
|
+
lastName: string;
|
|
109
|
+
email: string;
|
|
110
|
+
phoneNumber: string;
|
|
111
|
+
language: string;
|
|
112
|
+
twoFactorEnabled?: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
105
115
|
// ── Re-authentication credentials ──
|
|
106
116
|
// Required by Setup2FA, Disable2FA, DeleteAccount, LogoutEverywhere — server
|
|
107
117
|
// rejects with 401 if neither `password` nor `twoFactorCode` is supplied.
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
PushPlatform, PushRegisterRequest,
|
|
9
9
|
ReauthCredentials,
|
|
10
10
|
SignupConsent, SocialLoginRequest, SocialProvider,
|
|
11
|
+
UserProfile,
|
|
11
12
|
} from '../model/auth';
|
|
12
13
|
import { RestURL } from './rest_url';
|
|
13
14
|
import { Observable, ReplaySubject } from 'rxjs';
|
|
@@ -51,6 +52,11 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
51
52
|
protected readonly deleteAccountUrl = this.url(RestURL.deleteAccountURL);
|
|
52
53
|
protected readonly pushRegisterUrl = this.url(RestURL.pushRegisterURL);
|
|
53
54
|
protected readonly pushRevokeUrl = this.url(RestURL.pushRevokeURL);
|
|
55
|
+
protected readonly profileUrl = this.url(RestURL.profileURL);
|
|
56
|
+
protected readonly profileEmailUrl = this.url(RestURL.profileEmailURL);
|
|
57
|
+
protected readonly profileEmailConfirmUrl = this.url(RestURL.profileEmailConfirmURL);
|
|
58
|
+
protected readonly profilePhoneUrl = this.url(RestURL.profilePhoneURL);
|
|
59
|
+
protected readonly profilePhoneConfirmUrl = this.url(RestURL.profilePhoneConfirmURL);
|
|
54
60
|
|
|
55
61
|
token: string | null = null;
|
|
56
62
|
readonly isLoggedIn = signal(false);
|
|
@@ -216,6 +222,34 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
216
222
|
return this.http.post<{ message: string }>(this.chpassUrl, { username });
|
|
217
223
|
}
|
|
218
224
|
|
|
225
|
+
// ── Self-service profile (keel ProfileHandler) ──
|
|
226
|
+
// Name/locale apply immediately; email/phone are verify-before-apply: a code
|
|
227
|
+
// is sent to the new value, the change lands only on confirm.
|
|
228
|
+
|
|
229
|
+
getProfile() {
|
|
230
|
+
return this.http.get<UserProfile>(this.profileUrl);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
updateProfile(firstName: string, lastName: string, locale: string) {
|
|
234
|
+
return this.http.post<{ message: string }>(this.profileUrl, { firstName, lastName, locale });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
requestEmailChange(value: string) {
|
|
238
|
+
return this.http.post<{ message: string }>(this.profileEmailUrl, { value });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
confirmEmailChange(value: string, code: number) {
|
|
242
|
+
return this.http.post<{ message: string }>(this.profileEmailConfirmUrl, { value, code });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
requestPhoneChange(value: string) {
|
|
246
|
+
return this.http.post<{ message: string }>(this.profilePhoneUrl, { value });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
confirmPhoneChange(value: string, code: number) {
|
|
250
|
+
return this.http.post<{ message: string }>(this.profilePhoneConfirmUrl, { value, code });
|
|
251
|
+
}
|
|
252
|
+
|
|
219
253
|
confirmRegister(email: string, code: string) {
|
|
220
254
|
const params = new HttpParams().set('email', email).set('code', code);
|
|
221
255
|
return this.http.post<ConfirmRegisterResponse>(this.confirmRegisterUrl, null, { params });
|
package/src/service/rest_url.ts
CHANGED
|
@@ -33,6 +33,15 @@ export const RestURL = {
|
|
|
33
33
|
loginSocialURL: '/public/login/social',
|
|
34
34
|
logoutEverywhereURL: '/api/user/logout-everywhere',
|
|
35
35
|
deleteAccountURL: '/api/user/account',
|
|
36
|
+
// Self-service profile (keel ProfileHandler). GET profileURL reads the
|
|
37
|
+
// current profile; POST updates name/locale. Email/phone are verify-before-
|
|
38
|
+
// apply (request → confirm). A consumer whose backend mounts these under a
|
|
39
|
+
// different prefix overrides them via configureRestUrls.
|
|
40
|
+
profileURL: '/api/user/profile',
|
|
41
|
+
profileEmailURL: '/api/user/profile/email',
|
|
42
|
+
profileEmailConfirmURL: '/api/user/profile/email/confirm',
|
|
43
|
+
profilePhoneURL: '/api/user/profile/phone',
|
|
44
|
+
profilePhoneConfirmURL: '/api/user/profile/phone/confirm',
|
|
36
45
|
pushRegisterURL: '/api/push/register',
|
|
37
46
|
pushRevokeURL: '/api/push/revoke',
|
|
38
47
|
// Payout — keel/payout endpoints.
|