sw-fe/src/auth/AuthContext.jsx

1087 lines
31 KiB
JavaScript

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 <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}
export function useAuth() {
return useContext(AuthCtx);
}