@nauticana/sail 1.1.32 → 1.1.34

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 (42) hide show
  1. package/README.md +35 -19
  2. package/package.json +1 -1
  3. package/src/agent/component/approval_inbox.html +63 -0
  4. package/src/agent/component/approval_inbox.ts +91 -0
  5. package/src/agent/component/decision_timeline.html +4 -4
  6. package/src/agent/component/decision_timeline.ts +2 -0
  7. package/src/agent/component/prompt_section_editor.html +4 -3
  8. package/src/agent/component/prompt_section_editor.ts +12 -5
  9. package/src/agent/component/release_sections.html +1 -1
  10. package/src/agent/component/release_sections.ts +2 -1
  11. package/src/agent/component/studio_page.html +269 -0
  12. package/src/agent/component/studio_page.ts +346 -0
  13. package/src/agent/component/studio_tab.ts +14 -0
  14. package/src/agent/component/turn_event_list.html +9 -0
  15. package/src/agent/index.ts +6 -0
  16. package/src/agent/model/agent_studio.ts +122 -9
  17. package/src/agent/model/approval.ts +42 -0
  18. package/src/agent/model/conversation.ts +9 -1
  19. package/src/agent/model/decision.ts +2 -1
  20. package/src/agent/model/turn_event.ts +10 -0
  21. package/src/agent/service/agent_studio.service.ts +36 -1
  22. package/src/agent/service/approval.service.ts +18 -0
  23. package/src/agent/util/release_diff.ts +33 -0
  24. package/src/agent/util/studio_errors.ts +11 -0
  25. package/src/agent/util/turn_timeline.ts +7 -1
  26. package/src/component/login/account_deletion.html +17 -4
  27. package/src/component/login/account_deletion.ts +35 -28
  28. package/src/component/login/login_component.html +11 -0
  29. package/src/component/login/login_component.ts +2 -1
  30. package/src/component/login/social_login.ts +6 -5
  31. package/src/component/security/sessions.html +46 -0
  32. package/src/component/security/sessions.ts +45 -0
  33. package/src/component/security/twofactor_verify.html +12 -8
  34. package/src/component/security/twofactor_verify.ts +2 -1
  35. package/src/index.ts +2 -0
  36. package/src/model/appdata.ts +12 -0
  37. package/src/model/auth.ts +17 -2
  38. package/src/service/auth.service.ts +93 -48
  39. package/src/service/caption_pipe.ts +26 -0
  40. package/src/service/label.service.ts +5 -4
  41. package/src/service/rest_url.ts +3 -0
  42. package/src/util/errors.ts +7 -1
@@ -7,7 +7,7 @@ import { BaseAuthService } from '../../service/auth.service';
7
7
  import { loadScript } from '../../service/script_loader';
8
8
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
9
9
  import {
10
- ConsentState, LoginResponseSocial, SignupConsent, SocialProvider,
10
+ ConsentState, LoginResponseSocial, SignupConsent, SocialName, SocialProvider,
11
11
  } from '../../model/auth';
12
12
  import { firstValueFrom } from 'rxjs';
13
13
 
@@ -22,7 +22,8 @@ const APPLE_URL = 'https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/
22
22
  * buttons using each provider's official SDK.
23
23
  *
24
24
  * Flow: click → SDK returns ID token → BaseAuthService.loginSocial(provider,
25
- * idToken, signupConsent) → emits loginSuccess / loginError.
25
+ * idToken, signupConsent) → emits loginSuccess / loginError. Apple's
26
+ * first-authorization name rides along as loginSuccess.socialName.
26
27
  *
27
28
  * Requires `googleClientId` / `appleServiceId` in SailGuiConfig; providers
28
29
  * without config are hidden (with a console.warn for diagnostics).
@@ -104,17 +105,17 @@ export class SocialLoginComponent extends BaseAsync implements OnInit {
104
105
  try {
105
106
  const res = await AppleID.auth.signIn();
106
107
  const token = res?.authorization?.id_token;
107
- if (token) this.onIdToken('apple', token);
108
+ if (token) this.onIdToken('apple', token, res.user?.name);
108
109
  } catch (err: unknown) {
109
110
  this.loginError.emit(err instanceof Error ? err : new Error(String(err)));
110
111
  }
111
112
  }
112
113
 
