@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.
- package/README.md +35 -19
- package/package.json +1 -1
- package/src/agent/component/approval_inbox.html +63 -0
- package/src/agent/component/approval_inbox.ts +91 -0
- package/src/agent/component/decision_timeline.html +4 -4
- package/src/agent/component/decision_timeline.ts +2 -0
- package/src/agent/component/prompt_section_editor.html +4 -3
- package/src/agent/component/prompt_section_editor.ts +12 -5
- package/src/agent/component/release_sections.html +1 -1
- package/src/agent/component/release_sections.ts +2 -1
- package/src/agent/component/studio_page.html +269 -0
- package/src/agent/component/studio_page.ts +346 -0
- package/src/agent/component/studio_tab.ts +14 -0
- package/src/agent/component/turn_event_list.html +9 -0
- package/src/agent/index.ts +6 -0
- package/src/agent/model/agent_studio.ts +122 -9
- package/src/agent/model/approval.ts +42 -0
- package/src/agent/model/conversation.ts +9 -1
- package/src/agent/model/decision.ts +2 -1
- package/src/agent/model/turn_event.ts +10 -0
- package/src/agent/service/agent_studio.service.ts +36 -1
- package/src/agent/service/approval.service.ts +18 -0
- package/src/agent/util/release_diff.ts +33 -0
- package/src/agent/util/studio_errors.ts +11 -0
- package/src/agent/util/turn_timeline.ts +7 -1
- package/src/component/login/account_deletion.html +17 -4
- package/src/component/login/account_deletion.ts +35 -28
- package/src/component/login/login_component.html +11 -0
- package/src/component/login/login_component.ts +2 -1
- package/src/component/login/social_login.ts +6 -5
- package/src/component/security/sessions.html +46 -0
- package/src/component/security/sessions.ts +45 -0
- package/src/component/security/twofactor_verify.html +12 -8
- package/src/component/security/twofactor_verify.ts +2 -1
- package/src/index.ts +2 -0
- package/src/model/appdata.ts +12 -0
- package/src/model/auth.ts +17 -2
- package/src/service/auth.service.ts +93 -48
- package/src/service/caption_pipe.ts +26 -0
- package/src/service/label.service.ts +5 -4
- package/src/service/rest_url.ts +3 -0
- 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 (
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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!,
|
|
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';
|
package/src/model/appdata.ts
CHANGED
|
@@ -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
|
|
119
|
-
// Disable2FA additionally requires the current TOTP `code
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
|
367
|
-
*
|
|
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
|
-
|
|
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;
|
|
407
|
+
/** Verify a login-flow backup code; same contract as verify2FALogin. */
|
|
390
408
|
verifyBackupCode(code: string): Observable<TwoFactorVerifyResponse> {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
|
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
|
|
607
|
-
* `
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
}),
|
package/src/service/rest_url.ts
CHANGED
|
@@ -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
|
package/src/util/errors.ts
CHANGED
|
@@ -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) {
|