@nauticana/sail 1.0.8 → 1.1.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.
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.0.8",
3
+ "version": "1.1.0",
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": {
@@ -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; 'R'/'U' (read-only
44
- // and keel-set audit stamp) show display-only but hide when empty —
45
- // so a new record's audit fields disappear and on edit only
46
- // populated ones show.
47
- .filter(col => col.DisplayMode !== 'H')
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) {
@@ -57,6 +57,7 @@ export class Navigation {
57
57
  );
58
58
 
59
59
  readonly isLoggedIn = this.authService.isLoggedIn;
60
+ protected readonly accountLink = this.guiConfig.accountLink;
60
61
 
61
62
  logout() {
62
63
  this.authService.logout();
@@ -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">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>
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;
@@ -38,7 +38,7 @@
38
38
  </td>
39
39
  }
40
40
  <td>
41
- <button matIconButton (click)="saveRow()" title="Save"><mat-icon>save</mat-icon></button>
41
+ <button matIconButton (click)="saveRow()" [title]="isNew ? 'Add' : 'Update'"><mat-icon>{{ isNew ? 'add' : 'check' }}</mat-icon></button>
42
42
  <button matIconButton (click)="cancelRow(record)" title="Cancel"><mat-icon>cancel</mat-icon></button>
43
43
  </td>
44
44
  } @else {
@@ -1,4 +1,4 @@
1
- import { ChangeDetectionStrategy, Component, effect, inject, input, linkedSignal, OnInit, ViewEncapsulation } from "@angular/core";
1
+ import { ChangeDetectionStrategy, Component, effect, inject, input, linkedSignal, OnInit, output, ViewEncapsulation } from "@angular/core";
2
2
  import { DynamicField } from "../form/form_field";
3
3
  import { MatButtonModule } from "@angular/material/button";
4
4
  import { BaseForm } from "../abstract/base_form";
@@ -26,6 +26,9 @@ export class TableDetail extends BaseForm implements OnInit {
26
26
  readonly parentRecord = input<any>({});
27
27
  /** When true, the parent view is in read-only mode — disable all child row editing. */
28
28
  readonly parentReadOnly = input(false);
29
+ /** Fires after any grid mutation (add/edit/delete/undelete/cancel) so a parent
30
+ * can re-evaluate unsaved-changes state. Local only — no server call. */
31
+ readonly changed = output<void>();
29
32
 
30
33
  override readonly tableName = linkedSignal<string, string>({ source: () => this.tableNameInput(), computation: (v, p) => v || p?.value || '' });
31
34
 
@@ -88,6 +91,7 @@ export class TableDetail extends BaseForm implements OnInit {
88
91
  } else {
89
92
  Object.assign(record, result);
90
93
  }
94
+ this.changed.emit();
91
95
  }
92
96
  });
93
97
  }
@@ -123,6 +127,7 @@ export class TableDetail extends BaseForm implements OnInit {
123
127
  this.editingRecord = null;
124
128
  this.originalRecord = null;
125
129
  this.isNew = false;
130
+ this.changed.emit();
126
131
  }
127
132
 
128
133
  cancelRow(record: any) {
@@ -137,6 +142,7 @@ export class TableDetail extends BaseForm implements OnInit {
137
142
  this.editingRecord = null;
138
143
  this.originalRecord = null;
139
144
  this.isNew = false;
145
+ this.changed.emit();
140
146
  }
141
147
 
142
148
  deleteRow(record: any) {
@@ -148,11 +154,13 @@ export class TableDetail extends BaseForm implements OnInit {
148
154
  } else {
149
155
  record[this.config.opField] = OpCode.Delete;
150
156
  }
157
+ this.changed.emit();
151
158
  }
152
159
 
153
160
  unDeleteRow(record: any) {
154
161
  if (record[this.config.opField] === OpCode.Delete) {
155
162
  record[this.config.opField] = OpCode.Update;
156
163
  }
164
+ this.changed.emit();
157
165
  }
158
166
  }
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/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,11 +8,12 @@ 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';
14
15
  import { ApplicationMenu, ConstantValue } from '../model/common';
15
- import { CanActivateFn, Router, Routes } from '@angular/router';
16
+ import { CanActivateFn, CanDeactivateFn, Router, Routes } from '@angular/router';
16
17
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../config';
17
18
  import { BaseRestService } from './base_rest.service';
18
19
 
@@ -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 });
@@ -500,6 +534,15 @@ export abstract class BaseAuthService extends BaseRestService {
500
534
  );
501
535
  };
502
536
 
537
+ // Confirms navigation away when the routed component reports unsaved changes.
538
+ // Opt in by implementing hasUnsavedChanges(): boolean on the component.
539
+ readonly canDeactivate: CanDeactivateFn<{ hasUnsavedChanges?: () => boolean }> = (component) => {
540
+ if (component && typeof component.hasUnsavedChanges === 'function' && component.hasUnsavedChanges()) {
541
+ return window.confirm('You have unsaved changes. Leave this page without saving?');
542
+ }
543
+ return true;
544
+ };
545
+
503
546
  /** Override in subclasses to add project-specific routes */
504
547
  protected buildExtraRoutes(data: ApplicationData): Routes {
505
548
  if (this.guiConfig.extraRoutes) {
@@ -615,6 +658,13 @@ export abstract class BaseAuthService extends BaseRestService {
615
658
  }
616
659
  }
617
660
 
661
+ // Guard navigation away from any component-backed page that
662
+ // tracks unsaved changes (opt-in via hasUnsavedChanges()).
663
+ for (const r of children) {
664
+ if ((r as { component?: unknown }).component) {
665
+ (r as { canDeactivate?: unknown[] }).canDeactivate = [this.canDeactivate];
666
+ }
667
+ }
618
668
  newRoutes.push({ path: menuPath, children });
619
669
  }
620
670
 
@@ -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.