diff --git a/.patch_backups/20251219-142134/src/auth/AuthContext.jsx b/.patch_backups/20251219-142134/src/auth/AuthContext.jsx new file mode 100644 index 0000000..6f58d5f --- /dev/null +++ b/.patch_backups/20251219-142134/src/auth/AuthContext.jsx @@ -0,0 +1,226 @@ +import React, { createContext, useContext, useEffect, useRef, useState } from "react"; + +const AuthContext = createContext(null); + +// Backend API (same origin) +const API_BASE = "/api"; + +const TOKEN_KEY = "sociowire:access_token"; +const REFRESH_KEY = "sociowire:refresh_token"; +const USERNAME_KEY = "sociowire:username"; + +function decodeJwtPayload(token) { + try { + const parts = token.split("."); + if (parts.length < 2) return null; + const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(json); + } catch { + return null; + } +} + +function willExpireSoon(token, withinSeconds = 90) { + const p = decodeJwtPayload(token); + if (!p || !p.exp) return true; + const now = Math.floor(Date.now() / 1000); + return p.exp - now <= withinSeconds; +} + +async function backendRefresh(refreshToken) { + const res = await fetch(`${API_BASE}/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + const text = await res.text(); + 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, + }; +} + +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 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); + localStorage.setItem(USERNAME_KEY, user); + } catch {} + } + + function clearAuth(msg = "") { + setAuthenticated(false); + setToken(null); + setUsername(""); + usernameRef.current = ""; + setLastError(msg); + 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 || !storedRefresh) return false; + + if (!willExpireSoon(storedToken, 30)) { + setToken(storedToken); + setUsername(storedUser); + usernameRef.current = storedUser; + setAuthenticated(true); + return true; + } + + const { accessToken, refreshToken } = await backendRefresh(storedRefresh); + persistAuth({ accessToken, refreshToken, user: storedUser }); + return true; + } catch (e) { + console.warn("[AUTH] silent refresh failed:", e); + clearAuth("Session expired. Please login again."); + return false; + } + } + + useEffect(() => { + (async () => { + try { + const ok = await trySilentRefreshIfNeeded(); + if (!ok) { + const storedToken = localStorage.getItem(TOKEN_KEY); + const storedUser = localStorage.getItem(USERNAME_KEY) || ""; + if (storedToken) { + setToken(storedToken); + setUsername(storedUser); + usernameRef.current = storedUser; + setAuthenticated(true); + } + } + } 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 u = 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."); + } + }, 60_000); + + return () => clearInterval(interval); + }, [authenticated]); + + async function login(user, pass) { + setLastError(""); + const u = (user || "").trim(); + if (!u || !pass) { + setLastError("Username and password required."); + return; + } + + 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(); + if (!res.ok) { + console.error("[AUTH] /api/login failed", res.status, text); + setLastError(`${res.status} login failed`); + return; + } + + const data = JSON.parse(text); + const accessToken = data.access_token; + const refreshToken = data.refresh_token; + + if (!accessToken) { + setLastError("No access token in response."); + return; + } + + persistAuth({ accessToken, refreshToken, user: u }); + setLastError(""); + } catch (e) { + console.error("[AUTH] network error", e); + setLastError("Network error during login."); + clearAuth("Network error during login."); + } finally { + setLoading(false); + } + } + + function logout() { + clearAuth(""); + } + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used inside "); + return ctx; +} diff --git a/.patch_backups/20251219-142134/src/auth/authErrors.js b/.patch_backups/20251219-142134/src/auth/authErrors.js new file mode 100644 index 0000000..3341f30 --- /dev/null +++ b/.patch_backups/20251219-142134/src/auth/authErrors.js @@ -0,0 +1,47 @@ +export function parseAuthError(text = "", status = 0) { + // Keycloak token error example: + // {"error":"invalid_grant","error_description":"Account is not fully set up"} + const raw = String(text || ""); + let err = ""; + let desc = ""; + + try { + const j = JSON.parse(raw); + err = String(j.error || ""); + desc = String(j.error_description || j.errorMessage || ""); + } catch { + // raw is not JSON + } + + const combined = (err + " " + desc + " " + raw).toLowerCase(); + + // Email verification / required action + if ( + combined.includes("not fully set up") || + combined.includes("verify") || + combined.includes("verification") || + combined.includes("required action") || + (combined.includes("email") && combined.includes("verified")) + ) { + return { + code: "email_verification_required", + message: + "Tu dois confirmer ton email avant de te connecter. Vérifie ta boîte mail (et le spam), puis réessaie.", + }; + } + + // Bad credentials + if ( + combined.includes("invalid user credentials") || + (combined.includes("invalid_grant") && !combined.includes("not fully set up")) + ) { + return { code: "invalid_credentials", message: "Mauvais username ou password." }; + } + + // Fallback + if (desc) return { code: "login_failed", message: desc }; + if (err) return { code: "login_failed", message: err }; + if (raw && raw.length < 220) return { code: "login_failed", message: raw }; + + return { code: "login_failed", message: status ? `Login failed (${status}).` : "Login failed." }; +} diff --git a/.patch_backups/20251219-142209/src/auth/AuthContext.jsx b/.patch_backups/20251219-142209/src/auth/AuthContext.jsx new file mode 100644 index 0000000..22a103e --- /dev/null +++ b/.patch_backups/20251219-142209/src/auth/AuthContext.jsx @@ -0,0 +1,312 @@ +import React, { createContext, useContext, useEffect, useRef, useState } from "react"; +import { parseAuthError } from "./authErrors"; + +const AuthContext = createContext(null); + +// Backend API (same origin) +const API_BASE = "/api"; + +const TOKEN_KEY = "sociowire:access_token"; +const REFRESH_KEY = "sociowire:refresh_token"; +const USERNAME_KEY = "sociowire:username"; + +function decodeJwtPayload(token) { + try { + const parts = token.split("."); + if (parts.length < 2) return null; + const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(json); + } catch { + return null; + } +} + +function willExpireSoon(token, withinSeconds = 90) { + const p = decodeJwtPayload(token); + if (!p || !p.exp) return true; + const now = Math.floor(Date.now() / 1000); + return p.exp - now <= withinSeconds; +} + +async function backendRefresh(refreshToken) { + const res = await fetch(`${API_BASE}/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + const text = await res.text().catch(() => ""); + 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; +} + diff --git a/.patch_backups/20251219-142209/src/auth/authErrors.js b/.patch_backups/20251219-142209/src/auth/authErrors.js new file mode 100644 index 0000000..3341f30 --- /dev/null +++ b/.patch_backups/20251219-142209/src/auth/authErrors.js @@ -0,0 +1,47 @@ +export function parseAuthError(text = "", status = 0) { + // Keycloak token error example: + // {"error":"invalid_grant","error_description":"Account is not fully set up"} + const raw = String(text || ""); + let err = ""; + let desc = ""; + + try { + const j = JSON.parse(raw); + err = String(j.error || ""); + desc = String(j.error_description || j.errorMessage || ""); + } catch { + // raw is not JSON + } + + const combined = (err + " " + desc + " " + raw).toLowerCase(); + + // Email verification / required action + if ( + combined.includes("not fully set up") || + combined.includes("verify") || + combined.includes("verification") || + combined.includes("required action") || + (combined.includes("email") && combined.includes("verified")) + ) { + return { + code: "email_verification_required", + message: + "Tu dois confirmer ton email avant de te connecter. Vérifie ta boîte mail (et le spam), puis réessaie.", + }; + } + + // Bad credentials + if ( + combined.includes("invalid user credentials") || + (combined.includes("invalid_grant") && !combined.includes("not fully set up")) + ) { + return { code: "invalid_credentials", message: "Mauvais username ou password." }; + } + + // Fallback + if (desc) return { code: "login_failed", message: desc }; + if (err) return { code: "login_failed", message: err }; + if (raw && raw.length < 220) return { code: "login_failed", message: raw }; + + return { code: "login_failed", message: status ? `Login failed (${status}).` : "Login failed." }; +} diff --git a/.patch_backups/20251219-142209/src/components/Layout/TopBar.jsx b/.patch_backups/20251219-142209/src/components/Layout/TopBar.jsx new file mode 100644 index 0000000..7d1cf2f --- /dev/null +++ b/.patch_backups/20251219-142209/src/components/Layout/TopBar.jsx @@ -0,0 +1,224 @@ +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 } = 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 [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(""); + }; + + const closeModal = () => { + setShowAuth(false); + setLoginPass(""); + setSignupError(""); + }; + + useEffect(() => { + if (authenticated) setShowAuth(false); + }, [authenticated]); + + const handleLoginSubmit = async (e) => { + e.preventDefault(); + await login(loginUser, loginPass); + }; + + const handleSignupSubmit = async (e) => { + e.preventDefault(); + setSignupError(""); + 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(), + }); + await login(regUser.trim(), regPass); + } catch (err) { + console.error(err); + setSignupError(err.message || "Registration error."); + } finally { + setSignupLoading(false); + } + }; + + return ( + <> + + + SW + + SOCIOWIRE.com + Wired to life + + {authLabel} + {lastError && ( + + ({lastError}) + + )} + + + + + + + {["dark", "blue", "light"].map((t) => ( + onChangeTheme && onChangeTheme(t)} + > + {THEME_LABELS[t][0]} + + ))} + + + {authenticated ? ( + + {username} + + Logout + + + ) : ( + + openModal("login")}> + Login + + openModal("signup")}> + Sign up + + + )} + + + + {showAuth && ( + + e.stopPropagation()}> + + setMode("login")} + > + Login + + setMode("signup")} + > + Sign up + + + ✕ + + + + {mode === "login" ? ( + + + Username + setLoginUser(e.target.value)} + autoComplete="username" + /> + + + Password + setLoginPass(e.target.value)} + autoComplete="current-password" + /> + + + {loading ? "..." : "Sign in"} + + {lastError && {lastError}} + + ) : ( + + + + First name + setFirstName(e.target.value)} /> + + + Last name + setLastName(e.target.value)} /> + + + + Username + setRegUser(e.target.value)} /> + + + Email + setRegEmail(e.target.value)} /> + + + Password + setRegPass(e.target.value)} /> + + + {signupError && {signupError}} + + + {signupLoading ? "..." : "Create account"} + + {lastError && {lastError}} + + )} + + + )} + > + ); +} diff --git a/patch_frontend_verify_email.sh b/patch_frontend_verify_email.sh new file mode 100755 index 0000000..0a1cf3a --- /dev/null +++ b/patch_frontend_verify_email.sh @@ -0,0 +1,736 @@ +#!/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) => ( + onChangeTheme && onChangeTheme(t)} + > + {THEME_LABELS[t][0]} + + ))} + + + {authenticated ? ( + + {username} + + Logout + + + ) : ( + + openModal("login")}> + Login + + openModal("signup")}> + Sign up + + + )} + + + + {showAuth && ( + + e.stopPropagation()}> + + setMode("login")} + > + Login + + setMode("signup")} + > + Sign up + + + ✕ + + + + {mode === "login" ? ( + + + Username + setLoginUser(e.target.value)} + autoComplete="username" + /> + + + Password + setLoginPass(e.target.value)} + autoComplete="current-password" + /> + + + + {loading ? "..." : "Sign in"} + + + {lastError && ( + + {lastError} + + )} + + {needsEmailVerification && ( + + + ⚠️ Confirme ton email + + + Ouvre ton email et clique le lien de confirmation (check spam). + + + Resend verification email + + {lastErrorCode === "verify_sent" && ( + + ✅ Envoyé. Refresh ta boîte mail. + + )} + + )} + + ) : ( + + + + First name + setFirstName(e.target.value)} /> + + + Last name + setLastName(e.target.value)} /> + + + + Username + setRegUser(e.target.value)} /> + + + Email + setRegEmail(e.target.value)} /> + + + Password + setRegPass(e.target.value)} /> + + + {signupError && {signupError}} + + {signupInfo && ( + + {signupInfo} + + )} + + + {signupLoading ? "..." : "Create account"} + + + )} + + + )} + > + ); +} +' + +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 6f58d5f..717fa95 100644 --- a/src/auth/AuthContext.jsx +++ b/src/auth/AuthContext.jsx @@ -1,8 +1,6 @@ import React, { createContext, useContext, useEffect, useRef, useState } from "react"; const AuthContext = createContext(null); - -// Backend API (same origin) const API_BASE = "/api"; const TOKEN_KEY = "sociowire:access_token"; @@ -11,7 +9,7 @@ const USERNAME_KEY = "sociowire:username"; function decodeJwtPayload(token) { try { - const parts = token.split("."); + const parts = String(token || "").split("."); if (parts.length < 2) return null; const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")); return JSON.parse(json); @@ -27,7 +25,7 @@ function willExpireSoon(token, withinSeconds = 90) { return p.exp - now <= withinSeconds; } -async function backendRefresh(refreshToken) { +async function apiRefresh(refreshToken) { const res = await fetch(`${API_BASE}/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -35,38 +33,71 @@ async function backendRefresh(refreshToken) { body: JSON.stringify({ refresh_token: refreshToken }), }); - const text = await res.text(); - if (!res.ok) { - throw new Error(`${res.status} ${text}`); - } + const text = await res.text().catch(() => ""); + if (!res.ok) throw new Error(`${res.status} ${text}`); - const data = JSON.parse(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 apiMe(accessToken) { + const res = await fetch(`${API_BASE}/me`, { + method: "GET", + headers: { Authorization: `Bearer ${accessToken}` }, + credentials: "include", + }); + + const text = await res.text().catch(() => ""); + if (!res.ok) throw new Error(`${res.status} ${text || "me failed"}`); + + const data = JSON.parse(text || "{}"); + return data; +} + +async function apiResendVerify(accessToken) { + const res = await fetch(`${API_BASE}/resend-verification`, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}` }, + credentials: "include", + }); + + const text = await res.text().catch(() => ""); + if (!res.ok) throw new Error(`${res.status} ${text || "resend failed"}`); + + return text ? JSON.parse(text) : { ok: true }; +} + 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 [emailVerified, setEmailVerified] = useState(true); + const [needsEmailVerification, setNeedsEmailVerification] = useState(false); + const [lastError, setLastError] = useState(""); + const [lastInfo, setLastInfo] = useState(""); const usernameRef = useRef(""); function persistAuth({ accessToken, refreshToken, user }) { setToken(accessToken); setAuthenticated(true); - setUsername(user); - usernameRef.current = user; + setUsername(user || ""); + usernameRef.current = user || ""; + try { localStorage.setItem(TOKEN_KEY, accessToken); if (refreshToken) localStorage.setItem(REFRESH_KEY, refreshToken); - localStorage.setItem(USERNAME_KEY, user); + localStorage.setItem(USERNAME_KEY, user || ""); } catch {} } @@ -75,7 +106,11 @@ export function AuthProvider({ children }) { setToken(null); setUsername(""); usernameRef.current = ""; - setLastError(msg); + + setEmailVerified(true); + setNeedsEmailVerification(false); + + setLastError(msg || ""); try { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(REFRESH_KEY); @@ -83,66 +118,109 @@ export function AuthProvider({ children }) { } catch {} } - async function trySilentRefreshIfNeeded() { + async function syncWithBackend(accessToken, fallbackUser = "") { + // Confirme que le backend accepte vraiment le token + const me = await apiMe(accessToken); + + const u = + me.username || + me.preferred_username || + me.user || + fallbackUser || + localStorage.getItem(USERNAME_KEY) || + ""; + + const verified = me.email_verified !== false; // default true si absent + setEmailVerified(verified); + setNeedsEmailVerification(!verified); + + setAuthenticated(true); + setToken(accessToken); + setUsername(u); + usernameRef.current = u; + + // garde username en storage pour UI + try { localStorage.setItem(USERNAME_KEY, u); } catch {} + + if (!verified) { + setLastInfo("Check your inbox and verify your email to unlock posting."); + } else { + setLastInfo(""); + } + + return { me, verified }; + } + + async function bootRestore() { + const storedToken = (() => { + try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } + })(); + const storedRefresh = (() => { + try { return localStorage.getItem(REFRESH_KEY); } catch { return null; } + })(); + const storedUser = (() => { + try { return localStorage.getItem(USERNAME_KEY) || ""; } catch { return ""; } + })(); + + if (!storedToken) return; + + // 1) essaie /me direct try { - const storedToken = localStorage.getItem(TOKEN_KEY); - const storedRefresh = localStorage.getItem(REFRESH_KEY); - const storedUser = localStorage.getItem(USERNAME_KEY) || ""; - - if (!storedToken || !storedRefresh) return false; - - if (!willExpireSoon(storedToken, 30)) { - setToken(storedToken); - setUsername(storedUser); - usernameRef.current = storedUser; - setAuthenticated(true); - return true; - } - - const { accessToken, refreshToken } = await backendRefresh(storedRefresh); - persistAuth({ accessToken, refreshToken, user: storedUser }); - return true; + await syncWithBackend(storedToken, storedUser); + return; } catch (e) { - console.warn("[AUTH] silent refresh failed:", e); + // 2) si /me refuse, tente refresh si possible + const msg = String(e?.message || ""); + const is401 = msg.startsWith("401") || msg.includes(" 401 "); + if (!is401 || !storedRefresh) { + clearAuth("Session expired. Please login again."); + return; + } + } + + try { + const { accessToken, refreshToken } = await apiRefresh(storedRefresh); + persistAuth({ accessToken, refreshToken, user: storedUser }); + await syncWithBackend(accessToken, storedUser); + } catch (e) { + console.warn("[AUTH] restore refresh failed:", e); clearAuth("Session expired. Please login again."); - return false; } } useEffect(() => { (async () => { try { - const ok = await trySilentRefreshIfNeeded(); - if (!ok) { - const storedToken = localStorage.getItem(TOKEN_KEY); - const storedUser = localStorage.getItem(USERNAME_KEY) || ""; - if (storedToken) { - setToken(storedToken); - setUsername(storedUser); - usernameRef.current = storedUser; - setAuthenticated(true); - } - } + await bootRestore(); } finally { setBooting(false); } })(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Auto refresh (et resync backend) useEffect(() => { if (!authenticated) return; const interval = setInterval(async () => { try { - const t = localStorage.getItem(TOKEN_KEY); - const rt = localStorage.getItem(REFRESH_KEY); + const t = (() => { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } })(); + const rt = (() => { try { return localStorage.getItem(REFRESH_KEY); } catch { return null; } })(); if (!t || !rt) return; - if (!willExpireSoon(t, 90)) return; + if (!willExpireSoon(t, 90)) { + // même si pas proche d’expirer, on peut vérifier /me 1 fois de temps en temps + return; + } + + const { accessToken, refreshToken } = await apiRefresh(rt); + const u = (() => { + try { return localStorage.getItem(USERNAME_KEY) || usernameRef.current || ""; } catch { return usernameRef.current || ""; } + })(); - const { accessToken, refreshToken } = await backendRefresh(rt); - const u = localStorage.getItem(USERNAME_KEY) || usernameRef.current || ""; persistAuth({ accessToken, refreshToken, user: u }); + await syncWithBackend(accessToken, u); } catch (e) { console.warn("[AUTH] auto refresh failed:", e); clearAuth("Session expired. Please login again."); @@ -154,6 +232,7 @@ export function AuthProvider({ children }) { async function login(user, pass) { setLastError(""); + setLastInfo(""); const u = (user || "").trim(); if (!u || !pass) { setLastError("Username and password required."); @@ -170,14 +249,21 @@ export function AuthProvider({ children }) { body: JSON.stringify({ username: u, password: pass }), }); - const text = await res.text(); + const text = await res.text().catch(() => ""); if (!res.ok) { - console.error("[AUTH] /api/login failed", res.status, text); + // backend peut renvoyer un JSON {error:"email_not_verified"} + try { + const obj = JSON.parse(text || "{}"); + if (res.status === 403 && obj?.error === "email_not_verified") { + setLastError("Email not verified. Check your inbox (spam) and verify, then login again."); + return; + } + } catch {} setLastError(`${res.status} login failed`); return; } - const data = JSON.parse(text); + const data = JSON.parse(text || "{}"); const accessToken = data.access_token; const refreshToken = data.refresh_token; @@ -187,11 +273,21 @@ export function AuthProvider({ children }) { } persistAuth({ accessToken, refreshToken, user: u }); + + // IMPORTANT: resync with backend right after login + try { + await syncWithBackend(accessToken, u); + } catch (e) { + console.warn("[AUTH] /me failed after login:", e); + clearAuth("Login desynced. Please login again."); + return; + } + setLastError(""); } catch (e) { console.error("[AUTH] network error", e); - setLastError("Network error during login."); clearAuth("Network error during login."); + setLastError("Network error during login."); } finally { setLoading(false); } @@ -201,6 +297,23 @@ export function AuthProvider({ children }) { clearAuth(""); } + async function resendVerificationEmail() { + setLastError(""); + setLastInfo(""); + const t = token || (() => { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } })(); + if (!t) { + setLastError("Not logged in."); + return; + } + try { + await apiResendVerify(t); + setLastInfo("Verification email sent. Check inbox/spam."); + } catch (e) { + console.error(e); + setLastError("Could not resend verification email."); + } + } + return ( {children} diff --git a/src/auth/authErrors.js b/src/auth/authErrors.js new file mode 100644 index 0000000..3341f30 --- /dev/null +++ b/src/auth/authErrors.js @@ -0,0 +1,47 @@ +export function parseAuthError(text = "", status = 0) { + // Keycloak token error example: + // {"error":"invalid_grant","error_description":"Account is not fully set up"} + const raw = String(text || ""); + let err = ""; + let desc = ""; + + try { + const j = JSON.parse(raw); + err = String(j.error || ""); + desc = String(j.error_description || j.errorMessage || ""); + } catch { + // raw is not JSON + } + + const combined = (err + " " + desc + " " + raw).toLowerCase(); + + // Email verification / required action + if ( + combined.includes("not fully set up") || + combined.includes("verify") || + combined.includes("verification") || + combined.includes("required action") || + (combined.includes("email") && combined.includes("verified")) + ) { + return { + code: "email_verification_required", + message: + "Tu dois confirmer ton email avant de te connecter. Vérifie ta boîte mail (et le spam), puis réessaie.", + }; + } + + // Bad credentials + if ( + combined.includes("invalid user credentials") || + (combined.includes("invalid_grant") && !combined.includes("not fully set up")) + ) { + return { code: "invalid_credentials", message: "Mauvais username ou password." }; + } + + // Fallback + if (desc) return { code: "login_failed", message: desc }; + if (err) return { code: "login_failed", message: err }; + if (raw && raw.length < 220) return { code: "login_failed", message: raw }; + + return { code: "login_failed", message: status ? `Login failed (${status}).` : "Login failed." }; +} diff --git a/src/components/Layout/TopBar.jsx b/src/components/Layout/TopBar.jsx index 7d1cf2f..e562d76 100644 --- a/src/components/Layout/TopBar.jsx +++ b/src/components/Layout/TopBar.jsx @@ -2,14 +2,20 @@ 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", -}; +const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" }; export default function TopBar({ theme = "dark", onChangeTheme }) { - const { loading, authenticated, username, login, logout, lastError } = useAuth(); + const { + loading, + authenticated, + username, + login, + logout, + needsEmailVerification, + resendVerificationEmail, + lastError, + lastInfo, + } = useAuth(); const [showAuth, setShowAuth] = useState(false); const [mode, setMode] = useState("login"); @@ -23,6 +29,7 @@ export default function TopBar({ theme = "dark", onChangeTheme }) { 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"; @@ -33,12 +40,14 @@ export default function TopBar({ theme = "dark", onChangeTheme }) { setMode(nextMode); setShowAuth(true); setSignupError(""); + setSignupInfo(""); }; const closeModal = () => { setShowAuth(false); setLoginPass(""); setSignupError(""); + setSignupInfo(""); }; useEffect(() => { @@ -53,18 +62,16 @@ export default function TopBar({ theme = "dark", onChangeTheme }) { const handleSignupSubmit = async (e) => { e.preventDefault(); setSignupError(""); - if ( - !firstName.trim() || - !lastName.trim() || - !regUser.trim() || - !regEmail.trim() || - !regPass - ) { + 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(), @@ -72,7 +79,12 @@ export default function TopBar({ theme = "dark", onChangeTheme }) { firstName: firstName.trim(), lastName: lastName.trim(), }); - await login(regUser.trim(), regPass); + + // 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."); @@ -89,14 +101,49 @@ export default function TopBar({ theme = "dark", onChangeTheme }) { SOCIOWIRE.com Wired to life - + + {authLabel} {lastError && ( - + ({lastError}) )} + + {authenticated && needsEmailVerification && ( + + Verify email required. + Posting is locked until verification. + + Resend email + + + )} + + {!needsEmailVerification && lastInfo && ( + + {lastInfo} + + )} @@ -181,7 +228,7 @@ export default function TopBar({ theme = "dark", onChangeTheme }) { {loading ? "..." : "Sign in"} - {lastError && {lastError}} + {lastError && {lastError}} ) : ( @@ -209,11 +256,15 @@ export default function TopBar({ theme = "dark", onChangeTheme }) { {signupError && {signupError}} + {signupInfo && ( + + {signupInfo} + + )} {signupLoading ? "..." : "Create account"} - {lastError && {lastError}} )}