This commit is contained in:
Your Name 2025-12-19 14:29:13 -05:00
parent c9872d6260
commit a6076135f2
9 changed files with 1878 additions and 71 deletions

View File

@ -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 (
<AuthContext.Provider
value={{
booting,
loading,
authenticated,
username,
token,
login,
logout,
lastError,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx;
}

View File

@ -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." };
}

View File

@ -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 (
<AuthContext.Provider
value={{
booting,
loading,
authenticated,
username,
token,
login,
logout,
lastError,
lastErrorCode,
needsEmailVerification: lastErrorCode === "email_verification_required",
resendVerifyEmail,
clearAuthError: () => {
setLastError("");
setLastErrorCode("");
},
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx;
}

View File

@ -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." };
}

View File

@ -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 (
<>
<header className="topbar">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<div className="logo-circle">SW</div>
<div className="title-block">
<div className="main-title">SOCIOWIRE.com</div>
<div className="sub-title">Wired to life</div>
<div style={{ fontSize: "0.65rem", opacity: 0.7 }}>
{authLabel}
{lastError && (
<span style={{ color: "#f97373", marginLeft: "0.3rem" }}>
({lastError})
</span>
)}
</div>
</div>
</div>
<div className="topbar-right">
<div className="theme-switch">
{["dark", "blue", "light"].map((t) => (
<button
key={t}
type="button"
className={"theme-dot" + (theme === t ? " theme-dot-active" : "")}
title={THEME_LABELS[t]}
onClick={() => onChangeTheme && onChangeTheme(t)}
>
{THEME_LABELS[t][0]}
</button>
))}
</div>
{authenticated ? (
<div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<span style={{ fontSize: "0.8rem" }}>{username}</span>
<button className="btn-primary" onClick={logout}>
Logout
</button>
</div>
) : (
<div style={{ display: "flex", gap: "0.3rem" }}>
<button type="button" className="btn-primary" onClick={() => openModal("login")}>
Login
</button>
<button type="button" className="btn-secondary" onClick={() => openModal("signup")}>
Sign up
</button>
</div>
)}
</div>
</header>
{showAuth && (
<div className="auth-modal-backdrop" onClick={closeModal}>
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
<div className="auth-modal-header">
<button
type="button"
className={"auth-tab" + (mode === "login" ? " auth-tab-active" : "")}
onClick={() => setMode("login")}
>
Login
</button>
<button
type="button"
className={"auth-tab" + (mode === "signup" ? " auth-tab-active" : "")}
onClick={() => setMode("signup")}
>
Sign up
</button>
<button type="button" className="auth-close" onClick={closeModal}>
</button>
</div>
{mode === "login" ? (
<form className="auth-form" onSubmit={handleLoginSubmit}>
<label>
Username
<input
type="text"
value={loginUser}
onChange={(e) => setLoginUser(e.target.value)}
autoComplete="username"
/>
</label>
<label>
Password
<input
type="password"
value={loginPass}
onChange={(e) => setLoginPass(e.target.value)}
autoComplete="current-password"
/>
</label>
<button type="submit" className="btn-primary btn-full" disabled={loading}>
{loading ? "..." : "Sign in"}
</button>
{lastError && <div className="auth-error" style={{marginTop:"0.6rem"}}>{lastError}</div>}
</form>
) : (
<form className="auth-form" onSubmit={handleSignupSubmit}>
<div className="auth-row">
<label>
First name
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</label>
<label>
Last name
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</label>
</div>
<label>
Username
<input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)} />
</label>
<label>
Email
<input type="email" value={regEmail} onChange={(e) => setRegEmail(e.target.value)} />
</label>
<label>
Password
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
</label>
{signupError && <div className="auth-error">{signupError}</div>}
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
{signupLoading ? "..." : "Create account"}
</button>
{lastError && <div className="auth-error" style={{marginTop:"0.6rem"}}>{lastError}</div>}
</form>
)}
</div>
</div>
)}
</>
);
}

736
patch_frontend_verify_email.sh Executable file
View File

