diff --git a/patch_frontend_verify_email.sh b/patch_frontend_verify_email.sh deleted file mode 100755 index 0a1cf3a..0000000 --- a/patch_frontend_verify_email.sh +++ /dev/null @@ -1,736 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Run from frontend root (where ./src exists) -if [[ ! -d "src" ]]; then - echo "ERROR: ./src not found. Run from frontend root." - exit 1 -fi - -TS="$(date +%Y%m%d-%H%M%S)" -BK=".patch_backups/$TS" -mkdir -p "$BK" - -backup_file() { - local f="$1" - [[ -f "$f" ]] || return 0 - mkdir -p "$BK/$(dirname "$f")" - cp -a "$f" "$BK/$f" -} - -write_file() { - local f="$1" - shift - mkdir -p "$(dirname "$f")" - backup_file "$f" - cat > "$f" < ""); - if (!res.ok) { - throw new Error(`${res.status} ${text}`); - } - - const data = JSON.parse(text || "{}"); - if (!data.access_token) throw new Error("No access_token in refresh response"); - return { - accessToken: data.access_token, - refreshToken: data.refresh_token || refreshToken, - }; -} - -async function backendWhoami(accessToken) { - const res = await fetch(`${API_BASE}/whoami`, { - method: "GET", - headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {}, - credentials: "include", - }); - - const text = await res.text().catch(() => ""); - if (!res.ok) return { ok: false, status: res.status, text }; - try { - const j = JSON.parse(text || "{}"); - return { ok: true, status: res.status, json: j, text }; - } catch { - return { ok: true, status: res.status, json: {}, text }; - } -} - -async function backendResendVerify(payload) { - const res = await fetch(`${API_BASE}/resend-verify`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify(payload), - }); - const text = await res.text().catch(() => ""); - return { ok: res.ok, status: res.status, text }; -} - -export function AuthProvider({ children }) { - const [booting, setBooting] = useState(true); - const [loading, setLoading] = useState(false); - - const [authenticated, setAuthenticated] = useState(false); - const [username, setUsername] = useState(""); - const [token, setToken] = useState(null); - - const [lastError, setLastError] = useState(""); - const [lastErrorCode, setLastErrorCode] = useState(""); - - const usernameRef = useRef(""); - - function persistAuth({ accessToken, refreshToken, user }) { - setToken(accessToken); - setAuthenticated(true); - setUsername(user || ""); - usernameRef.current = user || ""; - try { - localStorage.setItem(TOKEN_KEY, accessToken); - if (refreshToken) localStorage.setItem(REFRESH_KEY, refreshToken); - if (user) localStorage.setItem(USERNAME_KEY, user); - } catch {} - } - - function clearAuth(msg = "", code = "") { - setAuthenticated(false); - setToken(null); - setUsername(""); - usernameRef.current = ""; - setLastError(msg); - setLastErrorCode(code); - try { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_KEY); - localStorage.removeItem(USERNAME_KEY); - } catch {} - } - - async function trySilentRefreshIfNeeded() { - try { - const storedToken = localStorage.getItem(TOKEN_KEY) || ""; - const storedRefresh = localStorage.getItem(REFRESH_KEY) || ""; - const storedUser = localStorage.getItem(USERNAME_KEY) || ""; - - if (!storedToken) return false; - - // If token still valid, confirm with whoami (prevents stale local token) - if (!willExpireSoon(storedToken, 30)) { - const me = await backendWhoami(storedToken); - if (me.ok && me.json?.authenticated) { - setToken(storedToken); - setUsername(me.json.username || storedUser); - usernameRef.current = me.json.username || storedUser; - setAuthenticated(true); - return true; - } - } - - // Expired or rejected -> refresh - if (!storedRefresh) return false; - - const { accessToken, refreshToken } = await backendRefresh(storedRefresh); - - const me = await backendWhoami(accessToken); - const finalUser = (me.ok && me.json?.authenticated && (me.json.username || storedUser)) || storedUser; - - persistAuth({ accessToken, refreshToken, user: finalUser }); - return true; - } catch (e) { - console.warn("[AUTH] silent refresh failed:", e); - clearAuth("Session expired. Please login again.", "session_expired"); - return false; - } - } - - useEffect(() => { - (async () => { - try { - await trySilentRefreshIfNeeded(); - } finally { - setBooting(false); - } - })(); - }, []); - - useEffect(() => { - if (!authenticated) return; - - const interval = setInterval(async () => { - try { - const t = localStorage.getItem(TOKEN_KEY) || ""; - const rt = localStorage.getItem(REFRESH_KEY) || ""; - if (!t || !rt) return; - - if (!willExpireSoon(t, 90)) return; - - const { accessToken, refreshToken } = await backendRefresh(rt); - const me = await backendWhoami(accessToken); - const u = - (me.ok && me.json?.authenticated && (me.json.username || "")) || - localStorage.getItem(USERNAME_KEY) || - usernameRef.current || - ""; - - persistAuth({ accessToken, refreshToken, user: u }); - } catch (e) { - console.warn("[AUTH] auto refresh failed:", e); - clearAuth("Session expired. Please login again.", "session_expired"); - } - }, 60_000); - - return () => clearInterval(interval); - }, [authenticated]); - - async function login(user, pass) { - setLastError(""); - setLastErrorCode(""); - - const u = (user || "").trim(); - if (!u || !pass) { - setLastError("Username and password required."); - setLastErrorCode("missing_fields"); - return false; - } - - try { - setLoading(true); - - const res = await fetch(`${API_BASE}/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ username: u, password: pass }), - }); - - const text = await res.text().catch(() => ""); - if (!res.ok) { - const nice = parseAuthError(text, res.status); - setLastError(nice.message); - setLastErrorCode(nice.code); - return false; - } - - const data = JSON.parse(text || "{}"); - const accessToken = data.access_token; - const refreshToken = data.refresh_token; - - if (!accessToken) { - setLastError("No access token in response."); - setLastErrorCode("token_missing"); - return false; - } - - // confirm + fetch real username - const me = await backendWhoami(accessToken); - const finalUser = - (me.ok && me.json?.authenticated && (me.json.username || "")) || u; - - persistAuth({ accessToken, refreshToken, user: finalUser }); - setLastError(""); - setLastErrorCode(""); - return true; - } catch (e) { - console.error("[AUTH] network error", e); - clearAuth("Network error during login.", "network_error"); - return false; - } finally { - setLoading(false); - } - } - - async function resendVerifyEmail(usernameOrEmail) { - const v = String(usernameOrEmail || "").trim(); - if (!v) { - setLastError("Entre ton username ou email, puis clique renvoyer."); - setLastErrorCode("missing_fields"); - return false; - } - - try { - const res = await backendResendVerify({ value: v }); - if (!res.ok) { - setLastError(`Resend failed (${res.status})`); - setLastErrorCode("resend_failed"); - return false; - } - setLastError("Email de confirmation renvoyé. Vérifie ta boîte mail (et le spam)."); - setLastErrorCode("verify_sent"); - return true; - } catch (e) { - console.error(e); - setLastError("Network error while resending email."); - setLastErrorCode("network_error"); - return false; - } - } - - function logout() { - clearAuth("", ""); - } - - return ( - { - setLastError(""); - setLastErrorCode(""); - }, - }} - > - {children} - - ); -} - -export function useAuth() { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error("useAuth must be used inside "); - return ctx; -} -' - -echo "✅ Replaced: src/auth/AuthContext.jsx" - - -# ------------------------- -# 3) Patch: src/components/Layout/TopBar.jsx -# ------------------------- -backup_file "src/components/Layout/TopBar.jsx" - -# Replace file fully (safe, based on YOUR current content with minimal edits) -write_file "src/components/Layout/TopBar.jsx" \ -'import React, { useState, useEffect } from "react"; -import { useAuth } from "../../auth/AuthContext"; -import { registerUser } from "../../api/client"; - -const THEME_LABELS = { - dark: "Dark", - blue: "Blue", - light: "Light", -}; - -export default function TopBar({ theme = "dark", onChangeTheme }) { - const { - loading, - authenticated, - username, - login, - logout, - lastError, - lastErrorCode, - needsEmailVerification, - resendVerifyEmail, - clearAuthError, - } = useAuth(); - - const [showAuth, setShowAuth] = useState(false); - const [mode, setMode] = useState("login"); - - const [loginUser, setLoginUser] = useState(""); - const [loginPass, setLoginPass] = useState(""); - - const [firstName, setFirstName] = useState(""); - const [lastName, setLastName] = useState(""); - const [regUser, setRegUser] = useState(""); - const [regEmail, setRegEmail] = useState(""); - const [regPass, setRegPass] = useState(""); - const [signupError, setSignupError] = useState(""); - const [signupInfo, setSignupInfo] = useState(""); - const [signupLoading, setSignupLoading] = useState(false); - - let authLabel = "AUTH: anon"; - if (loading) authLabel = "AUTH: loading…"; - else if (authenticated) authLabel = `AUTH: user=${username || "?"}`; - - const openModal = (nextMode) => { - setMode(nextMode); - setShowAuth(true); - setSignupError(""); - setSignupInfo(""); - if (clearAuthError) clearAuthError(); - }; - - const closeModal = () => { - setShowAuth(false); - setLoginPass(""); - setSignupError(""); - setSignupInfo(""); - if (clearAuthError) clearAuthError(); - }; - - useEffect(() => { - if (authenticated) setShowAuth(false); - }, [authenticated]); - - const handleLoginSubmit = async (e) => { - e.preventDefault(); - await login(loginUser, loginPass); - }; - - const handleSignupSubmit = async (e) => { - e.preventDefault(); - setSignupError(""); - setSignupInfo(""); - - if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) { - setSignupError("First name, last name, username, email and password are required."); - return; - } - - try { - setSignupLoading(true); - - await registerUser({ - username: regUser.trim(), - email: regEmail.trim(), - password: regPass, - firstName: firstName.trim(), - lastName: lastName.trim(), - }); - - // IMPORTANT: do NOT auto-login now, because user must verify email first - setSignupInfo("Compte créé ✅. Va confirmer ton email (check spam). Ensuite reviens te connecter."); - setMode("login"); - setLoginUser(regUser.trim()); - setLoginPass(""); - } catch (err) { - console.error(err); - setSignupError(err.message || "Registration error."); - } finally { - setSignupLoading(false); - } - }; - - const resendFromLogin = async () => { - // prefer email if user typed it, else username - const v = (loginUser || "").trim() || (regEmail || "").trim(); - await resendVerifyEmail(v); - }; - - return ( - <> -
-
-
SW
-
-
SOCIOWIRE.com
-
Wired to life
-
- {authLabel} - {lastError && ( - - ({lastError}) - - )} -
-
-
- -
-
- {["dark", "blue", "light"].map((t) => ( - - ))} -
- - {authenticated ? ( -
- {username} - -
- ) : ( -
- - -
- )} -
-
- - {showAuth && ( -
-
e.stopPropagation()}> -
- - - -
- - {mode === "login" ? ( -
- - - - - - {lastError && ( -
- {lastError} -
- )} - - {needsEmailVerification && ( -
-
- ⚠️ Confirme ton email -
-
- Ouvre ton email et clique le lien de confirmation (check spam). -
- - {lastErrorCode === "verify_sent" && ( -
- ✅ Envoyé. Refresh ta boîte mail. -
- )} -
- )} -
- ) : ( -
-
- - -
- - - - - {signupError &&
{signupError}
} - - {signupInfo && ( -
- {signupInfo} -
- )} - - -
- )} -
-
- )} - - ); -} -' - -echo "✅ Replaced: src/components/Layout/TopBar.jsx" -echo "" -echo "DONE. Now run:" -echo " chmod +x patch_frontend_verify_email.sh" -echo " ./patch_frontend_verify_email.sh" -echo "" -echo "Then:" -echo " npm run dev" -echo "And test: signup -> should NOT auto-login; it tells you to verify email." -echo "Login before verify -> friendly message + resend button." -echo "" -echo "Backups saved in: $BK" - diff --git a/src/auth/AuthContext.jsx b/src/auth/AuthContext.jsx index 179037f..98722c0 100644 --- a/src/auth/AuthContext.jsx +++ b/src/auth/AuthContext.jsx @@ -1,5 +1,7 @@ import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { getEmailVerifiedFromClaims } from "./emailVerified"; import { clearAuth, guessUsernameFromToken, jwtExpSeconds, loadAuth, saveAuth } from "./authStorage"; +import { parseAuthError } from "./authErrors"; const AuthCtx = createContext(null); @@ -7,6 +9,10 @@ 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, { @@ -20,7 +26,6 @@ async function apiFetch(path, opts = {}) { return res; } -// 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", @@ -29,11 +34,7 @@ async function whoami(accessToken) { const text = await res.text().catch(() => ""); let json = null; - try { - json = text ? JSON.parse(text) : null; - } catch { - json = null; - } + try { json = text ? JSON.parse(text) : null; } catch {} const uname = (json && (json.username || json.user || json.preferred_username)) || @@ -55,12 +56,7 @@ async function refreshTokens(refreshToken) { const text = await res.text().catch(() => ""); let json = null; - try { - json = text ? JSON.parse(text) : null; - } catch { - json = null; - } - + try { json = text ? JSON.parse(text) : null; } catch {} return { ok: res.ok, status: res.status, json, text }; } @@ -70,16 +66,27 @@ function nowSec() { export function AuthProvider({ children }) { const [status, setStatus] = useState("loading"); // loading | anon | auth - const [user, setUser] = useState(null); // { username } - const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... } + const [user, setUser] = useState(null); + const [tokens, setTokens] = useState(null); const [error, setError] = useState(""); + const [errorCode, setErrorCode] = useState(""); + const [info, setInfo] = useState(""); const refreshTimer = useRef(null); - function setAnon(msg = "") { + function clearErrorUI() { + setError(""); + setErrorCode(""); + } + function clearInfoUI() { + setInfo(""); + } + + function setAnon(msg = "", code = "") { setStatus("anon"); setUser(null); setTokens(null); setError(msg || ""); + setErrorCode(code || ""); clearAuth(); } @@ -89,7 +96,7 @@ export function AuthProvider({ children }) { const u = usernameGuess || guessUsernameFromToken(t?.access_token) || ""; setUser(u ? { username: u } : null); setStatus("auth"); - setError(""); + clearErrorUI(); } async function ensureFreshSession(existing) { @@ -120,7 +127,7 @@ export function AuthProvider({ children }) { if (w.ok && w.authenticated) { setUser(w.username ? { username: w.username } : null); setStatus("auth"); - setError(""); + clearErrorUI(); return { ok: true }; } @@ -159,7 +166,7 @@ export function AuthProvider({ children }) { async function restore() { setStatus("loading"); - setError(""); + clearErrorUI(); const saved = loadAuth(); if (!saved?.access_token) { setAnon(""); @@ -176,7 +183,8 @@ export function AuthProvider({ children }) { } async function login(username, password) { - setError(""); + clearInfoUI(); + clearErrorUI(); setStatus("loading"); const res = await apiFetch("/api/login", { @@ -186,14 +194,11 @@ export function AuthProvider({ children }) { const text = await res.text().catch(() => ""); let j = null; - try { - j = text ? JSON.parse(text) : null; - } catch { - j = null; - } + try { j = text ? JSON.parse(text) : null; } catch {} if (!res.ok || !j?.access_token) { - setAnon(j?.error_description || j?.error || text || "Login failed"); + const parsed = parseAuthError(text || j?.error_description || j?.error || "", res.status); + setAnon(parsed.message, parsed.code); return { ok: false, status: res.status, json: j, text }; } @@ -209,6 +214,7 @@ export function AuthProvider({ children }) { setUser(w.username ? { username: w.username } : null); setStatus("auth"); + clearErrorUI(); return { ok: true }; } @@ -218,6 +224,19 @@ 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 {} + }, []); + useEffect(() => { restore(); const onStorage = (e) => { @@ -228,37 +247,44 @@ export function AuthProvider({ children }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Backward-compatible fields expected by TopBar/MapView + // Optional helper: open Keycloak account + function openKeycloak() { + try { window.open(keycloakBase(), "_blank", "noopener,noreferrer"); } catch {} + } + const value = useMemo(() => { const loading = status === "loading"; const authenticated = status === "auth"; const username = user?.username || ""; const token = tokens?.access_token || ""; - return { - // internal - status, - user, - tokens, - error, + const needsEmailVerification = errorCode === "email_verification_required"; + + return { + status, user, tokens, - // expected by UI loading, authenticated, username, token, lastError: error || "", - lastInfo: "", - needsEmailVerification: false, - resendVerificationEmail: async () => {}, + lastErrorCode: errorCode || "", + lastInfo: info || "", + + clearError: clearErrorUI, + clearInfo: clearInfoUI, + setInfo, + setError, // keep for compatibility + + needsEmailVerification, + openKeycloak, login, logout, restore, - setError, }; - }, [status, user, tokens, error]); + }, [status, user, tokens, error, errorCode, info]); return {children}; } @@ -266,3 +292,13 @@ 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/auth/AuthContext.jsx.swbak.20251219-170456 b/src/auth/AuthContext.jsx.swbak.20251219-170456 new file mode 100644 index 0000000..179037f --- /dev/null +++ b/src/auth/AuthContext.jsx.swbak.20251219-170456 @@ -0,0 +1,268 @@ +import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { clearAuth, guessUsernameFromToken, jwtExpSeconds, loadAuth, saveAuth } from "./authStorage"; + +const AuthCtx = createContext(null); + +function apiBase() { + return (import.meta?.env?.VITE_API_BASE_URL || "").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; +} + +// 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 }; +} + +async function refreshTokens(refreshToken) { + const res = await apiFetch("/api/refresh", { + method: "POST", + 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 }; +} + +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 } + const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... } + const [error, setError] = useState(""); + const refreshTimer = useRef(null); + + function setAnon(msg = "") { + setStatus("anon"); + setUser(null); + setTokens(null); + setError(msg || ""); + 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"); + setError(""); + } + + async function ensureFreshSession(existing) { + 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?.access_token) { + return { ok: false, reason: `refresh_failed_${rr.status}`, detail: rr.text }; + } + + const merged = { ...existing, ...rr.json }; + if (!merged.refresh_token) merged.refresh_token = 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(""); + return { ok: true }; + } + + return { ok: false, reason: `whoami_${w.status}`, detail: w.text || "" }; + } + + function scheduleAutoRefresh() { + if (refreshTimer.current) { + clearInterval(refreshTimer.current); + refreshTimer.current = null; + } + + refreshTimer.current = setInterval(async () => { + const cur = loadAuth(); + if (!cur?.access_token) return; + + const exp = jwtExpSeconds(cur.access_token); + if (!exp) return; + + if (exp <= nowSec() + 90) { + if (!cur.refresh_token) { + 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); + } + }, 30000); + } + + async function restore() { + setStatus("loading"); + setError(""); + const saved = loadAuth(); + if (!saved?.access_token) { + setAnon(""); + return; + } + + const r = await ensureFreshSession(saved); + if (!r.ok) { + setAnon("Login desynced. Please login again."); + return; + } + + scheduleAutoRefresh(); + } + + async function login(username, password) { + setError(""); + setStatus("loading"); + + const res = await apiFetch("/api/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + + const text = await res.text().catch(() => ""); + let j = null; + try { + j = text ? JSON.parse(text) : null; + } catch { + j = null; + } + + if (!res.ok || !j?.access_token) { + setAnon(j?.error_description || j?.error || text || "Login failed"); + return { ok: false, status: res.status, json: j, text }; + } + + const t = { ...j, obtained_at: Date.now() }; + setAuthFromTokens(t); + scheduleAutoRefresh(); + + const w = await whoami(t.access_token); + if (!w.ok || !w.authenticated) { + setAnon("Login desynced. Please login again."); + return { ok: false, status: w.status, json: w.json, text: w.text }; + } + + setUser(w.username ? { username: w.username } : null); + setStatus("auth"); + return { ok: true }; + } + + function logout() { + if (refreshTimer.current) clearInterval(refreshTimer.current); + refreshTimer.current = null; + setAnon(""); + } + + useEffect(() => { + restore(); + const onStorage = (e) => { + if (e.key === "sociowire:auth") restore(); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 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 || ""; + + return { + // internal + status, + user, + tokens, + error, + + // expected by UI + loading, + authenticated, + username, + token, + + lastError: error || "", + lastInfo: "", + needsEmailVerification: false, + resendVerificationEmail: async () => {}, + + login, + logout, + restore, + setError, + }; + }, [status, user, tokens, error]); + + return {children}; +} + +export function useAuth() { + return useContext(AuthCtx); +} diff --git a/src/auth/emailVerified.js b/src/auth/emailVerified.js new file mode 100644 index 0000000..3ee55bd --- /dev/null +++ b/src/auth/emailVerified.js @@ -0,0 +1,11 @@ +export function getEmailVerifiedFromClaims(claims) { + if (!claims) return false; + // Keycloak/OIDC standard: + if (typeof claims.email_verified === "boolean") return claims.email_verified; + // Some apps mistakenly use camelCase: + if (typeof claims.emailVerified === "boolean") return claims.emailVerified; + // Rare: string "true"/"false" + if (typeof claims.email_verified === "string") return claims.email_verified === "true"; + if (typeof claims.emailVerified === "string") return claims.emailVerified === "true"; + return false; +} diff --git a/src/components/Auth/AuthToast.jsx b/src/components/Auth/AuthToast.jsx new file mode 100644 index 0000000..c67cb1c --- /dev/null +++ b/src/components/Auth/AuthToast.jsx @@ -0,0 +1,43 @@ +import React from "react"; + +export default function AuthToast({ + type = "info", + title = "", + message = "", + actionLabel = "", + onAction, + onClose, +}) { + if (!message) return null; + + const icon = + type === "success" ? "fa-solid fa-circle-check" : + type === "error" ? "fa-solid fa-triangle-exclamation" : + type === "warn" ? "fa-solid fa-circle-exclamation" : + "fa-solid fa-circle-info"; + + return ( +
+
+ +
+ +
+ {title ?
{title}
: null} +
{message}
+ + {actionLabel && typeof onAction === "function" ? ( +
+ +
+ ) : null} +
+ + +
+ ); +} diff --git a/src/components/Layout/TopBar.jsx b/src/components/Layout/TopBar.jsx index e562d76..5a0e45b 100644 --- a/src/components/Layout/TopBar.jsx +++ b/src/components/Layout/TopBar.jsx @@ -1,9 +1,15 @@ -import React, { useState, useEffect } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useAuth } from "../../auth/AuthContext"; import { registerUser } from "../../api/client"; +import AuthToast from "../Auth/AuthToast"; const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" }; +function initialLetter(name = "") { + const s = (name || "").trim(); + return s ? s[0].toUpperCase() : "G"; +} + export default function TopBar({ theme = "dark", onChangeTheme }) { const { loading, @@ -12,9 +18,12 @@ export default function TopBar({ theme = "dark", onChangeTheme }) { login, logout, needsEmailVerification, - resendVerificationEmail, + openKeycloak, lastError, + lastErrorCode, lastInfo, + clearError, + clearInfo, } = useAuth(); const [showAuth, setShowAuth] = useState(false); @@ -32,10 +41,6 @@ export default function TopBar({ theme = "dark", onChangeTheme }) { const [signupInfo, setSignupInfo] = useState(""); const [signupLoading, setSignupLoading] = useState(false); - let authLabel = "AUTH: anon"; - if (loading) authLabel = "AUTH: loading…"; - else if (authenticated) authLabel = `AUTH: user=${username || "?"}`; - const openModal = (nextMode) => { setMode(nextMode); setShowAuth(true); @@ -80,7 +85,6 @@ export default function TopBar({ theme = "dark", onChangeTheme }) { lastName: lastName.trim(), }); - // IMPORTANT: si Verify Email est activé côté Keycloak, on ne tente PAS de login auto. setSignupInfo("Account created. Check your inbox (spam) to verify your email, then login."); setMode("login"); setLoginUser(regUser.trim()); @@ -93,57 +97,48 @@ export default function TopBar({ theme = "dark", onChangeTheme }) { } }; + const toast = useMemo(() => { + if (lastError) { + const t = needsEmailVerification ? "warn" : "error"; + const title = needsEmailVerification ? "Vérification email requise" : "Connexion impossible"; + return { type: t, title, message: lastError }; + } + if (lastInfo) { + return { type: "success", title: "", message: lastInfo }; + } + return null; + }, [lastError, lastInfo, needsEmailVerification]); + return ( <> + {/* Toast area (under topbar) */} + {(toast?.message) && ( +
+ { + if (lastError) clearError(); + if (lastInfo) clearInfo(); + }} + /> + {needsEmailVerification && ( +
+ +
+ )} +
+ )} +
-
+
SW
SOCIOWIRE.com
Wired to life
- -
- {authLabel} - {lastError && ( - - ({lastError}) - - )} -
- - {authenticated && needsEmailVerification && ( -
- Verify email required. - Posting is locked until verification. - -
- )} - - {!needsEmailVerification && lastInfo && ( -
- {lastInfo} -
- )}
@@ -163,14 +158,32 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
{authenticated ? ( -
- {username} +
+
+
+ {initialLetter(username)} +
+
+
Connecté
+
@{username || "user"}
+
+
) : ( -
+
+
+
+ +
+
+
{loading ? "Loading…" : "Guest"}
+
anon
+
+
+ @@ -228,7 +241,6 @@ export default function TopBar({ theme = "dark", onChangeTheme }) { - {lastError &&
{lastError}
} ) : (
diff --git a/src/components/Layout/TopBar.jsx.bak.20251219-163055 b/src/components/Layout/TopBar.jsx.bak.20251219-163055 new file mode 100644 index 0000000..e562d76 --- /dev/null +++ b/src/components/Layout/TopBar.jsx.bak.20251219-163055 @@ -0,0 +1,275 @@ +import React, { useState, useEffect } from "react"; +import { useAuth } from "../../auth/AuthContext"; +import { registerUser } from "../../api/client"; + +const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" }; + +export default function TopBar({ theme = "dark", onChangeTheme }) { + const { + loading, + authenticated, + username, + login, + logout, + needsEmailVerification, + resendVerificationEmail, + lastError, + lastInfo, + } = useAuth(); + + const [showAuth, setShowAuth] = useState(false); + const [mode, setMode] = useState("login"); + + const [loginUser, setLoginUser] = useState(""); + const [loginPass, setLoginPass] = useState(""); + + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [regUser, setRegUser] = useState(""); + const [regEmail, setRegEmail] = useState(""); + const [regPass, setRegPass] = useState(""); + const [signupError, setSignupError] = useState(""); + const [signupInfo, setSignupInfo] = useState(""); + const [signupLoading, setSignupLoading] = useState(false); + + let authLabel = "AUTH: anon"; + if (loading) authLabel = "AUTH: loading…"; + else if (authenticated) authLabel = `AUTH: user=${username || "?"}`; + + const openModal = (nextMode) => { + setMode(nextMode); + setShowAuth(true); + setSignupError(""); + setSignupInfo(""); + }; + + const closeModal = () => { + setShowAuth(false); + setLoginPass(""); + setSignupError(""); + setSignupInfo(""); + }; + + useEffect(() => { + if (authenticated) setShowAuth(false); + }, [authenticated]); + + const handleLoginSubmit = async (e) => { + e.preventDefault(); + await login(loginUser, loginPass); + }; + + const handleSignupSubmit = async (e) => { + e.preventDefault(); + setSignupError(""); + setSignupInfo(""); + + if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) { + setSignupError("First name, last name, username, email and password are required."); + return; + } + + try { + setSignupLoading(true); + + await registerUser({ + username: regUser.trim(), + email: regEmail.trim(), + password: regPass, + firstName: firstName.trim(), + lastName: lastName.trim(), + }); + + // IMPORTANT: si Verify Email est activé côté Keycloak, on ne tente PAS de login auto. + setSignupInfo("Account created. Check your inbox (spam) to verify your email, then login."); + setMode("login"); + setLoginUser(regUser.trim()); + setLoginPass(""); + } catch (err) { + console.error(err); + setSignupError(err.message || "Registration error."); + } finally { + setSignupLoading(false); + } + }; + + return ( + <> +
+
+
SW
+
+
SOCIOWIRE.com
+
Wired to life
+ +
+ {authLabel} + {lastError && ( + + ({lastError}) + + )} +
+ + {authenticated && needsEmailVerification && ( +
+ Verify email required. + Posting is locked until verification. + +
+ )} + + {!needsEmailVerification && lastInfo && ( +
+ {lastInfo} +
+ )} +
+
+ +
+
+ {["dark", "blue", "light"].map((t) => ( + + ))} +
+ + {authenticated ? ( +
+ {username} + +
+ ) : ( +
+ + +
+ )} +
+
+ + {showAuth && ( +
+
e.stopPropagation()}> +
+ + + +
+ + {mode === "login" ? ( + + + + + {lastError &&
{lastError}
} + + ) : ( +
+
+ + +
+ + + + + {signupError &&
{signupError}
} + {signupInfo && ( +
+ {signupInfo} +
+ )} + + +
+ )} +
+
+ )} + + ); +} diff --git a/src/components/Layout/TopBar.jsx.bak.20251219-164233 b/src/components/Layout/TopBar.jsx.bak.20251219-164233 new file mode 100644 index 0000000..3938917 --- /dev/null +++ b/src/components/Layout/TopBar.jsx.bak.20251219-164233 @@ -0,0 +1,390 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { useAuth } from "../../auth/AuthContext"; +import { registerUser } from "../../api/client"; + +const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" }; +const LAST_SIGNUP_USER_KEY = "sociowire:lastSignupUser"; + +function normalizeAuthMessage(raw) { + const msg = String(raw || "").trim(); + if (!msg) return ""; + + const lower = msg.toLowerCase(); + + // Keycloak classic when email verification required + if ( + lower.includes("not fully set up") || + lower.includes("verify") || + lower.includes("verification") || + lower.includes("required action") || + (lower.includes("email") && lower.includes("verified")) + ) { + return "Tu dois confirmer ton email avant de te connecter. Vérifie ta boîte mail (et le spam), puis réessaie."; + } + + // Bad credentials + if ( + lower.includes("invalid user credentials") || + lower.includes("bad credentials") || + (lower.includes("invalid_grant") && !lower.includes("not fully set up")) + ) { + return "Mauvais username ou mot de passe."; + } + + // Fallback (shorten very long raw blobs) + if (msg.length > 240) return msg.slice(0, 240) + "…"; + return msg; +} + +export default function TopBar({ theme = "dark", onChangeTheme }) { + const { + loading, + authenticated, + username, + login, + logout, + needsEmailVerification, + resendVerificationEmail, + lastError, + lastInfo, + } = useAuth(); + + const [showAuth, setShowAuth] = useState(false); + const [mode, setMode] = useState("login"); + + const [loginUser, setLoginUser] = useState(""); + const [loginPass, setLoginPass] = useState(""); + + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [regUser, setRegUser] = useState(""); + const [regEmail, setRegEmail] = useState(""); + const [regPass, setRegPass] = useState(""); + const [signupError, setSignupError] = useState(""); + const [signupInfo, setSignupInfo] = useState(""); + const [signupLoading, setSignupLoading] = useState(false); + + const isVerifiedPage = + typeof window !== "undefined" && window.location?.pathname === "/auth/verified"; + + const prettyError = useMemo(() => normalizeAuthMessage(lastError), [lastError]); + + const openModal = (nextMode) => { + setMode(nextMode); + setShowAuth(true); + setSignupError(""); + setSignupInfo(""); + }; + + const closeModal = () => { + setShowAuth(false); + setLoginPass(""); + setSignupError(""); + setSignupInfo(""); + }; + + // close modal automatically on auth + useEffect(() => { + if (authenticated) setShowAuth(false); + }, [authenticated]); + + // ✅ If user lands on /auth/verified (after clicking email link), + // open login modal + prefill username from last signup. + useEffect(() => { + if (!isVerifiedPage) return; + + setMode("login"); + setShowAuth(true); + + try { + const lastU = localStorage.getItem(LAST_SIGNUP_USER_KEY) || ""; + if (lastU && !loginUser) setLoginUser(lastU); + } catch {} + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isVerifiedPage]); + + // ✅ After successful login, if still on /auth/verified, cleanly go back home + useEffect(() => { + if (!authenticated) return; + if (typeof window === "undefined") return; + const path = window.location?.pathname || ""; + if (path === "/auth/verified") { + try { + window.history.replaceState({}, "", "/"); + } catch {} + } + }, [authenticated]); + + const handleLoginSubmit = async (e) => { + e.preventDefault(); + await login(loginUser, loginPass); + }; + + const handleSignupSubmit = async (e) => { + e.preventDefault(); + setSignupError(""); + setSignupInfo(""); + + if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) { + setSignupError("First name, last name, username, email and password are required."); + return; + } + + try { + setSignupLoading(true); + + await registerUser({ + username: regUser.trim(), + email: regEmail.trim(), + password: regPass, + firstName: firstName.trim(), + lastName: lastName.trim(), + }); + + // Store last signup username so /auth/verified can prefill login. + try { + localStorage.setItem(LAST_SIGNUP_USER_KEY, regUser.trim()); + } catch {} + + // IMPORTANT: si Verify Email est activé côté Keycloak, on ne tente PAS de login auto. + setSignupInfo("Compte créé ✅ Vérifie ton email (spam inclus), puis reviens te connecter."); + setMode("login"); + setLoginUser(regUser.trim()); + setLoginPass(""); + } catch (err) { + console.error(err); + setSignupError(err.message || "Registration error."); + } finally { + setSignupLoading(false); + } + }; + + const AuthPill = () => { + if (loading) { + return ( + + + Loading… + + ); + } + if (authenticated) { + return ( + + + Connecté + + ); + } + return ( + + + Guest + + ); + }; + + return ( + <> +
+
+
SW
+ +
+
SOCIOWIRE.com
+
Wired to life
+ + {/* ✅ Clean auth row */} +
+ + {authenticated && username && ( + + + {username} + + )} +
+ + {/* ✅ Verified page hint */} + {isVerifiedPage && !authenticated && ( +
+ ✅ Email confirmé. + Connecte-toi pour continuer. +
+ )} + + {/* Email verification banner (if you later wire it from backend) */} + {authenticated && needsEmailVerification && ( +
+ + + Verify email required. + + Posting is locked until verification. + +
+ )} + + {/* ✅ Nice error banner */} + {prettyError && ( +
+ + {prettyError} +
+ )} + + {/* Optional lastInfo from AuthContext */} + {!needsEmailVerification && lastInfo && ( +
+ + {lastInfo} +
+ )} +
+
+ +
+
+ {["dark", "blue", "light"].map((t) => ( + + ))} +
+ + {authenticated ? ( +
+
+ + + + {username || "user"} +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ + {showAuth && ( +
+
e.stopPropagation()}> +
+ + + +
+ + {mode === "login" ? ( +
+ + + +
+ ) : ( +
+
+ + +
+ + + + + {signupError &&
{signupError}
} + {signupInfo && ( +
+ {signupInfo} +
+ )} + + +
+ )} +
+
+ )} + + ); +} diff --git a/src/components/Layout/TopBar.jsx.swbak.20251219-170456 b/src/components/Layout/TopBar.jsx.swbak.20251219-170456 new file mode 100644 index 0000000..058ea18 --- /dev/null +++ b/src/components/Layout/TopBar.jsx.swbak.20251219-170456 @@ -0,0 +1,379 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { useAuth } from "../../auth/AuthContext"; +import { registerUser } from "../../api/client"; + +const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" }; +const LAST_SIGNUP_USER_KEY = "sociowire:lastSignupUser"; + +const KC_BASE = + (import.meta?.env?.VITE_KEYCLOAK_BASE_URL) || "https://auth.sociowire.com"; +const KC_REALM = + (import.meta?.env?.VITE_KEYCLOAK_REALM) || "sociowire"; +const KC_CLIENT = + (import.meta?.env?.VITE_KEYCLOAK_CLIENT_ID) || "sociowire"; + +function isEmailVerifyError(raw) { + const msg = String(raw || "").toLowerCase(); + return ( + msg.includes("not fully set up") || + msg.includes("verify") || + msg.includes("verification") || + msg.includes("required action") || + (msg.includes("email") && msg.includes("verified")) + ); +} + +function normalizeAuthMessage(raw) { + const msg = String(raw || "").trim(); + if (!msg) return ""; + + const lower = msg.toLowerCase(); + + if (isEmailVerifyError(lower)) { + return "Ton compte est créé, mais l’email n’est pas confirmé. Clique “Vérifier mon email” (Keycloak), puis utilise le lien reçu par email."; + } + + if ( + lower.includes("invalid user credentials") || + lower.includes("bad credentials") || + lower.includes("invalid_grant") + ) { + return "Mauvais username ou mot de passe."; + } + + if (msg.length > 220) return msg.slice(0, 220) + "…"; + return msg; +} + +function buildKeycloakAuthUrl() { + const redirectUri = `${window.location.origin}/auth/verified`; + const url = + `${KC_BASE}/realms/${encodeURIComponent(KC_REALM)}` + + `/protocol/openid-connect/auth` + + `?client_id=${encodeURIComponent(KC_CLIENT)}` + + `&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&response_type=code&scope=openid`; + return url; +} + +export default function TopBar({ theme = "dark", onChangeTheme }) { + const { + loading, + authenticated, + username, + login, + logout, + resendVerificationEmail, + lastError, + lastInfo, + } = useAuth(); + + const [showAuth, setShowAuth] = useState(false); + const [mode, setMode] = useState("login"); + + const [loginUser, setLoginUser] = useState(""); + const [loginPass, setLoginPass] = useState(""); + + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [regUser, setRegUser] = useState(""); + const [regEmail, setRegEmail] = useState(""); + const [regPass, setRegPass] = useState(""); + const [signupError, setSignupError] = useState(""); + const [signupInfo, setSignupInfo] = useState(""); + const [signupLoading, setSignupLoading] = useState(false); + + const prettyError = useMemo(() => normalizeAuthMessage(lastError), [lastError]); + const showVerifyHelp = useMemo(() => isEmailVerifyError(lastError), [lastError]); + + const isVerifiedPage = + typeof window !== "undefined" && window.location?.pathname === "/auth/verified"; + + const openModal = (nextMode) => { + setMode(nextMode); + setShowAuth(true); + setSignupError(""); + setSignupInfo(""); + }; + + const closeModal = () => { + setShowAuth(false); + setLoginPass(""); + setSignupError(""); + setSignupInfo(""); + }; + + // ✅ If error happens, open modal so errors are not in topbar + useEffect(() => { + if (prettyError) { + setMode("login"); + setShowAuth(true); + } + }, [prettyError]); + + // ✅ On /auth/verified: open login + prefill username + useEffect(() => { + if (!isVerifiedPage) return; + setMode("login"); + setShowAuth(true); + + try { + const lastU = localStorage.getItem(LAST_SIGNUP_USER_KEY) || ""; + if (lastU && !loginUser) setLoginUser(lastU); + } catch {} + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isVerifiedPage]); + + // ✅ After login on /auth/verified, go home + useEffect(() => { + if (!authenticated) return; + if (typeof window === "undefined") return; + const path = window.location?.pathname || ""; + if (path === "/auth/verified") { + try { window.history.replaceState({}, "", "/"); } catch {} + } + }, [authenticated]); + + const handleLoginSubmit = async (e) => { + e.preventDefault(); + await login(loginUser, loginPass); + }; + + const handleSignupSubmit = async (e) => { + e.preventDefault(); + setSignupError(""); + setSignupInfo(""); + + if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) { + setSignupError("First name, last name, username, email and password are required."); + return; + } + + try { + setSignupLoading(true); + + await registerUser({ + username: regUser.trim(), + email: regEmail.trim(), + password: regPass, + firstName: firstName.trim(), + lastName: lastName.trim(), + }); + + try { localStorage.setItem(LAST_SIGNUP_USER_KEY, regUser.trim()); } catch {} + + // ✅ Do NOT auto-login (verify email flow) + setSignupInfo("Compte créé ✅ Vérifie ton email (spam inclus), puis connecte-toi."); + setMode("login"); + setLoginUser(regUser.trim()); + setLoginPass(""); + } catch (err) { + console.error(err); + setSignupError(err.message || "Registration error."); + } finally { + setSignupLoading(false); + } + }; + + const NoticeBox = ({ variant, children }) => ( +
+ {children} +
+ ); + + return ( + <> +
+
+
SW
+ +
+
SOCIOWIRE.com
+
Wired to life
+
+
+ +
+
+ {["dark", "blue", "light"].map((t) => ( + + ))} +
+ + {authenticated ? ( +
+ {/* ✅ one single user chip */} +
+ + + + {username || "user"} + + + Connecté + +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+ + {showAuth && ( +
+
e.stopPropagation()}> +
+ + + +
+ + {/* ✅ Verified page success message */} + {isVerifiedPage && !authenticated && ( + + ✅ Email confirmé. Connecte-toi pour continuer. + + )} + + {/* ✅ Any error/info now lives INSIDE modal */} + {prettyError && ( + + + {prettyError} + + )} + + {!prettyError && lastInfo && ( + + + {lastInfo} + + )} + + {/* ✅ Email verify helper buttons */} + {showVerifyHelp && ( + +
+
+ + Vérification email requise +
+
+ Ton login “modal” ne peut pas compléter l’action Keycloak. Ouvre la page Keycloak, + clique “proceed”, puis clique le lien reçu par email. +
+
+ + Vérifier mon email (Keycloak) + + {typeof resendVerificationEmail === "function" && ( + + )} +
+
+
+ )} + + {mode === "login" ? ( +
+ + + +
+ ) : ( +
+
+ + +
+ + + + + {signupError && {signupError}} + {signupInfo && {signupInfo}} + + +
+ )} +
+
+ )} + + ); +} diff --git a/src/components/auth/AuthPill.jsx b/src/components/auth/AuthPill.jsx new file mode 100644 index 0000000..f2e527f --- /dev/null +++ b/src/components/auth/AuthPill.jsx @@ -0,0 +1,36 @@ +import React from "react"; +import { decodeJwt, readAccessToken } from "../../lib/jwt"; + +export default function AuthPill() { + const token = readAccessToken(); + const claims = decodeJwt(token); + const username = claims?.preferred_username || claims?.name || "Guest"; + const emailVerified = + typeof claims?.email_verified === "boolean" ? claims.email_verified : + (typeof claims?.emailVerified === "boolean" ? claims.emailVerified : null); + + const initial = (username?.[0] || "G").toUpperCase(); + + return ( +
+
+ {initial} +
+
+
+ {username} + {emailVerified === true ? : null} +
+
+ {token ? "Connecté" : "Invité"} +
+
+
+ ); +} diff --git a/src/components/ui/ToastHost.jsx b/src/components/ui/ToastHost.jsx new file mode 100644 index 0000000..12be294 --- /dev/null +++ b/src/components/ui/ToastHost.jsx @@ -0,0 +1,64 @@ +import React, { useEffect, useState } from "react"; + +export function toast({ title, message, kind = "info", ms = 4500 } = {}) { + window.dispatchEvent(new CustomEvent("sw:toast", { detail: { title, message, kind, ms } })); +} + +export default function ToastHost() { + const [items, setItems] = useState([]); + + useEffect(() => { + const onToast = (e) => { + const id = Math.random().toString(16).slice(2); + const it = { id, ...e.detail }; + setItems((p) => [it, ...p].slice(0, 3)); + setTimeout(() => setItems((p) => p.filter((x) => x.id !== id)), it.ms || 4500); + }; + window.addEventListener("sw:toast", onToast); + return () => window.removeEventListener("sw:toast", onToast); + }, []); + + if (!items.length) return null; + + return ( +
+ {items.map((t) => ( +
+
+
+ {t.title || (t.kind === "error" ? "Erreur" : "Info")} +
+ +
+ {t.message ?
{t.message}
: null} +
+ ))} +
+ ); +} diff --git a/src/lib/jwt.js b/src/lib/jwt.js new file mode 100644 index 0000000..ebec423 --- /dev/null +++ b/src/lib/jwt.js @@ -0,0 +1,19 @@ +export function decodeJwt(token) { + try { + if (!token || token.split(".").length < 2) return null; + const payload = token.split(".")[1]; + const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(json); + } catch { + return null; + } +} + +export function readAccessToken() { + return ( + localStorage.getItem("kc_token") || + localStorage.getItem("access_token") || + localStorage.getItem("token") || + "" + ); +} diff --git a/src/main.jsx b/src/main.jsx index 63d1419..8295cfc 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -12,6 +12,7 @@ import "./styles/overlays.css"; import "./styles/posts.css"; import "./styles/theme.css"; import "./styles/auth-modal.css"; +import "./styles/auth-toast.css"; import "./styles/filters.css"; import "./styles/mapMarkers.css"; diff --git a/src/main.jsx.swbak.20251219-170456 b/src/main.jsx.swbak.20251219-170456 new file mode 100644 index 0000000..63d1419 --- /dev/null +++ b/src/main.jsx.swbak.20251219-170456 @@ -0,0 +1,26 @@ +import "@fortawesome/fontawesome-free/css/all.min.css"; // local via npm (no CDN) + +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.jsx"; + +import "./styles/base.css"; +import "./styles/layout.css"; +import "./styles/topbar.css"; +import "./styles/map.css"; +import "./styles/overlays.css"; +import "./styles/posts.css"; +import "./styles/theme.css"; +import "./styles/auth-modal.css"; +import "./styles/filters.css"; +import "./styles/mapMarkers.css"; + +import { AuthProvider } from "./auth/AuthContext.jsx"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + + + +); diff --git a/src/styles/auth-toast.css b/src/styles/auth-toast.css new file mode 100644 index 0000000..f0daa28 --- /dev/null +++ b/src/styles/auth-toast.css @@ -0,0 +1,93 @@ +:root{ + --sw-topbar-h: 64px; + --sw-safe-top: env(safe-area-inset-top, 0px); +} + +.sw-toast-wrap{ + position: fixed; + left: 0; + right: 0; + top: calc(var(--sw-safe-top) + var(--sw-topbar-h) + 8px); + z-index: 300; /* under modal, above map */ + padding: 10px 12px 0; + pointer-events: none; +} + +.sw-toast{ + pointer-events: auto; + display:flex; + gap:10px; + align-items:flex-start; + border-radius: 16px; + padding: 10px 12px; + backdrop-filter: blur(10px); + border: 1px solid rgba(148,163,184,.45); + box-shadow: 0 12px 34px rgba(0,0,0,.45); + max-width: 980px; + margin: 0 auto; +} + +.sw-toast-icon{ + width: 30px; height: 30px; + border-radius: 999px; + display:flex; align-items:center; justify-content:center; + flex-shrink:0; + background: rgba(15,23,42,.55); + border: 1px solid rgba(148,163,184,.35); +} + +.sw-toast-body{ flex: 1; min-width: 0; } + +.sw-toast-title{ + font-weight: 900; + font-size: 12px; + letter-spacing: .02em; + margin-bottom: 2px; +} + +.sw-toast-msg{ + font-size: 12px; + opacity: .95; + line-height: 1.25; +} + +.sw-toast-actions{ + margin-top: 8px; + display: flex; + gap: 8px; +} + +.sw-toast-btn{ + border-radius: 999px; + padding: 8px 12px; + border: 1px solid rgba(148,163,184,.40); + background: rgba(15,23,42,.35); + color: inherit; + cursor: pointer; + font-weight: 700; + font-size: 12px; +} + +.sw-toast-x{ + margin-left:auto; + width: 30px; height: 30px; + border-radius: 999px; + border: 1px solid rgba(148,163,184,.35); + background: rgba(15,23,42,.35); + color: inherit; + cursor: pointer; +} + +.sw-toast-success{ background: rgba(20, 80, 45, 0.35); border-color: rgba(34,197,94,.45); } +.sw-toast-error{ background: rgba(90, 25, 25, 0.35); border-color: rgba(248,113,113,.55); } +.sw-toast-warn{ background: rgba(100, 80, 20, 0.28); border-color: rgba(250,204,21,.55); } +.sw-toast-info{ background: rgba(15, 23, 42, 0.35); } + +body[data-theme="light"] .sw-toast{ + background: rgba(255,255,255,.92); + color: #0B1220; + border-color: rgba(148,163,184,.55); + box-shadow: 0 12px 24px rgba(0,0,0,.12); +} +body[data-theme="light"] .sw-toast-icon{ background: rgba(255,255,255,.95); } +body[data-theme="light"] .sw-toast-x{ background: rgba(255,255,255,.95); } diff --git a/src/styles/topbar.css b/src/styles/topbar.css index aa73bf4..ddb2af1 100644 --- a/src/styles/topbar.css +++ b/src/styles/topbar.css @@ -59,3 +59,193 @@ .sub-title{ font-size:.7rem; } .topbar-right{ width:100%; justify-content:flex-start; gap:.45rem; } } + +/* SW_AUTH_UI:BEGIN */ +.auth-strip{ + margin-top: 6px; + display:flex; + align-items:center; + gap: .45rem; + flex-wrap: wrap; +} + +.auth-pill{ + display:inline-flex; + align-items:center; + gap: .35rem; + padding: .18rem .55rem; + border-radius: 999px; + font-size: .70rem; + font-weight: 900; + letter-spacing: .02em; + border: 1px solid rgba(148,163,184,.55); + background: rgba(2,6,23,.25); + color: #e5e7eb; +} +.auth-pill i{ font-size: .85rem; opacity:.95; } + +.auth-pill--ok{ + border-color: rgba(34,197,94,.65); + background: rgba(34,197,94,.16); +} +.auth-pill--loading{ + border-color: rgba(56,189,248,.65); + background: rgba(56,189,248,.14); +} +.auth-pill--anon{ + border-color: rgba(148,163,184,.55); + background: rgba(2,6,23,.20); +} + +.auth-banner{ + margin-top: 6px; + padding: 6px 10px; + border-radius: 14px; + font-size: .72rem; + font-weight: 800; + display:flex; + gap: 8px; + align-items:center; + flex-wrap:wrap; +} +.auth-banner--error{ + background: rgba(248,113,113,0.14); + border: 1px solid rgba(248,113,113,0.55); + color: #ffe4e6; +} +.auth-banner--info{ + background: rgba(56,189,248,0.14); + border: 1px solid rgba(56,189,248,0.55); + color: #e0f2fe; +} +.auth-banner--success{ + background: rgba(34,197,94,0.14); + border: 1px solid rgba(34,197,94,0.55); + color: #d1fae5; +} + +.user-chip{ + display:inline-flex; + align-items:center; + gap: .45rem; + padding: .22rem .55rem; + border-radius: 999px; + background: rgba(2,6,23,.25); + border: 1px solid rgba(148,163,184,.55); +} +.user-avatar{ + width: 26px; + height: 26px; + border-radius: 999px; + display:flex; + align-items:center; + justify-content:center; + background: rgba(56,189,248,0.18); + border: 1px solid rgba(56,189,248,0.35); +} +.user-avatar i{ font-size: 14px; } + +.user-name{ + font-size: .80rem; + font-weight: 950; + color: #f9fafb; + max-width: 220px; + overflow:hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Light theme readability */ +body[data-theme="light"] .auth-pill{ + background: rgba(255,255,255,0.82); + border-color: rgba(148,163,184,0.85); + color: #0b1220; +} +body[data-theme="light"] .user-chip{ + background: rgba(255,255,255,0.82); + border-color: rgba(148,163,184,0.85); +} +body[data-theme="light"] .user-name{ color:#0b1220; } +body[data-theme="light"] .user-avatar{ + background: rgba(30,107,255,0.12); + border-color: rgba(30,107,255,0.35); + color:#0b1220; +} +/* SW_AUTH_UI:END */ + +/* SW_AUTH_UI2:BEGIN */ +.sw-notice{ + margin: 10px 14px 0 14px; + padding: 10px 12px; + border-radius: 16px; + font-size: .78rem; + font-weight: 800; + line-height: 1.2; + border: 1px solid rgba(148,163,184,.35); + background: rgba(2,6,23,.22); + color: #e5e7eb; +} +.sw-notice--error{ + background: rgba(248,113,113,0.14); + border-color: rgba(248,113,113,0.55); + color: #ffe4e6; +} +.sw-notice--info{ + background: rgba(56,189,248,0.14); + border-color: rgba(56,189,248,0.55); + color: #e0f2fe; +} +.sw-notice--success{ + background: rgba(34,197,94,0.14); + border-color: rgba(34,197,94,0.55); + color: #d1fae5; +} +.sw-notice--warn{ + background: rgba(251,191,36,0.12); + border-color: rgba(251,191,36,0.40); + color: #fffbeb; +} + +/* single user chip (no duplicates) */ +.user-chip{ + display:inline-flex; + align-items:center; + gap: .45rem; + padding: .22rem .55rem; + border-radius: 999px; + background: rgba(2,6,23,.25); + border: 1px solid rgba(148,163,184,.55); +} +.user-avatar{ + width: 26px; + height: 26px; + border-radius: 999px; + display:flex; + align-items:center; + justify-content:center; + background: rgba(56,189,248,0.18); + border: 1px solid rgba(56,189,248,0.35); +} +.user-avatar i{ font-size: 14px; } +.user-name{ + font-size: .82rem; + font-weight: 950; + color: #f9fafb; + max-width: 180px; + overflow:hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.user-badge{ + display:inline-flex; + align-items:center; + gap: .35rem; + padding: .10rem .45rem; + border-radius: 999px; + font-size: .70rem; + font-weight: 950; + border: 1px solid rgba(34,197,94,.55); + background: rgba(34,197,94,.14); + color: #d1fae5; +} +/* SW_AUTH_UI2:END */ diff --git a/src/styles/topbar.css.bak.20251219-163055 b/src/styles/topbar.css.bak.20251219-163055 new file mode 100644 index 0000000..aa73bf4 --- /dev/null +++ b/src/styles/topbar.css.bak.20251219-163055 @@ -0,0 +1,61 @@ +.topbar{ + position: sticky; top:0; z-index:100; + width:100%; + padding: .55rem .9rem; + display:flex; align-items:center; justify-content:space-between; gap:.6rem; + background: linear-gradient(135deg,#071225 0%,#0b2b5f 38%,#0e65c0 72%,#27a8ff 100%); + border-bottom-left-radius:18px; border-bottom-right-radius:18px; + box-shadow: 0 10px 24px rgba(0,0,0,.55), 0 0 18px rgba(56,189,248,.45); + overflow:hidden; +} + +.logo-circle{ + width:40px; height:40px; border-radius:50%; flex-shrink:0; + background-image:url("/icons/logo-master.png"); + background-size:cover; background-position:center; background-repeat:no-repeat; + box-shadow: 0 0 10px rgba(37,99,235,.8), 0 0 18px rgba(56,189,248,.6); + color:transparent; +} + +.title-block{ display:flex; flex-direction:column; line-height:1.05; min-width:0; } +.main-title{ font-size:1.02rem; font-weight:800; letter-spacing:.04em; color:#f9fafb; } +.sub-title{ font-size:.74rem; color:#dbeafe; opacity:.95; } + +.topbar-right{ display:flex; align-items:center; gap:.5rem; margin-left:auto; flex-wrap:wrap; } + +.theme-switch{ display:flex; gap:.28rem; } +.theme-dot{ + width:22px; height:22px; border-radius:999px; + border:1px solid rgba(148,163,184,.85); + background:rgba(15,23,42,.9); color:#e5e7eb; font-size:.65rem; + display:flex; align-items:center; justify-content:center; padding:0; cursor:pointer; + box-shadow:0 0 6px rgba(15,23,42,.9); transition:transform 0.36s ease; +} +.theme-dot:hover{ transform:scale(1.06); } +.theme-dot-active{ border-color:#facc15; box-shadow:0 0 10px rgba(250,204,21,.9),0 0 14px rgba(56,189,248,.55); } + +.btn-primary, .btn-secondary{ + padding:.32rem .85rem; font-size:.78rem; border-radius:999px; font-weight:700; cursor:pointer; + white-space:nowrap; line-height:1; +} +.btn-primary{ + border:1px solid #22c55e; + background:radial-gradient(circle at 30% 30%,#22c55e,#16a34a); + color:#f9fafb; + box-shadow:0 4px 10px rgba(22,163,74,.5),0 0 8px rgba(22,163,74,.6); +} +.btn-primary:disabled{ opacity:.6; cursor:default; } +.btn-secondary{ + border:1px solid #bfdbfe; + background:rgba(15,23,42,.25); + color:#e0f2fe; + box-shadow:0 3px 10px rgba(15,23,42,.6); +} +.btn-secondary:hover{ background:rgba(15,23,42,.45); } + +@media (max-width:640px){ + .topbar{ padding:.5rem .7rem; border-bottom-left-radius:16px; border-bottom-right-radius:16px; } + .main-title{ font-size:1rem; } + .sub-title{ font-size:.7rem; } + .topbar-right{ width:100%; justify-content:flex-start; gap:.45rem; } +} diff --git a/src/styles/topbar.css.bak.20251219-164233 b/src/styles/topbar.css.bak.20251219-164233 new file mode 100644 index 0000000..bb73d50 --- /dev/null +++ b/src/styles/topbar.css.bak.20251219-164233 @@ -0,0 +1,174 @@ +.topbar{ + position: sticky; top:0; z-index:100; + width:100%; + padding: .55rem .9rem; + display:flex; align-items:center; justify-content:space-between; gap:.6rem; + background: linear-gradient(135deg,#071225 0%,#0b2b5f 38%,#0e65c0 72%,#27a8ff 100%); + border-bottom-left-radius:18px; border-bottom-right-radius:18px; + box-shadow: 0 10px 24px rgba(0,0,0,.55), 0 0 18px rgba(56,189,248,.45); + overflow:hidden; +} + +.logo-circle{ + width:40px; height:40px; border-radius:50%; flex-shrink:0; + background-image:url("/icons/logo-master.png"); + background-size:cover; background-position:center; background-repeat:no-repeat; + box-shadow: 0 0 10px rgba(37,99,235,.8), 0 0 18px rgba(56,189,248,.6); + color:transparent; +} + +.title-block{ display:flex; flex-direction:column; line-height:1.05; min-width:0; } +.main-title{ font-size:1.02rem; font-weight:800; letter-spacing:.04em; color:#f9fafb; } +.sub-title{ font-size:.74rem; color:#dbeafe; opacity:.95; } + +.topbar-right{ display:flex; align-items:center; gap:.5rem; margin-left:auto; flex-wrap:wrap; } + +.theme-switch{ display:flex; gap:.28rem; } +.theme-dot{ + width:22px; height:22px; border-radius:999px; + border:1px solid rgba(148,163,184,.85); + background:rgba(15,23,42,.9); color:#e5e7eb; font-size:.65rem; + display:flex; align-items:center; justify-content:center; padding:0; cursor:pointer; + box-shadow:0 0 6px rgba(15,23,42,.9); transition:transform 0.36s ease; +} +.theme-dot:hover{ transform:scale(1.06); } +.theme-dot-active{ border-color:#facc15; box-shadow:0 0 10px rgba(250,204,21,.9),0 0 14px rgba(56,189,248,.55); } + +.btn-primary, .btn-secondary{ + padding:.32rem .85rem; font-size:.78rem; border-radius:999px; font-weight:700; cursor:pointer; + white-space:nowrap; line-height:1; +} +.btn-primary{ + border:1px solid #22c55e; + background:radial-gradient(circle at 30% 30%,#22c55e,#16a34a); + color:#f9fafb; + box-shadow:0 4px 10px rgba(22,163,74,.5),0 0 8px rgba(22,163,74,.6); +} +.btn-primary:disabled{ opacity:.6; cursor:default; } +.btn-secondary{ + border:1px solid #bfdbfe; + background:rgba(15,23,42,.25); + color:#e0f2fe; + box-shadow:0 3px 10px rgba(15,23,42,.6); +} +.btn-secondary:hover{ background:rgba(15,23,42,.45); } + +@media (max-width:640px){ + .topbar{ padding:.5rem .7rem; border-bottom-left-radius:16px; border-bottom-right-radius:16px; } + .main-title{ font-size:1rem; } + .sub-title{ font-size:.7rem; } + .topbar-right{ width:100%; justify-content:flex-start; gap:.45rem; } +} + +/* SW_AUTH_UI:BEGIN */ +.auth-strip{ + margin-top: 6px; + display:flex; + align-items:center; + gap: .45rem; + flex-wrap: wrap; +} + +.auth-pill{ + display:inline-flex; + align-items:center; + gap: .35rem; + padding: .18rem .55rem; + border-radius: 999px; + font-size: .70rem; + font-weight: 900; + letter-spacing: .02em; + border: 1px solid rgba(148,163,184,.55); + background: rgba(2,6,23,.25); + color: #e5e7eb; +} +.auth-pill i{ font-size: .85rem; opacity:.95; } + +.auth-pill--ok{ + border-color: rgba(34,197,94,.65); + background: rgba(34,197,94,.16); +} +.auth-pill--loading{ + border-color: rgba(56,189,248,.65); + background: rgba(56,189,248,.14); +} +.auth-pill--anon{ + border-color: rgba(148,163,184,.55); + background: rgba(2,6,23,.20); +} + +.auth-banner{ + margin-top: 6px; + padding: 6px 10px; + border-radius: 14px; + font-size: .72rem; + font-weight: 800; + display:flex; + gap: 8px; + align-items:center; + flex-wrap:wrap; +} +.auth-banner--error{ + background: rgba(248,113,113,0.14); + border: 1px solid rgba(248,113,113,0.55); + color: #ffe4e6; +} +.auth-banner--info{ + background: rgba(56,189,248,0.14); + border: 1px solid rgba(56,189,248,0.55); + color: #e0f2fe; +} +.auth-banner--success{ + background: rgba(34,197,94,0.14); + border: 1px solid rgba(34,197,94,0.55); + color: #d1fae5; +} + +.user-chip{ + display:inline-flex; + align-items:center; + gap: .45rem; + padding: .22rem .55rem; + border-radius: 999px; + background: rgba(2,6,23,.25); + border: 1px solid rgba(148,163,184,.55); +} +.user-avatar{ + width: 26px; + height: 26px; + border-radius: 999px; + display:flex; + align-items:center; + justify-content:center; + background: rgba(56,189,248,0.18); + border: 1px solid rgba(56,189,248,0.35); +} +.user-avatar i{ font-size: 14px; } + +.user-name{ + font-size: .80rem; + font-weight: 950; + color: #f9fafb; + max-width: 220px; + overflow:hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Light theme readability */ +body[data-theme="light"] .auth-pill{ + background: rgba(255,255,255,0.82); + border-color: rgba(148,163,184,0.85); + color: #0b1220; +} +body[data-theme="light"] .user-chip{ + background: rgba(255,255,255,0.82); + border-color: rgba(148,163,184,0.85); +} +body[data-theme="light"] .user-name{ color:#0b1220; } +body[data-theme="light"] .user-avatar{ + background: rgba(30,107,255,0.12); + border-color: rgba(30,107,255,0.35); + color:#0b1220; +} +/* SW_AUTH_UI:END */