113
- private onIdToken(provider: SocialProvider, idToken: string) {
114
+ private onIdToken(provider: SocialProvider, idToken: string, socialName?: SocialName) {
114
115
  const fallback = `Sign-in with ${provider} failed.`;
115
116
  this.run(
116
117
  this.auth.loginSocial(provider, idToken, this.buildSignupConsent()),
117
- (resp) => this.loginSuccess.emit(resp),
118
+ (resp) => this.loginSuccess.emit(socialName ? { ...resp, socialName } : resp),
118
119
  fallback,
119
120
  (err) => this.loginError.emit(this.toError(err, fallback)),
120
121
  );
@@ -0,0 +1,46 @@
1
+ <div class="auth-container">
2
+ <div class="auth-card">
3
+ <h2 class="auth-title">Sessions</h2>
4
+ <p class="auth-instructions">Where your account is signed in.</p>
5
+
6
+ @if (sessions().length > 0) {
7
+ <table class="sessions-table">
8
+ <thead>
9
+ <tr>
10
+ <th>Device</th>
11
+ <th>Address</th>
12
+ <th>Signed In</th>
13
+ <th>Last Active</th>
14
+ <th><span class="cdk-visually-hidden">Actions</span></th>
15
+ </tr>
16
+ </thead>
17
+ <tbody>
18
+ @for (session of sessions(); track session.id) {
19
+ <tr>
20
+ <td>{{ session.userAgent || 'Unknown device' }} ({{ methodCaption(session.signInMethod) }})</td>
21
+ <td>{{ session.clientIp }}</td>
22
+ <td>{{ session.createdAt | date: 'short' }}</td>
23
+ <td>{{ session.lastSeenAt | date: 'short' }}</td>
24
+ <td>
25
+ @if (session.current) {
26
+ This device
27
+ } @else {
28
+ <button matButton type="button" (click)="revoke(session)" [disabled]="loading()">Sign out</button>
29
+ }
30
+ </td>
31
+ </tr>
32
+ }
33
+ </tbody>
34
+ </table>
35
+ } @else if (!loading()) {
36
+ <p class="empty-state">No active sessions.</p>
37
+ }
38
+
39
+ @if (errorMessage()) {
40
+ <div class="auth-error" role="alert">{{ errorMessage() }}</div>
41
+ }
42
+ @if (successMessage()) {
43
+ <div class="auth-success" role="status">{{ successMessage() }}</div>
44
+ }
45
+ </div>
46
+ </div>
@@ -0,0 +1,45 @@
1
+ import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, inject, signal } from '@angular/core';
2
+ import { DatePipe } from '@angular/common';
3
+ import { MatButtonModule } from '@angular/material/button';
4
+ import { BaseAsync } from '../abstract/base_async';
5
+ import { BaseAuthService } from '../../service/auth.service';
6
+ import { ActiveSession } from '../../model/appdata';
7
+
8
+ @Component({
9
+ selector: 'sail-sessions',
10
+ templateUrl: './sessions.html',
11
+ changeDetection: ChangeDetectionStrategy.OnPush,
12
+ encapsulation: ViewEncapsulation.None,
13
+ imports: [DatePipe, MatButtonModule],
14
+ })
15
+ export class SessionsComponent extends BaseAsync implements OnInit {
16
+ private readonly auth = inject(BaseAuthService);
17
+
18
+ readonly sessions = signal<ActiveSession[]>([]);
19
+ private readonly methods = new Map(
20
+ (this.auth.getDomainValues('sign_in_method') ?? []).map((v) => [v.Value, v.Caption]));
21
+
22
+ ngOnInit() {
23
+ this.loadSessions();
24
+ }
25
+
26
+ loadSessions() {
27
+ this.run(this.auth.getSessions(), (sessions) => this.sessions.set(sessions), 'Failed to load sessions.');
28
+ }
29
+
30
+ methodCaption(code: string): string {
31
+ return this.methods.get(code) ?? code;
32
+ }
33
+
34
+ revoke(session: ActiveSession) {
35
+ if (!confirm(`Sign out the session on ${session.userAgent || 'an unknown device'}?`)) return;
36
+ this.run(
37
+ this.auth.revokeSession(session.id),
38
+ () => {
39
+ this.loadSessions();
40
+ this.successMessage.set('Session signed out.');
41
+ },
42
+ 'Failed to sign out the session.',
43
+ );
44
+ }
45
+ }
@@ -5,9 +5,11 @@
5
5
  </div>
6
6
  }
7
7
  <div class="auth-card">
8
- <h2 class="auth-title">Two-Factor Verification</h2>
8
+ <h2 class="auth-title">{{ emailed ? 'Confirm Sign-In' : 'Two-Factor Verification' }}</h2>
9
9
  <p class="auth-instructions">