@ -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" <<EOF
$*
EOF
}
echo "== Frontend patch: verify-email UX =="
echo "Backup dir: $BK"
echo ""
# -------------------------
# 1) Add helper: src/auth/authErrors.js
# -------------------------
write_file "src/auth/authErrors.js" \
'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." };
}'
echo "✅ Wrote: src/auth/authErrors.js"
# -------------------------
# 2) Replace: src/auth/AuthContext.jsx
# -------------------------
write_file "src/auth/AuthContext.jsx" \
'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 (
<AuthContext.Provider
value={{
booting,
loading,
authenticated,
username,
token,
login,
logout,
lastError,
lastErrorCode,
needsEmailVerification: lastErrorCode === "email_verification_required",
resendVerifyEmail,
clearAuthError: () => {
setLastError("");
setLastErrorCode("");
},
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
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 (
<>
<header className="topbar">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<div className="logo-circle">SW</div>
<div className="title-block">
<div className="main-title">SOCIOWIRE.com</div>
<div className="sub-title">Wired to life</div>
<div style={{ fontSize: "0.65rem", opacity: 0.7 }}>
{authLabel}
{lastError && (
<span style={{ color: "#f97373", marginLeft: "0.3rem" }}>
({lastError})
</span>
)}
</div>
</div>
</div>
<div className="topbar-right">
<div className="theme-switch">
{["dark", "blue", "light"].map((t) => (
<button
key={t}
type="button"
className={"theme-dot" + (theme === t ? " theme-dot-active" : "")}
title={THEME_LABELS[t]}
onClick={() => onChangeTheme && onChangeTheme(t)}
>
{THEME_LABELS[t][0]}
</button>
))}
</div>
{authenticated ? (
<div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<span style={{ fontSize: "0.8rem" }}>{username}</span>
<button className="btn-primary" onClick={logout}>
Logout
</button>
</div>
) : (
<div style={{ display: "flex", gap: "0.3rem" }}>
<button type="button" className="btn-primary" onClick={() => openModal("login")}>
Login
</button>
<button type="button" className="btn-secondary" onClick={() => openModal("signup")}>
Sign up
</button>
</div>
)}
</div>
</header>
{showAuth && (
<div className="auth-modal-backdrop" onClick={closeModal}>
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
<div className="auth-modal-header">
<button
type="button"
className={"auth-tab" + (mode === "login" ? " auth-tab-active" : "")}
onClick={() => setMode("login")}
>
Login
</button>
<button
type="button"
className={"auth-tab" + (mode === "signup" ? " auth-tab-active" : "")}
onClick={() => setMode("signup")}
>
Sign up
</button>
<button type="button" className="auth-close" onClick={closeModal}>
</button>
</div>
{mode === "login" ? (
<form className="auth-form" onSubmit={handleLoginSubmit}>
<label>
Username
<input
type="text"
value={loginUser}
onChange={(e) => setLoginUser(e.target.value)}
autoComplete="username"
/>
</label>
<label>
Password
<input
type="password"
value={loginPass}
onChange={(e) => setLoginPass(e.target.value)}
autoComplete="current-password"
/>
</label>
<button type="submit" className="btn-primary btn-full" disabled={loading}>
{loading ? "..." : "Sign in"}
</button>
{lastError && (
<div className="auth-error" style={{ marginTop: "0.6rem" }}>
{lastError}
</div>
)}
{needsEmailVerification && (
<div
style={{
marginTop: "0.6rem",
fontSize: "0.8rem",
background: "rgba(30, 64, 175, 0.25)",
border: "1px solid rgba(191, 219, 254, 0.5)",
borderRadius: 10,
padding: "0.5rem 0.6rem",
}}
>
<div style={{ fontWeight: 800, marginBottom: 6 }}>
⚠️ Confirme ton email
</div>
<div style={{ opacity: 0.95 }}>
Ouvre ton email et clique le lien de confirmation (check spam).
</div>
<button
type="button"
className="btn-secondary"
style={{ marginTop: 10 }}
onClick={resendFromLogin}
disabled={loading}
title="Renvoyer l'email de confirmation"
>
Resend verification email
</button>
{lastErrorCode === "verify_sent" && (
<div style={{ marginTop: 8, opacity: 0.95 }}>
✅ Envoyé. Refresh ta boîte mail.
</div>
)}
</div>
)}
</form>
) : (
<form className="auth-form" onSubmit={handleSignupSubmit}>
<div className="auth-row">
<label>
First name
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</label>
<label>
Last name
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</label>
</div>
<label>
Username
<input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)} />
</label>
<label>
Email
<input type="email" value={regEmail} onChange={(e) => setRegEmail(e.target.value)} />
</label>
<label>
Password
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
</label>
{signupError && <div className="auth-error">{signupError}</div>}
{signupInfo && (
<div
style={{
marginTop: "0.25rem",
fontSize: "0.8rem",
color: "#dbeafe",
background: "rgba(34, 197, 94, 0.16)",
border: "1px solid rgba(34, 197, 94, 0.35)",
borderRadius: 10,
padding: "0.5rem 0.6rem",
}}
>
{signupInfo}
</div>
)}
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
{signupLoading ? "..." : "Create account"}
</button>
</form>
)}
</div>
</div>
)}
</>
);
}
'
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"

