@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.
@@ -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
+ }