10
- @if (useBackupCode()) {
10
+ @if (emailed) {
11
+ We emailed you a sign-in code because this device is new. Enter it to continue.
12
+ } @else if (useBackupCode()) {
11
13
  Enter one of your backup codes.
12
14
  } @else {
13
15
  Enter the 6-digit code from your authenticator app.
@@ -19,7 +21,7 @@
19
21
  <input matInput type="text" formControlName="code" autocomplete="one-time-code" required>
20
22
  </mat-form-field>
21
23
 
22
- @if (!useBackupCode()) {
24
+ @if (!useBackupCode() && !emailed) {
23
25
  <div class="auth-checkbox">
24
26
  <mat-checkbox formControlName="trustDevice">
25
27
  Trust this device for 30 days
@@ -36,11 +38,13 @@
36
38
  </button>
37
39
  </form>
38
40
  <div class="auth-actions">
39
- <a tabindex="0" role="button" (click)="toggleBackupCode()" (keydown.enter)="toggleBackupCode()"
40
- (keydown.space)="$event.preventDefault(); toggleBackupCode()">
41
- {{ useBackupCode() ? 'Use authenticator code' : 'Use a backup code instead' }}
42
- </a>
43
- <a routerLink="/login/local">Back to Login</a>
41
+ @if (!emailed) {
42
+ <a tabindex="0" role="button" (click)="toggleBackupCode()" (keydown.enter)="toggleBackupCode()"
43
+ (keydown.space)="$event.preventDefault(); toggleBackupCode()">
44
+ {{ useBackupCode() ? 'Use authenticator code' : 'Use a backup code instead' }}
45
+ </a>
46
+ }
47
+ <a routerLink="/login/local" queryParamsHandling="preserve">Back to Login</a>
44
48
  </div>
45
49
  </div>
46
50
  </div>
@@ -29,6 +29,7 @@ export class TwoFactorVerifyComponent extends BaseAsync {
29
29
  protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
30
30
 
31
31
  readonly useBackupCode = signal(false);
32
+ readonly emailed = this.auth.pendingTwoFactorMethod() === 'email';
32
33
 
33
34
  readonly verifyForm = this.fb.group({
34
35
  code: ['', [Validators.required, Validators.minLength(6)]],
@@ -58,7 +59,7 @@ export class TwoFactorVerifyComponent extends BaseAsync {
58
59
  );
59
60
  } else {
60
61
  this.run(
61
- this.auth.verify2FALogin(code!, trustDevice ?? false, this.getDeviceName()),
62
+ this.auth.verify2FALogin(code!, !this.emailed && !!trustDevice, this.getDeviceName()),
62
63
  (res) => {
63
64
  if (!res.valid || !res.token) {
64
65
  this.errorMessage.set('Invalid authentication code.');
package/src/index.ts CHANGED
@@ -52,6 +52,7 @@ export * from './component/account/my_account';
52
52
  export * from './component/security/twofactor_setup';
53
53
  export * from './component/security/twofactor_verify';
54
54
  export * from './component/security/trusted_devices';
55
+ export * from './component/security/sessions';
55
56
  export * from './component/billing/plan_selector';
56
57
  export * from './component/billing/price_selector';
57
58
  export * from './component/billing/checkout_button';
@@ -93,6 +94,7 @@ export * from './service/api-response.interceptor';
93
94
  export * from './service/rest_service';
94
95
  export * from './service/rest_url';
95
96
  export * from './service/label.service';
97
+ export * from './service/caption_pipe';
96
98
  export * from './service/base_rest.service';
97
99
  export * from './util/text';
98
100
  export * from './util/money';
@@ -164,6 +164,8 @@ export interface LoginResponse2FA {
164
164
  refreshToken?: string;
165
165
  twoFactorRequired: boolean;
166
166
  loginToken?: string;
167
+ /** 'email': a new-device step-up code was emailed; absent: the authenticator app. */
168
+ twoFactorMethod?: 'email';
167
169
  userId?: number;
168
170
  partnerId?: number;
169
171
  // Populated for users with the single-device session policy enabled.
@@ -198,6 +200,16 @@ export interface TrustedDevice {
198
200
  created_at: string;
199
201
  }
200
202
 
203
+ export interface ActiveSession {
204
+ id: number;
205
+ userAgent: string;
206
+ clientIp: string;
207
+ signInMethod: string;
208
+ createdAt: string;
209
+ lastSeenAt: string;
210
+ current: boolean;
211
+ }
212
+
201
213
  // Mirrors keel `user.AccountRegistration`.
202
214
  export interface AccountRegistration {
203
215
  firstName: string;
package/src/model/auth.ts CHANGED
@@ -115,11 +115,19 @@ export interface UserProfile {
115
115
 
116
116
  // ── Re-authentication credentials ──
117
117
  // Required by Setup2FA, Disable2FA, DeleteAccount, LogoutEverywhere — server
118
- // rejects with 401 if neither `password` nor `twoFactorCode` is supplied.
119
- // Disable2FA additionally requires the current TOTP `code` alongside password.
118
+ // rejects with 401 unless one is supplied. `reauthCode` comes from
119
+ // sendReauthCode(). Disable2FA additionally requires the current TOTP `code`.
120
120
  export interface ReauthCredentials {
121
121
  password?: string;
122
122
  twoFactorCode?: string;
123
+ reauthCode?: string;
124
+ }
125
+
126
+ export type ReauthChannel = 'email' | 'phone';
127
+
128
+ export interface ReauthSendResponse {
129
+ channel: ReauthChannel;
130
+ resendCountdownSec: number;
123
131
  }
124
132
 
125
133
  // ── Social login (ID token flow) ──
@@ -144,6 +152,13 @@ export interface LoginResponseSocial {
144
152
  isNewUser: boolean;
145
153
  singleDeviceSession?: boolean;
146
154
  previousDeviceRevoked?: boolean;
155
+ // Client-side: the name Apple returns only on first authorization, never in the ID token.
156
+ socialName?: SocialName;
157
+ }
158
+
159
+ export interface SocialName {
160
+ firstName?: string;
161
+ lastName?: string;
147
162
  }
148
163
 
149
164
  // ── Push notification tokens ──
@@ -2,12 +2,12 @@ import { Injectable, WritableSignal, inject, signal } from '@angular/core';
2
2
  import { HttpErrorResponse, HttpParams } from '@angular/common/http';
3
3
  import { filter, finalize, map, mergeMap, shareReplay, take, tap } from 'rxjs/operators';
4
4
  import { PasswordRules } from '../util/password_policy';
5
- import { ApplicationData, ConfirmRegisterResponse, DictionaryPath, LoginResponse2FA, PartnerCreated, PartnerRegistration, PartnerSetup, RestReport, TableDefinition, TokenPair, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
5
+ import { ActiveSession, ApplicationData, ConfirmRegisterResponse, DictionaryPath, LoginResponse2FA, PartnerCreated, PartnerRegistration, PartnerSetup, RestReport, TableDefinition, TokenPair, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
6
6
  import {
7
7
  LoginResponseSocial,
8
8
  OtpRequest, OtpResendRequest, OtpResponse, OtpVerifyRequest, OtpVerifyResponse,
9
9
  PushPlatform, PushRegisterRequest,
10
- ReauthCredentials,
10
+ ReauthChannel, ReauthCredentials, ReauthSendResponse,
11
11
  SignupConsent, SocialLoginRequest, SocialProvider,
12
12
  UserProfile,
13
13
  } from '../model/auth';
@@ -104,12 +104,15 @@ export abstract class BaseAuthService extends BaseRestService {
104
104
  protected get trustedDeviceListUrl() { return this.url(RestURL.trustedDeviceListURL); }
105
105
  protected get trustedDeviceRegisterUrl() { return this.url(RestURL.trustedDeviceRegisterURL); }
106
106
  protected get trustedDeviceRevokeUrl() { return this.url(RestURL.trustedDeviceRevokeURL); }
107
+ protected get sessionListUrl() { return this.url(RestURL.sessionListURL); }
108
+ protected get sessionRevokeUrl() { return this.url(RestURL.sessionRevokeURL); }
107
109
  protected get otpSendUrl() { return this.url(RestURL.otpSendURL); }
108
110
  protected get otpVerifyUrl() { return this.url(RestURL.otpVerifyURL); }
109
111
  protected get otpResendUrl() { return this.url(RestURL.otpResendURL); }
110
112
  protected get loginSocialUrl() { return this.url(RestURL.loginSocialURL); }
111
113
  protected get logoutEverywhereUrl() { return this.url(RestURL.logoutEverywhereURL); }
112
114
  protected get deleteAccountUrl() { return this.url(RestURL.deleteAccountURL); }
115
+ protected get reauthSendUrl() { return this.url(RestURL.reauthSendURL); }
113
116
  protected get pushRegisterUrl() { return this.url(RestURL.pushRegisterURL); }
114
117
  protected get pushRevokeUrl() { return this.url(RestURL.pushRevokeURL); }
115
118
  protected get profileUrl() { return this.url(RestURL.profileURL); }
@@ -146,6 +149,8 @@ export abstract class BaseAuthService extends BaseRestService {
146
149
  * signs in again to retry instead of being redirected without a session. */
147
150
  readonly sessionHandoffError = signal<string | null>(null);
148
151
 
152
+ readonly signInPrompt = signal<'login' | 'select_account' | null>(null);
153
+
149
154
  /**
150
155
  * Multi-role app support. Consumers that distinguish rider/driver/admin
151
156
  * (or any equivalent split) call setRole() after login, then
@@ -219,9 +224,14 @@ export abstract class BaseAuthService extends BaseRestService {
219
224
  private completeLogin(token: string, refreshToken?: string) {
220
225
  this.refreshInFlight = null; // a rotation started under the previous session must not land
221
226
  this.sessionGeneration++;
227
+ const replaced = localStorage.getItem('refreshToken');
228
+ if (replaced && replaced !== refreshToken) this.revokeRefreshToken(replaced).subscribe();
222
229
  this.storeTokens(token, refreshToken);
230
+ localStorage.removeItem('loginToken');
231
+ localStorage.removeItem('twoFactorMethod');
223
232
  this.isLoggedIn.set(true);
224
233
  this.sessionHandoffError.set(null);
234
+ this.signInPrompt.set(null);
225
235
  resetAuthCircuit(); // a new session must not inherit a prior session's open circuit
226
236
  if (this.startHandoff()) return;
227
237
  this.loadAppData();
@@ -231,13 +241,27 @@ export abstract class BaseAuthService extends BaseRestService {
231
241
  /** Hands the session to the OAuth authorization server when the page carries an
232
242
  * allowed `?return=` and oauthServerUrl is set; false when there is nothing to hand off. */
233
243
  private startHandoff(): boolean {
234
- const ret = this.validatedReturnUrl();
235
- const oauthServer = this.oauthServerBase();
236
- if (!ret || !oauthServer) return false;
237
- this.handOffSession(oauthServer, ret);
244
+ if (!this.handoffPending()) return false;
245
+ this.handOffSession(this.oauthServerBase(), this.validatedReturnUrl());
238
246
  return true;
239
247
  }
240
248
 
249
+ private handoffPending(): boolean {
250
+ return !!this.validatedReturnUrl() && !!this.oauthServerBase();
251
+ }
252
+
253
+ continueSession(): void {
254
+ if (this.signInPrompt() !== 'select_account') return;
255
+ this.signInPrompt.set(null);
256
+ this.adoptStoredToken();
257
+ this.startHandoff();
258
+ }
259
+
260
+ private adoptStoredToken(): void {
261
+ this.token = localStorage.getItem('jwt');
262
+ this.isLoggedIn.set(!!this.token);
263
+ }
264
+
241
265
  /** Exchange the bearer session for the authorization server's own cookie session
242
266
  * (keel POST /oauth/session/handoff), then leave the SPA for the URL it returns.
243
267
  * A 401 rotates the refresh token once and retries. Any failure stays on the
@@ -297,7 +321,10 @@ export abstract class BaseAuthService extends BaseRestService {
297
321
  private handleLoginResponse(res: LoginResponse2FA): void {
298
322
  if (res.twoFactorRequired && res.loginToken) {
299
323
  localStorage.setItem('loginToken', res.loginToken);
300
- this.router.navigate(['/login/2fa']);
324
+ if (res.twoFactorMethod) localStorage.setItem('twoFactorMethod', res.twoFactorMethod);
325
+ else localStorage.removeItem('twoFactorMethod');
326
+ // Preserve ?return= so the login still hands off after the second factor.
327
+ this.router.navigate(['/login/2fa'], { queryParamsHandling: 'preserve' });
301
328
  } else {
302
329
  this.completeLogin(res.token, res.refreshToken);
303
330
  }
@@ -330,7 +357,8 @@ export abstract class BaseAuthService extends BaseRestService {
330
357
  /**
331
358
  * POST credentials; success completes the login (or routes to 2FA). Callers
332
359
  * subscribe and handle the error channel to surface failures in the UI.
333
- * withCredentials sends keel's `keel_td` trusted-device cookie (skips 2FA).
360
+ * withCredentials sends keel's `keel_td` trusted-device cookie (skips 2FA)
361
+ * and `keel_device` cookie (a recognized device), as every session-minting call does.
334
362
  */
335
363
  login(username: string, password: string): Observable<LoginResponse2FA> {
336
364
  return this.http.post<LoginResponse2FA>(this.loginUrl, { username, password }, { withCredentials: true }).pipe(
@@ -362,40 +390,29 @@ export abstract class BaseAuthService extends BaseRestService {
362
390
  return this.http.post<TwoFactorVerifyResponse>(this.twoFactorVerifyUrl, request);
363
391
  }
364
392
 
393
+ pendingTwoFactorMethod(): LoginResponse2FA['twoFactorMethod'] {
394
+ return localStorage.getItem('twoFactorMethod') === 'email' ? 'email' : undefined;
395
+ }
396
+
365
397
  /**
366
- * Verify a TOTP code during the LOGIN flow (public endpoint, `/public/2fa/verify`).
367
- * Returns the response so callers can drive loading / invalid-code UI; on a
368
- * valid code the login is completed (token stored, app data + routes loaded).
398
+ * Verify the login-time code (`/public/2fa/verify`): a TOTP code, or the
399
+ * emailed code of a new-device step-up. A valid code completes the login.
369
400
  * withCredentials so the browser stores the `keel_td` cookie keel sets on a
370
401
  * `trustDevice:true` verify — that cookie is what skips 2FA next login.
371
402
  */
372
403
  verify2FALogin(code: string, trustDevice = false, deviceName?: string): Observable<TwoFactorVerifyResponse> {
373
- const request: TwoFactorVerifyRequest = {
374
- code,
375
- loginToken: localStorage.getItem('loginToken') ?? '',
376
- trustDevice,
377
- deviceName,
378
- };
379
- return this.http.post<TwoFactorVerifyResponse>(this.twoFactorLoginVerifyUrl, request, { withCredentials: true }).pipe(
380
- tap((res) => {
381
- if (res.valid && res.token) {
382
- localStorage.removeItem('loginToken');
383
- this.completeLogin(res.token, res.refreshToken);
384
- }
385
- }),
386
- );
404
+ return this.verifyLogin(this.twoFactorLoginVerifyUrl, { code, trustDevice, deviceName });
387
405
  }
388
406
 
389
- /** Verify a login-flow backup code; a valid code completes the login (same contract as verify2FALogin). */
407
+ /** Verify a login-flow backup code; same contract as verify2FALogin. */
390
408
  verifyBackupCode(code: string): Observable<TwoFactorVerifyResponse> {
391
- const loginToken = localStorage.getItem('loginToken');
392
- return this.http.post<TwoFactorVerifyResponse>(this.twoFactorBackupVerifyUrl, { code, loginToken }, { withCredentials: true }).pipe(
393
- tap((res) => {
394
- if (res.valid && res.token) {
395
- localStorage.removeItem('loginToken');
396
- this.completeLogin(res.token, res.refreshToken);
397
- }
398
- }),
409
+ return this.verifyLogin(this.twoFactorBackupVerifyUrl, { code });
410
+ }
411
+
412
+ private verifyLogin(url: string, body: object): Observable<TwoFactorVerifyResponse> {
413
+ const request = { ...body, loginToken: localStorage.getItem('loginToken') ?? '' };
414
+ return this.http.post<TwoFactorVerifyResponse>(url, request, { withCredentials: true }).pipe(
415
+ tap((res) => { if (res.valid && res.token) this.completeLogin(res.token, res.refreshToken); }),
399
416
  );
400
417
  }
401
418
 
@@ -417,6 +434,14 @@ export abstract class BaseAuthService extends BaseRestService {
417
434
  return this.http.post<{ message: string }>(this.trustedDeviceRevokeUrl, { deviceId });
418
435
  }
419
436
 
437
+ getSessions() {
438
+ return this.http.get<ActiveSession[]>(this.sessionListUrl);
439
+ }
440
+
441
+ revokeSession(id: number) {
442
+ return this.http.post<void>(this.sessionRevokeUrl, { id });
443
+ }
444
+
420
445
  chpass(username: string, new_password: string, old_password: string) {
421
446
  return this.http.post<{ status: string }>(this.chpassUrl, { username, old_password, new_password })
422
447
  .pipe(tap(() => this.passwordChanged()));
@@ -458,7 +483,7 @@ export abstract class BaseAuthService extends BaseRestService {
458
483
  * answers. A registration without partner fields leaves `partnerId` 0. */
459
484
  confirmRegister(email: string, code: string): Observable<ConfirmRegisterResponse> {
460
485
  const params = new HttpParams().set('email', email).set('code', code);
461
- return this.http.post<ConfirmRegisterResponse>(this.confirmRegisterUrl, null, { params }).pipe(
486
+ return this.http.post<ConfirmRegisterResponse>(this.confirmRegisterUrl, null, { params, withCredentials: true }).pipe(
462
487
  tap((res) => this.completeLogin(res.token, res.refreshToken)),
463
488
  );
464
489
  }
@@ -488,11 +513,20 @@ export abstract class BaseAuthService extends BaseRestService {
488
513
  }
489
514
 
490
515
  loadStoredSession() {
491
- this.token = localStorage.getItem('jwt');
492
- if (!this.token) return;
493
- this.isLoggedIn.set(true);
494
- // A signed-in user sent to the login page by the authorization server is handed off without signing in again.
495
- if (window.location.pathname.startsWith('/login') && this.startHandoff()) return;
516
+ if (!localStorage.getItem('jwt')) return;
517
+ // A signed-in user sent to the login page by the authorization server is handed
518
+ // off without signing in again, unless the server asks for a sign-in.
519
+ const handoff = window.location.pathname.startsWith('/login') && this.handoffPending();
520
+ const prompt = handoff ? new URLSearchParams(window.location.search).get('prompt') : null;
521
+ if (prompt === 'login' || prompt === 'select_account') {
522
+ this.signInPrompt.set(prompt);
523
+ return;
524
+ }
525
+ this.adoptStoredToken();
526
+ if (handoff) {
527
+ this.startHandoff();
528
+ return;
529
+ }
496
530
  this.loadAppData();
497
531
  this.initRoutes();
498
532
  }
@@ -500,13 +534,17 @@ export abstract class BaseAuthService extends BaseRestService {
500
534
  /** Revoke the refresh token server-side (best effort: the local session is
501
535
  * cleared regardless), then land on the login screen. */
502
536
  logout() {
537
+ this.signOutHere();
538
+ this.router.navigate(['/login/local'], { queryParams: { loggedOut: 'true' } });
539
+ }
540
+
541
+ /** logout() without navigating, so the login page keeps its `?return=`. */
542
+ signOutHere() {
503
543
  this.revokeRefreshToken().subscribe();
504
544
  this.clearSession();
505
- this.router.navigate(['/login/local'], { queryParams: { loggedOut: 'true' } });
506
545
  }
507
546
 
508
- private revokeRefreshToken(): Observable<unknown> {
509
- const refreshToken = localStorage.getItem('refreshToken');
547
+ private revokeRefreshToken(refreshToken = localStorage.getItem('refreshToken')): Observable<unknown> {
510
548
  if (!refreshToken) return EMPTY;
511
549
  return this.http.post(this.logoutUrl, { refreshToken }).pipe(
512
550
  catchError((err) => { console.warn('refresh token revocation failed:', err); return EMPTY; }),
@@ -549,7 +587,7 @@ export abstract class BaseAuthService extends BaseRestService {
549
587
  * omitted (single-role apps), the role stays unchanged.
550
588
  */
551
589
  verifyOtp(req: OtpVerifyRequest, role?: string): Observable<OtpVerifyResponse> {
552
- return this.http.post<OtpVerifyResponse>(this.otpVerifyUrl, req).pipe(
590
+ return this.http.post<OtpVerifyResponse>(this.otpVerifyUrl, req, { withCredentials: true }).pipe(
553
591
  tap((res) => {
554
592
  if (res?.token) {
555
593
  this.completeLogin(res.token, res.refreshToken);
@@ -569,7 +607,7 @@ export abstract class BaseAuthService extends BaseRestService {
569
607
  consent?: SignupConsent,
570
608
  ): Observable<LoginResponseSocial> {
571
609
  const payload: SocialLoginRequest = { provider, token: idToken, ...consent };
572
- return this.http.post<LoginResponseSocial>(this.loginSocialUrl, payload).pipe(
610
+ return this.http.post<LoginResponseSocial>(this.loginSocialUrl, payload, { withCredentials: true }).pipe(
573
611
  tap((res) => { if (res?.token) this.completeLogin(res.token, res.refreshToken); }),
574
612
  );
575
613
  }
@@ -588,9 +626,14 @@ export abstract class BaseAuthService extends BaseRestService {
588
626
 
589
627
  // ── Account lifecycle ──
590
628
 
629
+ /** Send a one-time code for ReauthCredentials.reauthCode; no channel prefers email. */
630
+ sendReauthCode(channel?: ReauthChannel): Observable<ReauthSendResponse> {
631
+ return this.http.post<ReauthSendResponse>(this.reauthSendUrl, { channel });
632
+ }
633
+
591
634
  /**
592
635
  * Soft-delete the caller's account. Requires recent re-authentication —
593
- * pass current `password` and/or `twoFactorCode`. Server anonymizes
636
+ * pass `password`, `twoFactorCode` or `reauthCode`. Server anonymizes
594
637
  * user_account, revokes all tokens / trusted devices / social links.
595
638
  * Clears local session on success.
596
639
  */
@@ -603,8 +646,8 @@ export abstract class BaseAuthService extends BaseRestService {
603
646
 
604
647
  /**
605
648
  * Revoke every active refresh token for this user (sign out everywhere).
606
- * Requires recent re-authentication — pass current `password` and/or
607
- * `twoFactorCode`.
649
+ * Requires recent re-authentication — pass `password`, `twoFactorCode`
650
+ * or `reauthCode`.
608
651
  */
609
652
  logoutEverywhere(reauth: ReauthCredentials): Observable<void> {
610
653
  return this.http.post<void>(this.logoutEverywhereUrl, reauth);
@@ -635,11 +678,13 @@ export abstract class BaseAuthService extends BaseRestService {
635
678
  localStorage.removeItem('refreshToken');
636
679
  localStorage.removeItem('menu');
637
680
  localStorage.removeItem('loginToken');
681
+ localStorage.removeItem('twoFactorMethod');
638
682
  this.clearRole();
639
683
  this.cache = undefined;
640
684
  this.authIndex.clear();
641
685
  this.appDataError.set(null);
642
686
  this.sessionHandoffError.set(null);
687
+ this.signInPrompt.set(null);
643
688
  this.refreshInFlight = null;
644
689
  this.landing = null;
645
690
  this.sessionGeneration++; // in-flight loads and refreshes from this session are ignored
@@ -0,0 +1,26 @@
1
+ import { DestroyRef, Pipe, PipeTransform, inject, signal } from '@angular/core';
2
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
3
+ import { LabelService } from './label.service';
4
+
5
+ /**
6
+ * `{{ code | sailCaption:'constant_id' }}` — the backend caption of a constant code, the code
7
+ * itself until its domain loads or when the domain lacks it. Impure so it re-renders on load.
8
+ */
9
+ @Pipe({ name: 'sailCaption', pure: false })
10
+ export class CaptionPipe implements PipeTransform {
11
+ private readonly labels = inject(LabelService);
12
+ private readonly destroyRef = inject(DestroyRef);
13
+ private readonly requested = new Set<string>();
14
+ private readonly loaded = signal(0);
15
+
16
+ transform(code: string | null | undefined, constantId: string): string {
17
+ if (!code) return '';
18
+ this.loaded();
19
+ if (!this.requested.has(constantId)) {
20
+ this.requested.add(constantId);
21
+ this.labels.loadLabels(constantId).pipe(takeUntilDestroyed(this.destroyRef))
22
+ .subscribe({ next: () => this.loaded.update((n) => n + 1), error: () => this.requested.delete(constantId) });
23
+ }
24
+ return this.labels.getLabel(constantId, code);
25
+ }
26
+ }
@@ -36,9 +36,10 @@ export class LabelService {
36
36
  }
37
37
 
38
38
  /**
39
- * Load one backend-provided `constant_value` domain from ApplicationData.
40
- * Completed and concurrent requests are cached; a failed application-data
41
- * load is evicted so a later call can retry.
39
+ * Load one caption map from ApplicationData: a `constant_value` domain by its
40
+ * constant id, or a catalog table registered in `foreign_key_lookup` by its
41
+ * table name. Completed and concurrent requests are cached; a failed
42
+ * application-data load is evicted so a later call can retry.
42
43
  */
43
44
  loadLabels(constantId: string): Observable<void> {
44
45
  const domain = constantId.trim();
@@ -53,7 +54,7 @@ export class LabelService {
53
54
  tap({
54
55
  next: (data) => this.labels.set(
55
56
  domain,
56
- new Map(Object.entries(data.ConstantCache?.[domain] ?? {})),
57
+ new Map(Object.entries(data.ConstantCache?.[domain] ?? data.TableCache?.[domain] ?? {})),
57
58
  ),
58
59
  error: () => this.pendingLoads.delete(domain),
59
60
  }),
@@ -23,6 +23,8 @@ export const RestURL = {
23
23
  trustedDeviceListURL: '/api/user/trusted-device/list',
24
24
  trustedDeviceRegisterURL: '/api/user/trusted-device/register',
25
25
  trustedDeviceRevokeURL: '/api/user/trusted-device/revoke',
26
+ sessionListURL: '/api/user/sessions',
27
+ sessionRevokeURL: '/api/user/sessions/revoke',
26
28
  checkoutURL: '/api/billing/checkout',
27
29
  subscriptionURL: '/api/billing/subscription',
28
30
  cancelSubURL: '/api/billing/subscription/cancel',
@@ -43,6 +45,7 @@ export const RestURL = {
43
45
  ssoSamlMetadataURL: '/public/sso/saml/metadata',
44
46
  logoutEverywhereURL: '/api/user/logout-everywhere',
45
47
  deleteAccountURL: '/api/user/account',
48
+ reauthSendURL: '/api/user/reauth/send',
46
49
  // Self-service profile (keel ProfileHandler). GET profileURL reads the
47
50
  // current profile; POST updates name/locale. Email/phone are verify-before-
48
51
  // apply (request → confirm). A consumer whose backend mounts these under a
@@ -11,6 +11,12 @@ function problemOf(err: unknown): ProblemShape | undefined {
11
11
  return body && typeof body === 'object' ? (body as ProblemShape) : undefined;
12
12
  }
13
13
 
14
+ const codeMessages = new Map([
15
+ ['signin_network', 'Sign-in is not allowed from this network.'],
16
+ ['signin_network_lockout', 'The allowed networks must include your current address.'],
17
+ ['stepup_unavailable', 'A sign-in code cannot be sent right now. Please try again later.'],
18
+ ]);
19
+
14
20
  function asString(value: unknown): string | undefined {
15
21
  return typeof value === 'string' && value ? value : undefined;
16
22
  }
@@ -58,7 +64,7 @@ export function errorDetail(err: unknown, fallback: string, statuses?: readonly
58
64
  }
59
65
  const problem = problemOf(err);
60
66
  if (problem) {
61
- const message = asString(problem.detail) ?? asString(problem.title) ?? asString(problem.message);
67
+ const message = codeMessages.get(asString(problem.code) ?? '') ?? asString(problem.detail) ?? asString(problem.title) ?? asString(problem.message);
62
68
  if (message) return message;
63
69
  }
64
70
  if (err instanceof Error && err.message) {