1087 lines
31 KiB
JavaScript
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);
|
|
}
|