@nauticana/sail 1.1.31 → 1.1.33

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  A shared Angular component library for building CRUD-based admin frontends. Provides table management, form handling, navigation, authentication, two-factor authentication, and trusted device management — all driven by metadata from a [keel](https://github.com/nauticana/keel) Go backend.
4
4
 
5
- > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.31 adopts keel v1.2.100 (tenant single sign-on, DNS and HTTP-file domain verification), sail v1.1.30 adopts keel v1.2.99 (password routes; a password change signs out), sail v1.1.29 adopts keel v1.2.94 (signup: confirmation signs in, partner setup, sign-in hand-off code), sail v1.1.28 adopts keel v1.2.93 (absolute `return` for the OAuth hand-off), sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
5
+ > **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.33 adopts keel v1.2.106 (`prompt` on the login page, new-device step-up, sign-in sessions), sail v1.1.32 adopts keel v1.2.101 (the OAuth connect callback ticket), sail v1.1.31 adopts keel v1.2.100 (tenant single sign-on, DNS and HTTP-file domain verification), sail v1.1.30 adopts keel v1.2.99 (password routes; a password change signs out), sail v1.1.29 adopts keel v1.2.94 (signup: confirmation signs in, partner setup, sign-in hand-off code), sail v1.1.28 adopts keel v1.2.93 (absolute `return` for the OAuth hand-off), sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
6
6
 
7
7
  **Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows. `passwordPolicyValidator` + `BaseAuthService.ensurePasswordPolicy()` — validate passwords against keel's policy (via `SailGuiConfig.passwordPolicyUrl`); see Configuration reference.
8
8
 
@@ -16,7 +16,7 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
16
16
  | **Form components** | `DynamicField`, `RecordForm`, `TableForm` |
17
17
  | **Navigation** | `Navigation` (sidenav + toolbar with menu, responsive) |
18
18
  | **Login** | `LoginComponent`, `RegisterComponent`, `ChpassComponent`, `ConfirmRegisterComponent`, `ConfirmChpassComponent` |
19
- | **Security** | `TwoFactorSetupComponent`, `TwoFactorVerifyComponent`, `TrustedDevicesComponent`, `AccountDeletionComponent` |
19
+ | **Security** | `TwoFactorSetupComponent`, `TwoFactorVerifyComponent`, `TrustedDevicesComponent`, `SessionsComponent`, `AccountDeletionComponent` |
20
20
  | **Account** | `MyAccountComponent` (self-service hub), `ProfileEditorComponent` (name/locale immediate; email/phone verify-before-apply) |
21
21
  | **Auth** | `ConsentGateComponent`, `OtpInputComponent`, `SocialLoginComponent`, `SsoLoginComponent`, `SsoReturnComponent` |
22
22
  | **Billing** | `PlanSelectorComponent`, `PriceSelectorComponent`, `CheckoutButtonComponent`, `PaymentMethodsComponent`, `PortalButtonComponent`, `UsageMeterComponent`, `StatusChipComponent`, `TrialBannerComponent`, `SeatSelectorComponent`, `DunningBannerComponent` |
@@ -221,7 +221,7 @@ Key CSS classes used by components:
221
221
 
222
222
  /* Security */
223
223
  .twofactor-qr, .twofactor-backup, .backup-code-list,
224
- .trusted-devices-table
224
+ .trusted-devices-table, .sessions-table
225
225
  ```
226
226
 
227
227
  ## How it works
@@ -331,6 +331,11 @@ origin, is not followed. Failures set `BaseAuthService.sessionHandoffError` (ren
331
331
  `<sail-login>`) and never redirect: 400 means keel rejected the return URL, 401 means the user
332
332
  signs in again to retry. The authorization server must allow the SPA origin by CORS.
333
333
 
334
+ With `prompt=login` or `prompt=select_account`, `<sail-login>` holds back the stored session and
335
+ offers sign-out (`isLoggedIn` stays false); account selection also offers `continueSession()`. A
336
+ fresh sign-in, including its 2FA step, keeps the hand-off query. Any sign-in revokes the refresh
337
+ token it replaces.
338
+
334
339
  Set `passwordPolicyUrl` to the app's public route for keel's `PublicHandler.GetPasswordPolicy`
335
340
  (e.g. `'/public/v1/auth/password/policy'`). `BaseAuthService.ensurePasswordPolicy()` fetches it
336
341
  once into the `passwordRules` signal, and `passwordPolicyValidator(() => auth.passwordRules())`
@@ -348,7 +353,7 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
348
353
  | `POST /public/otp/send` | Send OTP code to phone or email; returns opaque `otpToken` |
349
354
  | `POST /public/otp/verify` | Verify OTP code with `otpToken`, returns JWT |
350
355
  | `POST /public/otp/resend` | Re-issue OTP for an existing `otpToken` |
351
- | `POST /public/2fa/verify` | Login-time TOTP verification (uses `loginToken`) |
356
+ | `POST /public/2fa/verify` | Login-time TOTP or emailed step-up code verification (uses `loginToken`) |
352
357
  | `POST /public/2fa/backup-verify` | Login-time backup-code verification |
353
358
  | `POST /public/register` | Start a signup from a `PartnerRegistration`; emails the confirmation code |
354
359
  | `POST /public/register/confirm` | Confirm `?email=&code=`; answers session tokens and the partner result (`confirmRegister`) |
@@ -367,6 +372,8 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
367
372
  | `POST /api/user/2fa/disable` | Disable 2FA — **requires password + current TOTP code** |
368
373
  | `GET /api/user/trusted-device/list` | List trusted devices |
369
374
  | `POST /api/user/trusted-device/revoke` | Revoke a trusted device |
375
+ | `GET /api/user/sessions` | The caller's live sign-in sessions (`getSessions`) |
376
+ | `POST /api/user/sessions/revoke` | Sign out one session `{id}` (`revokeSession`); 404 `session_not_found` |
370
377
  | `POST /api/user/logout-everywhere` | Sign out of all devices — **requires re-auth** |
371
378
  | `DELETE /api/user/account` | Soft-delete the caller's account — **requires re-auth** |
372
379
  | `GET /api/notifications` | Notification inbox page — `?before=<id>&limit=<n>`, newest first (keel v1.2.71) |
@@ -382,13 +389,13 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
382
389
  | `GET /api/billing/invoices` | Invoice history |
383
390
  | `GET /api/billing/payment-methods` | Saved payment methods |
384
391
 
385
- Device registration happens within `/public/2fa/verify` when `trustDevice=true`. Since **keel v0.9 / sail v0.9.0**, the trusted-device credential is a server-minted `keel_td` cookie (HttpOnly + Secure + SameSite=Strict) — not a client-supplied fingerprint. sail sets `withCredentials: true` on the login and 2FA-verify calls so the cookie round-trips; see [Enabling 2FA → Cross-origin cookie requirement](#cross-origin-cookie-requirement) below.
392
+ Device registration happens within `/public/2fa/verify` when `trustDevice=true`. Since **keel v0.9 / sail v0.9.0**, the trusted-device credential is a server-minted `keel_td` cookie (HttpOnly + Secure + SameSite=Strict) — not a client-supplied fingerprint. sail sets `withCredentials: true` on every session-minting call (password, Google, social, OTP, 2FA and backup verify, registration confirm and exchange) so this cookie and keel's `keel_device` cookie (keel v1.2.106: a recognized device gets no new-device notice or step-up) round-trip; see [Enabling 2FA → Cross-origin cookie requirement](#cross-origin-cookie-requirement) below.
386
393
 
387
394
  ## Enabling 2FA in your project
388
395
 
389
396
  Add the 2FA verification route to your `publicRoutes` config:
390
397
  ```typescript
391
- import { TwoFactorVerifyComponent, TwoFactorSetupComponent, TrustedDevicesComponent } from '@nauticana/sail';
398
+ import { SessionsComponent, TrustedDevicesComponent, TwoFactorSetupComponent, TwoFactorVerifyComponent } from '@nauticana/sail';
392
399
 
393
400
  // In publicRoutes:
394
401
  { path: 'login/2fa', component: TwoFactorVerifyComponent }
@@ -396,9 +403,12 @@ import { TwoFactorVerifyComponent, TwoFactorSetupComponent, TrustedDevicesCompon
396
403
  // In extraRoutes or authenticated routes (optional — for user self-service):
397
404
  { path: 'security/2fa', component: TwoFactorSetupComponent }
398
405
  { path: 'security/devices', component: TrustedDevicesComponent }
406
+ { path: 'security/sessions', component: SessionsComponent }
399
407
  ```
400
408
 
401
- The login flow handles 2FA automatically: when the backend returns `twoFactorRequired: true`, sail redirects to `/login/2fa`. No other code changes needed.
409
+ The login flow redirects `twoFactorRequired` responses to `/login/2fa`. With `twoFactorMethod: 'email'`, the page explains the new-device check and hides device trust and backup codes. Sign-in refusals `signin_network` and `stepup_unavailable` have user-facing messages.
410
+
411
+ `SessionsComponent` (`<sail-sessions>`) lists the sessions with their `sign_in_method` captions, marks the current one, and signs out any other.
402
412
 
403
413
  ### Cross-origin cookie requirement
404
414
 
@@ -407,7 +417,7 @@ The "trust this device" feature (keel v0.9+) is backed by a server-set HttpOnly
407
417
  1. On `POST /public/2fa/verify` with `trustDevice: true`, keel mints a 32-byte secret, stores its SHA-256, and returns the raw secret to the browser **only** as a `Set-Cookie: keel_td=…` header (never in the JSON body).
408
418
  2. On the next `POST /public/login/local` (or `/public/login/gmail`), the browser sends the `keel_td` cookie back, keel hashes it, finds the matching row, and **skips the 2FA prompt**.
409
419
 
410
- For the browser to store and resend that cookie, the requests must be credentialed. sail already sets `withCredentials: true` on `login()`, `loginWithGoogle()`, `verify2FALogin()`, and `verifyBackupCode()` — **you do not add this in your app code**. But the keel backend must permit credentialed CORS, or the browser drops the cookie and every login re-prompts for 2FA:
420
+ For the browser to store and resend that cookie, the requests must be credentialed. sail already sets `withCredentials: true` on these calls — **you do not add this in your app code**. But the keel backend must permit credentialed CORS, or the browser drops the cookie and every login re-prompts for 2FA:
411
421
 
412
422
  - **Same-origin** deployments (SPA served from the same host as the API) work with no extra config — credentialed same-origin requests are always allowed.
413
423
  - **Cross-origin** deployments (SPA on a different host/port than the API) require the keel `HttpBackend` to set:
@@ -1242,8 +1252,19 @@ catalog and any routing via the outputs.
1242
1252
  - Each `OAuthProviderField` has a **`role`**: `credential` (sealed secret), `endpoint` (stored
1243
1253
  as `api_endpoint`), or `param` (OAuth authorize query param) — so field order is
1244
1254
  irrelevant. `icon` is a Material name or an image URL.
1255
+ - **Connect flow (keel v1.2.101).** `startOAuth` sends the signed-in user to provider consent.
1256
+ keel's callback validates the response, parks it under a single-use ticket and redirects the
1257
+ browser to its `FrontendReturnURL` with `?connect=<provider>&ticket=<ticket>`. The panel on
1258
+ that page strips both parameters from the URL (hash preserved, so a reload never re-posts a
1259
+ spent ticket), posts the ticket with the user's bearer token to `/api/oauth/<provider>/complete`,
1260
+ then reloads and emits `connected` once the row is visible. A `connect` value that is not a
1261
+ configured provider is ignored. keel binds the connection to the user who started the flow
1262
+ (RFC 6749 §10.12): a 403 ("started by another user") and a 400 (expired or already used
1263
+ ticket) show distinct messages asking the user to start again. **The page at
1264
+ `FrontendReturnURL` must require sign-in before the panel runs.**
1245
1265
  - **`OAuthConnectionService`** — `startOAuth(provider, extra)` → consent URL,
1246
- `testConnection`, `list(entityId)` / `disconnect` (generic `partner_credential` REST), and
1266
+ `completeOAuth(provider, ticket)` → `{status, provider}`, `testConnection`,
1267
+ `list(entityId)` / `disconnect` (generic `partner_credential` REST), and
1247
1268
  `saveApiKey` (POSTs the sealed `/api/oauth/apikey`).
1248
1269
 
1249
1270
  ```html
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.31",
3
+ "version": "1.1.33",
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
  "exports": {
@@ -15,6 +15,17 @@
15
15
  @if (sessionHandoffError()) {
16
16
  <p class="auth-error" role="alert">{{ sessionHandoffError() }}</p>
17
17
  }
18
+ @if (signInPrompt(); as prompt) {
19
+ <p class="auth-message" role="status">
20
+ {{ prompt === 'login' ? 'Sign in again to continue.' : 'Choose the account to continue with.' }}
21
+ </p>
22
+ <div class="auth-actions">
23
+ @if (prompt === 'select_account') {
24
+ <button matButton="elevated" class="primary" type="button" (click)="auth.continueSession()">Continue with the signed-in account</button>
25
+ }
26
+ <button matButton type="button" (click)="auth.signOutHere()">Sign out of the current account</button>
27
+ </div>
28
+ }
18
29
  <form [formGroup]="loginForm" (ngSubmit)="login()" class="auth-form">
19
30
  <mat-form-field appearance="outline">
20
31
  <mat-label>Username</mat-label>
@@ -27,7 +27,7 @@ import { SsoLoginComponent } from '../sso/sso_login';
27
27
  ],
28
28
  })
