sw-fe/patch_frontend_verify_email.sh

737 lines
22 KiB
Bash
Executable File

#!/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"