nwnage
This commit is contained in:
parent
0b6bb6cc07
commit
9abf38326e
427
fix.sh
427
fix.sh
|
|
@ -1,427 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Run from frontend repo root
|
|
||||||
# (the folder that contains src/)
|
|
||||||
test -d "src" || { echo "❌ Run this from the FRONTEND repo root (where ./src exists)"; exit 1; }
|
|
||||||
|
|
||||||
TS="$(date +%Y%m%d-%H%M%S)"
|
|
||||||
echo "✅ Patching SocioWire frontend (timestamp: $TS)"
|
|
||||||
|
|
||||||
# backup
|
|
||||||
for f in \
|
|
||||||
src/components/Map/useMapCore.js \
|
|
||||||
src/components/Map/usePostsEngine.js
|
|
||||||
do
|
|
||||||
if [ -f "$f" ]; then
|
|
||||||
cp -a "$f" "$f.bak.$TS"
|
|
||||||
echo "📦 Backup: $f -> $f.bak.$TS"
|
|
||||||
else
|
|
||||||
echo "⚠️ Missing file: $f (will be created)"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# FILE: src/components/Map/useMapCore.js
|
|
||||||
# -----------------------------
|
|
||||||
mkdir -p src/components/Map
|
|
||||||
cat > src/components/Map/useMapCore.js <<'EOF'
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import maplibregl from "maplibre-gl";
|
|
||||||
import { LAST_VIEW_KEY } from "./mapConfig";
|
|
||||||
import { getViewFromMap } from "./mapGeo";
|
|
||||||
import { clearAllMarkers, applyOcclusionForExpanded } from "./markerManager";
|
|
||||||
|
|
||||||
// MapLibre base style by theme
|
|
||||||
function getBaseStyle(theme) {
|
|
||||||
const t = theme || "dark";
|
|
||||||
let tiles;
|
|
||||||
|
|
||||||
switch (t) {
|
|
||||||
case "light":
|
|
||||||
tiles = ["https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"];
|
|
||||||
break;
|
|
||||||
case "blue":
|
|
||||||
tiles = ["https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png"];
|
|
||||||
break;
|
|
||||||
case "dark":
|
|
||||||
default:
|
|
||||||
tiles = ["https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
version: 8,
|
|
||||||
sources: {
|
|
||||||
"carto-base": {
|
|
||||||
type: "raster",
|
|
||||||
tiles,
|
|
||||||
tileSize: 256,
|
|
||||||
attribution: "© OpenStreetMap contributors © CARTO",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: "carto-base-layer",
|
|
||||||
type: "raster",
|
|
||||||
source: "carto-base",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMapCore(theme) {
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
const mapRef = useRef(null);
|
|
||||||
|
|
||||||
const markersRef = useRef([]);
|
|
||||||
const expandedElRef = useRef(null);
|
|
||||||
|
|
||||||
// default large radius
|
|
||||||
const [viewParams, setViewParams] = useState({
|
|
||||||
center: null,
|
|
||||||
radiusKm: 750,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [hasLastView, setHasLastView] = useState(false);
|
|
||||||
|
|
||||||
function closeExpandedIfAny() {
|
|
||||||
const el = expandedElRef.current;
|
|
||||||
if (el && el.__renderCompact) el.__renderCompact();
|
|
||||||
expandedElRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeUpdateViewParams(map) {
|
|
||||||
try {
|
|
||||||
const vp = getViewFromMap(map);
|
|
||||||
if (vp && Array.isArray(vp.center) && vp.center.length === 2) {
|
|
||||||
setViewParams(vp);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerRef.current) return;
|
|
||||||
|
|
||||||
const map = new maplibregl.Map({
|
|
||||||
container: containerRef.current,
|
|
||||||
style: getBaseStyle(theme || "dark"),
|
|
||||||
center: [-95, 40],
|
|
||||||
zoom: 3.5,
|
|
||||||
pitch: 0,
|
|
||||||
bearing: 0,
|
|
||||||
antialias: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore last view if available
|
|
||||||
let hadLastView = false;
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LAST_VIEW_KEY);
|
|
||||||
if (raw) {
|
|
||||||
const v = JSON.parse(raw);
|
|
||||||
if (
|
|
||||||
typeof v.lat === "number" &&
|
|
||||||
typeof v.lon === "number" &&
|
|
||||||
typeof v.zoom === "number"
|
|
||||||
) {
|
|
||||||
map.setCenter([v.lon, v.lat]);
|
|
||||||
map.setZoom(v.zoom);
|
|
||||||
hadLastView = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
setHasLastView(hadLastView);
|
|
||||||
|
|
||||||
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
|
||||||
mapRef.current = map;
|
|
||||||
|
|
||||||
// Re-occlude markers when post expanded
|
|
||||||
const reOcclude = () => {
|
|
||||||
const el = expandedElRef.current;
|
|
||||||
if (el) applyOcclusionForExpanded(markersRef, el);
|
|
||||||
};
|
|
||||||
map.on("move", reOcclude);
|
|
||||||
map.on("zoom", reOcclude);
|
|
||||||
|
|
||||||
// Close expanded when clicking/dragging map
|
|
||||||
map.on("click", () => closeExpandedIfAny());
|
|
||||||
map.on("dragstart", () => closeExpandedIfAny());
|
|
||||||
|
|
||||||
// ✅ CRITICAL FIX:
|
|
||||||
// Set view params ASAP and also on load/idle/moveend.
|
|
||||||
// On some devices/prod, relying on 'load' only can miss initial fetch triggers.
|
|
||||||
const update = () => safeUpdateViewParams(map);
|
|
||||||
|
|
||||||
map.on("load", update);
|
|
||||||
map.on("idle", update);
|
|
||||||
map.on("moveend", () => {
|
|
||||||
update();
|
|
||||||
try {
|
|
||||||
const vp = getViewFromMap(map);
|
|
||||||
const center = vp.center;
|
|
||||||
localStorage.setItem(
|
|
||||||
LAST_VIEW_KEY,
|
|
||||||
JSON.stringify({
|
|
||||||
lat: center[1],
|
|
||||||
lon: center[0],
|
|
||||||
zoom: map.getZoom(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also do an early update next frame (helps first load)
|
|
||||||
requestAnimationFrame(update);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearAllMarkers(markersRef, expandedElRef);
|
|
||||||
map.remove();
|
|
||||||
mapRef.current = null;
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Theme style switch
|
|
||||||
useEffect(() => {
|
|
||||||
const map = mapRef.current;
|
|
||||||
if (!map) return;
|
|
||||||
|
|
||||||
// setStyle triggers style reload; we still want viewParams updated after
|
|
||||||
map.setStyle(getBaseStyle(theme || "dark"));
|
|
||||||
|
|
||||||
// update view params after style swap (next frame)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
try {
|
|
||||||
safeUpdateViewParams(map);
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
containerRef,
|
|
||||||
mapRef,
|
|
||||||
markersRef,
|
|
||||||
expandedElRef,
|
|
||||||
viewParams,
|
|
||||||
hasLastView,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# FILE: src/components/Map/usePostsEngine.js
|
|
||||||
# -----------------------------
|
|
||||||
cat > src/components/Map/usePostsEngine.js <<'EOF'
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { fetchPosts } from "../../api/client"; // keep existing endpoint for now
|
|
||||||
import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
|
|
||||||
import { haversineKm } from "./mapGeo";
|
|
||||||
import { createMarkerForPost, clearAllMarkers } from "./markerManager";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Posts engine:
|
|
||||||
* - keeps a cache (allPostsRef)
|
|
||||||
* - applies filters (cat/sub/time) for map + Sociowall
|
|
||||||
* - fetches by zone
|
|
||||||
*
|
|
||||||
* ✅ FIX: first page load must fetch even if viewParams.center is null.
|
|
||||||
* We fallback to map.getCenter() and default radius 750.
|
|
||||||
*/
|
|
||||||
export function usePostsEngine({
|
|
||||||
mapRef,
|
|
||||||
viewParams,
|
|
||||||
mainFilter,
|
|
||||||
subFilter,
|
|
||||||
timeFilter,
|
|
||||||
markersRef,
|
|
||||||
expandedElRef,
|
|
||||||
}) {
|
|
||||||
const allPostsRef = useRef([]);
|
|
||||||
|
|
||||||
const lastFetchRef = useRef({
|
|
||||||
center: null,
|
|
||||||
radiusKm: null,
|
|
||||||
filterKey: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [status, setStatus] = useState("Loading posts...");
|
|
||||||
const [visiblePosts, setVisiblePosts] = useState([]);
|
|
||||||
const [loadingPosts, setLoadingPosts] = useState(false);
|
|
||||||
const [loadError, setLoadError] = useState("");
|
|
||||||
|
|
||||||
const rebuildMarkersForFilters = useCallback(
|
|
||||||
(tf) => {
|
|
||||||
const map = mapRef.current;
|
|
||||||
if (!map) return;
|
|
||||||
|
|
||||||
clearAllMarkers(markersRef, expandedElRef);
|
|
||||||
|
|
||||||
const posts = allPostsRef.current || [];
|
|
||||||
const catCode = categoryCode(mainFilter);
|
|
||||||
|
|
||||||
const visible = posts.filter((p) => {
|
|
||||||
if (catCode) {
|
|
||||||
const pc = (p.category || p.Category || "").toString().toUpperCase();
|
|
||||||
if (pc !== catCode) return false;
|
|
||||||
}
|
|
||||||
if (!matchesSubFilter(p, subFilter)) return false;
|
|
||||||
if (!matchesTimeFilter(p.created_at || p.CreatedAt, tf)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
visible.forEach((post) => {
|
|
||||||
createMarkerForPost(post, mapRef, markersRef, expandedElRef);
|
|
||||||
});
|
|
||||||
|
|
||||||
setVisiblePosts(visible);
|
|
||||||
setStatus(visible.length ? "" : "No posts found.");
|
|
||||||
},
|
|
||||||
[mapRef, markersRef, expandedElRef, mainFilter, subFilter]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
rebuildMarkersForFilters(timeFilter);
|
|
||||||
}, [timeFilter, mainFilter, subFilter, rebuildMarkersForFilters]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const map = mapRef.current;
|
|
||||||
if (!map) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
// ✅ fallback center/radius if viewParams not ready yet
|
|
||||||
const centerArr =
|
|
||||||
(viewParams && Array.isArray(viewParams.center) && viewParams.center.length === 2)
|
|
||||||
? viewParams.center
|
|
||||||
: (() => {
|
|
||||||
const c = map.getCenter();
|
|
||||||
return [c.lng, c.lat];
|
|
||||||
})();
|
|
||||||
|
|
||||||
const radiusKm =
|
|
||||||
(viewParams && typeof viewParams.radiusKm === "number" && viewParams.radiusKm > 0)
|
|
||||||
? viewParams.radiusKm
|
|
||||||
: 750;
|
|
||||||
|
|
||||||
const [lng, lat] = centerArr;
|
|
||||||
|
|
||||||
const filterKey = `${mainFilter}|${subFilter}|${timeFilter}`;
|
|
||||||
const last = lastFetchRef.current;
|
|
||||||
|
|
||||||
if (last.center && last.filterKey === filterKey) {
|
|
||||||
const [lastLng, lastLat] = last.center;
|
|
||||||
const distKm = haversineKm(lastLat, lastLng, lat, lng);
|
|
||||||
|
|
||||||
const lastR = last.radiusKm || 0;
|
|
||||||
const radiusChanged =
|
|
||||||
!lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.25;
|
|
||||||
|
|
||||||
const minMoveKm = Math.max(50, radiusKm * 0.25);
|
|
||||||
|
|
||||||
if (!radiusChanged && distKm < minMoveKm) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setStatus("Loading posts...");
|
|
||||||
setLoadingPosts(true);
|
|
||||||
setLoadError("");
|
|
||||||
|
|
||||||
const catCode = categoryCode(mainFilter);
|
|
||||||
const subCatParam =
|
|
||||||
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All"
|
|
||||||
? subFilter
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const newPosts = await fetchPosts({
|
|
||||||
category: catCode,
|
|
||||||
subCategory: subCatParam,
|
|
||||||
time: "",
|
|
||||||
lat,
|
|
||||||
lon: lng,
|
|
||||||
radiusKm,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
const existing = allPostsRef.current || [];
|
|
||||||
const byId = new Map();
|
|
||||||
|
|
||||||
for (const p of existing) {
|
|
||||||
if (p && typeof p.id !== "undefined") byId.set(p.id, p);
|
|
||||||
}
|
|
||||||
if (Array.isArray(newPosts)) {
|
|
||||||
for (const p of newPosts) {
|
|
||||||
if (p && typeof p.id !== "undefined" && !byId.has(p.id)) {
|
|
||||||
byId.set(p.id, p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
allPostsRef.current = Array.from(byId.values());
|
|
||||||
|
|
||||||
lastFetchRef.current = {
|
|
||||||
center: [...centerArr],
|
|
||||||
radiusKm,
|
|
||||||
filterKey,
|
|
||||||
};
|
|
||||||
|
|
||||||
rebuildMarkersForFilters(timeFilter);
|
|
||||||
setLoadingPosts(false);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Erreur chargement posts:", err);
|
|
||||||
if (!cancelled) {
|
|
||||||
setStatus("Error loading posts.");
|
|
||||||
setLoadError("Error loading posts.");
|
|
||||||
setLoadingPosts(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [viewParams, mainFilter, subFilter, timeFilter, mapRef, rebuildMarkersForFilters, markersRef, expandedElRef]);
|
|
||||||
|
|
||||||
const handleIncomingPost = useCallback(
|
|
||||||
(p) => {
|
|
||||||
allPostsRef.current = [...allPostsRef.current, p];
|
|
||||||
|
|
||||||
const catCode = categoryCode(mainFilter);
|
|
||||||
if (catCode) {
|
|
||||||
const pc = (p.category || p.Category || "").toString().toUpperCase();
|
|
||||||
if (pc !== catCode) return;
|
|
||||||
}
|
|
||||||
if (!matchesSubFilter(p, subFilter)) return;
|
|
||||||
if (!matchesTimeFilter(p.created_at || p.CreatedAt, timeFilter)) return;
|
|
||||||
|
|
||||||
createMarkerForPost(p, mapRef, markersRef, expandedElRef);
|
|
||||||
setVisiblePosts((current) => [...current, p]);
|
|
||||||
},
|
|
||||||
[mainFilter, subFilter, timeFilter, mapRef, markersRef, expandedElRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
visiblePosts,
|
|
||||||
loadingPosts,
|
|
||||||
loadError,
|
|
||||||
handleIncomingPost,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "✅ Files written."
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Next:"
|
|
||||||
echo " npm run dev"
|
|
||||||
echo "or build/deploy as you do normally."
|
|
||||||
echo ""
|
|
||||||
echo "Tip: open DevTools Network -> you should see /api/posts on first page load."
|
|
||||||
|
|
@ -2,16 +2,13 @@ import React, { createContext, useContext, useEffect, useRef, useState } from "r
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
// Keycloak config
|
// Backend API (same origin)
|
||||||
const KC_BASE = "https://web.sociowire.com:8443";
|
const API_BASE = "/api";
|
||||||
const KC_REALM = "sociowire";
|
|
||||||
const KC_CLIENT_ID = "sociowire-frontend";
|
|
||||||
|
|
||||||
const TOKEN_KEY = "sociowire:access_token";
|
const TOKEN_KEY = "sociowire:access_token";
|
||||||
const REFRESH_KEY = "sociowire:refresh_token";
|
const REFRESH_KEY = "sociowire:refresh_token";
|
||||||
const USERNAME_KEY = "sociowire:username";
|
const USERNAME_KEY = "sociowire:username";
|
||||||
|
|
||||||
// --- helpers ---
|
|
||||||
function decodeJwtPayload(token) {
|
function decodeJwtPayload(token) {
|
||||||
try {
|
try {
|
||||||
const parts = token.split(".");
|
const parts = token.split(".");
|
||||||
|
|
@ -30,26 +27,17 @@ function willExpireSoon(token, withinSeconds = 90) {
|
||||||
return p.exp - now <= withinSeconds;
|
return p.exp - now <= withinSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshWithKeycloak(refreshToken) {
|
async function backendRefresh(refreshToken) {
|
||||||
const params = new URLSearchParams();
|
const res = await fetch(`${API_BASE}/refresh`, {
|
||||||
params.set("grant_type", "refresh_token");
|
|
||||||
params.set("client_id", KC_CLIENT_ID);
|
|
||||||
params.set("refresh_token", refreshToken);
|
|
||||||
|
|
||||||
const res = await fetch(`${KC_BASE}/realms/${KC_REALM}/protocol/openid-connect/token`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: params.toString(),
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = text;
|
throw new Error(`${res.status} ${text}`);
|
||||||
try {
|
|
||||||
const j = JSON.parse(text);
|
|
||||||
msg = j.error_description || j.error || text;
|
|
||||||
} catch {}
|
|
||||||
throw new Error(`${res.status} ${msg}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = JSON.parse(text);
|
const data = JSON.parse(text);
|
||||||
|
|
@ -111,7 +99,7 @@ export function AuthProvider({ children }) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken, refreshToken } = await refreshWithKeycloak(storedRefresh);
|
const { accessToken, refreshToken } = await backendRefresh(storedRefresh);
|
||||||
persistAuth({ accessToken, refreshToken, user: storedUser });
|
persistAuth({ accessToken, refreshToken, user: storedUser });
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -152,7 +140,7 @@ export function AuthProvider({ children }) {
|
||||||
|
|
||||||
if (!willExpireSoon(t, 90)) return;
|
if (!willExpireSoon(t, 90)) return;
|
||||||
|
|
||||||
const { accessToken, refreshToken } = await refreshWithKeycloak(rt);
|
const { accessToken, refreshToken } = await backendRefresh(rt);
|
||||||
const u = localStorage.getItem(USERNAME_KEY) || usernameRef.current || "";
|
const u = localStorage.getItem(USERNAME_KEY) || usernameRef.current || "";
|
||||||
persistAuth({ accessToken, refreshToken, user: u });
|
persistAuth({ accessToken, refreshToken, user: u });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -172,29 +160,20 @@ export function AuthProvider({ children }) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set("grant_type", "password");
|
|
||||||
params.set("client_id", KC_CLIENT_ID);
|
|
||||||
params.set("username", u);
|
|
||||||
params.set("password", pass);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const res = await fetch(`${KC_BASE}/realms/${KC_REALM}/protocol/openid-connect/token`, {
|
const res = await fetch(`${API_BASE}/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: params.toString(),
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ username: u, password: pass }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = "Invalid credentials or auth error.";
|
console.error("[AUTH] /api/login failed", res.status, text);
|
||||||
try {
|
setLastError(`${res.status} login failed`);
|
||||||
const j = JSON.parse(text);
|
|
||||||
msg = j.error_description || j.error || msg;
|
|
||||||
} catch {}
|
|
||||||
clearAuth(`${res.status} ${msg}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,7 +182,7 @@ export function AuthProvider({ children }) {
|
||||||
const refreshToken = data.refresh_token;
|
const refreshToken = data.refresh_token;
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
clearAuth("No access token in response.");
|
setLastError("No access token in response.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,6 +190,7 @@ export function AuthProvider({ children }) {
|
||||||
setLastError("");
|
setLastError("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[AUTH] network error", e);
|
console.error("[AUTH] network error", e);
|
||||||
|
setLastError("Network error during login.");
|
||||||
clearAuth("Network error during login.");
|
clearAuth("Network error during login.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
// Keycloak config
|
||||||
|
const KC_BASE = "https://web.sociowire.com:8443";
|
||||||
|
const KC_REALM = "sociowire";
|
||||||
|
const KC_CLIENT_ID = "sociowire-frontend";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "sociowire:access_token";
|
||||||
|
const REFRESH_KEY = "sociowire:refresh_token";
|
||||||
|
const USERNAME_KEY = "sociowire:username";
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
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 refreshWithKeycloak(refreshToken) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("grant_type", "refresh_token");
|
||||||
|
params.set("client_id", KC_CLIENT_ID);
|
||||||
|
params.set("refresh_token", refreshToken);
|
||||||
|
|
||||||
|
const res = await fetch(`${KC_BASE}/realms/${KC_REALM}/protocol/openid-connect/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = text;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(text);
|
||||||
|
msg = j.error_description || j.error || text;
|
||||||
|
} catch {}
|
||||||
|
throw new Error(`${res.status} ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 refreshWithKeycloak(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 refreshWithKeycloak(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("grant_type", "password");
|
||||||
|
params.set("client_id", KC_CLIENT_ID);
|
||||||
|
params.set("username", u);
|
||||||
|
params.set("password", pass);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const res = await fetch(`${KC_BASE}/realms/${KC_REALM}/protocol/openid-connect/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = "Invalid credentials or auth error.";
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(text);
|
||||||
|
msg = j.error_description || j.error || msg;
|
||||||
|
} catch {}
|
||||||
|
clearAuth(`${res.status} ${msg}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
const accessToken = data.access_token;
|
||||||
|
const refreshToken = data.refresh_token;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
clearAuth("No access token in response.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
persistAuth({ accessToken, refreshToken, user: u });
|
||||||
|
setLastError("");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[AUTH] network error", e);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
// Keycloak config
|
||||||
|
|
||||||
|
const TOKEN_KEY = "sociowire:access_token";
|
||||||
|
const REFRESH_KEY = "sociowire:refresh_token";
|
||||||
|
const USERNAME_KEY = "sociowire:username";
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
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 refreshViaBackend(refreshToken) {
|
||||||
|
const res = await fetch("/api/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = text;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(text);
|
||||||
|
msg = j.error_description || j.error || text;
|
||||||
|
} catch {}
|
||||||
|
throw new Error(` `);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 refreshViaBackend(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 refreshViaBackend(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/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) {
|
||||||
|
let msg = "Invalid credentials or auth error.";
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(text);
|
||||||
|
msg = j.error_description || j.error || msg;
|
||||||
|
} catch {}
|
||||||
|
clearAuth(` `);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
const accessToken = data.access_token;
|
||||||
|
const refreshToken = data.refresh_token;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
clearAuth("No access token in response.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
persistAuth({ accessToken, refreshToken, user: u });
|
||||||
|
setLastError("");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[AUTH] network error", e);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
// Keycloak config
|
||||||
|
|
||||||
|
const TOKEN_KEY = "sociowire:access_token";
|
||||||
|
const REFRESH_KEY = "sociowire:refresh_token";
|
||||||
|
const USERNAME_KEY = "sociowire:username";
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
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 refreshViaBackend(refreshToken) {
|
||||||
|
const res = await fetch("/api/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = text;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(text);
|
||||||
|
msg = j.error_description || j.error || text;
|
||||||
|
} catch {}
|
||||||
|
throw new Error(` `);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 refreshViaBackend(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 refreshViaBackend(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/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) {
|
||||||
|
let msg = "Invalid credentials or auth error.";
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(text);
|
||||||
|
msg = j.error_description || j.error || msg;
|
||||||
|
} catch {}
|
||||||
|
clearAuth(` `);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
const accessToken = data.access_token;
|
||||||
|
const refreshToken = data.refresh_token;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
clearAuth("No access token in response.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
persistAuth({ accessToken, refreshToken, user: u });
|
||||||
|
setLastError("");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[AUTH] network error", e);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
// Keycloak config
|
||||||
|
|
||||||
|
const TOKEN_KEY = "sociowire:access_token";
|
||||||
|
const REFRESH_KEY = "sociowire:refresh_token";
|
||||||
|
const USERNAME_KEY = "sociowire:username";
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
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 refreshViaBackend(refreshToken) {
|
||||||
|
const res = await fetch("/api/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = text;
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(text);
|
||||||
|
msg = j.error_description || j.error || text;
|
||||||
|
} catch {}
|
||||||
|
throw new Error(` `);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 refreshViaBackend(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 refreshViaBackend(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/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) {
|
||||||
|
let msg = "Invalid credentials or auth error.";
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(text);
|
||||||
|
msg = j.error_description || j.error || msg;
|
||||||
|
} catch {}
|
||||||
|
clearAuth(` `);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
const accessToken = data.access_token;
|
||||||
|
const refreshToken = data.refresh_token;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
clearAuth("No access token in response.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
persistAuth({ accessToken, refreshToken, user: u });
|
||||||
|
setLastError("");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[AUTH] network error", e);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -181,6 +181,7 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
||||||
{loading ? "..." : "Sign in"}
|
{loading ? "..." : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
|
{lastError && <div className="auth-error" style={{marginTop:"0.6rem"}}>{lastError}</div>}
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form className="auth-form" onSubmit={handleSignupSubmit}>
|
<form className="auth-form" onSubmit={handleSignupSubmit}>
|
||||||
|
|
@ -212,6 +213,7 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
||||||
{signupLoading ? "..." : "Create account"}
|
{signupLoading ? "..." : "Create account"}
|
||||||
</button>
|
</button>
|
||||||
|
{lastError && <div className="auth-error" style={{marginTop:"0.6rem"}}>{lastError}</div>}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
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>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
500
utils/fix.sh
500
utils/fix.sh
|
|
@ -1,95 +1,427 @@
|
||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
echo "=== SOCIOWIRE LOGOS + METADATA INSTALLER ==="
|
# Run from frontend repo root
|
||||||
|
# (the folder that contains src/)
|
||||||
|
test -d "src" || { echo "❌ Run this from the FRONTEND repo root (where ./src exists)"; exit 1; }
|
||||||
|
|
||||||
# --- REQUIRE Imagemagick ---
|
TS="$(date +%Y%m%d-%H%M%S)"
|
||||||
if ! command -v convert &> /dev/null
|
echo "✅ Patching SocioWire frontend (timestamp: $TS)"
|
||||||
then
|
|
||||||
echo "Imagemagick non installé — installation..."
|
# backup
|
||||||
sudo apt install -y imagemagick
|
for f in \
|
||||||
|
src/components/Map/useMapCore.js \
|
||||||
|
src/components/Map/usePostsEngine.js
|
||||||
|
do
|
||||||
|
if [ -f "$f" ]; then
|
||||||
|
cp -a "$f" "$f.bak.$TS"
|
||||||
|
echo "📦 Backup: $f -> $f.bak.$TS"
|
||||||
|
else
|
||||||
|
echo "⚠️ Missing file: $f (will be created)"
|
||||||
fi
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# --- CREATE FOLDERS ---
|
# -----------------------------
|
||||||
mkdir -p public/icons
|
# FILE: src/components/Map/useMapCore.js
|
||||||
|
# -----------------------------
|
||||||
|
mkdir -p src/components/Map
|
||||||
|
cat > src/components/Map/useMapCore.js <<'EOF'
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { LAST_VIEW_KEY } from "./mapConfig";
|
||||||
|
import { getViewFromMap } from "./mapGeo";
|
||||||
|
import { clearAllMarkers, applyOcclusionForExpanded } from "./markerManager";
|
||||||
|
|
||||||
# --- COPY MASTER LOGO ---
|
// MapLibre base style by theme
|
||||||
|
function getBaseStyle(theme) {
|
||||||
|
const t = theme || "dark";
|
||||||
|
let tiles;
|
||||||
|
|
||||||
# --- GENERATE ICONS ---
|
switch (t) {
|
||||||
echo "Génération des icônes…"
|
case "light":
|
||||||
|
tiles = ["https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"];
|
||||||
convert public/icons/logo-master.png -resize 16x16 public/icons/favicon-16.png
|
break;
|
||||||
convert public/icons/logo-master.png -resize 32x32 public/icons/favicon-32.png
|
case "blue":
|
||||||
convert public/icons/logo-master.png -resize 48x48 public/icons/favicon-48.png
|
tiles = ["https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png"];
|
||||||
convert public/icons/logo-master.png -resize 180x180 public/icons/apple-touch-icon.png
|
break;
|
||||||
convert public/icons/logo-master.png -resize 192x192 public/icons/icon-192.png
|
case "dark":
|
||||||
convert public/icons/logo-master.png -resize 512x512 public/icons/icon-512.png
|
default:
|
||||||
convert public/icons/logo-master.png -resize 1200x630 public/icons/og-image.png
|
tiles = ["https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"];
|
||||||
|
break;
|
||||||
echo "Icônes générées."
|
|
||||||
|
|
||||||
# --- ADD METADATA INTO index.html ---
|
|
||||||
echo "Injection des balises SEO et OpenGraph…"
|
|
||||||
|
|
||||||
sed -i '/<head>/a \
|
|
||||||
<!-- ===== SOCIOWIRE SEO + SHARE + ICONS ===== -->\
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" />\
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />\
|
|
||||||
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48.png" />\
|
|
||||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />\
|
|
||||||
<link rel="manifest" href="/manifest.json" />\
|
|
||||||
<meta name="theme-color" content="#0ea5e9" />\
|
|
||||||
<meta name="description" content="SocioWire — The real-time world feed. Live posts, geolocated events, discover everything happening right now." />\
|
|
||||||
<meta property="og:title" content="SocioWire — Wired to Life" />\
|
|
||||||
<meta property="og:description" content="Live posts from the world. Real-time events, map-based stories, and instant geolocated content." />\
|
|
||||||
<meta property="og:image" content="/icons/og-image.png" />\
|
|
||||||
<meta property="og:image:width" content="1200" />\
|
|
||||||
<meta property="og:image:height" content="630" />\
|
|
||||||
<meta property="og:type" content="website" />\
|
|
||||||
<meta property="og:url" content="https://sociowire.com" />\
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />\
|
|
||||||
<meta name="twitter:title" content="SocioWire — Wired to Life" />\
|
|
||||||
<meta name="twitter:description" content="Share what you see. Discover what others are living — in real time." />\
|
|
||||||
<meta name="twitter:image" content="/icons/og-image.png" />\
|
|
||||||
<!-- ===== END META ===== -->\
|
|
||||||
' index.html
|
|
||||||
|
|
||||||
echo "Metadonnées ajoutées."
|
|
||||||
|
|
||||||
# --- INSTALL MANIFEST.JSON FOR PWA + GOOGLE + ANDROID ---
|
|
||||||
echo "Création du manifest.json…"
|
|
||||||
|
|
||||||
cat << 'EOF' > public/manifest.json
|
|
||||||
{
|
|
||||||
"name": "SocioWire",
|
|
||||||
"short_name": "SocioWire",
|
|
||||||
"description": "Real-time posts on a global map — wired to life.",
|
|
||||||
"start_url": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#020617",
|
|
||||||
"theme_color": "#0ea5e9",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
|
return {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
"carto-base": {
|
||||||
|
type: "raster",
|
||||||
|
tiles,
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: "© OpenStreetMap contributors © CARTO",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: "carto-base-layer",
|
||||||
|
type: "raster",
|
||||||
|
source: "carto-base",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMapCore(theme) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const mapRef = useRef(null);
|
||||||
|
|
||||||
|
const markersRef = useRef([]);
|
||||||
|
const expandedElRef = useRef(null);
|
||||||
|
|
||||||
|
// default large radius
|
||||||
|
const [viewParams, setViewParams] = useState({
|
||||||
|
center: null,
|
||||||
|
radiusKm: 750,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [hasLastView, setHasLastView] = useState(false);
|
||||||
|
|
||||||
|
function closeExpandedIfAny() {
|
||||||
|
const el = expandedElRef.current;
|
||||||
|
if (el && el.__renderCompact) el.__renderCompact();
|
||||||
|
expandedElRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeUpdateViewParams(map) {
|
||||||
|
try {
|
||||||
|
const vp = getViewFromMap(map);
|
||||||
|
if (vp && Array.isArray(vp.center) && vp.center.length === 2) {
|
||||||
|
setViewParams(vp);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
container: containerRef.current,
|
||||||
|
style: getBaseStyle(theme || "dark"),
|
||||||
|
center: [-95, 40],
|
||||||
|
zoom: 3.5,
|
||||||
|
pitch: 0,
|
||||||
|
bearing: 0,
|
||||||
|
antialias: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore last view if available
|
||||||
|
let hadLastView = false;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LAST_VIEW_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const v = JSON.parse(raw);
|
||||||
|
if (
|
||||||
|
typeof v.lat === "number" &&
|
||||||
|
typeof v.lon === "number" &&
|
||||||
|
typeof v.zoom === "number"
|
||||||
|
) {
|
||||||
|
map.setCenter([v.lon, v.lat]);
|
||||||
|
map.setZoom(v.zoom);
|
||||||
|
hadLastView = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
setHasLastView(hadLastView);
|
||||||
|
|
||||||
|
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
|
// Re-occlude markers when post expanded
|
||||||
|
const reOcclude = () => {
|
||||||
|
const el = expandedElRef.current;
|
||||||
|
if (el) applyOcclusionForExpanded(markersRef, el);
|
||||||
|
};
|
||||||
|
map.on("move", reOcclude);
|
||||||
|
map.on("zoom", reOcclude);
|
||||||
|
|
||||||
|
// Close expanded when clicking/dragging map
|
||||||
|
map.on("click", () => closeExpandedIfAny());
|
||||||
|
map.on("dragstart", () => closeExpandedIfAny());
|
||||||
|
|
||||||
|
// ✅ CRITICAL FIX:
|
||||||
|
// Set view params ASAP and also on load/idle/moveend.
|
||||||
|
// On some devices/prod, relying on 'load' only can miss initial fetch triggers.
|
||||||
|
const update = () => safeUpdateViewParams(map);
|
||||||
|
|
||||||
|
map.on("load", update);
|
||||||
|
map.on("idle", update);
|
||||||
|
map.on("moveend", () => {
|
||||||
|
update();
|
||||||
|
try {
|
||||||
|
const vp = getViewFromMap(map);
|
||||||
|
const center = vp.center;
|
||||||
|
localStorage.setItem(
|
||||||
|
LAST_VIEW_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
lat: center[1],
|
||||||
|
lon: center[0],
|
||||||
|
zoom: map.getZoom(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also do an early update next frame (helps first load)
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearAllMarkers(markersRef, expandedElRef);
|
||||||
|
map.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Theme style switch
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
// setStyle triggers style reload; we still want viewParams updated after
|
||||||
|
map.setStyle(getBaseStyle(theme || "dark"));
|
||||||
|
|
||||||
|
// update view params after style swap (next frame)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
safeUpdateViewParams(map);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerRef,
|
||||||
|
mapRef,
|
||||||
|
markersRef,
|
||||||
|
expandedElRef,
|
||||||
|
viewParams,
|
||||||
|
hasLastView,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "manifest.json généré."
|
# -----------------------------
|
||||||
|
# FILE: src/components/Map/usePostsEngine.js
|
||||||
|
# -----------------------------
|
||||||
|
cat > src/components/Map/usePostsEngine.js <<'EOF'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { fetchPosts } from "../../api/client"; // keep existing endpoint for now
|
||||||
|
import { categoryCode, matchesSubFilter, matchesTimeFilter } from "./mapFilter";
|
||||||
|
import { haversineKm } from "./mapGeo";
|
||||||
|
import { createMarkerForPost, clearAllMarkers } from "./markerManager";
|
||||||
|
|
||||||
echo "=== INSTALLATION TERMINÉE ==="
|
/**
|
||||||
echo "Ton site a maintenant :"
|
* Posts engine:
|
||||||
echo "✔ Favicon"
|
* - keeps a cache (allPostsRef)
|
||||||
echo "✔ Apple Touch Icon"
|
* - applies filters (cat/sub/time) for map + Sociowall
|
||||||
echo "✔ Android App Icon"
|
* - fetches by zone
|
||||||
echo "✔ OpenGraph Facebook"
|
*
|
||||||
echo "✔ Twitter Card"
|
* ✅ FIX: first page load must fetch even if viewParams.center is null.
|
||||||
echo "✔ SEO Google complet"
|
* We fallback to map.getCenter() and default radius 750.
|
||||||
echo "✔ PWA Manifest"
|
*/
|
||||||
|
export function usePostsEngine({
|
||||||
|
mapRef,
|
||||||
|
viewParams,
|
||||||
|
mainFilter,
|
||||||
|
subFilter,
|
||||||
|
timeFilter,
|
||||||
|
markersRef,
|
||||||
|
expandedElRef,
|
||||||
|
}) {
|
||||||
|
const allPostsRef = useRef([]);
|
||||||
|
|
||||||
|
const lastFetchRef = useRef({
|
||||||
|
center: null,
|
||||||
|
radiusKm: null,
|
||||||
|
filterKey: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [status, setStatus] = useState("Loading posts...");
|
||||||
|
const [visiblePosts, setVisiblePosts] = useState([]);
|
||||||
|
const [loadingPosts, setLoadingPosts] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState("");
|
||||||
|
|
||||||
|
const rebuildMarkersForFilters = useCallback(
|
||||||
|
(tf) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
clearAllMarkers(markersRef, expandedElRef);
|
||||||
|
|
||||||
|
const posts = allPostsRef.current || [];
|
||||||
|
const catCode = categoryCode(mainFilter);
|
||||||
|
|
||||||
|
const visible = posts.filter((p) => {
|
||||||
|
if (catCode) {
|
||||||
|
const pc = (p.category || p.Category || "").toString().toUpperCase();
|
||||||
|
if (pc !== catCode) return false;
|
||||||
|
}
|
||||||
|
if (!matchesSubFilter(p, subFilter)) return false;
|
||||||
|
if (!matchesTimeFilter(p.created_at || p.CreatedAt, tf)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
visible.forEach((post) => {
|
||||||
|
createMarkerForPost(post, mapRef, markersRef, expandedElRef);
|
||||||
|
});
|
||||||
|
|
||||||
|
setVisiblePosts(visible);
|
||||||
|
setStatus(visible.length ? "" : "No posts found.");
|
||||||
|
},
|
||||||
|
[mapRef, markersRef, expandedElRef, mainFilter, subFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rebuildMarkersForFilters(timeFilter);
|
||||||
|
}, [timeFilter, mainFilter, subFilter, rebuildMarkersForFilters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
// ✅ fallback center/radius if viewParams not ready yet
|
||||||
|
const centerArr =
|
||||||
|
(viewParams && Array.isArray(viewParams.center) && viewParams.center.length === 2)
|
||||||
|
? viewParams.center
|
||||||
|
: (() => {
|
||||||
|
const c = map.getCenter();
|
||||||
|
return [c.lng, c.lat];
|
||||||
|
})();
|
||||||
|
|
||||||
|
const radiusKm =
|
||||||
|
(viewParams && typeof viewParams.radiusKm === "number" && viewParams.radiusKm > 0)
|
||||||
|
? viewParams.radiusKm
|
||||||
|
: 750;
|
||||||
|
|
||||||
|
const [lng, lat] = centerArr;
|
||||||
|
|
||||||
|
const filterKey = `${mainFilter}|${subFilter}|${timeFilter}`;
|
||||||
|
const last = lastFetchRef.current;
|
||||||
|
|
||||||
|
if (last.center && last.filterKey === filterKey) {
|
||||||
|
const [lastLng, lastLat] = last.center;
|
||||||
|
const distKm = haversineKm(lastLat, lastLng, lat, lng);
|
||||||
|
|
||||||
|
const lastR = last.radiusKm || 0;
|
||||||
|
const radiusChanged =
|
||||||
|
!lastR || Math.abs(radiusKm - lastR) / Math.max(lastR, 1) >= 0.25;
|
||||||
|
|
||||||
|
const minMoveKm = Math.max(50, radiusKm * 0.25);
|
||||||
|
|
||||||
|
if (!radiusChanged && distKm < minMoveKm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setStatus("Loading posts...");
|
||||||
|
setLoadingPosts(true);
|
||||||
|
setLoadError("");
|
||||||
|
|
||||||
|
const catCode = categoryCode(mainFilter);
|
||||||
|
const subCatParam =
|
||||||
|
subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All"
|
||||||
|
? subFilter
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const newPosts = await fetchPosts({
|
||||||
|
category: catCode,
|
||||||
|
subCategory: subCatParam,
|
||||||
|
time: "",
|
||||||
|
lat,
|
||||||
|
lon: lng,
|
||||||
|
radiusKm,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const existing = allPostsRef.current || [];
|
||||||
|
const byId = new Map();
|
||||||
|
|
||||||
|
for (const p of existing) {
|
||||||
|
if (p && typeof p.id !== "undefined") byId.set(p.id, p);
|
||||||
|
}
|
||||||
|
if (Array.isArray(newPosts)) {
|
||||||
|
for (const p of newPosts) {
|
||||||
|
if (p && typeof p.id !== "undefined" && !byId.has(p.id)) {
|
||||||
|
byId.set(p.id, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allPostsRef.current = Array.from(byId.values());
|
||||||
|
|
||||||
|
lastFetchRef.current = {
|
||||||
|
center: [...centerArr],
|
||||||
|
radiusKm,
|
||||||
|
filterKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
rebuildMarkersForFilters(timeFilter);
|
||||||
|
setLoadingPosts(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erreur chargement posts:", err);
|
||||||
|
if (!cancelled) {
|
||||||
|
setStatus("Error loading posts.");
|
||||||
|
setLoadError("Error loading posts.");
|
||||||
|
setLoadingPosts(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [viewParams, mainFilter, subFilter, timeFilter, mapRef, rebuildMarkersForFilters, markersRef, expandedElRef]);
|
||||||
|
|
||||||
|
const handleIncomingPost = useCallback(
|
||||||
|
(p) => {
|
||||||
|
allPostsRef.current = [...allPostsRef.current, p];
|
||||||
|
|
||||||
|
const catCode = categoryCode(mainFilter);
|
||||||
|
if (catCode) {
|
||||||
|
const pc = (p.category || p.Category || "").toString().toUpperCase();
|
||||||
|
if (pc !== catCode) return;
|
||||||
|
}
|
||||||
|
if (!matchesSubFilter(p, subFilter)) return;
|
||||||
|
if (!matchesTimeFilter(p.created_at || p.CreatedAt, timeFilter)) return;
|
||||||
|
|
||||||
|
createMarkerForPost(p, mapRef, markersRef, expandedElRef);
|
||||||
|
setVisiblePosts((current) => [...current, p]);
|
||||||
|
},
|
||||||
|
[mainFilter, subFilter, timeFilter, mapRef, markersRef, expandedElRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
visiblePosts,
|
||||||
|
loadingPosts,
|
||||||
|
loadError,
|
||||||
|
handleIncomingPost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✅ Files written."
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Next:"
|
||||||
|
echo " npm run dev"
|
||||||
|
echo "or build/deploy as you do normally."
|
||||||
|
echo ""
|
||||||
|
echo "Tip: open DevTools Network -> you should see /api/posts on first page load."
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ===== CONFIG =====
|
||||||
|
BACKEND_DIR="${BACKEND_DIR:-sociowire-backend}"
|
||||||
|
|
||||||
|
cd "$BACKEND_DIR"
|
||||||
|
|
||||||
|
echo "[1/2] Write auth_refresh.go"
|
||||||
|
cat > auth_refresh.go <<'GO'
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func refreshHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
setCommonHeaders(w)
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type req struct {
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
var body req
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
|
||||||
|
rt := strings.TrimSpace(body.RefreshToken)
|
||||||
|
if rt == "" {
|
||||||
|
http.Error(w, "refresh_token required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
kcURL := strings.TrimRight(strings.TrimSpace(os.Getenv("KEYCLOAK_URL")), "/")
|
||||||
|
realm := strings.TrimSpace(os.Getenv("KEYCLOAK_REALM"))
|
||||||
|
clientID := strings.TrimSpace(os.Getenv("KC_PUBLIC_CLIENT_ID"))
|
||||||
|
clientSecret := strings.TrimSpace(os.Getenv("KC_PUBLIC_CLIENT_SECRET"))
|
||||||
|
|
||||||
|
if kcURL == "" || realm == "" || clientID == "" {
|
||||||
|
http.Error(w, "keycloak env missing", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenURL := kcURL + "/realms/" + realm + "/protocol/openid-connect/token"
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("grant_type", "refresh_token")
|
||||||
|
form.Set("client_id", clientID)
|
||||||
|
form.Set("refresh_token", rt)
|
||||||
|
if clientSecret != "" {
|
||||||
|
form.Set("client_secret", clientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
req2, _ := http.NewRequest("POST", tokenURL, strings.NewReader(form.Encode()))
|
||||||
|
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 8 * time.Second}
|
||||||
|
resp, err := client.Do(req2)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "keycloak refresh error", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write(raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(raw)
|
||||||
|
}
|
||||||
|
GO
|
||||||
|
|
||||||
|
echo "[2/2] Patch main.go to add /api/refresh route"
|
||||||
|
if ! grep -q '"/api/refresh"' main.go; then
|
||||||
|
# Insert after /api/login line if present, else near auth routes block
|
||||||
|
# This is a simple awk insert.
|
||||||
|
awk '
|
||||||
|
{print}
|
||||||
|
/mux\.HandleFunc\("\/api\/login", loginHandler\)/ {
|
||||||
|
print "\tmux.HandleFunc(\"/api/refresh\", refreshHandler)"
|
||||||
|
}
|
||||||
|
' main.go > main.go.tmp
|
||||||
|
|
||||||
|
# If insertion didn’t happen (no match), append under auth routes area:
|
||||||
|
if ! grep -q '"/api/refresh"' main.go.tmp; then
|
||||||
|
awk '
|
||||||
|
{print}
|
||||||
|
/mux\.HandleFunc\("\/api\/login", loginHandler\)/==0 && /\/\/ Auth\/debug/ {
|
||||||
|
# no-op marker
|
||||||
|
}
|
||||||
|
' main.go.tmp > /dev/null 2>&1 || true
|
||||||
|
# fallback: append near other routes (safe)
|
||||||
|
sed -i 's|mux.HandleFunc("/api/login", loginHandler)|mux.HandleFunc("/api/login", loginHandler)\n\tmux.HandleFunc("/api/refresh", refreshHandler)|' main.go.tmp || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
mv main.go.tmp main.go
|
||||||
|
else
|
||||||
|
echo "main.go already has /api/refresh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "DONE backend patch."
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
FRONTEND_DIR="${FRONTEND_DIR:-sociowire-frontend}"
|
||||||
|
FILE="$FRONTEND_DIR/src/auth/AuthContext.jsx"
|
||||||
|
|
||||||
|
if [ ! -f "$FILE" ]; then
|
||||||
|
echo "ERROR: not found: $FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[1/3] Backup AuthContext.jsx"
|
||||||
|
cp -a "$FILE" "$FILE.bak.$(date +%s)"
|
||||||
|
|
||||||
|
echo "[2/3] Remove direct Keycloak constants (KC_BASE/KC_REALM/KC_CLIENT_ID) if present"
|
||||||
|
# delete block lines that define KC_BASE/KC_REALM/KC_CLIENT_ID
|
||||||
|
sed -i \
|
||||||
|
-e '/^const KC_BASE = /d' \
|
||||||
|
-e '/^const KC_REALM = /d' \
|
||||||
|
-e '/^const KC_CLIENT_ID = /d' \
|
||||||
|
"$FILE"
|
||||||
|
|
||||||
|
echo "[3/3] Replace refreshWithKeycloak + login() to use backend /api/login + /api/refresh"
|
||||||
|
|
||||||
|
# Replace function refreshWithKeycloak(...) { ... } with refreshViaBackend(...)
|
||||||
|
perl -0777 -i -pe '
|
||||||
|
s/async function refreshWithKeycloak\([^\)]*\)\s*\{.*?\n\}\n/async function refreshViaBackend(refreshToken) {\n const res = await fetch(\"\/api\/refresh\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application\/json\" },\n credentials: \"include\",\n body: JSON.stringify({ refresh_token: refreshToken }),\n });\n\n const text = await res.text();\n if (!res.ok) {\n let msg = text;\n try {\n const j = JSON.parse(text);\n msg = j.error_description || j.error || text;\n } catch {}\n throw new Error(`${res.status} ${msg}`);\n }\n\n const data = JSON.parse(text);\n if (!data.access_token) throw new Error(\"No access_token in refresh response\");\n return {\n accessToken: data.access_token,\n refreshToken: data.refresh_token || refreshToken,\n };\n}\n/sms
|
||||||
|
' "$FILE"
|
||||||
|
|
||||||
|
# Replace any calls to refreshWithKeycloak( with refreshViaBackend(
|
||||||
|
sed -i 's/refreshWithKeycloak(/refreshViaBackend(/g' "$FILE"
|
||||||
|
|
||||||
|
# Replace login() body to call /api/login
|
||||||
|
perl -0777 -i -pe '
|
||||||
|
s/async function login\([^\)]*\)\s*\{.*?\n\s*\}\n\n\s*function logout\(/async function login(user, pass) {\n setLastError(\"\");\n const u = (user || \"\").trim();\n if (!u || !pass) {\n setLastError(\"Username and password required.\");\n return;\n }\n\n try {\n setLoading(true);\n\n const res = await fetch(\"\/api\/login\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application\/json\" },\n credentials: \"include\",\n body: JSON.stringify({ username: u, password: pass }),\n });\n\n const text = await res.text();\n if (!res.ok) {\n let msg = \"Invalid credentials or auth error.\";\n try {\n const j = JSON.parse(text);\n msg = j.error_description || j.error || msg;\n } catch {}\n clearAuth(`${res.status} ${msg}`);\n return;\n }\n\n const data = JSON.parse(text);\n const accessToken = data.access_token;\n const refreshToken = data.refresh_token;\n\n if (!accessToken) {\n clearAuth(\"No access token in response.\");\n return;\n }\n\n persistAuth({ accessToken, refreshToken, user: u });\n setLastError(\"\");\n } catch (e) {\n console.error(\"[AUTH] network error\", e);\n clearAuth(\"Network error during login.\");\n } finally {\n setLoading(false);\n }\n }\n\n function logout(/sms
|
||||||
|
' "$FILE"
|
||||||
|
|
||||||
|
echo "DONE frontend patch."
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
APP_DIR="${APP_DIR:-sociowire-frontend}"
|
||||||
|
FILE="$APP_DIR/src/components/Layout/TopBar.jsx"
|
||||||
|
|
||||||
|
cp -a "$FILE" "$FILE.bak.$(date +%s)"
|
||||||
|
|
||||||
|
# Ajoute l'affichage lastError dans le form login (sous le bouton)
|
||||||
|
perl -0777 -i -pe '
|
||||||
|
s|(</button>\s*</form>)|</button>\n {lastError && <div className="auth-error" style={{marginTop:"0.6rem"}}>{lastError}</div>}\n </form>|ms
|
||||||
|
' "$FILE"
|
||||||
|
|
||||||
|
echo "OK patched: $FILE"
|
||||||
Loading…
Reference in New Issue