From ae44f2e019ed8781678ae072e74778e1731946c0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 19 Dec 2025 20:47:42 -0500 Subject: [PATCH] marxhe --- src/api/client.js | 25 ++- src/auth/AuthContext.jsx | 269 ++++++++++++++++++++++++--------- src/components/Map/MapView.jsx | 27 +++- 3 files changed, 241 insertions(+), 80 deletions(-) diff --git a/src/api/client.js b/src/api/client.js index cbc919b..7accfc8 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -103,13 +103,34 @@ export async function createPost(payload, token) { if (!res.ok) { const text = await res.text().catch(() => ""); - console.error("createPost failed", res.status, text); - throw new Error("Failed to create post"); + let json = null; + try { json = text ? JSON.parse(text) : null; } catch {} + + const errMsg = + (json && (json.message || json.error_description || json.error)) || + (text && text.slice(0, 300)) || + `HTTP ${res.status}`; + + const err = new Error(errMsg); + err.status = res.status; + err.body = text; + + // Common backend shapes + err.code = + (json && (json.code || json.error)) || + (res.status === 403 && /email[_\s-]*not[_\s-]*verified/i.test(text || "") + ? "email_not_verified" + : "") || + ""; + + throw err; } return res.json().catch(() => ({})); } + + /** * Crée un nouvel utilisateur via le backend * (backend: POST /api/signup) diff --git a/src/auth/AuthContext.jsx b/src/auth/AuthContext.jsx index 98722c0..38e3ace 100644 --- a/src/auth/AuthContext.jsx +++ b/src/auth/AuthContext.jsx @@ -1,6 +1,12 @@ import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { getEmailVerifiedFromClaims } from "./emailVerified"; -import { clearAuth, guessUsernameFromToken, jwtExpSeconds, loadAuth, saveAuth } from "./authStorage"; +import { + clearAuth, + guessUsernameFromToken, + jwtExpSeconds, + loadAuth, + saveAuth, + decodeJwtPayload, +} from "./authStorage"; import { parseAuthError } from "./authErrors"; const AuthCtx = createContext(null); @@ -9,23 +15,31 @@ function apiBase() { return (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, ""); } -function keycloakBase() { - return (import.meta?.env?.VITE_KEYCLOAK_BASE_URL || "https://auth.sociowire.com").replace(/\/+$/, ""); -} - async function apiFetch(path, opts = {}) { const url = apiBase() + path; const res = await fetch(url, { credentials: "include", ...opts, headers: { - "Content-Type": "application/json", ...(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", @@ -34,7 +48,11 @@ async function whoami(accessToken) { const text = await res.text().catch(() => ""); let json = null; - try { json = text ? JSON.parse(text) : null; } catch {} + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } const uname = (json && (json.username || json.user || json.preferred_username)) || @@ -43,20 +61,85 @@ async function whoami(accessToken) { const authed = !!res.ok && - (json?.authenticated === true || json?.ok === true || !!json?.username || !!json?.user || !!uname); + (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, + emailVerified, + }; +} + 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 {} + 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 }; } @@ -66,37 +149,57 @@ function nowSec() { export function AuthProvider({ children }) { const [status, setStatus] = useState("loading"); // loading | anon | auth - const [user, setUser] = useState(null); - const [tokens, setTokens] = useState(null); + const [user, setUser] = useState(null); // { username } + const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... } + const [error, setError] = useState(""); - const [errorCode, setErrorCode] = useState(""); - const [info, setInfo] = useState(""); + const [lastInfo, setLastInfo] = useState(""); + const [needsEmailVerification, setNeedsEmailVerification] = useState(false); + const refreshTimer = useRef(null); - function clearErrorUI() { - setError(""); - setErrorCode(""); - } - function clearInfoUI() { - setInfo(""); - } - - function setAnon(msg = "", code = "") { + function setAnon(msg = "") { setStatus("anon"); setUser(null); setTokens(null); setError(msg || ""); - setErrorCode(code || ""); + setLastInfo(""); + setNeedsEmailVerification(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"); - clearErrorUI(); + 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 (typeof verified !== "boolean") { + const tv = tokenEmailVerified(at); + if (typeof tv === "boolean") verified = tv; + } + + if (typeof verified === "boolean") { + setNeedsEmailVerification(!verified); + return { ok: true, verified }; + } + + // If cannot determine, do not block UI aggressively + setNeedsEmailVerification(false); + return { ok: mr.ok, verified: null }; } async function ensureFreshSession(existing) { @@ -127,7 +230,11 @@ export function AuthProvider({ children }) { if (w.ok && w.authenticated) { setUser(w.username ? { username: w.username } : null); setStatus("auth"); - clearErrorUI(); + setError(""); + + // ✅ Update email verification state from backend + await refreshProfile(existing.access_token); + return { ok: true }; } @@ -152,21 +259,33 @@ export function AuthProvider({ children }) { setAnon("Session expirée (pas de refresh_token)."); return; } + const rr = await refreshTokens(cur.refresh_token); if (!rr.ok || !rr.json?.access_token) { setAnon("Session expirée. Please login again."); return; } + const merged = { ...cur, ...rr.json }; if (!merged.refresh_token) merged.refresh_token = cur.refresh_token; + setAuthFromTokens(merged); + + // keep verification fresh too + await refreshProfile(merged.access_token); + } else { + // lightweight: refresh verification occasionally without spamming + // (every 30s interval already runs; but this call is only parsing token unless needed) + const tv = tokenEmailVerified(cur.access_token); + if (typeof tv === "boolean") setNeedsEmailVerification(!tv); } }, 30000); } async function restore() { setStatus("loading"); - clearErrorUI(); + setError(""); + setLastInfo(""); const saved = loadAuth(); if (!saved?.access_token) { setAnon(""); @@ -183,22 +302,28 @@ export function AuthProvider({ children }) { } async function login(username, password) { - clearInfoUI(); - clearErrorUI(); + setError(""); + setLastInfo(""); setStatus("loading"); const res = await apiFetch("/api/login", { method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); const text = await res.text().catch(() => ""); let j = null; - try { j = text ? JSON.parse(text) : null; } catch {} + try { + j = text ? JSON.parse(text) : null; + } catch { + j = null; + } if (!res.ok || !j?.access_token) { - const parsed = parseAuthError(text || j?.error_description || j?.error || "", res.status); - setAnon(parsed.message, parsed.code); + const parsed = parseAuthError(text || "", res.status); + setNeedsEmailVerification(parsed.code === "email_verification_required"); + setAnon(parsed.message || j?.error_description || j?.error || text || "Login failed"); return { ok: false, status: res.status, json: j, text }; } @@ -214,7 +339,10 @@ export function AuthProvider({ children }) { setUser(w.username ? { username: w.username } : null); setStatus("auth"); - clearErrorUI(); + + // ✅ update verified state from backend + await refreshProfile(t.access_token); + return { ok: true }; } @@ -224,18 +352,25 @@ export function AuthProvider({ children }) { setAnon(""); } - // ✅ IMPORTANT: arriving on /auth/verified should CLEAR old errors (stale banner) - useEffect(() => { - try { - const p = window.location.pathname || ""; - if (p.startsWith("/auth/verified") || p.startsWith("/auth/verif")) { - clearErrorUI(); - setInfo("✅ Email confirmé. Connecte-toi pour continuer."); - // optional: clean URL so refresh doesn't re-trigger forever - try { window.history.replaceState({}, "", "/"); } catch {} - } - } catch {} - }, []); + 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 }; + } useEffect(() => { restore(); @@ -247,44 +382,40 @@ export function AuthProvider({ children }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Optional helper: open Keycloak account - function openKeycloak() { - try { window.open(keycloakBase(), "_blank", "noopener,noreferrer"); } catch {} - } - + // Backward-compatible fields expected by TopBar/MapView const value = useMemo(() => { const loading = status === "loading"; const authenticated = status === "auth"; const username = user?.username || ""; const token = tokens?.access_token || ""; - const needsEmailVerification = errorCode === "email_verification_required"; - return { - status, user, tokens, + // internal + status, + user, + tokens, + error, + // expected by UI loading, authenticated, username, token, lastError: error || "", - lastErrorCode: errorCode || "", - lastInfo: info || "", - - clearError: clearErrorUI, - clearInfo: clearInfoUI, - setInfo, - setError, // keep for compatibility - - needsEmailVerification, - openKeycloak, + lastInfo: lastInfo || "", + needsEmailVerification: !!needsEmailVerification, + resendVerificationEmail, login, logout, restore, + refreshProfile, + setError, + setLastInfo, + setNeedsEmailVerification, }; - }, [status, user, tokens, error, errorCode, info]); + }, [status, user, tokens, error, lastInfo, needsEmailVerification]); return {children}; } @@ -292,13 +423,3 @@ export function AuthProvider({ children }) { export function useAuth() { return useContext(AuthCtx); } - -/* SW_AUTH_DEBUG_EMAIL */ -try { - // Helps debug: look for email_verified in token - // Toggle by setting: localStorage.SW_AUTH_DEBUG_EMAIL="1" - if (typeof window !== "undefined" && localStorage.getItem("SW_AUTH_DEBUG_EMAIL") === "1") { - console.log("[SW AUTH] tokenParsed:", tokenParsed); - console.log("[SW AUTH] email_verified:", tokenParsed?.email_verified, "emailVerified:", tokenParsed?.emailVerified); - } -} catch {} diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index 6816770..979fc08 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -25,7 +25,7 @@ export default function MapView({ selectedPost, onSelectPost, }) { - const { authenticated, username, token } = useAuth(); + const { authenticated, username, token, needsEmailVerification } = useAuth(); const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } = useMapCore(theme); @@ -207,7 +207,12 @@ export default function MapView({ alert("You must be logged in to place a wire."); return; } - setIsCreating(true); + + if (needsEmailVerification) { + alert("You must verify your email before posting. Use 'Resend email' in the top bar, then login again."); + return; + } +setIsCreating(true); setDraftTitle(""); setDraftBody(""); const main = mainFilter === "All" ? "News" : mainFilter; @@ -233,7 +238,12 @@ export default function MapView({ return; } - const authorName = (username || "").trim() || "anon"; + + if (needsEmailVerification) { + setSaveError("Verify your email to post. Use 'Resend email' in the top bar, then login again."); + return; + } +const authorName = (username || "").trim() || "anon"; let baseLng; let baseLat; @@ -283,7 +293,16 @@ export default function MapView({ } catch (e) { console.error(e); setIsSaving(false); - setSaveError("Error creating post."); + + const code = e && (e.code || ""); + const msg = String((e && e.message) || "").toLowerCase(); + const isNotVerified = code === "email_not_verified" || (e && e.status === 403 && (msg.includes("not verified") || msg.includes("verify"))); + + if (isNotVerified) { + setSaveError("Verify your email to post. Use 'Resend email' in the top bar, then login again."); + } else { + setSaveError((e && e.message) || "Error creating post."); + } } };