tout fonctionne logins et post correct

This commit is contained in:
Your Name 2025-12-10 19:45:06 +00:00
parent 48d5ed01a0
commit d2bde41aa4
11 changed files with 316 additions and 32 deletions

0
deploy.sh Normal file → Executable file
View File

10
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"axios": "^1.13.2", "axios": "^1.13.2",
"keycloak-js": "^26.2.1",
"maplibre-gl": "^5.14.0", "maplibre-gl": "^5.14.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@ -2200,6 +2201,15 @@
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/keycloak-js": {
"version": "26.2.1",
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.1.tgz",
"integrity": "sha512-bZt6fQj/TLBAmivXSxSlqAJxBx/knNZDQGJIW4ensGYGN4N6tUKV8Zj3Y7/LOV8eIpvWsvqV70fbACihK8Ze0Q==",
"license": "Apache-2.0",
"workspaces": [
"test"
]
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",

View File

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.13.2", "axios": "^1.13.2",
"keycloak-js": "^26.2.1",
"maplibre-gl": "^5.14.0", "maplibre-gl": "^5.14.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",

View File

@ -1,6 +1,8 @@
import React, { useEffect, useState } from "react"; import React, { Suspense, useEffect, useState } from "react";
import TopBar from "./components/Layout/TopBar"; import TopBar from "./components/Layout/TopBar";
import MapView from "./components/Map/MapView";
// Lazy-load de la carte (gros chunk : maplibre, CSS, etc.)
const MapView = React.lazy(() => import("./components/Map/MapView"));
const THEME_KEY = "sociowire:theme"; const THEME_KEY = "sociowire:theme";
@ -39,8 +41,9 @@ export default function App() {
<TopBar theme={theme} onChangeTheme={setTheme} /> <TopBar theme={theme} onChangeTheme={setTheme} />
<div className="main-shell"> <div className="main-shell">
<div className="map-shell"> <div className="map-shell">
{/* on passe le thème à la carte */} <Suspense fallback={<div style={{ padding: 16 }}>Loading map</div>}>
<MapView theme={theme} /> <MapView theme={theme} />
</Suspense>
</div> </div>
</div> </div>
</div> </div>

View File

@ -4,17 +4,6 @@
const API_BASE = "/api"; const API_BASE = "/api";
/**
* Récupère la liste des posts avec filtres.
* filters: {
* category?: "NEWS" | "FRIENDS" | "EVENT" | "MARKET"
* subCategory?: string
* time?: "NOW" | "TODAY" | "RECENT" | "PAST"
* lat?: number
* lon?: number
* radiusKm?: number
* }
*/
export async function fetchPosts(filters = {}) { export async function fetchPosts(filters = {}) {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -45,7 +34,6 @@ export async function fetchPosts(filters = {}) {
/** /**
* Récupère la position approx de l'utilisateur depuis le backend. * Récupère la position approx de l'utilisateur depuis le backend.
* Backend: GET /api/ip-location { lat: number, lon: number } ou "null"
*/ */
export async function fetchIpLocation() { export async function fetchIpLocation() {
try { try {
@ -93,15 +81,19 @@ export async function fetchIpLocation() {
/** /**
* Crée un nouveau post * Crée un nouveau post
* Backend: POST /api/post (postCreateHandler) * token: access token Keycloak (string)
* Attend: { title, snippet, category, sub_category, lat, lon, author }
*/ */
export async function createPost(payload) { export async function createPost(payload, token) {
const headers = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}/post`, { const res = await fetch(`${API_BASE}/post`, {
method: "POST", method: "POST",
headers: { headers,
"Content-Type": "application/json",
},
credentials: "include", credentials: "include",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });

111
src/auth/AuthContext.jsx Normal file
View File

@ -0,0 +1,111 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import keycloak from "./keycloak";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [kc, setKc] = useState(null);
const [loading, setLoading] = useState(true);
const [authenticated, setAuthenticated] = useState(false);
const [username, setUsername] = useState("");
const [lastError, setLastError] = useState("");
useEffect(() => {
let isMounted = true;
async function init() {
try {
console.log("[AUTH] init keycloak…");
const ok = await keycloak.init({
onLoad: "check-sso", // ne force pas le login au chargement
checkLoginIframe: false,
});
if (!isMounted) return;
console.log("[AUTH] init result:", ok);
window.kc = keycloak; // debug global
setKc(keycloak);
setAuthenticated(ok);
if (ok && keycloak.tokenParsed) {
const p = keycloak.tokenParsed;
const name =
p.preferred_username ||
p.email ||
p.name ||
(p.sub ? String(p.sub) : "");
setUsername(name || "");
} else {
setUsername("");
}
} catch (err) {
console.error("[AUTH] keycloak init error", err);
if (isMounted) {
setLastError(String(err));
}
} finally {
if (isMounted) setLoading(false);
}
}
init();
// refresh token simplifié
const interval = setInterval(() => {
if (!keycloak || !keycloak.authenticated) return;
keycloak
.updateToken(60)
.then((refreshed) => {
if (refreshed) {
console.log("[AUTH] token refreshed");
}
})
.catch((err) => {
console.warn("[AUTH] token refresh error", err);
});
}, 30000);
return () => {
isMounted = false;
clearInterval(interval);
};
}, []);
const login = () => {
keycloak.login({
redirectUri: window.location.href,
});
};
const logout = () => {
keycloak.logout({
redirectUri: window.location.origin + "/",
});
};
return (
<AuthContext.Provider
value={{
keycloak: kc,
loading,
authenticated,
username,
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;
}

57
src/auth/authUtils.js Normal file
View File

@ -0,0 +1,57 @@
import keycloak from "./keycloak";
/**
* Initialise Keycloak sans forcer le login.
* - onLoad: "check-sso" => ne redirige pas si user non loggé
*/
export function initAuth() {
return keycloak.init({
onLoad: "check-sso",
checkLoginIframe: false,
});
}
/**
* Appelle une action qui nécessite d'être authentifié.
* Si non loggé => ouvre la page de login Keycloak, puis revient sur la même page.
*/
export function requireAuth(action) {
if (keycloak.authenticated) {
return action();
}
keycloak.login({
redirectUri: window.location.href,
});
}
/**
* Retourne le token JWT actuel (ou null)
*/
export function getToken() {
return keycloak.token || null;
}
/**
* Retourne le username depuis le token (si présent)
*/
export function getUsername() {
const profile = keycloak.tokenParsed || {};
return (
profile.preferred_username ||
profile.email ||
profile.name ||
null
);
}
/**
* Logout et revient sur la même page.
*/
export function logout() {
keycloak.logout({
redirectUri: window.location.origin,
});
}
export default keycloak;

9
src/auth/keycloak.js Normal file
View File

@ -0,0 +1,9 @@
import Keycloak from "keycloak-js";
const keycloak = new Keycloak({
url: "https://web.sociowire.com:8443",
realm: "sociowire",
clientId: "sociowire-frontend",
});
export default keycloak;

View File

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { useAuth } from "../../auth/AuthContext";
const THEME_LABELS = { const THEME_LABELS = {
dark: "Dark", dark: "Dark",
@ -7,6 +8,12 @@ const THEME_LABELS = {
}; };
export default function TopBar({ theme = "dark", onChangeTheme }) { export default function TopBar({ theme = "dark", onChangeTheme }) {
const { loading, authenticated, username, login, logout, lastError } = useAuth();
let authLabel = "AUTH: anon";
if (loading) authLabel = "AUTH: loading…";
else if (authenticated) authLabel = `AUTH: user=${username || "?"}`;
return ( return (
<header className="topbar"> <header className="topbar">
<div className="logo-circle">SW</div> <div className="logo-circle">SW</div>
@ -14,6 +21,14 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
<div className="title-block"> <div className="title-block">
<div className="main-title">SOCIOWIRE.com</div> <div className="main-title">SOCIOWIRE.com</div>
<div className="sub-title">Wired to life</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" }}>
(auth err)
</span>
)}
</div>
</div> </div>
<div className="topbar-right"> <div className="topbar-right">
@ -32,7 +47,23 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
</button> </button>
))} ))}
</div> </div>
<button className="btn-primary">Login</button>
{loading ? (
<button className="btn-primary" disabled>
</button>
) : 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>
) : (
<button className="btn-primary" onClick={login}>
Login
</button>
)}
</div> </div>
</header> </header>
); );

View File

@ -14,8 +14,11 @@ import { useMapCore } from "./useMapCore";
import { useUserPosition } from "./useUserPosition"; import { useUserPosition } from "./useUserPosition";
import { usePostsEngine } from "./usePostsEngine"; import { usePostsEngine } from "./usePostsEngine";
import PostList from "../Posts/PostList"; import PostList from "../Posts/PostList";
import { useAuth } from "../../auth/AuthContext";
export default function MapView({ theme = "dark" }) { export default function MapView({ theme = "dark" }) {
const { authenticated, username } = useAuth();
const { const {
containerRef, containerRef,
mapRef, mapRef,
@ -69,6 +72,7 @@ export default function MapView({ theme = "dark" }) {
} }
}, [mainFilter, bottomCategories, subFilter]); }, [mainFilter, bottomCategories, subFilter]);
// WebSocket live
useEffect(() => { useEffect(() => {
const proto = window.location.protocol === "https:" ? "wss" : "ws"; const proto = window.location.protocol === "https:" ? "wss" : "ws";
const host = const host =
@ -102,6 +106,10 @@ export default function MapView({ theme = "dark" }) {
}; };
const handleOpenCreate = () => { const handleOpenCreate = () => {
if (!authenticated) {
alert("You must be logged in to place a wire.");
return;
}
setIsCreating(true); setIsCreating(true);
setDraftTitle(""); setDraftTitle("");
setDraftBody(""); setDraftBody("");
@ -121,26 +129,76 @@ export default function MapView({ theme = "dark" }) {
setSaveError(""); setSaveError("");
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
let coords = draftCoords || [map.getCenter().lng, map.getCenter().lat];
const [lng, lat] = coords; if (!authenticated) {
setSaveError("You must be logged in to post.");
return;
}
const authorName = (username || "").trim() || "anon";
// ----- CROSSHAIR OFFSET FIX -----
// Point de départ : soit coords définies, soit centre de la map
let baseLng;
let baseLat;
if (draftCoords && draftCoords.length === 2) {
baseLng = draftCoords[0];
baseLat = draftCoords[1];
} else {
const center = map.getCenter();
baseLng = center.lng;
baseLat = center.lat;
}
// Offset vertical en pixels :
// si le post apparaît trop bas, on remonte (pixelOffsetY négatif)
const pixelOffsetY = -20; // ajuste à -25/-30 si nécessaire
// lng/lat -> pixels
const screenPoint = map.project([baseLng, baseLat]);
// appliquer l'offset vertical
const correctedPoint = {
x: screenPoint.x,
y: screenPoint.y + pixelOffsetY,
};
// pixels -> lng/lat corrigé
const correctedLngLat = map.unproject([correctedPoint.x, correctedPoint.y]);
const lng = correctedLngLat.lng;
const lat = correctedLngLat.lat;
// ----- END FIX -----
if (!draftTitle.trim()) { if (!draftTitle.trim()) {
setSaveError("Title required."); setSaveError("Title required.");
return; return;
} }
try { try {
setIsSaving(true); setIsSaving(true);
await createPost({
// On récupère le post retourné par le backend
const newPost = await createPost({
title: draftTitle.trim(), title: draftTitle.trim(),
snippet: draftBody.trim() || draftTitle.trim(), snippet: draftBody.trim() || draftTitle.trim(),
category: draftCategory.toUpperCase(), category: draftCategory.toUpperCase(),
sub_category: draftSubCategory || "ALL", sub_category: draftSubCategory || "ALL",
lat, lat,
lon: lng, lon: lng,
author: "tommy", author: authorName,
}); });
// On linjecte direct dans le moteur de posts,
// en plus du broadcast WebSocket
if (newPost && newPost.id) {
handleIncomingPost(newPost);
}
setIsSaving(false); setIsSaving(false);
setIsCreating(false); setIsCreating(false);
} catch { } catch (e) {
console.error(e);
setIsSaving(false); setIsSaving(false);
setSaveError("Error creating post."); setSaveError("Error creating post.");
} }
@ -233,7 +291,14 @@ export default function MapView({ theme = "dark" }) {
</button> </button>
</div> </div>
{/* CREATE POST */} {/* CROSSHAIR au centre pendant la création */}
{isCreating && (
<div className="map-overlay map-crosshair">
<div className="crosshair-aim" />
</div>
)}
{/* CREATE POST PANEL */}
{isCreating && ( {isCreating && (
<div className="map-overlay create-post-panel"> <div className="map-overlay create-post-panel">
<div className="create-post-header"> <div className="create-post-header">
@ -246,7 +311,8 @@ export default function MapView({ theme = "dark" }) {
</button> </button>
</div> </div>
<span className="crosshair-label"> <span className="crosshair-label">
Move the map, then tap to fix the position. Move the map, aim with the crosshair your wire will be pinned
there.
</span> </span>
<div className="create-row"> <div className="create-row">
<select <select
@ -293,7 +359,7 @@ export default function MapView({ theme = "dark" }) {
onClick={handleSubmitPost} onClick={handleSubmitPost}
disabled={isSaving} disabled={isSaving}
> >
{isSaving ? "Posting..." : "Post as tommy"} {isSaving ? "Posting..." : "Post"}
</button> </button>
</div> </div>
</div> </div>

View File

@ -12,8 +12,12 @@ import "./styles/overlays.css";
import "./styles/posts.css"; import "./styles/posts.css";
import "./styles/theme.css"; import "./styles/theme.css";
import { AuthProvider } from "./auth/AuthContext.jsx";
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<AuthProvider>
<App /> <App />
</AuthProvider>
</React.StrictMode> </React.StrictMode>
); );