@hotosm/hanko-auth 0.2.5
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 +210 -0
- package/dist/hanko-auth.esm.js +4990 -0
- package/package.json +56 -0
- package/src/hanko-auth.ts +1690 -0
|
@@ -0,0 +1,1690 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hotosm/hanko-auth Web Component (Lit Version)
|
|
3
|
+
*
|
|
4
|
+
* Smart authentication component that handles:
|
|
5
|
+
* - Hanko SSO (Google, GitHub, Email)
|
|
6
|
+
* - Optional OSM connection
|
|
7
|
+
* - Session management
|
|
8
|
+
* - Event dispatching
|
|
9
|
+
* - URL fallback chain for production builds
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { LitElement, html, css } from "lit";
|
|
13
|
+
import { customElement, property, state } from "lit/decorators.js";
|
|
14
|
+
import { register } from "@teamhanko/hanko-elements";
|
|
15
|
+
import "@awesome.me/webawesome";
|
|
16
|
+
|
|
17
|
+
// Module-level singleton state - shared across all instances
|
|
18
|
+
const sharedAuth = {
|
|
19
|
+
primary: null as any, // The primary instance that makes API calls
|
|
20
|
+
user: null as any,
|
|
21
|
+
osmConnected: false,
|
|
22
|
+
osmData: null as any,
|
|
23
|
+
loading: true,
|
|
24
|
+
hanko: null as any,
|
|
25
|
+
initialized: false,
|
|
26
|
+
instances: new Set<any>(),
|
|
27
|
+
profileDisplayName: "", // Shared profile display name
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Session storage key generators to avoid duplication
|
|
31
|
+
const getSessionVerifyKey = (hostname: string) => `hanko-verified-${hostname}`;
|
|
32
|
+
const getSessionOnboardingKey = (hostname: string) =>
|
|
33
|
+
`hanko-onboarding-${hostname}`;
|
|
34
|
+
|
|
35
|
+
interface UserState {
|
|
36
|
+
id: string;
|
|
37
|
+
email: string | null;
|
|
38
|
+
username: string | null;
|
|
39
|
+
emailVerified: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface OSMData {
|
|
43
|
+
osm_username?: string;
|
|
44
|
+
connected: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@customElement("hotosm-auth")
|
|
48
|
+
export class HankoAuth extends LitElement {
|
|
49
|
+
// Properties (from attributes)
|
|
50
|
+
@property({ type: String, attribute: "hanko-url" }) hankoUrlAttr = "";
|
|
51
|
+
@property({ type: String, attribute: "base-path" }) basePath = "";
|
|
52
|
+
@property({ type: String, attribute: "auth-path" }) authPath =
|
|
53
|
+
"/api/auth/osm";
|
|
54
|
+
@property({ type: Boolean, attribute: "osm-required" }) osmRequired = false;
|
|
55
|
+
@property({ type: String, attribute: "osm-scopes" }) osmScopes = "read_prefs";
|
|
56
|
+
@property({ type: Boolean, attribute: "show-profile" }) showProfile = false;
|
|
57
|
+
@property({ type: String, attribute: "redirect-after-login" })
|
|
58
|
+
redirectAfterLogin = "";
|
|
59
|
+
@property({ type: Boolean, attribute: "auto-connect" }) autoConnect = false;
|
|
60
|
+
@property({ type: Boolean, attribute: "verify-session" }) verifySession =
|
|
61
|
+
false;
|
|
62
|
+
@property({ type: String, attribute: "redirect-after-logout" })
|
|
63
|
+
redirectAfterLogout = "";
|
|
64
|
+
@property({ type: String, attribute: "display-name" })
|
|
65
|
+
displayNameAttr = "";
|
|
66
|
+
// URL to check if user has app mapping (for cross-app auth scenarios)
|
|
67
|
+
@property({ type: String, attribute: "mapping-check-url" }) mappingCheckUrl =
|
|
68
|
+
"";
|
|
69
|
+
// App identifier for onboarding redirect
|
|
70
|
+
@property({ type: String, attribute: "app-id" }) appId = "";
|
|
71
|
+
// Custom login page URL (for standalone mode - overrides ${hankoUrl}/app)
|
|
72
|
+
@property({ type: String, attribute: "login-url" }) loginUrl = "";
|
|
73
|
+
// Custom login page URL (for standalone mode - overrides ${hankoUrl}/app)
|
|
74
|
+
@property({ type: String, attribute: "login-url" }) loginUrl = "";
|
|
75
|
+
|
|
76
|
+
// Internal state
|
|
77
|
+
@state() private user: UserState | null = null;
|
|
78
|
+
@state() private osmConnected = false;
|
|
79
|
+
@state() private osmData: OSMData | null = null;
|
|
80
|
+
@state() private osmLoading = false;
|
|
81
|
+
@state() private loading = true;
|
|
82
|
+
@state() private error: string | null = null;
|
|
83
|
+
@state() private profileDisplayName: string = "";
|
|
84
|
+
@state() private hasAppMapping = false; // True if user has mapping in the app
|
|
85
|
+
|
|
86
|
+
// Private fields
|
|
87
|
+
private _trailingSlashCache: Record<string, boolean> = {};
|
|
88
|
+
private _debugMode = false;
|
|
89
|
+
private _lastSessionId: string | null = null;
|
|
90
|
+
private _hanko: any = null;
|
|
91
|
+
private _isPrimary = false; // Is this the primary instance?
|
|
92
|
+
|
|
93
|
+
static styles = css`
|
|
94
|
+
:host {
|
|
95
|
+
display: block;
|
|
96
|
+
font-family: var(--hot-font-sans);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.container {
|
|
100
|
+
max-width: 400px;
|
|
101
|
+
margin: 0 auto;
|
|
102
|
+
padding: var(--hot-spacing-large);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.loading {
|
|
106
|
+
text-align: center;
|
|
107
|
+
padding: var(--hot-spacing-3x-large);
|
|
108
|
+
color: var(--hot-color-gray-600);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.osm-connecting {
|
|
112
|
+
display: flex;
|
|
113
|
+
flex-direction: column;
|
|
114
|
+
align-items: center;
|
|
115
|
+
gap: var(--hot-spacing-small);
|
|
116
|
+
padding: var(--hot-spacing-large);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.spinner {
|
|
120
|
+
width: var(--hot-spacing-3x-large);
|
|
121
|
+
height: var(--hot-spacing-3x-large);
|
|
122
|
+
border: var(--hot-spacing-2x-small) solid var(--hot-color-gray-50);
|
|
123
|
+
border-top: var(--hot-spacing-2x-small) solid var(--hot-color-red-600);
|
|
124
|
+
border-radius: 50%;
|
|
125
|
+
animation: spin 1s linear infinite;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@keyframes spin {
|
|
129
|
+
0% {
|
|
130
|
+
transform: rotate(0deg);
|
|
131
|
+
}
|
|
132
|
+
100% {
|
|
133
|
+
transform: rotate(360deg);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.connecting-text {
|
|
138
|
+
font-size: var(--hot-font-size-small);
|
|
139
|
+
color: var(--hot-color-gray-600);
|
|
140
|
+
font-weight: var(--hot-font-weight-semibold);
|
|
141
|
+
}
|
|
142
|
+
// TODO replace with WA button
|
|
143
|
+
button {
|
|
144
|
+
width: 100%;
|
|
145
|
+
padding: 12px 20px;
|
|
146
|
+
border: none;
|
|
147
|
+
border-radius: 6px;
|
|
148
|
+
font-size: 14px;
|
|
149
|
+
font-weight: 500;
|
|
150
|
+
cursor: pointer;
|
|
151
|
+
transition: all 0.2s;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.btn-primary {
|
|
155
|
+
background: #d73f3f;
|
|
156
|
+
color: white;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.btn-primary:hover {
|
|
160
|
+
background: #c23535;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.btn-secondary {
|
|
164
|
+
background: #f0f0f0;
|
|
165
|
+
color: #333;
|
|
166
|
+
margin-top: 8px;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.btn-secondary:hover {
|
|
170
|
+
background: #e0e0e0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.error {
|
|
174
|
+
background: var(--hot-color-red-50);
|
|
175
|
+
border: var(--hot-border-width, 1px) solid var(--hot-color-red-200);
|
|
176
|
+
border-radius: var(--hot-border-radius-medium);
|
|
177
|
+
padding: var(--hot-spacing-small);
|
|
178
|
+
color: var(--hot-color-red-700);
|
|
179
|
+
margin-bottom: var(--hot-spacing-medium);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.profile {
|
|
183
|
+
background: var(--hot-color-gray-50);
|
|
184
|
+
border-radius: var(--hot-border-radius-large);
|
|
185
|
+
padding: var(--hot-spacing-large);
|
|
186
|
+
margin-bottom: var(--hot-spacing-medium);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.profile-header {
|
|
190
|
+
display: flex;
|
|
191
|
+
align-items: center;
|
|
192
|
+
gap: var(--hot-spacing-small);
|
|
193
|
+
margin-bottom: var(--hot-spacing-medium);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.profile-avatar {
|
|
197
|
+
width: var(--hot-spacing-3x-large);
|
|
198
|
+
height: var(--hot-spacing-3x-large);
|
|
199
|
+
border-radius: 50%;
|
|
200
|
+
background: var(--hot-color-gray-200);
|
|
201
|
+
display: flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
justify-content: center;
|
|
204
|
+
font-size: var(--hot-font-size-large);
|
|
205
|
+
font-weight: var(--hot-font-weight-bold);
|
|
206
|
+
color: var(--hot-color-gray-600);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.profile-info {
|
|
210
|
+
padding: var(--hot-spacing-x-small) var(--hot-spacing-medium);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.profile-email {
|
|
214
|
+
font-size: var(--hot-font-size-small);
|
|
215
|
+
font-weight: var(--hot-font-weight-bold);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.osm-section {
|
|
219
|
+
border-top: var(--hot-border-width, 1px) solid var(--hot-color-gray-100);
|
|
220
|
+
padding-top: var(--hot-spacing-medium);
|
|
221
|
+
padding-bottom: var(--hot-spacing-medium);
|
|
222
|
+
margin-top: var(--hot-spacing-medium);
|
|
223
|
+
margin-bottom: var(--hot-spacing-medium);
|
|
224
|
+
text-align: center;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.osm-connected {
|
|
228
|
+
display: flex;
|
|
229
|
+
align-items: center;
|
|
230
|
+
justify-content: center;
|
|
231
|
+
padding: var(--hot-spacing-small);
|
|
232
|
+
background: linear-gradient(
|
|
233
|
+
135deg,
|
|
234
|
+
var(--hot-color-success-50) 0%,
|
|
235
|
+
var(--hot-color-success-50) 100%
|
|
236
|
+
);
|
|
237
|
+
border-radius: var(--hot-border-radius-large);
|
|
238
|
+
border: var(--hot-border-width, 1px) solid var(--hot-color-success-200);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.osm-badge {
|
|
242
|
+
display: flex;
|
|
243
|
+
align-items: center;
|
|
244
|
+
gap: var(--hot-spacing-x-small);
|
|
245
|
+
color: var(--hot-color-success-800);
|
|
246
|
+
font-weight: var(--hot-font-weight-semibold);
|
|
247
|
+
font-size: var(--hot-font-size-small);
|
|
248
|
+
text-align: left;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.osm-badge-icon {
|
|
252
|
+
font-size: var(--hot-font-size-medium);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.osm-username {
|
|
256
|
+
font-size: var(--hot-font-size-x-small);
|
|
257
|
+
color: var(--hot-color-success-700);
|
|
258
|
+
margin-top: var(--hot-spacing-2x-small);
|
|
259
|
+
}
|
|
260
|
+
.osm-prompt {
|
|
261
|
+
background: var(--hot-color-warning-50);
|
|
262
|
+
border: var(--hot-border-width, 1px) solid var(--hot-color-warning-200);
|
|
263
|
+
border-radius: var(--hot-border-radius-large);
|
|
264
|
+
padding: var(--hot-spacing-large);
|
|
265
|
+
margin-bottom: var(--hot-spacing-medium);
|
|
266
|
+
text-align: center;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.osm-prompt-title {
|
|
270
|
+
font-weight: var(--hot-font-weight-semibold);
|
|
271
|
+
font-size: var(--hot-font-size-medium);
|
|
272
|
+
margin-bottom: var(--hot-spacing-small);
|
|
273
|
+
color: var(--hot-color-gray-900);
|
|
274
|
+
text-align: center;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.osm-prompt-text {
|
|
278
|
+
font-size: var(--hot-font-size-small);
|
|
279
|
+
color: var(--hot-color-gray-600);
|
|
280
|
+
margin-bottom: var(--hot-spacing-medium);
|
|
281
|
+
line-height: var(--hot-line-height-normal);
|
|
282
|
+
text-align: center;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.osm-status-badge {
|
|
286
|
+
position: absolute;
|
|
287
|
+
top: calc(-1 * var(--hot-spacing-2x-small));
|
|
288
|
+
right: var(--hot-spacing-x-small);
|
|
289
|
+
width: var(--hot-font-size-small);
|
|
290
|
+
height: var(--hot-font-size-small);
|
|
291
|
+
border-radius: 50%;
|
|
292
|
+
border: var(--hot-spacing-3x-small) solid white;
|
|
293
|
+
display: flex;
|
|
294
|
+
align-items: center;
|
|
295
|
+
justify-content: center;
|
|
296
|
+
font-size: var(--hot-font-size-2x-small);
|
|
297
|
+
color: white;
|
|
298
|
+
font-weight: var(--hot-font-weight-bold);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.osm-status-badge.connected {
|
|
302
|
+
background-color: var(--hot-color-success-600);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.osm-status-badge.required {
|
|
306
|
+
background-color: var(--hot-color-warning-600);
|
|
307
|
+
}
|
|
308
|
+
.header-avatar {
|
|
309
|
+
width: var(--hot-spacing-2x-large);
|
|
310
|
+
height: var(--hot-spacing-2x-large);
|
|
311
|
+
border-radius: 50%;
|
|
312
|
+
background: var(--hot-color-gray-800);
|
|
313
|
+
display: inline-flex;
|
|
314
|
+
align-items: center;
|
|
315
|
+
justify-content: center;
|
|
316
|
+
font-size: var(--hot-font-size-small);
|
|
317
|
+
font-weight: var(--hot-font-weight-semibold);
|
|
318
|
+
color: white;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* Remove hover styles from the dropdown trigger button */
|
|
322
|
+
wa-button.no-hover::part(base) {
|
|
323
|
+
transition: none;
|
|
324
|
+
}
|
|
325
|
+
wa-button.no-hover::part(base):hover,
|
|
326
|
+
wa-button.no-hover::part(base):focus,
|
|
327
|
+
wa-button.no-hover::part(base):active {
|
|
328
|
+
background: transparent !important;
|
|
329
|
+
box-shadow: none !important;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
wa-dropdown::part(menu) {
|
|
333
|
+
/* anchor the right edge of the panel to the right edge of the trigger (0 offset).
|
|
334
|
+
*/
|
|
335
|
+
right: 0 !important;
|
|
336
|
+
left: auto !important; /* Ensures 'right' takes precedence */
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
wa-dropdown-item {
|
|
340
|
+
font-size: var(--hot-font-size-small);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
wa-dropdown-item:hover {
|
|
344
|
+
background-color: var(--hot-color-neutral-50);
|
|
345
|
+
}
|
|
346
|
+
`;
|
|
347
|
+
|
|
348
|
+
// Get computed hankoUrl (priority: attribute > meta tag > window.HANKO_URL > origin)
|
|
349
|
+
get hankoUrl(): string {
|
|
350
|
+
if (this.hankoUrlAttr) {
|
|
351
|
+
return this.hankoUrlAttr;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const metaTag = document.querySelector('meta[name="hanko-url"]');
|
|
355
|
+
if (metaTag) {
|
|
356
|
+
const content = metaTag.getAttribute("content");
|
|
357
|
+
if (content) {
|
|
358
|
+
this.log("🔍 hanko-url auto-detected from <meta> tag:", content);
|
|
359
|
+
return content;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if ((window as any).HANKO_URL) {
|
|
364
|
+
this.log(
|
|
365
|
+
"🔍 hanko-url auto-detected from window.HANKO_URL:",
|
|
366
|
+
(window as any).HANKO_URL,
|
|
367
|
+
);
|
|
368
|
+
return (window as any).HANKO_URL;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const origin = window.location.origin;
|
|
372
|
+
this.log("🔍 hanko-url auto-detected from window.location.origin:", origin);
|
|
373
|
+
return origin;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
connectedCallback() {
|
|
377
|
+
super.connectedCallback();
|
|
378
|
+
this._debugMode = this._checkDebugMode();
|
|
379
|
+
this.log("🔌 hanko-auth connectedCallback called");
|
|
380
|
+
|
|
381
|
+
// Inject Hanko styles early, before any Hanko elements render
|
|
382
|
+
this.injectHankoStyles();
|
|
383
|
+
// Register this instance
|
|
384
|
+
sharedAuth.instances.add(this);
|
|
385
|
+
|
|
386
|
+
// Listen for page visibility changes to re-check session
|
|
387
|
+
// This handles the case where user logs in on /login and comes back
|
|
388
|
+
document.addEventListener("visibilitychange", this._handleVisibilityChange);
|
|
389
|
+
window.addEventListener("focus", this._handleWindowFocus);
|
|
390
|
+
|
|
391
|
+
// Listen for login events from other components (e.g., login page)
|
|
392
|
+
document.addEventListener("hanko-login", this._handleExternalLogin);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Use firstUpdated instead of connectedCallback to ensure React props are set
|
|
396
|
+
firstUpdated() {
|
|
397
|
+
this.log("🔌 hanko-auth firstUpdated called");
|
|
398
|
+
this.log(" hankoUrl:", this.hankoUrl);
|
|
399
|
+
this.log(" basePath:", this.basePath);
|
|
400
|
+
|
|
401
|
+
// If already initialized or being initialized by another instance, sync state and skip init
|
|
402
|
+
if (sharedAuth.initialized || sharedAuth.primary) {
|
|
403
|
+
this.log("🔄 Using shared state from primary instance");
|
|
404
|
+
this._syncFromShared();
|
|
405
|
+
this._isPrimary = false;
|
|
406
|
+
} else {
|
|
407
|
+
// This is the first/primary instance - claim it immediately to prevent race conditions
|
|
408
|
+
this.log("👑 This is the primary instance");
|
|
409
|
+
this._isPrimary = true;
|
|
410
|
+
sharedAuth.primary = this;
|
|
411
|
+
sharedAuth.initialized = true; // Mark as initialized immediately to prevent other instances from also initializing
|
|
412
|
+
this.init();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
disconnectedCallback() {
|
|
417
|
+
super.disconnectedCallback();
|
|
418
|
+
document.removeEventListener(
|
|
419
|
+
"visibilitychange",
|
|
420
|
+
this._handleVisibilityChange,
|
|
421
|
+
);
|
|
422
|
+
window.removeEventListener("focus", this._handleWindowFocus);
|
|
423
|
+
document.removeEventListener("hanko-login", this._handleExternalLogin);
|
|
424
|
+
|
|
425
|
+
// Unregister this instance
|
|
426
|
+
sharedAuth.instances.delete(this);
|
|
427
|
+
|
|
428
|
+
// If this was the primary and there are other instances, promote one
|
|
429
|
+
if (this._isPrimary && sharedAuth.instances.size > 0) {
|
|
430
|
+
const newPrimary = sharedAuth.instances.values().next().value;
|
|
431
|
+
if (newPrimary) {
|
|
432
|
+
this.log("👑 Promoting new primary instance");
|
|
433
|
+
newPrimary._isPrimary = true;
|
|
434
|
+
sharedAuth.primary = newPrimary;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// If no instances left, reset shared state
|
|
439
|
+
if (sharedAuth.instances.size === 0) {
|
|
440
|
+
sharedAuth.initialized = false;
|
|
441
|
+
sharedAuth.primary = null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Sync local state from shared state (only if values changed to prevent render loops)
|
|
446
|
+
private _syncFromShared() {
|
|
447
|
+
if (this.user !== sharedAuth.user) this.user = sharedAuth.user;
|
|
448
|
+
if (this.osmConnected !== sharedAuth.osmConnected)
|
|
449
|
+
this.osmConnected = sharedAuth.osmConnected;
|
|
450
|
+
if (this.osmData !== sharedAuth.osmData) this.osmData = sharedAuth.osmData;
|
|
451
|
+
if (this.loading !== sharedAuth.loading) this.loading = sharedAuth.loading;
|
|
452
|
+
if (this._hanko !== sharedAuth.hanko) this._hanko = sharedAuth.hanko;
|
|
453
|
+
if (this.profileDisplayName !== sharedAuth.profileDisplayName)
|
|
454
|
+
this.profileDisplayName = sharedAuth.profileDisplayName;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Update shared state and broadcast to all instances
|
|
458
|
+
private _broadcastState() {
|
|
459
|
+
sharedAuth.user = this.user;
|
|
460
|
+
sharedAuth.osmConnected = this.osmConnected;
|
|
461
|
+
sharedAuth.osmData = this.osmData;
|
|
462
|
+
sharedAuth.loading = this.loading;
|
|
463
|
+
sharedAuth.profileDisplayName = this.profileDisplayName;
|
|
464
|
+
|
|
465
|
+
// Sync to all other instances
|
|
466
|
+
sharedAuth.instances.forEach((instance) => {
|
|
467
|
+
if (instance !== this) {
|
|
468
|
+
instance._syncFromShared();
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private _handleVisibilityChange = () => {
|
|
474
|
+
// Only primary instance should handle visibility changes to prevent race conditions
|
|
475
|
+
if (!this._isPrimary) return;
|
|
476
|
+
|
|
477
|
+
if (!document.hidden && !this.showProfile && !this.user) {
|
|
478
|
+
// Page became visible, we're in header mode, and no user is logged in
|
|
479
|
+
// Re-check session in case user logged in elsewhere
|
|
480
|
+
this.log("👁️ Page visible, re-checking session...");
|
|
481
|
+
this.checkSession();
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
private _handleWindowFocus = () => {
|
|
486
|
+
// Only primary instance should handle window focus to prevent race conditions
|
|
487
|
+
if (!this._isPrimary) return;
|
|
488
|
+
|
|
489
|
+
if (!this.showProfile && !this.user) {
|
|
490
|
+
// Window focused, we're in header mode, and no user is logged in
|
|
491
|
+
// Re-check session in case user logged in
|
|
492
|
+
this.log("🎯 Window focused, re-checking session...");
|
|
493
|
+
this.checkSession();
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
private _handleExternalLogin = (event: Event) => {
|
|
498
|
+
// Only primary instance should handle external login events to prevent race conditions
|
|
499
|
+
if (!this._isPrimary) return;
|
|
500
|
+
|
|
501
|
+
const customEvent = event as CustomEvent;
|
|
502
|
+
if (!this.showProfile && !this.user && customEvent.detail?.user) {
|
|
503
|
+
// Another component (e.g., login page) logged in
|
|
504
|
+
this.log("🔔 External login detected, updating user state...");
|
|
505
|
+
this.user = customEvent.detail.user;
|
|
506
|
+
this._broadcastState();
|
|
507
|
+
// Also re-check OSM connection (only if required)
|
|
508
|
+
if (this.osmRequired) {
|
|
509
|
+
this.checkOSMConnection();
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
private _checkDebugMode(): boolean {
|
|
515
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
516
|
+
if (urlParams.get("debug") === "true") {
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
return localStorage.getItem("hanko-auth-debug") === "true";
|
|
522
|
+
} catch (e) {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private log(...args: any[]) {
|
|
528
|
+
if (this._debugMode) {
|
|
529
|
+
console.log(...args);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private warn(...args: any[]) {
|
|
534
|
+
console.warn(...args);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private logError(...args: any[]) {
|
|
538
|
+
console.error(...args);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private getBasePath(): string {
|
|
542
|
+
// Use basePath property directly (works with both attribute and React props)
|
|
543
|
+
if (this.basePath) {
|
|
544
|
+
this.log("🔍 getBasePath() using basePath:", this.basePath);
|
|
545
|
+
return this.basePath;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// For single-page apps (like Portal), default to empty base path
|
|
549
|
+
// The authPath already contains the full API path
|
|
550
|
+
this.log("🔍 getBasePath() using default: empty string");
|
|
551
|
+
return "";
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private addTrailingSlash(path: string, basePath: string): string {
|
|
555
|
+
const needsSlash = this._trailingSlashCache[basePath];
|
|
556
|
+
if (needsSlash !== undefined && needsSlash && !path.endsWith("/")) {
|
|
557
|
+
return path + "/";
|
|
558
|
+
}
|
|
559
|
+
return path;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private injectHankoStyles() {
|
|
563
|
+
// Inject HOT design system CSS from CDN (only once)
|
|
564
|
+
if (!document.getElementById("hot-design-system")) {
|
|
565
|
+
const styleLinks = [
|
|
566
|
+
"https://cdn.jsdelivr.net/npm/hotosm-ui-design@latest/dist/hot.css",
|
|
567
|
+
"https://cdn.jsdelivr.net/npm/hotosm-ui-design@latest/dist/hot-font-face.css",
|
|
568
|
+
"https://cdn.jsdelivr.net/npm/hotosm-ui-design@latest/dist/hot-wa.css",
|
|
569
|
+
];
|
|
570
|
+
|
|
571
|
+
styleLinks.forEach((href, index) => {
|
|
572
|
+
const link = document.createElement("link");
|
|
573
|
+
link.rel = "stylesheet";
|
|
574
|
+
link.href = href;
|
|
575
|
+
if (index === 0) {
|
|
576
|
+
link.id = "hot-design-system"; // Mark first one to prevent duplicate injection
|
|
577
|
+
}
|
|
578
|
+
document.head.appendChild(link);
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Inject Google Fonts - Archivo (only once)
|
|
583
|
+
if (!document.getElementById("google-font-archivo")) {
|
|
584
|
+
const link = document.createElement("link");
|
|
585
|
+
link.rel = "stylesheet";
|
|
586
|
+
link.href =
|
|
587
|
+
"https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700&display=swap";
|
|
588
|
+
link.id = "google-font-archivo";
|
|
589
|
+
document.head.appendChild(link);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private async init() {
|
|
594
|
+
// Only primary instance should initialize
|
|
595
|
+
if (!this._isPrimary) {
|
|
596
|
+
this.log("⏭️ Not primary, skipping init...");
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
try {
|
|
601
|
+
await register(this.hankoUrl, {
|
|
602
|
+
enablePasskeys: false,
|
|
603
|
+
hidePasskeyButtonOnLogin: true,
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// Create persistent Hanko instance and set up session event listeners
|
|
607
|
+
const { Hanko } = await import("@teamhanko/hanko-elements");
|
|
608
|
+
|
|
609
|
+
// Configure cookie domain for cross-subdomain SSO
|
|
610
|
+
const hostname = window.location.hostname;
|
|
611
|
+
const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1";
|
|
612
|
+
const cookieOptions = isLocalhost
|
|
613
|
+
? {}
|
|
614
|
+
: {
|
|
615
|
+
cookieDomain: ".hotosm.org",
|
|
616
|
+
cookieName: "hanko",
|
|
617
|
+
cookieSameSite: "lax",
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
this._hanko = new Hanko(this.hankoUrl, cookieOptions);
|
|
621
|
+
sharedAuth.hanko = this._hanko;
|
|
622
|
+
|
|
623
|
+
// Set up session lifecycle event listeners (these persist across the component lifecycle)
|
|
624
|
+
this._hanko.onSessionExpired(() => {
|
|
625
|
+
this.log("🕒 Hanko session expired event received");
|
|
626
|
+
this.handleSessionExpired();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
this._hanko.onUserLoggedOut(() => {
|
|
630
|
+
this.log("🚪 Hanko user logged out event received");
|
|
631
|
+
this.handleUserLoggedOut();
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
await this.checkSession();
|
|
635
|
+
// Only check OSM and fetch profile if we have a logged-in user
|
|
636
|
+
if (this.user) {
|
|
637
|
+
if (this.osmRequired) {
|
|
638
|
+
await this.checkOSMConnection();
|
|
639
|
+
}
|
|
640
|
+
await this.fetchProfileDisplayName();
|
|
641
|
+
}
|
|
642
|
+
this.loading = false;
|
|
643
|
+
|
|
644
|
+
// Broadcast final state to other instances
|
|
645
|
+
this._broadcastState();
|
|
646
|
+
|
|
647
|
+
this.setupEventListeners();
|
|
648
|
+
} catch (error: any) {
|
|
649
|
+
this.logError("Failed to initialize hanko-auth:", error);
|
|
650
|
+
this.error = error.message;
|
|
651
|
+
this.loading = false;
|
|
652
|
+
this._broadcastState();
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private async checkSession() {
|
|
657
|
+
this.log("🔍 Checking for existing Hanko session...");
|
|
658
|
+
|
|
659
|
+
if (!this._hanko) {
|
|
660
|
+
this.log("⚠️ Hanko instance not initialized yet");
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
try {
|
|
665
|
+
this.log("📡 Checking session validity via cookie...");
|
|
666
|
+
|
|
667
|
+
// First, try to validate the session cookie directly with Hanko
|
|
668
|
+
// This works across subdomains because the cookie has domain: .hotosm.test
|
|
669
|
+
try {
|
|
670
|
+
const validateResponse = await fetch(
|
|
671
|
+
`${this.hankoUrl}/sessions/validate`,
|
|
672
|
+
{
|
|
673
|
+
method: "GET",
|
|
674
|
+
credentials: "include", // Include httpOnly cookies
|
|
675
|
+
headers: {
|
|
676
|
+
"Content-Type": "application/json",
|
|
677
|
+
},
|
|
678
|
+
},
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
if (validateResponse.ok) {
|
|
682
|
+
const sessionData = await validateResponse.json();
|
|
683
|
+
|
|
684
|
+
// Check if session is actually valid (endpoint returns 200 with is_valid:false when no session)
|
|
685
|
+
if (sessionData.is_valid === false) {
|
|
686
|
+
this.log(
|
|
687
|
+
"ℹ️ Session validation returned is_valid:false - no valid session",
|
|
688
|
+
);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
this.log("✅ Valid Hanko session found via cookie");
|
|
693
|
+
this.log("📋 Session data:", sessionData);
|
|
694
|
+
|
|
695
|
+
// Now get the full user data from the login backend /me endpoint
|
|
696
|
+
// This endpoint validates the JWT and returns complete user info
|
|
697
|
+
try {
|
|
698
|
+
const meResponse = await fetch(`${this.hankoUrl}/me`, {
|
|
699
|
+
method: "GET",
|
|
700
|
+
credentials: "include", // Include httpOnly cookies
|
|
701
|
+
headers: {
|
|
702
|
+
"Content-Type": "application/json",
|
|
703
|
+
},
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
let needsSdkFallback = true;
|
|
707
|
+
if (meResponse.ok) {
|
|
708
|
+
const userData = await meResponse.json();
|
|
709
|
+
this.log("👤 User data retrieved from /me:", userData);
|
|
710
|
+
|
|
711
|
+
// Only use /me if it has email (login.hotosm.org has it, Hanko vanilla doesn't)
|
|
712
|
+
if (userData.email) {
|
|
713
|
+
this.user = {
|
|
714
|
+
id: userData.user_id || userData.id,
|
|
715
|
+
email: userData.email,
|
|
716
|
+
username: userData.username || null,
|
|
717
|
+
emailVerified:
|
|
718
|
+
userData.email_verified || userData.verified || false,
|
|
719
|
+
};
|
|
720
|
+
needsSdkFallback = false;
|
|
721
|
+
} else {
|
|
722
|
+
this.log("⚠️ /me has no email, will use SDK fallback");
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (needsSdkFallback) {
|
|
727
|
+
this.log("🔄 Using SDK to get user with email");
|
|
728
|
+
// Fallback to SDK method which has email
|
|
729
|
+
const user = await this._hanko.user.getCurrent();
|
|
730
|
+
this.user = {
|
|
731
|
+
id: user.id,
|
|
732
|
+
email: user.email,
|
|
733
|
+
username: user.username,
|
|
734
|
+
emailVerified: user.email_verified || false,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
} catch (userError) {
|
|
738
|
+
this.log("⚠️ Failed to get user data:", userError);
|
|
739
|
+
// Last resort: use session data if available
|
|
740
|
+
if (sessionData.user_id) {
|
|
741
|
+
this.user = {
|
|
742
|
+
id: sessionData.user_id,
|
|
743
|
+
email: sessionData.email || null,
|
|
744
|
+
username: null,
|
|
745
|
+
emailVerified: false,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (this.user) {
|
|
751
|
+
// If verify-session is enabled and we have a redirect URL,
|
|
752
|
+
// redirect to the callback so the app can verify the user mapping
|
|
753
|
+
// Use sessionStorage to avoid redirect loops
|
|
754
|
+
const verifyKey = getSessionVerifyKey(window.location.hostname);
|
|
755
|
+
const alreadyVerified = sessionStorage.getItem(verifyKey);
|
|
756
|
+
|
|
757
|
+
if (
|
|
758
|
+
this.verifySession &&
|
|
759
|
+
this.redirectAfterLogin &&
|
|
760
|
+
!alreadyVerified
|
|
761
|
+
) {
|
|
762
|
+
this.log(
|
|
763
|
+
"🔄 verify-session enabled, redirecting to callback for app verification...",
|
|
764
|
+
);
|
|
765
|
+
sessionStorage.setItem(verifyKey, "true");
|
|
766
|
+
window.location.href = this.redirectAfterLogin;
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Silent app mapping check (for cross-app auth scenarios)
|
|
771
|
+
// If app-status-url is configured, check if user needs onboarding
|
|
772
|
+
const mappingOk = await this.checkAppMapping();
|
|
773
|
+
if (!mappingOk) {
|
|
774
|
+
// Redirect to onboarding in progress, don't proceed
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
this.dispatchEvent(
|
|
779
|
+
new CustomEvent("hanko-login", {
|
|
780
|
+
detail: { user: this.user },
|
|
781
|
+
bubbles: true,
|
|
782
|
+
composed: true,
|
|
783
|
+
}),
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
this.dispatchEvent(
|
|
787
|
+
new CustomEvent("auth-complete", {
|
|
788
|
+
bubbles: true,
|
|
789
|
+
composed: true,
|
|
790
|
+
}),
|
|
791
|
+
);
|
|
792
|
+
|
|
793
|
+
// Also check if we need to auto-connect to OSM
|
|
794
|
+
if (this.osmRequired) {
|
|
795
|
+
await this.checkOSMConnection();
|
|
796
|
+
}
|
|
797
|
+
// Fetch profile display name
|
|
798
|
+
await this.fetchProfileDisplayName();
|
|
799
|
+
if (this.osmRequired && this.autoConnect && !this.osmConnected) {
|
|
800
|
+
this.log("🔄 Auto-connecting to OSM (from existing session)...");
|
|
801
|
+
this.handleOSMConnect();
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
} else {
|
|
805
|
+
this.log("ℹ️ No valid session cookie found - user needs to login");
|
|
806
|
+
}
|
|
807
|
+
} catch (validateError) {
|
|
808
|
+
this.log("⚠️ Session validation failed:", validateError);
|
|
809
|
+
this.log("ℹ️ No valid session - user needs to login");
|
|
810
|
+
}
|
|
811
|
+
} catch (error) {
|
|
812
|
+
this.log("⚠️ Session check error:", error);
|
|
813
|
+
this.log("ℹ️ No existing session - user needs to login");
|
|
814
|
+
} finally {
|
|
815
|
+
// Broadcast state changes to other instances
|
|
816
|
+
if (this._isPrimary) {
|
|
817
|
+
this._broadcastState();
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private async checkOSMConnection() {
|
|
823
|
+
if (this.osmConnected) {
|
|
824
|
+
this.log("⏭️ Already connected to OSM, skipping check");
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Don't set osmLoading during init - keep component in loading state
|
|
829
|
+
// Only set osmLoading when user manually triggers OSM check after initial load
|
|
830
|
+
const wasLoading = this.loading;
|
|
831
|
+
if (!wasLoading) {
|
|
832
|
+
this.osmLoading = true;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
try {
|
|
836
|
+
const basePath = this.getBasePath();
|
|
837
|
+
const authPath = this.authPath;
|
|
838
|
+
|
|
839
|
+
// Simple path construction without trailing slash detection
|
|
840
|
+
// The backend should handle both with/without trailing slash
|
|
841
|
+
const statusPath = `${basePath}${authPath}/status`;
|
|
842
|
+
const statusUrl = `${statusPath}`; // Relative URL for proxy
|
|
843
|
+
|
|
844
|
+
this.log("🔍 Checking OSM connection at:", statusUrl);
|
|
845
|
+
this.log(" basePath:", basePath);
|
|
846
|
+
this.log(" authPath:", authPath);
|
|
847
|
+
this.log("🍪 Current cookies:", document.cookie);
|
|
848
|
+
|
|
849
|
+
const response = await fetch(statusUrl, {
|
|
850
|
+
credentials: "include",
|
|
851
|
+
redirect: "follow",
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
this.log("📡 OSM status response:", response.status);
|
|
855
|
+
this.log("📡 Final URL after redirects:", response.url);
|
|
856
|
+
this.log("📡 Response headers:", [...response.headers.entries()]);
|
|
857
|
+
|
|
858
|
+
if (response.ok) {
|
|
859
|
+
const text = await response.text();
|
|
860
|
+
this.log("📡 OSM raw response:", text.substring(0, 200));
|
|
861
|
+
|
|
862
|
+
let data;
|
|
863
|
+
try {
|
|
864
|
+
data = JSON.parse(text);
|
|
865
|
+
} catch (e) {
|
|
866
|
+
this.logError(
|
|
867
|
+
"Failed to parse OSM response as JSON:",
|
|
868
|
+
text.substring(0, 500),
|
|
869
|
+
);
|
|
870
|
+
throw new Error("Invalid JSON response from OSM status endpoint");
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
this.log("📡 OSM status data:", data);
|
|
874
|
+
|
|
875
|
+
if (data.connected) {
|
|
876
|
+
this.log("✅ OSM is connected:", data.osm_username);
|
|
877
|
+
this.osmConnected = true;
|
|
878
|
+
this.osmData = data;
|
|
879
|
+
|
|
880
|
+
this.dispatchEvent(
|
|
881
|
+
new CustomEvent("osm-connected", {
|
|
882
|
+
detail: { osmData: data },
|
|
883
|
+
bubbles: true,
|
|
884
|
+
composed: true,
|
|
885
|
+
}),
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
// Dispatch event so parent components can handle the connection
|
|
889
|
+
// Note: We don't auto-redirect here because that would cause loops
|
|
890
|
+
// The Login page's onboarding flow listens for 'osm-connected' event
|
|
891
|
+
// and handles the redirect to the app's onboarding endpoint
|
|
892
|
+
} else {
|
|
893
|
+
this.log("❌ OSM is NOT connected");
|
|
894
|
+
this.osmConnected = false;
|
|
895
|
+
this.osmData = null;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
} catch (error) {
|
|
899
|
+
this.logError("OSM connection check failed:", error);
|
|
900
|
+
} finally {
|
|
901
|
+
if (!wasLoading) {
|
|
902
|
+
this.osmLoading = false;
|
|
903
|
+
}
|
|
904
|
+
// Broadcast state changes to other instances
|
|
905
|
+
if (this._isPrimary) {
|
|
906
|
+
this._broadcastState();
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Check app mapping status (for cross-app auth scenarios)
|
|
912
|
+
// Only used when mapping-check-url is configured
|
|
913
|
+
private async checkAppMapping(): Promise<boolean> {
|
|
914
|
+
// Only check if mapping-check-url is configured
|
|
915
|
+
if (!this.mappingCheckUrl || !this.user) {
|
|
916
|
+
return true; // No check needed, proceed normally
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Prevent redirect loops - if we already tried onboarding this session, don't redirect again
|
|
920
|
+
const onboardingKey = getSessionOnboardingKey(window.location.hostname);
|
|
921
|
+
const alreadyTriedOnboarding = sessionStorage.getItem(onboardingKey);
|
|
922
|
+
|
|
923
|
+
this.log("🔍 Checking app mapping at:", this.mappingCheckUrl);
|
|
924
|
+
|
|
925
|
+
try {
|
|
926
|
+
const response = await fetch(this.mappingCheckUrl, {
|
|
927
|
+
credentials: "include",
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
if (response.ok) {
|
|
931
|
+
const data = await response.json();
|
|
932
|
+
this.log("📡 Mapping check response:", data);
|
|
933
|
+
|
|
934
|
+
if (data.needs_onboarding) {
|
|
935
|
+
if (alreadyTriedOnboarding) {
|
|
936
|
+
this.log(
|
|
937
|
+
"⚠️ Already tried onboarding this session, skipping redirect",
|
|
938
|
+
);
|
|
939
|
+
return true; // Don't loop, let user continue
|
|
940
|
+
}
|
|
941
|
+
// User has Hanko session but no app mapping - redirect to onboarding
|
|
942
|
+
this.log("⚠️ User needs onboarding, redirecting...");
|
|
943
|
+
sessionStorage.setItem(onboardingKey, "true");
|
|
944
|
+
const returnTo = encodeURIComponent(window.location.origin);
|
|
945
|
+
const appParam = this.appId ? `onboarding=${this.appId}` : "";
|
|
946
|
+
window.location.href = `${this.hankoUrl}/app?${appParam}&return_to=${returnTo}`;
|
|
947
|
+
return false; // Redirect in progress, don't proceed
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// User has mapping - clear the onboarding flag
|
|
951
|
+
sessionStorage.removeItem(onboardingKey);
|
|
952
|
+
this.hasAppMapping = true;
|
|
953
|
+
this.log("✅ User has app mapping");
|
|
954
|
+
return true;
|
|
955
|
+
} else if (response.status === 401 || response.status === 403) {
|
|
956
|
+
if (alreadyTriedOnboarding) {
|
|
957
|
+
this.log(
|
|
958
|
+
"⚠️ Already tried onboarding this session, skipping redirect",
|
|
959
|
+
);
|
|
960
|
+
return true;
|
|
961
|
+
}
|
|
962
|
+
// Needs onboarding
|
|
963
|
+
this.log("⚠️ 401/403 - User needs onboarding, redirecting...");
|
|
964
|
+
sessionStorage.setItem(onboardingKey, "true");
|
|
965
|
+
const returnTo = encodeURIComponent(window.location.origin);
|
|
966
|
+
const appParam = this.appId ? `onboarding=${this.appId}` : "";
|
|
967
|
+
window.location.href = `${this.hankoUrl}/app?${appParam}&return_to=${returnTo}`;
|
|
968
|
+
return false;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Other status codes - proceed without blocking
|
|
972
|
+
this.log("⚠️ Unexpected status from mapping check:", response.status);
|
|
973
|
+
return true;
|
|
974
|
+
} catch (error) {
|
|
975
|
+
this.log("⚠️ App mapping check failed:", error);
|
|
976
|
+
// Don't block the user, just log the error
|
|
977
|
+
return true;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Fetch profile display name from login backend
|
|
982
|
+
private async fetchProfileDisplayName() {
|
|
983
|
+
try {
|
|
984
|
+
const profileUrl = `${this.hankoUrl}/api/profile/me`;
|
|
985
|
+
this.log("👤 Fetching profile from:", profileUrl);
|
|
986
|
+
|
|
987
|
+
const response = await fetch(profileUrl, {
|
|
988
|
+
credentials: "include",
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
if (response.ok) {
|
|
992
|
+
const profile = await response.json();
|
|
993
|
+
this.log("👤 Profile data:", profile);
|
|
994
|
+
|
|
995
|
+
if (profile.first_name || profile.last_name) {
|
|
996
|
+
this.profileDisplayName =
|
|
997
|
+
`${profile.first_name || ""} ${profile.last_name || ""}`.trim();
|
|
998
|
+
this.log("👤 Display name set to:", this.profileDisplayName);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
this.log("⚠️ Could not fetch profile:", error);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
private setupEventListeners() {
|
|
1007
|
+
// Use updateComplete to ensure DOM is ready
|
|
1008
|
+
this.updateComplete.then(() => {
|
|
1009
|
+
const hankoAuth = this.shadowRoot?.querySelector("hanko-auth");
|
|
1010
|
+
|
|
1011
|
+
if (hankoAuth) {
|
|
1012
|
+
hankoAuth.addEventListener("onSessionCreated", (e: any) => {
|
|
1013
|
+
this.log(`🎯 Hanko event: onSessionCreated`, e.detail);
|
|
1014
|
+
|
|
1015
|
+
const sessionId = e.detail?.claims?.session_id;
|
|
1016
|
+
if (sessionId && this._lastSessionId === sessionId) {
|
|
1017
|
+
this.log("⏭️ Skipping duplicate session event");
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
this._lastSessionId = sessionId;
|
|
1021
|
+
|
|
1022
|
+
this.handleHankoSuccess(e);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
hankoAuth.addEventListener("hankoAuthLogout", () =>
|
|
1026
|
+
this.handleLogout(),
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
private async handleHankoSuccess(event: any) {
|
|
1033
|
+
this.log("Hanko auth success:", event.detail);
|
|
1034
|
+
|
|
1035
|
+
if (!this._hanko) {
|
|
1036
|
+
this.logError("Hanko instance not initialized");
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Try to get user info from /me endpoint first (preferred)
|
|
1041
|
+
// If that fails (e.g., NetworkError on first cross-origin request with mkcert),
|
|
1042
|
+
// fall back to the Hanko SDK method
|
|
1043
|
+
let userInfoRetrieved = false;
|
|
1044
|
+
|
|
1045
|
+
try {
|
|
1046
|
+
// Use AbortController with 5 second timeout to fail fast on connection issues
|
|
1047
|
+
const controller = new AbortController();
|
|
1048
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
1049
|
+
|
|
1050
|
+
const meResponse = await fetch(`${this.hankoUrl}/me`, {
|
|
1051
|
+
method: "GET",
|
|
1052
|
+
credentials: "include", // Include httpOnly cookies
|
|
1053
|
+
headers: {
|
|
1054
|
+
"Content-Type": "application/json",
|
|
1055
|
+
},
|
|
1056
|
+
signal: controller.signal,
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
clearTimeout(timeoutId);
|
|
1060
|
+
|
|
1061
|
+
if (meResponse.ok) {
|
|
1062
|
+
const userData = await meResponse.json();
|
|
1063
|
+
this.log("👤 User data retrieved from /me:", userData);
|
|
1064
|
+
|
|
1065
|
+
// Only use /me if it has email (login.hotosm.org has it, Hanko vanilla doesn't)
|
|
1066
|
+
if (userData.email) {
|
|
1067
|
+
this.user = {
|
|
1068
|
+
id: userData.user_id || userData.id,
|
|
1069
|
+
email: userData.email,
|
|
1070
|
+
username: userData.username || null,
|
|
1071
|
+
emailVerified:
|
|
1072
|
+
userData.email_verified || userData.verified || false,
|
|
1073
|
+
};
|
|
1074
|
+
userInfoRetrieved = true;
|
|
1075
|
+
} else {
|
|
1076
|
+
this.log("⚠️ /me has no email, will try SDK fallback");
|
|
1077
|
+
}
|
|
1078
|
+
} else {
|
|
1079
|
+
this.log(
|
|
1080
|
+
"⚠️ /me endpoint returned non-OK status, will try SDK fallback",
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
// NetworkError or timeout on cross-origin fetch is common with mkcert certs
|
|
1085
|
+
this.log(
|
|
1086
|
+
"⚠️ /me endpoint fetch failed (timeout or cross-origin TLS issue):",
|
|
1087
|
+
error,
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Fallback to SDK method if /me didn't work
|
|
1092
|
+
if (!userInfoRetrieved) {
|
|
1093
|
+
try {
|
|
1094
|
+
this.log("🔄 Trying SDK fallback for user info...");
|
|
1095
|
+
// Add timeout to SDK call in case it hangs
|
|
1096
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
1097
|
+
setTimeout(() => reject(new Error("SDK timeout")), 5000),
|
|
1098
|
+
);
|
|
1099
|
+
const user = (await Promise.race([
|
|
1100
|
+
this._hanko.user.getCurrent(),
|
|
1101
|
+
timeoutPromise,
|
|
1102
|
+
])) as any;
|
|
1103
|
+
this.user = {
|
|
1104
|
+
id: user.id,
|
|
1105
|
+
email: user.email,
|
|
1106
|
+
username: user.username,
|
|
1107
|
+
emailVerified: user.email_verified || false,
|
|
1108
|
+
};
|
|
1109
|
+
userInfoRetrieved = true;
|
|
1110
|
+
this.log("✅ User info retrieved via SDK fallback");
|
|
1111
|
+
} catch (sdkError) {
|
|
1112
|
+
this.log("⚠️ SDK fallback failed, trying JWT claims:", sdkError);
|
|
1113
|
+
// Last resort: extract user info from JWT claims in the event
|
|
1114
|
+
try {
|
|
1115
|
+
const claims = event.detail?.claims;
|
|
1116
|
+
if (claims?.sub) {
|
|
1117
|
+
this.user = {
|
|
1118
|
+
id: claims.sub,
|
|
1119
|
+
email: claims.email || null,
|
|
1120
|
+
username: null,
|
|
1121
|
+
emailVerified: claims.email_verified || false,
|
|
1122
|
+
};
|
|
1123
|
+
userInfoRetrieved = true;
|
|
1124
|
+
this.log("✅ User info extracted from JWT claims");
|
|
1125
|
+
} else {
|
|
1126
|
+
this.logError("No user claims available in event");
|
|
1127
|
+
this.user = null;
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
} catch (claimsError) {
|
|
1131
|
+
this.logError(
|
|
1132
|
+
"Failed to extract user info from claims:",
|
|
1133
|
+
claimsError,
|
|
1134
|
+
);
|
|
1135
|
+
this.user = null;
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
this.log("✅ User state updated:", this.user);
|
|
1142
|
+
|
|
1143
|
+
// Broadcast state changes to other instances
|
|
1144
|
+
if (this._isPrimary) {
|
|
1145
|
+
this._broadcastState();
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
this.dispatchEvent(
|
|
1149
|
+
new CustomEvent("hanko-login", {
|
|
1150
|
+
detail: { user: this.user },
|
|
1151
|
+
bubbles: true,
|
|
1152
|
+
composed: true,
|
|
1153
|
+
}),
|
|
1154
|
+
);
|
|
1155
|
+
|
|
1156
|
+
// Check OSM connection only if required
|
|
1157
|
+
if (this.osmRequired) {
|
|
1158
|
+
await this.checkOSMConnection();
|
|
1159
|
+
}
|
|
1160
|
+
// Fetch profile display name (only works with login.hotosm.org backend)
|
|
1161
|
+
await this.fetchProfileDisplayName();
|
|
1162
|
+
|
|
1163
|
+
// Auto-connect to OSM if required and auto-connect is enabled
|
|
1164
|
+
if (this.osmRequired && this.autoConnect && !this.osmConnected) {
|
|
1165
|
+
this.log("🔄 Auto-connecting to OSM...");
|
|
1166
|
+
this.handleOSMConnect();
|
|
1167
|
+
return; // Exit early - redirect will happen after OSM OAuth callback
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// Only redirect if OSM is not required OR if OSM is connected
|
|
1171
|
+
const canRedirect = !this.osmRequired || this.osmConnected;
|
|
1172
|
+
|
|
1173
|
+
this.log(
|
|
1174
|
+
"🔄 Checking redirect-after-login:",
|
|
1175
|
+
this.redirectAfterLogin,
|
|
1176
|
+
"showProfile:",
|
|
1177
|
+
this.showProfile,
|
|
1178
|
+
"canRedirect:",
|
|
1179
|
+
canRedirect,
|
|
1180
|
+
);
|
|
1181
|
+
|
|
1182
|
+
if (canRedirect) {
|
|
1183
|
+
this.dispatchEvent(
|
|
1184
|
+
new CustomEvent("auth-complete", {
|
|
1185
|
+
bubbles: true,
|
|
1186
|
+
composed: true,
|
|
1187
|
+
}),
|
|
1188
|
+
);
|
|
1189
|
+
|
|
1190
|
+
if (this.redirectAfterLogin) {
|
|
1191
|
+
this.log("✅ Redirecting to:", this.redirectAfterLogin);
|
|
1192
|
+
window.location.href = this.redirectAfterLogin;
|
|
1193
|
+
} else {
|
|
1194
|
+
this.log("❌ No redirect (redirectAfterLogin not set)");
|
|
1195
|
+
}
|
|
1196
|
+
} else {
|
|
1197
|
+
this.log("⏸️ Waiting for OSM connection before redirect");
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
private async handleOSMConnect() {
|
|
1202
|
+
const scopes = this.osmScopes.split(" ").join("+");
|
|
1203
|
+
const basePath = this.getBasePath();
|
|
1204
|
+
const authPath = this.authPath;
|
|
1205
|
+
|
|
1206
|
+
// Simple path construction
|
|
1207
|
+
const loginPath = `${basePath}${authPath}/login`;
|
|
1208
|
+
const fullUrl = `${loginPath}?scopes=${scopes}`;
|
|
1209
|
+
|
|
1210
|
+
this.log("🔗 OSM Connect clicked!");
|
|
1211
|
+
this.log(" basePath:", basePath);
|
|
1212
|
+
this.log(" authPath:", authPath);
|
|
1213
|
+
this.log(" Login path:", fullUrl);
|
|
1214
|
+
this.log(" Fetching redirect URL from backend...");
|
|
1215
|
+
|
|
1216
|
+
try {
|
|
1217
|
+
// Use fetch with credentials to get the redirect URL
|
|
1218
|
+
// The backend will return a RedirectResponse which fetch will follow
|
|
1219
|
+
const response = await fetch(fullUrl, {
|
|
1220
|
+
method: "GET",
|
|
1221
|
+
credentials: "include",
|
|
1222
|
+
redirect: "manual", // Don't follow redirect, we'll do it manually
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
this.log(" Response status:", response.status);
|
|
1226
|
+
this.log(" Response type:", response.type);
|
|
1227
|
+
|
|
1228
|
+
if (response.status === 0 || response.type === "opaqueredirect") {
|
|
1229
|
+
// This is a redirect response
|
|
1230
|
+
const redirectUrl = response.headers.get("Location") || response.url;
|
|
1231
|
+
this.log(" ✅ Got redirect URL:", redirectUrl);
|
|
1232
|
+
window.location.href = redirectUrl;
|
|
1233
|
+
} else if (response.status >= 300 && response.status < 400) {
|
|
1234
|
+
const redirectUrl = response.headers.get("Location");
|
|
1235
|
+
this.log(" ✅ Got redirect URL from header:", redirectUrl);
|
|
1236
|
+
if (redirectUrl) {
|
|
1237
|
+
window.location.href = redirectUrl;
|
|
1238
|
+
}
|
|
1239
|
+
} else {
|
|
1240
|
+
this.logError(" ❌ Unexpected response:", response.status);
|
|
1241
|
+
const text = await response.text();
|
|
1242
|
+
this.logError(" Response body:", text.substring(0, 200));
|
|
1243
|
+
}
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
this.logError(" ❌ Failed to fetch redirect URL:", error);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
private async handleLogout() {
|
|
1250
|
+
this.log("🚪 Logout initiated");
|
|
1251
|
+
this.log("📊 Current state before logout:", {
|
|
1252
|
+
user: this.user,
|
|
1253
|
+
osmConnected: this.osmConnected,
|
|
1254
|
+
osmData: this.osmData,
|
|
1255
|
+
});
|
|
1256
|
+
this.log("🍪 Cookies before logout:", document.cookie);
|
|
1257
|
+
|
|
1258
|
+
try {
|
|
1259
|
+
const basePath = this.getBasePath();
|
|
1260
|
+
const authPath = this.authPath;
|
|
1261
|
+
const disconnectPath = `${basePath}${authPath}/disconnect`;
|
|
1262
|
+
// If basePath is already a full URL, use it directly; otherwise prepend origin
|
|
1263
|
+
const disconnectUrl = disconnectPath.startsWith("http")
|
|
1264
|
+
? disconnectPath
|
|
1265
|
+
: `${window.location.origin}${disconnectPath}`;
|
|
1266
|
+
this.log("🔌 Calling OSM disconnect:", disconnectUrl);
|
|
1267
|
+
|
|
1268
|
+
const response = await fetch(disconnectUrl, {
|
|
1269
|
+
method: "POST",
|
|
1270
|
+
credentials: "include",
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
this.log("📡 Disconnect response status:", response.status);
|
|
1274
|
+
const data = await response.json();
|
|
1275
|
+
this.log("📡 Disconnect response data:", data);
|
|
1276
|
+
this.log("✅ OSM disconnected");
|
|
1277
|
+
} catch (error) {
|
|
1278
|
+
this.logError("❌ OSM disconnect failed:", error);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (this._hanko) {
|
|
1282
|
+
try {
|
|
1283
|
+
await this._hanko.user.logout();
|
|
1284
|
+
this.log("✅ Hanko logout successful");
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
this.logError("Hanko logout failed:", error);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Use shared cleanup method
|
|
1291
|
+
this._clearAuthState();
|
|
1292
|
+
|
|
1293
|
+
this.log(
|
|
1294
|
+
"✅ Logout complete - component will re-render with updated state",
|
|
1295
|
+
);
|
|
1296
|
+
|
|
1297
|
+
// Redirect after logout if configured
|
|
1298
|
+
if (this.redirectAfterLogout) {
|
|
1299
|
+
this.log("🔄 Redirecting after logout to:", this.redirectAfterLogout);
|
|
1300
|
+
window.location.href = this.redirectAfterLogout;
|
|
1301
|
+
}
|
|
1302
|
+
// Otherwise let Lit's reactivity handle the re-render
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Clear all auth state - shared between logout and session expired handlers
|
|
1307
|
+
*/
|
|
1308
|
+
private _clearAuthState() {
|
|
1309
|
+
// Clear cookies
|
|
1310
|
+
const hostname = window.location.hostname;
|
|
1311
|
+
document.cookie = `hanko=; path=/; domain=${hostname}; max-age=0`;
|
|
1312
|
+
document.cookie = "hanko=; path=/; max-age=0";
|
|
1313
|
+
document.cookie = `osm_connection=; path=/; domain=${hostname}; max-age=0`;
|
|
1314
|
+
document.cookie = "osm_connection=; path=/; max-age=0";
|
|
1315
|
+
this.log("🍪 Cookies cleared");
|
|
1316
|
+
|
|
1317
|
+
// Clear session verification and onboarding flags
|
|
1318
|
+
const verifyKey = getSessionVerifyKey(hostname);
|
|
1319
|
+
const onboardingKey = getSessionOnboardingKey(hostname);
|
|
1320
|
+
sessionStorage.removeItem(verifyKey);
|
|
1321
|
+
sessionStorage.removeItem(onboardingKey);
|
|
1322
|
+
this.log("🔄 Session flags cleared");
|
|
1323
|
+
|
|
1324
|
+
// Reset state
|
|
1325
|
+
this.user = null;
|
|
1326
|
+
this.osmConnected = false;
|
|
1327
|
+
this.osmData = null;
|
|
1328
|
+
this.hasAppMapping = false;
|
|
1329
|
+
|
|
1330
|
+
// Broadcast state changes to other instances
|
|
1331
|
+
if (this._isPrimary) {
|
|
1332
|
+
this._broadcastState();
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Dispatch logout event
|
|
1336
|
+
this.dispatchEvent(
|
|
1337
|
+
new CustomEvent("logout", {
|
|
1338
|
+
bubbles: true,
|
|
1339
|
+
composed: true,
|
|
1340
|
+
}),
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
private async handleSessionExpired() {
|
|
1345
|
+
this.log("🕒 Session expired event received");
|
|
1346
|
+
this.log("📊 Current state:", {
|
|
1347
|
+
user: this.user,
|
|
1348
|
+
osmConnected: this.osmConnected,
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
// If we have an active user, the session is still valid
|
|
1352
|
+
// The SDK may fire this event for old/stale sessions while a new session exists
|
|
1353
|
+
if (this.user) {
|
|
1354
|
+
this.log("✅ User is logged in, ignoring stale session expired event");
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
this.log("🧹 No active user - cleaning up state");
|
|
1359
|
+
|
|
1360
|
+
// Call OSM disconnect endpoint to clear httpOnly cookie
|
|
1361
|
+
try {
|
|
1362
|
+
const basePath = this.getBasePath();
|
|
1363
|
+
const authPath = this.authPath;
|
|
1364
|
+
const disconnectPath = `${basePath}${authPath}/disconnect`;
|
|
1365
|
+
// If basePath is already a full URL, use it directly; otherwise prepend origin
|
|
1366
|
+
const disconnectUrl = disconnectPath.startsWith("http")
|
|
1367
|
+
? disconnectPath
|
|
1368
|
+
: `${window.location.origin}${disconnectPath}`;
|
|
1369
|
+
this.log("🔌 Calling OSM disconnect (session expired):", disconnectUrl);
|
|
1370
|
+
|
|
1371
|
+
const response = await fetch(disconnectUrl, {
|
|
1372
|
+
method: "POST",
|
|
1373
|
+
credentials: "include",
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
this.log("📡 Disconnect response status:", response.status);
|
|
1377
|
+
const data = await response.json();
|
|
1378
|
+
this.log("📡 Disconnect response data:", data);
|
|
1379
|
+
this.log("✅ OSM disconnected");
|
|
1380
|
+
} catch (error) {
|
|
1381
|
+
this.logError("❌ OSM disconnect failed:", error);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Use shared cleanup method
|
|
1385
|
+
this._clearAuthState();
|
|
1386
|
+
|
|
1387
|
+
this.log("✅ Session cleanup complete");
|
|
1388
|
+
|
|
1389
|
+
// Redirect after session expired if configured
|
|
1390
|
+
if (this.redirectAfterLogout) {
|
|
1391
|
+
this.log(
|
|
1392
|
+
"🔄 Redirecting after session expired to:",
|
|
1393
|
+
this.redirectAfterLogout,
|
|
1394
|
+
);
|
|
1395
|
+
window.location.href = this.redirectAfterLogout;
|
|
1396
|
+
}
|
|
1397
|
+
// Otherwise component will re-render and show login button
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
private handleUserLoggedOut() {
|
|
1401
|
+
this.log("🚪 User logged out in another window/tab");
|
|
1402
|
+
// Same cleanup as session expired
|
|
1403
|
+
this.handleSessionExpired();
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
private handleDropdownSelect(event: CustomEvent) {
|
|
1407
|
+
const selectedValue = event.detail.item.value;
|
|
1408
|
+
this.log("🎯 Dropdown item selected:", selectedValue);
|
|
1409
|
+
|
|
1410
|
+
if (selectedValue === "profile") {
|
|
1411
|
+
// Profile page lives on the login site
|
|
1412
|
+
// Pass return URL so profile can navigate back to the app
|
|
1413
|
+
const baseUrl = this.hankoUrl;
|
|
1414
|
+
const returnTo = this.redirectAfterLogin || window.location.origin;
|
|
1415
|
+
window.location.href = `${baseUrl}/app/profile?return_to=${encodeURIComponent(returnTo)}`;
|
|
1416
|
+
} else if (selectedValue === "connect-osm") {
|
|
1417
|
+
// Smart return_to: if already on a login page, redirect to home instead
|
|
1418
|
+
const currentPath = window.location.pathname;
|
|
1419
|
+
const isOnLoginPage = currentPath.includes("/app");
|
|
1420
|
+
const returnTo = isOnLoginPage
|
|
1421
|
+
? window.location.origin
|
|
1422
|
+
: window.location.href;
|
|
1423
|
+
|
|
1424
|
+
// Use the getter which handles all fallbacks correctly
|
|
1425
|
+
const baseUrl = this.hankoUrl;
|
|
1426
|
+
window.location.href = `${baseUrl}/app?return_to=${encodeURIComponent(
|
|
1427
|
+
returnTo,
|
|
1428
|
+
)}&osm_required=true`;
|
|
1429
|
+
} else if (selectedValue === "logout") {
|
|
1430
|
+
this.handleLogout();
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
private handleSkipOSM() {
|
|
1435
|
+
this.dispatchEvent(new CustomEvent("osm-skipped"));
|
|
1436
|
+
this.dispatchEvent(new CustomEvent("auth-complete"));
|
|
1437
|
+
if (this.redirectAfterLogin) {
|
|
1438
|
+
window.location.href = this.redirectAfterLogin;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
render() {
|
|
1443
|
+
this.log(
|
|
1444
|
+
"🎨 RENDER - showProfile:",
|
|
1445
|
+
this.showProfile,
|
|
1446
|
+
"user:",
|
|
1447
|
+
!!this.user,
|
|
1448
|
+
"loading:",
|
|
1449
|
+
this.loading,
|
|
1450
|
+
);
|
|
1451
|
+
|
|
1452
|
+
if (this.loading) {
|
|
1453
|
+
return html`
|
|
1454
|
+
<wa-button appearance="plain" size="small" disabled>Log in</wa-button>
|
|
1455
|
+
`;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
if (this.error) {
|
|
1459
|
+
return html`
|
|
1460
|
+
<div class="container">
|
|
1461
|
+
<div class="error">${this.error}</div>
|
|
1462
|
+
</div>
|
|
1463
|
+
`;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
if (this.user) {
|
|
1467
|
+
// User is logged in
|
|
1468
|
+
const needsOSM =
|
|
1469
|
+
this.osmRequired && !this.osmConnected && !this.osmLoading;
|
|
1470
|
+
const displayName =
|
|
1471
|
+
this.displayNameAttr ||
|
|
1472
|
+
this.profileDisplayName ||
|
|
1473
|
+
this.user.username ||
|
|
1474
|
+
this.user.email ||
|
|
1475
|
+
this.user.id;
|
|
1476
|
+
const initial = displayName ? displayName[0].toUpperCase() : "U";
|
|
1477
|
+
|
|
1478
|
+
if (this.showProfile) {
|
|
1479
|
+
// Show full profile view
|
|
1480
|
+
return html`
|
|
1481
|
+
<div class="container">
|
|
1482
|
+
<div class="profile">
|
|
1483
|
+
<div class="profile-header">
|
|
1484
|
+
<div class="profile-avatar">${initial}</div>
|
|
1485
|
+
<div class="profile-info">
|
|
1486
|
+
<div class="profile-email">
|
|
1487
|
+
${this.user.email || this.user.id}
|
|
1488
|
+
</div>
|
|
1489
|
+
</div>
|
|
1490
|
+
</div>
|
|
1491
|
+
|
|
1492
|
+
${this.osmRequired && this.osmLoading
|
|
1493
|
+
? html`
|
|
1494
|
+
<div class="osm-section">
|
|
1495
|
+
<div class="loading">Checking OSM connection...</div>
|
|
1496
|
+
</div>
|
|
1497
|
+
`
|
|
1498
|
+
: this.osmRequired && this.osmConnected
|
|
1499
|
+
? html`
|
|
1500
|
+
<div class="osm-section">
|
|
1501
|
+
<div class="osm-connected">
|
|
1502
|
+
<div class="osm-badge">
|
|
1503
|
+
<span class="osm-badge-icon">🗺️</span>
|
|
1504
|
+
<div>
|
|
1505
|
+
<div>Connected to OpenStreetMap</div>
|
|
1506
|
+
${this.osmData?.osm_username
|
|
1507
|
+
? html`
|
|
1508
|
+
<div class="osm-username">
|
|
1509
|
+
@${this.osmData.osm_username}
|
|
1510
|
+
</div>
|
|
1511
|
+
`
|
|
1512
|
+
: ""}
|
|
1513
|
+
</div>
|
|
1514
|
+
</div>
|
|
1515
|
+
</div>
|
|
1516
|
+
</div>
|
|
1517
|
+
`
|
|
1518
|
+
: ""}
|
|
1519
|
+
${needsOSM
|
|
1520
|
+
? html`
|
|
1521
|
+
<div class="osm-section">
|
|
1522
|
+
${this.autoConnect
|
|
1523
|
+
? html`
|
|
1524
|
+
<div class="osm-connecting">
|
|
1525
|
+
<div class="spinner"></div>
|
|
1526
|
+
<div class="connecting-text">
|
|
1527
|
+
🗺️ Connecting to OpenStreetMap...
|
|
1528
|
+
</div>
|
|
1529
|
+
</div>
|
|
1530
|
+
`
|
|
1531
|
+
: html`
|
|
1532
|
+
<div class="osm-prompt-title">🌍 OSM Required</div>
|
|
1533
|
+
<div class="osm-prompt-text">
|
|
1534
|
+
This endpoint requires OSM connection.
|
|
1535
|
+
</div>
|
|
1536
|
+
<button
|
|
1537
|
+
@click=${this.handleOSMConnect}
|
|
1538
|
+
class="btn-primary"
|
|
1539
|
+
>
|
|
1540
|
+
Connect OSM Account
|
|
1541
|
+
</button>
|
|
1542
|
+
`}
|
|
1543
|
+
</div>
|
|
1544
|
+
`
|
|
1545
|
+
: ""}
|
|
1546
|
+
|
|
1547
|
+
<button @click=${this.handleLogout} class="btn-logout">
|
|
1548
|
+
Logout
|
|
1549
|
+
</button>
|
|
1550
|
+
</div>
|
|
1551
|
+
</div>
|
|
1552
|
+
`;
|
|
1553
|
+
} else {
|
|
1554
|
+
// Logged in, show-profile=false: render dropdown with WebAwesome
|
|
1555
|
+
return html`
|
|
1556
|
+
<wa-dropdown
|
|
1557
|
+
placement="bottom-end"
|
|
1558
|
+
distance="4"
|
|
1559
|
+
@wa-select=${this.handleDropdownSelect}
|
|
1560
|
+
>
|
|
1561
|
+
<wa-button
|
|
1562
|
+
slot="trigger"
|
|
1563
|
+
class="no-hover"
|
|
1564
|
+
appearance="plain"
|
|
1565
|
+
size="small"
|
|
1566
|
+
style="position: relative;"
|
|
1567
|
+
>
|
|
1568
|
+
<span class="header-avatar">${initial}</span>
|
|
1569
|
+
${this.osmConnected
|
|
1570
|
+
? html`
|
|
1571
|
+
<span
|
|
1572
|
+
class="osm-status-badge connected"
|
|
1573
|
+
title="Connected to OSM as @${this.osmData?.osm_username}"
|
|
1574
|
+
>✓</span
|
|
1575
|
+
>
|
|
1576
|
+
`
|
|
1577
|
+
: this.osmRequired
|
|
1578
|
+
? html`
|
|
1579
|
+
<span
|
|
1580
|
+
class="osm-status-badge required"
|
|
1581
|
+
title="OSM connection required"
|
|
1582
|
+
>!</span
|
|
1583
|
+
>
|
|
1584
|
+
`
|
|
1585
|
+
: ""}
|
|
1586
|
+
</wa-button>
|
|
1587
|
+
<div class="profile-info">
|
|
1588
|
+
<div class="profile-name">${displayName}</div>
|
|
1589
|
+
<div class="profile-email">
|
|
1590
|
+
${this.user.email || this.user.id}
|
|
1591
|
+
</div>
|
|
1592
|
+
</div>
|
|
1593
|
+
<wa-dropdown-item value="profile">
|
|
1594
|
+
<wa-icon slot="icon" name="address-card"></wa-icon>
|
|
1595
|
+
My HOT Account
|
|
1596
|
+
</wa-dropdown-item>
|
|
1597
|
+
${this.osmRequired
|
|
1598
|
+
? this.osmConnected
|
|
1599
|
+
? html`
|
|
1600
|
+
<wa-dropdown-item value="osm-connected" disabled>
|
|
1601
|
+
<wa-icon slot="icon" name="check"></wa-icon>
|
|
1602
|
+
Connected to OSM (@${this.osmData?.osm_username})
|
|
1603
|
+
</wa-dropdown-item>
|
|
1604
|
+
`
|
|
1605
|
+
: html`
|
|
1606
|
+
<wa-dropdown-item value="connect-osm">
|
|
1607
|
+
<wa-icon slot="icon" name="map"></wa-icon>
|
|
1608
|
+
Connect OSM
|
|
1609
|
+
</wa-dropdown-item>
|
|
1610
|
+
`
|
|
1611
|
+
: ""}
|
|
1612
|
+
<wa-dropdown-item value="logout" variant="danger">
|
|
1613
|
+
<wa-icon slot="icon" name="right-from-bracket"></wa-icon>
|
|
1614
|
+
Sign Out
|
|
1615
|
+
</wa-dropdown-item>
|
|
1616
|
+
</wa-dropdown>
|
|
1617
|
+
`;
|
|
1618
|
+
}
|
|
1619
|
+
} else {
|
|
1620
|
+
// Not logged in
|
|
1621
|
+
if (this.showProfile) {
|
|
1622
|
+
// On login page - show full Hanko auth form
|
|
1623
|
+
return html`
|
|
1624
|
+
<div
|
|
1625
|
+
class="container"
|
|
1626
|
+
style="
|
|
1627
|
+
--color: var(--hot-color-gray-900);
|
|
1628
|
+
--color-shade-1: var(--hot-color-gray-700);
|
|
1629
|
+
--color-shade-2: var(--hot-color-gray-100);
|
|
1630
|
+
--brand-color: var(--hot-color-gray-800);
|
|
1631
|
+
--brand-color-shade-1: var(--hot-color-gray-900);
|
|
1632
|
+
--brand-contrast-color: white;
|
|
1633
|
+
--background-color: white;
|
|
1634
|
+
--error-color: var(--hot-color-red-600);
|
|
1635
|
+
--link-color: var(--hot-color-gray-900);
|
|
1636
|
+
--font-family: var(--hot-font-sans);
|
|
1637
|
+
--font-weight: var(--hot-font-weight-normal);
|
|
1638
|
+
--border-radius: var(--hot-border-radius-medium);
|
|
1639
|
+
--item-height: 2.75rem;
|
|
1640
|
+
--item-margin: var(--hot-spacing-small) 0;
|
|
1641
|
+
--container-padding: 0;
|
|
1642
|
+
--headline1-font-size: var(--hot-font-size-large);
|
|
1643
|
+
--headline1-font-weight: var(--hot-font-weight-semibold);
|
|
1644
|
+
--headline2-font-size: var(--hot-font-size-medium);
|
|
1645
|
+
--headline2-font-weight: var(--hot-font-weight-semibold);
|
|
1646
|
+
"
|
|
1647
|
+
>
|
|
1648
|
+
<hanko-auth></hanko-auth>
|
|
1649
|
+
</div>
|
|
1650
|
+
`;
|
|
1651
|
+
} else {
|
|
1652
|
+
// In header - show login link
|
|
1653
|
+
// Use redirectAfterLogin if set, otherwise use current URL
|
|
1654
|
+
// Smart return_to: if already on a login page, redirect to home instead
|
|
1655
|
+
const currentPath = window.location.pathname;
|
|
1656
|
+
const isOnLoginPage = currentPath.includes("/app");
|
|
1657
|
+
const returnTo =
|
|
1658
|
+
this.redirectAfterLogin ||
|
|
1659
|
+
(isOnLoginPage ? window.location.origin : window.location.href);
|
|
1660
|
+
|
|
1661
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
1662
|
+
const autoConnectParam =
|
|
1663
|
+
urlParams.get("auto_connect") === "true" ? "&auto_connect=true" : "";
|
|
1664
|
+
|
|
1665
|
+
// Use the getter which handles all fallbacks correctly
|
|
1666
|
+
const baseUrl = this.hankoUrl;
|
|
1667
|
+
this.log("🔗 Login URL base:", baseUrl);
|
|
1668
|
+
|
|
1669
|
+
// Use custom loginUrl if provided (for standalone mode), otherwise use ${hankoUrl}/app
|
|
1670
|
+
const loginBase = this.loginUrl || `${baseUrl}/app`;
|
|
1671
|
+
const loginUrl = `${loginBase}?return_to=${encodeURIComponent(
|
|
1672
|
+
returnTo,
|
|
1673
|
+
)}${this.osmRequired ? "&osm_required=true" : ""}${autoConnectParam}`;
|
|
1674
|
+
|
|
1675
|
+
return html`<wa-button
|
|
1676
|
+
appearance="plain"
|
|
1677
|
+
size="small"
|
|
1678
|
+
href="${loginUrl}"
|
|
1679
|
+
>Log in
|
|
1680
|
+
</wa-button> `;
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
declare global {
|
|
1687
|
+
interface HTMLElementTagNameMap {
|
|
1688
|
+
"hotosm-auth": HankoAuth;
|
|
1689
|
+
}
|
|
1690
|
+
}
|