import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; import Keycloak from "keycloak-js"; import { clearAuth, clearBadTokens, guessUsernameFromToken, jwtExpSeconds, loadAuth, saveAuth, decodeJwtPayload, } from "./authStorage"; import { parseAuthError } from "./authErrors"; import { checkPasskey, loginWithPasskey as passkeyLogin, registerPasskey as passkeyRegister, signupWithPasskey as passkeySignup, listPasskeys, removePasskey as passkeyRemove, isPasskeySupported, } from "./passkey"; const AuthCtx = createContext(null); const KC_URL = (import.meta?.env?.VITE_KC_URL || "https://auth.sociowire.com").replace(/\/+$/, ""); const KC_REALM = import.meta?.env?.VITE_KC_REALM || "sociowire"; const KC_CLIENT_ID = import.meta?.env?.VITE_KC_CLIENT_ID || "sociowire-frontend"; const KC_SSO_ENABLED = (import.meta?.env?.VITE_KC_SSO || "true") !== "false"; // Check if Keycloak is on a different origin (cross-domain SSO) function isCrossOriginSSO() { try { const kcOrigin = new URL(KC_URL).origin; return kcOrigin !== window.location.origin; } catch { return true; } } // Check if we're on the isolated apps subdomain (no SSO sync with main site) function isAppsSubdomain() { try { return window.location.hostname === "apps.sociowire.com"; } catch { return false; } } let kcInstance = null; function getKeycloak() { if (!kcInstance) { kcInstance = new Keycloak({ url: KC_URL, realm: KC_REALM, clientId: KC_CLIENT_ID, }); } return kcInstance; } function keycloakEnabled() { return !!(KC_SSO_ENABLED && KC_URL && KC_REALM && KC_CLIENT_ID); } function hasKeycloakCallbackParams() { try { const params = new URLSearchParams(window.location.search); return params.has("code") || params.has("error") || params.has("kc_error"); } catch { return false; } } function apiBase() { return (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, ""); } function silentSsoRedirectUri() { return window.location.origin + "/silent-check-sso.html"; } async function apiFetch(path, opts = {}) { const url = apiBase() + path; const res = await fetch(url, { credentials: "include", ...opts, headers: { ...(opts.headers || {}), }, }); return res; } function tokenEmailVerified(accessToken) { try { const p = decodeJwtPayload(accessToken); if (!p) return null; if (typeof p.email_verified === "boolean") return p.email_verified; if (typeof p.emailVerified === "boolean") return p.emailVerified; return null; } catch { return null; } } // tolerant whoami: accepts {authenticated:true} OR {username:""} OR {user:""} OR just ok+token async function whoami(accessToken) { const res = await apiFetch("/api/whoami", { method: "GET", headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {}, }); const text = await res.text().catch(() => ""); let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } const uname = (json && (json.username || json.user || json.preferred_username)) || guessUsernameFromToken(accessToken) || ""; const authed = !!res.ok && (json?.authenticated === true || json?.ok === true || !!json?.username || !!json?.user || !!uname); return { ok: !!res.ok, status: res.status, json, text, authenticated: authed, username: uname }; } // NEW: /api/me is the source of truth for email_verified async function me(accessToken) { const res = await apiFetch("/api/me", { method: "GET", headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {}, }); const text = await res.text().catch(() => ""); let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } // Try multiple shapes const u = (json && (json.user || json.profile || json.me)) || json || null; const emailVerified = u && typeof u.email_verified === "boolean" ? u.email_verified : u && typeof u.emailVerified === "boolean" ? u.emailVerified : null; return { ok: !!res.ok, status: res.status, json, text, userId: u && typeof u.user_id === "number" ? u.user_id : null, username: (u && (u.username || u.user || u.preferred_username)) || "", emailVerified, avatarUrl: u && typeof u.avatar_url === "string" ? u.avatar_url : "", needsUsername: u && typeof u.needs_username === "boolean" ? u.needs_username : u && typeof u.needsUsername === "boolean" ? u.needsUsername : null, }; } async function refreshTokens(refreshToken) { const res = await apiFetch("/api/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refreshToken }), }); const text = await res.text().catch(() => ""); let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } return { ok: res.ok, status: res.status, json, text }; } async function resendVerification(accessToken) { const res = await apiFetch("/api/resend-verification", { method: "POST", headers: { "Content-Type": "application/json", ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), }, body: JSON.stringify({}), }); const text = await res.text().catch(() => ""); let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } return { ok: res.ok, status: res.status, json, text }; } function nowSec() { return Math.floor(Date.now() / 1000); } export function AuthProvider({ children }) { const [status, setStatus] = useState("loading"); // loading | anon | auth const [user, setUser] = useState(null); // { username, userId } const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... } const [avatarUrl, setAvatarUrl] = useState(""); const [profileLoaded, setProfileLoaded] = useState(false); const [error, setError] = useState(""); const [lastInfo, setLastInfo] = useState(""); const [needsEmailVerification, setNeedsEmailVerification] = useState(false); const [needsUsername, setNeedsUsername] = useState(false); const refreshTimer = useRef(null); const lastAuthStatusRef = useRef(null); const callbackProcessedRef = useRef(false); function setAnon(msg = "") { setStatus("anon"); setUser(null); setTokens(null); setAvatarUrl(""); setProfileLoaded(false); setError(msg || ""); setLastInfo(""); setNeedsEmailVerification(false); setNeedsUsername(false); clearAuth(); } function setAuthFromTokens(t, usernameGuess = "") { setTokens(t); saveAuth({ ...t, obtained_at: Date.now() }); const u = usernameGuess || guessUsernameFromToken(t?.access_token) || ""; setUser(u ? { username: u } : null); setStatus("auth"); setProfileLoaded(false); setError(""); } async function refreshProfile(accessTokenOverride) { const at = accessTokenOverride || tokens?.access_token; if (!at) return { ok: false }; // /api/me is the truth; token claim is fallback only const mr = await me(at); let verified = mr.emailVerified if (mr.avatarUrl) { setAvatarUrl(mr.avatarUrl); } const uname = mr.username || user?.username; if (mr.username) { setUser((prev) => ({ ...prev, username: mr.username, userId: mr.userId || prev?.userId || null, })); } // If no avatar from /api/me, try to fetch from island endpoint console.log("[Auth] refreshProfile - mr.avatarUrl:", mr.avatarUrl, "uname:", uname); if (!mr.avatarUrl && uname) { try { console.log("[Auth] Fetching avatar from island endpoint for:", uname); const islandRes = await apiFetch(`/api/islands/by-username?username=${encodeURIComponent(uname)}`, { method: "GET", headers: at ? { Authorization: `Bearer ${at}` } : {}, }); console.log("[Auth] Island response status:", islandRes.status); if (islandRes.ok) { const islandData = await islandRes.json(); console.log("[Auth] Island data avatar_url:", islandData?.avatar_url); if (islandData?.avatar_url) { setAvatarUrl(islandData.avatar_url); } } } catch (err) { console.error("[Auth] Island avatar fetch error:", err); } } if (typeof mr.needsUsername === "boolean") { setNeedsUsername(mr.needsUsername); } else { setNeedsUsername(false); } if (typeof verified !== "boolean") { const tv = tokenEmailVerified(at); if (typeof tv === "boolean") verified = tv; } if (typeof verified === "boolean") { setNeedsEmailVerification(!verified); setProfileLoaded(true); return { ok: true, verified }; } // If cannot determine, do not block UI aggressively setNeedsEmailVerification(false); setProfileLoaded(true); return { ok: mr.ok, verified: null }; } async function ensureFreshSession(existing) { try { if (!existing?.access_token) return { ok: false, reason: "no_access_token" }; const exp = jwtExpSeconds(existing.access_token); const skew = 60; const isExpiredOrSoon = !exp || exp <= nowSec() + skew; if (isExpiredOrSoon) { if (!existing.refresh_token) return { ok: false, reason: "expired_no_refresh" }; const rr = await refreshTokens(existing.refresh_token); if (!rr.ok || !rr.json?.accessToken) { return { ok: false, reason: `refresh_failed_${rr.status}`, detail: rr.text }; } // Map backend camelCase to frontend snake_case const merged = { ...existing, access_token: rr.json.accessToken, refresh_token: rr.json.refreshToken || existing.refresh_token, }; setAuthFromTokens(merged); existing = merged; } else { setAuthFromTokens(existing); } const w = await whoami(existing.access_token); if (w.ok && w.authenticated) { setUser(w.username ? { username: w.username } : null); setStatus("auth"); setError(""); // ✅ Update email verification state from backend await refreshProfile(existing.access_token).catch(() => {}); return { ok: true }; } return { ok: false, reason: `whoami_${w.status}`, detail: w.text || "" }; } catch (err) { console.error("[Auth] ensureFreshSession error:", err); return { ok: false, reason: "exception", detail: err?.message || "" }; } } // Core refresh logic - shared between interval and visibility handler async function doTokenRefreshIfNeeded(forceRefresh = false) { const cur = loadAuth(); if (!cur?.access_token) { console.log("[Auth] Refresh check: no access_token"); return false; } const exp = jwtExpSeconds(cur.access_token); const now = nowSec(); const timeLeft = exp - now; console.log("[Auth] Refresh check: token expires in", timeLeft, "seconds, has refresh_token:", !!cur.refresh_token, "forceRefresh:", forceRefresh); if (!exp) return false; // Refresh if token expires in less than 90 seconds OR forceRefresh is true // On mobile, we're more aggressive because intervals get throttled if (forceRefresh || exp <= now + 90) { console.log("[Auth] Refreshing token..."); if (!cur.refresh_token) { console.log("[Auth] No refresh_token available!"); // If token is already expired, logout if (exp <= now) { setAnon("Session expirée (pas de refresh_token)."); } return false; } const rr = await refreshTokens(cur.refresh_token); console.log("[Auth] Refresh result:", rr.ok, rr.status, rr.json ? "got tokens" : "no tokens"); if (!rr.ok || !rr.json?.accessToken) { console.log("[Auth] Refresh failed:", rr.text || rr.status); // Only logout if token is actually expired if (exp <= now) { setAnon("Session expirée. Please login again."); } return false; } // Map backend camelCase to frontend snake_case const merged = { ...cur, access_token: rr.json.accessToken, refresh_token: rr.json.refreshToken || cur.refresh_token, }; console.log("[Auth] Refresh successful, new token obtained"); setAuthFromTokens(merged); // keep verification fresh too await refreshProfile(merged.access_token); return true; } else { // lightweight: refresh verification occasionally without spamming const tv = tokenEmailVerified(cur.access_token); if (typeof tv === "boolean") setNeedsEmailVerification(!tv); return false; } } function scheduleAutoRefresh() { if (refreshTimer.current) { if (refreshTimer.current.cleanup) { refreshTimer.current.cleanup(); } else { clearInterval(refreshTimer.current); } refreshTimer.current = null; } console.log("[Auth] scheduleAutoRefresh called, starting 30s interval"); // Interval-based refresh (can be throttled when tab is minimized) refreshTimer.current = setInterval(() => { doTokenRefreshIfNeeded().catch((err) => { console.error("[Auth] Refresh tick error:", err); }); }, 30000); // Visibility-based refresh - runs immediately when tab becomes visible // On mobile, always force refresh when returning to tab (intervals are throttled) const handleVisibilityChange = () => { if (document.visibilityState === "visible") { console.log("[Auth] Tab became visible, forcing token refresh..."); // Force refresh on mobile to ensure we always have fresh tokens doTokenRefreshIfNeeded(true).catch((err) => { console.error("[Auth] Visibility refresh error:", err); }); } }; document.addEventListener("visibilitychange", handleVisibilityChange); // Also listen for focus event (more reliable on mobile) const handleFocus = () => { console.log("[Auth] Window focused, checking token..."); doTokenRefreshIfNeeded(true).catch((err) => { console.error("[Auth] Focus refresh error:", err); }); }; window.addEventListener("focus", handleFocus); // Store cleanup function const originalTimer = refreshTimer.current; refreshTimer.current = { intervalId: originalTimer, cleanup: () => { clearInterval(originalTimer); document.removeEventListener("visibilitychange", handleVisibilityChange); window.removeEventListener("focus", handleFocus); }, }; } async function restore() { setStatus("loading"); setError(""); setLastInfo(""); // Clear tokens from old/wrong issuer - must be from auth.sociowire.com now clearBadTokens("auth.sociowire.com"); const saved = loadAuth(); if (!saved?.access_token) { try { if (sessionStorage.getItem("sociowire:logout")) { setAnon(""); return; } } catch {} if (keycloakEnabled()) { const ok = await trySilentKeycloakSession(); if (ok) { scheduleAutoRefresh(); return; } } setAnon(""); return; } const r = await ensureFreshSession(saved); if (!r.ok) { setAnon("Login desynced. Please login again."); return; } scheduleAutoRefresh(); } async function login(username, password) { // Direct password login via /api/login endpoint setError(""); setLastInfo(""); setStatus("loading"); if (!username || !password) { setError("Username and password required"); setStatus("anon"); return { ok: false, error: "Username and password required" }; } try { sessionStorage.removeItem("sociowire:logout"); } catch {} try { const res = await apiFetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); const text = await res.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } if (!res.ok || !json?.accessToken) { const errMsg = json?.error || "Invalid credentials"; setError(errMsg); setStatus("anon"); return { ok: false, error: errMsg }; } // Got tokens from direct login const t = { access_token: json.accessToken, refresh_token: json.refreshToken || "", token_type: json.tokenType || "bearer", obtained_at: Date.now(), }; setAuthFromTokens(t, json.user?.preferred_username || json.user?.username || username); scheduleAutoRefresh(); setUser({ username: json.user?.preferred_username || json.user?.username || username, userId: json.user?.sub || null, }); setStatus("auth"); try { sessionStorage.removeItem("sociowire:logout"); } catch {} await refreshProfile(t.access_token); return { ok: true }; } catch (err) { console.error("[Auth] Direct login error:", err); setError(err?.message || "Login failed"); setStatus("anon"); return { ok: false, error: err?.message || "Login failed" }; } } // Check if user has passkey async function hasPasskey(username) { if (!isPasskeySupported()) return { ok: false, hasPasskey: false, reason: "not_supported" }; return await checkPasskey(username); } // Login with passkey async function loginWithPasskey(username) { setError(""); setLastInfo(""); setStatus("loading"); if (!isPasskeySupported()) { setAnon("Passkey not supported on this device"); return { ok: false, error: "not_supported" }; } const result = await passkeyLogin(username); if (!result.ok) { setAnon(result.error || "Passkey login failed"); return result; } // Got tokens from passkey auth const t = { access_token: result.accessToken, refresh_token: result.refreshToken, token_type: "bearer", obtained_at: Date.now(), }; setAuthFromTokens(t, result.user?.username || username); scheduleAutoRefresh(); setUser({ username: result.user?.username || username, userId: result.user?.id || null, }); setStatus("auth"); try { sessionStorage.removeItem("sociowire:logout"); } catch {} await refreshProfile(t.access_token); return { ok: true }; } // Signup with passkey (creates new account) async function signupWithPasskey({ username, email, firstName, lastName }) { setError(""); setLastInfo(""); setStatus("loading"); if (!isPasskeySupported()) { setAnon("Passkey not supported on this device"); return { ok: false, error: "not_supported" }; } const result = await passkeySignup({ username, email, firstName, lastName }); if (!result.ok) { setAnon(result.error || "Passkey signup failed"); return result; } // Got tokens from passkey signup console.log("[Auth] Passkey signup result - has accessToken:", !!result.accessToken, "has refreshToken:", !!result.refreshToken); const t = { access_token: result.accessToken, refresh_token: result.refreshToken, token_type: "bearer", obtained_at: Date.now(), }; console.log("[Auth] Storing tokens, refresh_token length:", result.refreshToken?.length || 0); setAuthFromTokens(t, result.user?.username || username); scheduleAutoRefresh(); setUser({ username: result.user?.username || username, userId: result.user?.id || null, }); setStatus("auth"); try { sessionStorage.removeItem("sociowire:logout"); } catch {} await refreshProfile(t.access_token); return { ok: true }; } // Register a new passkey (requires being logged in) async function registerNewPasskey(name = "Passkey") { if (!isPasskeySupported()) { return { ok: false, error: "Passkey not supported on this device" }; } // Force token refresh to ensure we have a valid token const cur = loadAuth(); if (!cur?.access_token || !cur?.refresh_token) { return { ok: false, error: "Not logged in" }; } // Always refresh before passkey registration to avoid token expiry issues const rr = await refreshTokens(cur.refresh_token); if (!rr.ok || !rr.json?.accessToken) { return { ok: false, error: "Session expired. Please login again." }; } // Map backend camelCase to frontend snake_case const merged = { ...cur, access_token: rr.json.accessToken, refresh_token: rr.json.refreshToken || cur.refresh_token, }; setAuthFromTokens(merged); return await passkeyRegister(merged.access_token, name); } // List user's passkeys async function getUserPasskeys() { const at = tokens?.access_token; if (!at) { return { ok: false, passkeys: [] }; } return await listPasskeys(at); } // Remove a passkey async function deletePasskey(credentialId) { const at = tokens?.access_token; if (!at) { return { ok: false, error: "Not logged in" }; } return await passkeyRemove(at, credentialId); } async function logout() { if (refreshTimer.current) { if (refreshTimer.current.cleanup) { refreshTimer.current.cleanup(); } else { clearInterval(refreshTimer.current); } } refreshTimer.current = null; const redirectUri = window.location.origin; // Don't do SSO logout for apps subdomain - keep it isolated const doSsoLogout = keycloakEnabled() && !isAppsSubdomain(); // Get id_token before clearing auth (needed for proper Keycloak logout) const idToken = tokens?.id_token || ""; console.log("[Auth] Logout initiated, doSsoLogout:", doSsoLogout, "hasIdToken:", !!idToken); try { sessionStorage.setItem("sociowire:logout", String(Date.now())); } catch {} // Clear local auth state setAnon(""); if (!doSsoLogout) return; // Build Keycloak logout URL const logoutUrl = `${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/logout`; const params = new URLSearchParams(); params.set("client_id", KC_CLIENT_ID); params.set("post_logout_redirect_uri", redirectUri); if (idToken) { params.set("id_token_hint", idToken); } const fullLogoutUrl = `${logoutUrl}?${params.toString()}`; console.log("[Auth] Redirecting to logout URL:", fullLogoutUrl); // Redirect to Keycloak logout - this will clear the SSO session window.location.href = fullLogoutUrl; } async function resendVerificationEmail() { const at = tokens?.access_token || ""; if (!at) { setError("Not logged in."); return { ok: false }; } setError(""); setLastInfo(""); const r = await resendVerification(at); if (!r.ok) { setError(r.json?.error || r.json?.message || r.text || `Resend failed (${r.status})`); return { ok: false, status: r.status }; } setLastInfo("Verification email sent. Check inbox/spam, then refresh session (logout/login)."); return { ok: true }; } async function completeKeycloakLogin() { if (!keycloakEnabled()) return { ok: false, reason: "disabled" }; try { sessionStorage.removeItem("sociowire:logout"); } catch {} setStatus("loading"); setError(""); setLastInfo(""); let kc = null; try { kc = getKeycloak(); } catch (err) { console.error("[Auth] Keycloak init error:", err); setAnon("SSO failed to initialize. Please try again."); return { ok: false, reason: "init_failed", detail: err?.message || "" }; } const params = new URLSearchParams(window.location.search); const errCode = params.get("error") || params.get("kc_error") || ""; const errDesc = params.get("error_description") || params.get("kc_error_description") || ""; if (errCode) { console.error("[Auth] SSO error from Keycloak:", errCode, errDesc); setAnon(`SSO failed: ${errCode}${errDesc ? " - " + errDesc : ""}`); cleanupAuthUrl(); return { ok: false, reason: "kc_error", detail: errDesc || errCode }; } let ok = false; try { console.log("[Auth] Initializing Keycloak for SSO callback..."); ok = await kc.init({ onLoad: "check-sso", pkceMethod: "S256", checkLoginIframe: false, enableLogging: true, }); console.log("[Auth] Keycloak init result:", ok, "authenticated:", kc.authenticated); } catch (err) { console.error("[Auth] Keycloak init failed:", err); setAnon("SSO failed to finish. Please try again."); cleanupAuthUrl(); return { ok: false, reason: "kc_init_failed", detail: err?.message || "" }; } if (!ok || !kc.authenticated || !kc.token) { console.warn("[Auth] Keycloak not authenticated after init"); setAnon("SSO login failed. Please try again."); cleanupAuthUrl(); return { ok: false, reason: "not_authenticated" }; } const t = { access_token: kc.token, refresh_token: kc.refreshToken || "", id_token: kc.idToken || "", token_type: "bearer", obtained_at: Date.now(), }; setAuthFromTokens(t); scheduleAutoRefresh(); try { const w = await whoami(t.access_token); if (!w.ok || !w.authenticated) { setAnon("SSO login desynced. Please login again."); cleanupAuthUrl(); return { ok: false, reason: "whoami_failed" }; } setUser(w.username ? { username: w.username } : null); try { sessionStorage.removeItem("sociowire:logout"); } catch {} setStatus("auth"); await refreshProfile(t.access_token); } catch (err) { console.error("[Auth] Post-SSO verification failed:", err); // Still set as authenticated since we have tokens try { sessionStorage.removeItem("sociowire:logout"); } catch {} setStatus("auth"); } cleanupAuthUrl(); return { ok: true }; } function cleanupAuthUrl() { try { const clean = window.location.origin + window.location.pathname + window.location.hash; window.history.replaceState({}, document.title, clean); } catch {} } async function trySilentKeycloakSession() { // Skip silent SSO check for cross-origin (third-party cookies blocked) if (isCrossOriginSSO()) { console.log("[Auth] Skipping silent SSO check (cross-origin)"); return false; } // Skip silent SSO for apps subdomain - keep it isolated from main site if (isAppsSubdomain()) { console.log("[Auth] Skipping silent SSO check (apps subdomain - isolated)"); return false; } let kc = null; try { if (sessionStorage.getItem("sociowire:logout")) { return false; } } catch {} try { kc = getKeycloak(); } catch { return false; } let ok = false; try { ok = await kc.init({ onLoad: "check-sso", pkceMethod: "S256", checkLoginIframe: false, silentCheckSsoRedirectUri: silentSsoRedirectUri(), }); } catch { return false; } if (!ok || !kc.authenticated || !kc.token) return false; const t = { access_token: kc.token, refresh_token: kc.refreshToken || "", id_token: kc.idToken || "", token_type: "bearer", obtained_at: Date.now(), }; setAuthFromTokens(t); const w = await whoami(t.access_token); if (!w.ok || !w.authenticated) { setAnon("Login desynced. Please login again."); return false; } setUser(w.username ? { username: w.username } : null); setStatus("auth"); await refreshProfile(t.access_token); return true; } function loginWithProvider(provider) { if (!keycloakEnabled()) { setAnon("SSO is not configured."); return; } try { sessionStorage.removeItem("sociowire:logout"); } catch {} const redirectUri = window.location.origin + window.location.pathname; try { const kc = getKeycloak(); kc.login({ redirectUri, idpHint: provider || "", }); } catch (err) { try { const base = KC_URL.replace(/\/+$/, ""); const params = new URLSearchParams({ client_id: KC_CLIENT_ID, redirect_uri: redirectUri, response_type: "code", scope: "openid", }); if (provider) params.set("kc_idp_hint", provider); window.location.href = `${base}/realms/${KC_REALM}/protocol/openid-connect/auth?${params.toString()}`; } catch { setAnon("SSO failed to start. Please try again."); } } } useEffect(() => { if (hasKeycloakCallbackParams()) { // Prevent double processing of callback (StrictMode, re-renders, etc.) if (callbackProcessedRef.current) { console.log("[Auth] Callback already processed, skipping"); return; } callbackProcessedRef.current = true; completeKeycloakLogin().catch((err) => { console.error("[Auth] SSO callback error:", err); setAnon("SSO failed unexpectedly. Please try again."); cleanupAuthUrl(); }); } else { restore().catch((err) => { console.error("[Auth] Restore error:", err); setAnon(""); }); } const onStorage = (e) => { if (e.key === "sociowire:auth") restore().catch(() => setAnon("")); }; window.addEventListener("storage", onStorage); return () => window.removeEventListener("storage", onStorage); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { const authenticated = status === "auth"; const username = user?.username || ""; const token = tokens?.access_token || ""; const nextKey = `${authenticated}:${username}:${token ? "t" : "n"}`; if (lastAuthStatusRef.current === nextKey) return; lastAuthStatusRef.current = nextKey; try { window.dispatchEvent( new CustomEvent("sociowire:auth-changed", { detail: { authenticated, username }, }) ); } catch {} }, [status, user, tokens]); // Backward-compatible fields expected by TopBar/MapView const value = useMemo(() => { const loading = status === "loading"; const authenticated = status === "auth"; const username = user?.username || ""; const userId = user?.userId || null; const token = tokens?.access_token || ""; return { // internal status, user, tokens, error, avatarUrl, profileLoaded, needsUsername, // expected by UI loading, authenticated, username, userId, token, lastError: error || "", lastInfo: lastInfo || "", needsEmailVerification: !!needsEmailVerification, resendVerificationEmail, loginWithProvider, ssoEnabled: keycloakEnabled(), login, logout, restore, refreshProfile, setAvatarUrl, setError, setLastInfo, setNeedsEmailVerification, setNeedsUsername, clearError: () => setError(""), clearInfo: () => setLastInfo(""), // Passkey functions passkeySupported: isPasskeySupported(), hasPasskey, loginWithPasskey, signupWithPasskey, registerNewPasskey, getUserPasskeys, deletePasskey, }; }, [status, user, tokens, error, lastInfo, needsEmailVerification, needsUsername, avatarUrl, profileLoaded]); return {children}; } export function useAuth() { return useContext(AuthCtx); }