marxhe
This commit is contained in:
parent
c8f916f602
commit
ae44f2e019
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue