This commit is contained in:
Your Name 2025-12-19 20:47:42 -05:00
parent c8f916f602
commit ae44f2e019
3 changed files with 241 additions and 80 deletions

View File

@ -103,13 +103,34 @@ export async function createPost(payload, token) {
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error("createPost failed", res.status, text);
throw new Error("Failed to create post");
let json = null;
try { json = text ? JSON.parse(text) : null; } catch {}
const errMsg =
(json && (json.message || json.error_description || json.error)) ||
(text && text.slice(0, 300)) ||
`HTTP ${res.status}`;
const err = new Error(errMsg);
err.status = res.status;
err.body = text;
// Common backend shapes
err.code =
(json && (json.code || json.error)) ||
(res.status === 403 && /email[_\s-]*not[_\s-]*verified/i.test(text || "")
? "email_not_verified"
: "") ||
"";
throw err;
}
return res.json().catch(() => ({}));
}
/**
* Crée un nouvel utilisateur via le backend
* (backend: POST /api/signup)

View File

@ -1,6 +1,12 @@
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import { getEmailVerifiedFromClaims } from "./emailVerified";
import { clearAuth, guessUsernameFromToken, jwtExpSeconds, loadAuth, saveAuth } from "./authStorage";
import {
clearAuth,
guessUsernameFromToken,
jwtExpSeconds,
loadAuth,
saveAuth,
decodeJwtPayload,
} from "./authStorage";
import { parseAuthError } from "./authErrors";
const AuthCtx = createContext(null);
@ -9,23 +15,31 @@ function apiBase() {
return (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, "");
}
function keycloakBase() {
return (import.meta?.env?.VITE_KEYCLOAK_BASE_URL || "https://auth.sociowire.com").replace(/\/+$/, "");
}
async function apiFetch(path, opts = {}) {
const url = apiBase() + path;
const res = await fetch(url, {
credentials: "include",
...opts,
headers: {
"Content-Type": "application/json",
...(opts.headers || {}),
},
});
return res;
}
function tokenEmailVerified(accessToken) {
try {
const p = decodeJwtPayload(accessToken);
if (!p) return null;
if (typeof p.email_verified === "boolean") return p.email_verified;
if (typeof p.emailVerified === "boolean") return p.emailVerified;
return null;
} catch {
return null;
}
}
// tolerant whoami: accepts {authenticated:true} OR {username:""} OR {user:""} OR just ok+token
async function whoami(accessToken) {
const res = await apiFetch("/api/whoami", {
method: "GET",
@ -34,7 +48,11 @@ async function whoami(accessToken) {
const text = await res.text().catch(() => "");
let json = null;
try { json = text ? JSON.parse(text) : null; } catch {}
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
const uname =
(json && (json.username || json.user || json.preferred_username)) ||
@ -43,20 +61,85 @@ async function whoami(accessToken) {
const authed =
!!res.ok &&
(json?.authenticated === true || json?.ok === true || !!json?.username || !!json?.user || !!uname);
(json?.authenticated === true ||
json?.ok === true ||
!!json?.username ||
!!json?.user ||
!!uname);
return { ok: !!res.ok, status: res.status, json, text, authenticated: authed, username: uname };
}
// NEW: /api/me is the source of truth for email_verified
async function me(accessToken) {
const res = await apiFetch("/api/me", {
method: "GET",
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
});
const text = await res.text().catch(() => "");
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
// Try multiple shapes
const u = (json && (json.user || json.profile || json.me)) || json || null;
const emailVerified =
u && typeof u.email_verified === "boolean"
? u.email_verified
: u && typeof u.emailVerified === "boolean"
? u.emailVerified
: null;
return {
ok: !!res.ok,
status: res.status,
json,
text,
emailVerified,
};
}
async function refreshTokens(refreshToken) {
const res = await apiFetch("/api/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
const text = await res.text().catch(() => "");
let json = null;
try { json = text ? JSON.parse(text) : null; } catch {}
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: res.ok, status: res.status, json, text };
}
async function resendVerification(accessToken) {
const res = await apiFetch("/api/resend-verification", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
body: JSON.stringify({}),
});
const text = await res.text().catch(() => "");
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: res.ok, status: res.status, json, text };
}
@ -66,37 +149,57 @@ function nowSec() {
export function AuthProvider({ children }) {
const [status, setStatus] = useState("loading"); // loading | anon | auth
const [user, setUser] = useState(null);
const [tokens, setTokens] = useState(null);
const [user, setUser] = useState(null); // { username }
const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... }
const [error, setError] = useState("");
const [errorCode, setErrorCode] = useState("");
const [info, setInfo] = useState("");
const [lastInfo, setLastInfo] = useState("");
const [needsEmailVerification, setNeedsEmailVerification] = useState(false);
const refreshTimer = useRef(null);
function clearErrorUI() {
setError("");
setErrorCode("");
}
function clearInfoUI() {
setInfo("");
}
function setAnon(msg = "", code = "") {
function setAnon(msg = "") {
setStatus("anon");
setUser(null);
setTokens(null);
setError(msg || "");
setErrorCode(code || "");
setLastInfo("");
setNeedsEmailVerification(false);
clearAuth();
}
function setAuthFromTokens(t, usernameGuess = "") {
setTokens(t);
saveAuth({ ...t, obtained_at: Date.now() });
const u = usernameGuess || guessUsernameFromToken(t?.access_token) || "";
setUser(u ? { username: u } : null);
setStatus("auth");
clearErrorUI();
setError("");
}
async function refreshProfile(accessTokenOverride) {
const at = accessTokenOverride || tokens?.access_token;
if (!at) return { ok: False };
// /api/me is the truth; token claim is fallback only
const mr = await me(at);
let verified = mr.emailVerified
if (typeof verified !== "boolean") {
const tv = tokenEmailVerified(at);
if (typeof tv === "boolean") verified = tv;
}
if (typeof verified === "boolean") {
setNeedsEmailVerification(!verified);
return { ok: true, verified };
}
// If cannot determine, do not block UI aggressively
setNeedsEmailVerification(false);
return { ok: mr.ok, verified: null };
}
async function ensureFreshSession(existing) {
@ -127,7 +230,11 @@ export function AuthProvider({ children }) {
if (w.ok && w.authenticated) {
setUser(w.username ? { username: w.username } : null);
setStatus("auth");
clearErrorUI();
setError("");
// Update email verification state from backend
await refreshProfile(existing.access_token);
return { ok: true };
}
@ -152,21 +259,33 @@ export function AuthProvider({ children }) {
setAnon("Session expirée (pas de refresh_token).");
return;
}
const rr = await refreshTokens(cur.refresh_token);
if (!rr.ok || !rr.json?.access_token) {
setAnon("Session expirée. Please login again.");
return;
}
const merged = { ...cur, ...rr.json };
if (!merged.refresh_token) merged.refresh_token = cur.refresh_token;
setAuthFromTokens(merged);
// keep verification fresh too
await refreshProfile(merged.access_token);
} else {
// lightweight: refresh verification occasionally without spamming
// (every 30s interval already runs; but this call is only parsing token unless needed)
const tv = tokenEmailVerified(cur.access_token);
if (typeof tv === "boolean") setNeedsEmailVerification(!tv);
}
}, 30000);
}
async function restore() {
setStatus("loading");
clearErrorUI();
setError("");
setLastInfo("");
const saved = loadAuth();
if (!saved?.access_token) {
setAnon("");
@ -183,22 +302,28 @@ export function AuthProvider({ children }) {
}
async function login(username, password) {
clearInfoUI();
clearErrorUI();
setError("");
setLastInfo("");
setStatus("loading");
const res = await apiFetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const text = await res.text().catch(() => "");
let j = null;
try { j = text ? JSON.parse(text) : null; } catch {}
try {
j = text ? JSON.parse(text) : null;
} catch {
j = null;
}
if (!res.ok || !j?.access_token) {
const parsed = parseAuthError(text || j?.error_description || j?.error || "", res.status);
setAnon(parsed.message, parsed.code);
const parsed = parseAuthError(text || "", res.status);
setNeedsEmailVerification(parsed.code === "email_verification_required");
setAnon(parsed.message || j?.error_description || j?.error || text || "Login failed");
return { ok: false, status: res.status, json: j, text };
}
@ -214,7 +339,10 @@ export function AuthProvider({ children }) {
setUser(w.username ? { username: w.username } : null);
setStatus("auth");
clearErrorUI();
// update verified state from backend
await refreshProfile(t.access_token);
return { ok: true };
}
@ -224,18 +352,25 @@ export function AuthProvider({ children }) {
setAnon("");
}
// IMPORTANT: arriving on /auth/verified should CLEAR old errors (stale banner)
useEffect(() => {
try {
const p = window.location.pathname || "";
if (p.startsWith("/auth/verified") || p.startsWith("/auth/verif")) {
clearErrorUI();
setInfo("✅ Email confirmé. Connecte-toi pour continuer.");
// optional: clean URL so refresh doesn't re-trigger forever
try { window.history.replaceState({}, "", "/"); } catch {}
async function resendVerificationEmail() {
const at = tokens?.access_token || "";
if (!at) {
setError("Not logged in.");
return { ok: false };
}
setError("");
setLastInfo("");
const r = await resendVerification(at);
if (!r.ok) {
setError(r.json?.error || r.json?.message || r.text || `Resend failed (${r.status})`);
return { ok: false, status: r.status };
}
setLastInfo("Verification email sent. Check inbox/spam, then refresh session (logout/login).");
return { ok: true };
}
} catch {}
}, []);
useEffect(() => {
restore();
@ -247,44 +382,40 @@ export function AuthProvider({ children }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Optional helper: open Keycloak account
function openKeycloak() {
try { window.open(keycloakBase(), "_blank", "noopener,noreferrer"); } catch {}
}
// Backward-compatible fields expected by TopBar/MapView
const value = useMemo(() => {
const loading = status === "loading";
const authenticated = status === "auth";
const username = user?.username || "";
const token = tokens?.access_token || "";
const needsEmailVerification = errorCode === "email_verification_required";
return {
status, user, tokens,
// internal
status,
user,
tokens,
error,
// expected by UI
loading,
authenticated,
username,
token,
lastError: error || "",
lastErrorCode: errorCode || "",
lastInfo: info || "",
clearError: clearErrorUI,
clearInfo: clearInfoUI,
setInfo,
setError, // keep for compatibility
needsEmailVerification,
openKeycloak,
lastInfo: lastInfo || "",
needsEmailVerification: !!needsEmailVerification,
resendVerificationEmail,
login,
logout,
restore,
refreshProfile,
setError,
setLastInfo,
setNeedsEmailVerification,
};
}, [status, user, tokens, error, errorCode, info]);
}, [status, user, tokens, error, lastInfo, needsEmailVerification]);
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}
@ -292,13 +423,3 @@ export function AuthProvider({ children }) {
export function useAuth() {
return useContext(AuthCtx);
}
/* SW_AUTH_DEBUG_EMAIL */
try {
// Helps debug: look for email_verified in token
// Toggle by setting: localStorage.SW_AUTH_DEBUG_EMAIL="1"
if (typeof window !== "undefined" && localStorage.getItem("SW_AUTH_DEBUG_EMAIL") === "1") {
console.log("[SW AUTH] tokenParsed:", tokenParsed);
console.log("[SW AUTH] email_verified:", tokenParsed?.email_verified, "emailVerified:", tokenParsed?.emailVerified);
}
} catch {}

View File

@ -25,7 +25,7 @@ export default function MapView({
selectedPost,
onSelectPost,
}) {
const { authenticated, username, token } = useAuth();
const { authenticated, username, token, needsEmailVerification } = useAuth();
const { containerRef, mapRef, markersRef, expandedElRef, viewParams, hasLastView } =
useMapCore(theme);
@ -207,7 +207,12 @@ export default function MapView({
alert("You must be logged in to place a wire.");
return;
}
setIsCreating(true);
if (needsEmailVerification) {
alert("You must verify your email before posting. Use 'Resend email' in the top bar, then login again.");
return;
}
setIsCreating(true);
setDraftTitle("");
setDraftBody("");
const main = mainFilter === "All" ? "News" : mainFilter;
@ -233,7 +238,12 @@ export default function MapView({
return;
}
const authorName = (username || "").trim() || "anon";
if (needsEmailVerification) {
setSaveError("Verify your email to post. Use 'Resend email' in the top bar, then login again.");
return;
}
const authorName = (username || "").trim() || "anon";
let baseLng;
let baseLat;
@ -283,7 +293,16 @@ export default function MapView({
} catch (e) {
console.error(e);
setIsSaving(false);
setSaveError("Error creating post.");
const code = e && (e.code || "");
const msg = String((e && e.message) || "").toLowerCase();
const isNotVerified = code === "email_not_verified" || (e && e.status === 403 && (msg.includes("not verified") || msg.includes("verify")));
if (isNotVerified) {
setSaveError("Verify your email to post. Use 'Resend email' in the top bar, then login again.");
} else {
setSaveError((e && e.message) || "Error creating post.");
}
}
};