29
29
  export class LoginComponent extends BaseAsync {
30
- private auth = inject(BaseAuthService);
30
+ protected readonly auth = inject(BaseAuthService);
31
31
  protected fb = inject(FormBuilder);
32
32
  protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
33
33
  private readonly queryParams = inject(ActivatedRoute).snapshot.queryParamMap;
@@ -35,6 +35,7 @@ export class LoginComponent extends BaseAsync {
35
35
  protected readonly passwordChanged = this.queryParams.get('passwordChanged') === 'true';
36
36
  /** OAuth session hand-off failure from BaseAuthService, shown below. */
37
37
  protected readonly sessionHandoffError = this.auth.sessionHandoffError;
38
+ protected readonly signInPrompt = this.auth.signInPrompt;
38
39
 
39
40
  /**
40
41
  * Auto-show the embedded SocialLoginComponent whenever at least one
@@ -62,21 +62,38 @@ export class OAuthConnectionPanelComponent implements OnInit {
62
62
  });
63
63
  }
64
64
 
65
- // After an OAuth round-trip keel redirects back with ?<provider>=success. Record
66
- // the codes (configured providers only), strip them — preserving the hash — and
67
- // emit `connected` once the reload confirms the row exists (confirmSuccess).
65
+ // After the provider callback keel redirects back with ?connect=<provider>&ticket=…
66
+ // The ticket is single-use, so strip it (hash preserved) before redeeming it for
67
+ // the signed-in user; the reload then confirms the row exists (confirmSuccess).
68
68
  ngOnInit(): void {
69
69
  const q = new URLSearchParams(window.location.search);
70
- const known = new Set(this.providers().map((p) => p.code));
71
- const succeeded = new Set<string>();
72
- q.forEach((value, key) => {
73
- if (value === 'success' && known.has(key)) succeeded.add(key);
74
- });
75
- if (!succeeded.size) return;
76
- this.pendingSuccess.set(succeeded);
77
- for (const code of succeeded) q.delete(code);
70
+ const provider = q.get('connect') ?? '';
71
+ const ticket = q.get('ticket') ?? '';
72
+ if (!provider && !ticket) return;
73
+ q.delete('connect');
74
+ q.delete('ticket');
78
75
  const qs = q.toString();
79
76
  window.history.replaceState({}, '', window.location.pathname + (qs ? '?' + qs : '') + window.location.hash);
77
+ if (!ticket || !this.providers().some((p) => p.code === provider)) return;
78
+ this.busy.set(provider);
79
+ this.svc.completeOAuth(provider, ticket).subscribe({
80
+ next: () => {
81
+ this.busy.set('');
82
+ this.pendingSuccess.set(new Set([provider]));
83
+ this.reload(this.entityId());
84
+ },
85
+ error: (e) => {
86
+ this.busy.set('');
87
+ this.error.set(this.completeMsg(e));
88
+ },
89
+ });
90
+ }
91
+
92
+ private completeMsg(e: unknown): string {
93
+ const status = (e as { status?: number })?.status;
94
+ if (status === 403) return 'This connection was started by another user — start again from this account.';
95
+ if (status === 400) return 'The connection link expired — start again.';
96
+ return this.msg(e, 'Could not complete the connection.');
80
97
  }
81
98
 
82
99
  private reload(entityId: number): void {
@@ -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';
@@ -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;
@@ -2,7 +2,7 @@ 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,
@@ -104,6 +104,8 @@ 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); }
@@ -146,6 +148,8 @@ export abstract class BaseAuthService extends BaseRestService {
146
148
  * signs in again to retry instead of being redirected without a session. */
147
149
  readonly sessionHandoffError = signal<string | null>(null);
148
150
 
151
+ readonly signInPrompt = signal<'login' | 'select_account' | null>(null);
152
+
149
153
  /**
150
154
  * Multi-role app support. Consumers that distinguish rider/driver/admin
151
155
  * (or any equivalent split) call setRole() after login, then
@@ -219,9 +223,14 @@ export abstract class BaseAuthService extends BaseRestService {
219
223
  private completeLogin(token: string, refreshToken?: string) {
220
224
  this.refreshInFlight = null; // a rotation started under the previous session must not land
221
225
  this.sessionGeneration++;
226
+ const replaced = localStorage.getItem('refreshToken');
227
+ if (replaced && replaced !== refreshToken) this.revokeRefreshToken(replaced).subscribe();
222
228
  this.storeTokens(token, refreshToken);
229
+ localStorage.removeItem('loginToken');
230
+ localStorage.removeItem('twoFactorMethod');
223
231
  this.isLoggedIn.set(true);
224
232
  this.sessionHandoffError.set(null);
233
+ this.signInPrompt.set(null);
225
234
  resetAuthCircuit(); // a new session must not inherit a prior session's open circuit
226
235
  if (this.startHandoff()) return;
227
236
  this.loadAppData();
@@ -231,13 +240,27 @@ export abstract class BaseAuthService extends BaseRestService {
231
240
  /** Hands the session to the OAuth authorization server when the page carries an
232
241
  * allowed `?return=` and oauthServerUrl is set; false when there is nothing to hand off. */
233
242
  private startHandoff(): boolean {
234
- const ret = this.validatedReturnUrl();
235
- const oauthServer = this.oauthServerBase();
236
- if (!ret || !oauthServer) return false;
237
- this.handOffSession(oauthServer, ret);
243
+ if (!this.handoffPending()) return false;
244
+ this.handOffSession(this.oauthServerBase(), this.validatedReturnUrl());
238
245
  return true;
239
246
  }
240
247
 
248
+ private handoffPending(): boolean {
249
+ return !!this.validatedReturnUrl() && !!this.oauthServerBase();
250
+ }
251
+
252
+ continueSession(): void {
253
+ if (this.signInPrompt() !== 'select_account') return;
254
+ this.signInPrompt.set(null);
255
+ this.adoptStoredToken();
256
+ this.startHandoff();
257
+ }
258
+
259
+ private adoptStoredToken(): void {
260
+ this.token = localStorage.getItem('jwt');
261
+ this.isLoggedIn.set(!!this.token);
262
+ }
263
+
241
264
  /** Exchange the bearer session for the authorization server's own cookie session
242
265
  * (keel POST /oauth/session/handoff), then leave the SPA for the URL it returns.
243
266
  * A 401 rotates the refresh token once and retries. Any failure stays on the
@@ -297,7 +320,10 @@ export abstract class BaseAuthService extends BaseRestService {
297
320
  private handleLoginResponse(res: LoginResponse2FA): void {
298
321
  if (res.twoFactorRequired && res.loginToken) {
299
322
  localStorage.setItem('loginToken', res.loginToken);
300
- this.router.navigate(['/login/2fa']);
323
+ if (res.twoFactorMethod) localStorage.setItem('twoFactorMethod', res.twoFactorMethod);
324
+ else localStorage.removeItem('twoFactorMethod');
325
+ // Preserve ?return= so the login still hands off after the second factor.
326
+ this.router.navigate(['/login/2fa'], { queryParamsHandling: 'preserve' });
301
327
  } else {
302
328
  this.completeLogin(res.token, res.refreshToken);
303
329
  }
@@ -330,7 +356,8 @@ export abstract class BaseAuthService extends BaseRestService {
330
356
  /**
331
357
  * POST credentials; success completes the login (or routes to 2FA). Callers
332
358
  * subscribe and handle the error channel to surface failures in the UI.
333
- * withCredentials sends keel's `keel_td` trusted-device cookie (skips 2FA).
359
+ * withCredentials sends keel's `keel_td` trusted-device cookie (skips 2FA)
360
+ * and `keel_device` cookie (a recognized device), as every session-minting call does.
334
361
  */
335
362
  login(username: string, password: string): Observable<LoginResponse2FA> {
336
363
  return this.http.post<LoginResponse2FA>(this.loginUrl, { username, password }, { withCredentials: true }).pipe(
@@ -362,40 +389,29 @@ export abstract class BaseAuthService extends BaseRestService {
362
389
  return this.http.post<TwoFactorVerifyResponse>(this.twoFactorVerifyUrl, request);
363
390
  }
364
391
 
392
+ pendingTwoFactorMethod(): LoginResponse2FA['twoFactorMethod'] {
393
+ return localStorage.getItem('twoFactorMethod') === 'email' ? 'email' : undefined;
394
+ }
395
+
365
396
  /**
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).
397
+ * Verify the login-time code (`/public/2fa/verify`): a TOTP code, or the
398
+ * emailed code of a new-device step-up. A valid code completes the login.
369
399
  * withCredentials so the browser stores the `keel_td` cookie keel sets on a
370
400
  * `trustDevice:true` verify — that cookie is what skips 2FA next login.
371
401
  */
372
402
  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
- );
403
+ return this.verifyLogin(this.twoFactorLoginVerifyUrl, { code, trustDevice, deviceName });
387
404
  }
388
405
 
389
- /** Verify a login-flow backup code; a valid code completes the login (same contract as verify2FALogin). */
406
+ /** Verify a login-flow backup code; same contract as verify2FALogin. */
390
407
  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
- }),
408
+ return this.verifyLogin(this.twoFactorBackupVerifyUrl, { code });
409
+ }
410
+
411
+ private verifyLogin(url: string, body: object): Observable<TwoFactorVerifyResponse> {
412
+ const request = { ...body, loginToken: localStorage.getItem('loginToken') ?? '' };
413
+ return this.http.post<TwoFactorVerifyResponse>(url, request, { withCredentials: true }).pipe(
414
+ tap((res) => { if (res.valid && res.token) this.completeLogin(res.token, res.refreshToken); }),
399
415
  );
400
416
  }