View File

@ -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 dexpirer, 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 (
<AuthContext.Provider
value={{
@ -209,9 +322,13 @@ export function AuthProvider({ children }) {
authenticated,
username,
token,
emailVerified,
needsEmailVerification,
login,
logout,
resendVerificationEmail,
lastError,
lastInfo,
}}
>
{children}

47
src/auth/authErrors.js Normal file
View File

@ -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." };
}

View File

@ -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 }) {
<div className="title-block">
<div className="main-title">SOCIOWIRE.com</div>
<div className="sub-title">Wired to life</div>
<div style={{ fontSize: "0.65rem", opacity: 0.7 }}>
<div style={{ fontSize: "0.65rem", opacity: 0.8 }}>
{authLabel}
{lastError && (
<span style={{ color: "#f97373", marginLeft: "0.3rem" }}>
<span style={{ color: "#f97373", marginLeft: "0.35rem" }}>
({lastError})
</span>
)}
</div>
{authenticated && needsEmailVerification && (
<div
style={{
marginTop: 6,
fontSize: "0.70rem",
background: "rgba(250,204,21,0.14)",
border: "1px solid rgba(250,204,21,0.55)",
padding: "6px 8px",
borderRadius: 12,
display: "flex",
gap: 8,
alignItems: "center",
flexWrap: "wrap",
}}
>
<span style={{ fontWeight: 800 }}>Verify email required.</span>
<span style={{ opacity: 0.9 }}>Posting is locked until verification.</span>
<button
type="button"
className="btn-secondary"
onClick={resendVerificationEmail}
style={{ padding: "0.30rem 0.70rem" }}
>
Resend email
</button>
</div>
)}
{!needsEmailVerification && lastInfo && (
<div style={{ marginTop: 6, fontSize: "0.70rem", opacity: 0.85 }}>
{lastInfo}
</div>
)}
</div>
</div>
@ -181,7 +228,7 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
<button type="submit" className="btn-primary btn-full" disabled={loading}>
{loading ? "..." : "Sign in"}
</button>
{lastError && <div className="auth-error" style={{marginTop:"0.6rem"}}>{lastError}</div>}
{lastError && <div className="auth-error" style={{ marginTop: "0.6rem" }}>{lastError}</div>}
</form>
) : (
<form className="auth-form" onSubmit={handleSignupSubmit}>
@ -209,11 +256,15 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
</label>
{signupError && <div className="auth-error">{signupError}</div>}
{signupInfo && (
<div className="auth-error" style={{ background: "rgba(34,197,94,0.14)", borderColor: "rgba(34,197,94,0.55)", color: "#d1fae5" }}>
{signupInfo}
</div>
)}
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
{signupLoading ? "..." : "Create account"}
</button>
{lastError && <div className="auth-error" style={{marginTop:"0.6rem"}}>{lastError}</div>}
</form>
)}
</div>