401
417
 
@@ -417,6 +433,14 @@ export abstract class BaseAuthService extends BaseRestService {
417
433
  return this.http.post<{ message: string }>(this.trustedDeviceRevokeUrl, { deviceId });
418
434
  }
419
435
 
436
+ getSessions() {
437
+ return this.http.get<ActiveSession[]>(this.sessionListUrl);
438
+ }
439
+
440
+ revokeSession(id: number) {
441
+ return this.http.post<void>(this.sessionRevokeUrl, { id });
442
+ }
443
+
420
444
  chpass(username: string, new_password: string, old_password: string) {
421
445
  return this.http.post<{ status: string }>(this.chpassUrl, { username, old_password, new_password })
422
446
  .pipe(tap(() => this.passwordChanged()));
@@ -458,7 +482,7 @@ export abstract class BaseAuthService extends BaseRestService {
458
482
  * answers. A registration without partner fields leaves `partnerId` 0. */
459
483
  confirmRegister(email: string, code: string): Observable<ConfirmRegisterResponse> {
460
484
  const params = new HttpParams().set('email', email).set('code', code);
461
- return this.http.post<ConfirmRegisterResponse>(this.confirmRegisterUrl, null, { params }).pipe(
485
+ return this.http.post<ConfirmRegisterResponse>(this.confirmRegisterUrl, null, { params, withCredentials: true }).pipe(
462
486
  tap((res) => this.completeLogin(res.token, res.refreshToken)),
463
487
  );
464
488
  }
@@ -488,11 +512,20 @@ export abstract class BaseAuthService extends BaseRestService {
488
512
  }
489
513
 
490
514
  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;
515
+ if (!localStorage.getItem('jwt')) return;
516
+ // A signed-in user sent to the login page by the authorization server is handed
517
+ // off without signing in again, unless the server asks for a sign-in.
518
+ const handoff = window.location.pathname.startsWith('/login') && this.handoffPending();
519
+ const prompt = handoff ? new URLSearchParams(window.location.search).get('prompt') : null;
520
+ if (prompt === 'login' || prompt === 'select_account') {
521
+ this.signInPrompt.set(prompt);
522
+ return;
523
+ }
524
+ this.adoptStoredToken();
525
+ if (handoff) {
526
+ this.startHandoff();
527
+ return;
528
+ }
496
529
  this.loadAppData();
497
530
  this.initRoutes();
498
531
  }
@@ -500,13 +533,17 @@ export abstract class BaseAuthService extends BaseRestService {
500
533
  /** Revoke the refresh token server-side (best effort: the local session is
501
534
  * cleared regardless), then land on the login screen. */
502
535
  logout() {
536
+ this.signOutHere();
537
+ this.router.navigate(['/login/local'], { queryParams: { loggedOut: 'true' } });
538
+ }
539
+
540
+ /** logout() without navigating, so the login page keeps its `?return=`. */
541
+ signOutHere() {
503
542
  this.revokeRefreshToken().subscribe();
504
543
  this.clearSession();
505
- this.router.navigate(['/login/local'], { queryParams: { loggedOut: 'true' } });
506
544
  }
507
545
 
508
- private revokeRefreshToken(): Observable<unknown> {
509
- const refreshToken = localStorage.getItem('refreshToken');
546
+ private revokeRefreshToken(refreshToken = localStorage.getItem('refreshToken')): Observable<unknown> {
510
547
  if (!refreshToken) return EMPTY;
511
548
  return this.http.post(this.logoutUrl, { refreshToken }).pipe(
512
549
  catchError((err) => { console.warn('refresh token revocation failed:', err); return EMPTY; }),
@@ -549,7 +586,7 @@ export abstract class BaseAuthService extends BaseRestService {
549
586
  * omitted (single-role apps), the role stays unchanged.
550
587
  */
551
588
  verifyOtp(req: OtpVerifyRequest, role?: string): Observable<OtpVerifyResponse> {
552
- return this.http.post<OtpVerifyResponse>(this.otpVerifyUrl, req).pipe(
589
+ return this.http.post<OtpVerifyResponse>(this.otpVerifyUrl, req, { withCredentials: true }).pipe(
553
590
  tap((res) => {
554
591
  if (res?.token) {
555
592
  this.completeLogin(res.token, res.refreshToken);
@@ -569,7 +606,7 @@ export abstract class BaseAuthService extends BaseRestService {
569
606
  consent?: SignupConsent,
570
607
  ): Observable<LoginResponseSocial> {
571
608
  const payload: SocialLoginRequest = { provider, token: idToken, ...consent };
572
- return this.http.post<LoginResponseSocial>(this.loginSocialUrl, payload).pipe(
609
+ return this.http.post<LoginResponseSocial>(this.loginSocialUrl, payload, { withCredentials: true }).pipe(
573
610
  tap((res) => { if (res?.token) this.completeLogin(res.token, res.refreshToken); }),
574
611
  );
575
612
  }
@@ -635,11 +672,13 @@ export abstract class BaseAuthService extends BaseRestService {
635
672
  localStorage.removeItem('refreshToken');
636
673
  localStorage.removeItem('menu');
637
674
  localStorage.removeItem('loginToken');
675
+ localStorage.removeItem('twoFactorMethod');
638
676
  this.clearRole();
639
677
  this.cache = undefined;
640
678
  this.authIndex.clear();
641
679
  this.appDataError.set(null);
642
680
  this.sessionHandoffError.set(null);
681
+ this.signInPrompt.set(null);
643
682
  this.refreshInFlight = null;
644
683
  this.landing = null;
645
684
  this.sessionGeneration++; // in-flight loads and refreshes from this session are ignored
@@ -24,6 +24,16 @@ export class OAuthConnectionService extends BaseRestService {
24
24
  return this.http.get<{ url: string }>(this.url(path)).pipe(map((r) => r.url));
25
25
  }
26
26
 
27
+ /** Finish an OAuth connect flow: redeem the single-use callback ticket keel put
28
+ * in `?connect=<provider>&ticket=...` for the signed-in user who started it.
29
+ * 400 = expired or spent ticket; 403 = started by another user or partner. */
30
+ completeOAuth(provider: string, ticket: string): Observable<{ status: string; provider: string }> {
31
+ return this.http.post<{ status: string; provider: string }>(
32
+ this.url(`/api/oauth/${encodeURIComponent(provider)}/complete`),
33
+ { ticket },
34
+ );
35
+ }
36
+
27
37
  /** Re-validate a stored connection (POST — it makes an outbound call and updates
28
38
  * status). entityId selects which one (0 = tenant-wide). */
29
39
  testConnection(provider: string, entityId = 0): Observable<{ status: string; message?: string }> {
@@ -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',
@@ -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) {