blehxx
This commit is contained in:
parent
2b3ee10ea7
commit
c8f916f602
|
|
@ -1,736 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Run from frontend root (where ./src exists)
|
|
||||||
if [[ ! -d "src" ]]; then
|
|
||||||
echo "ERROR: ./src not found. Run from frontend root."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TS="$(date +%Y%m%d-%H%M%S)"
|
|
||||||
BK=".patch_backups/$TS"
|
|
||||||
mkdir -p "$BK"
|
|
||||||
|
|
||||||
backup_file() {
|
|
||||||
local f="$1"
|
|
||||||
[[ -f "$f" ]] || return 0
|
|
||||||
mkdir -p "$BK/$(dirname "$f")"
|
|
||||||
cp -a "$f" "$BK/$f"
|
|
||||||
}
|
|
||||||
|
|
||||||
write_file() {
|
|
||||||
local f="$1"
|
|
||||||
shift
|
|
||||||
mkdir -p "$(dirname "$f")"
|
|
||||||
backup_file "$f"
|
|
||||||
cat > "$f" <<EOF
|
|
||||||
$*
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "== Frontend patch: verify-email UX =="
|
|
||||||
echo "Backup dir: $BK"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# 1) Add helper: src/auth/authErrors.js
|
|
||||||
# -------------------------
|
|
||||||
write_file "src/auth/authErrors.js" \
|
|
||||||
'export function parseAuthError(text = "", status = 0) {
|
|
||||||
// Keycloak token error example:
|
|
||||||
// {"error":"invalid_grant","error_description":"Account is not fully set up"}
|
|
||||||
const raw = String(text || "");
|
|
||||||
let err = "";
|
|
||||||
let desc = "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const j = JSON.parse(raw);
|
|
||||||
err = String(j.error || "");
|
|
||||||
desc = String(j.error_description || j.errorMessage || "");
|
|
||||||
} catch {
|
|
||||||
// raw is not JSON
|
|
||||||
}
|
|
||||||
|
|
||||||
const combined = (err + " " + desc + " " + raw).toLowerCase();
|
|
||||||
|
|
||||||
// Email verification / required action
|
|
||||||
if (
|
|
||||||
combined.includes("not fully set up") ||
|
|
||||||
combined.includes("verify") ||
|
|
||||||
combined.includes("verification") ||
|
|
||||||
combined.includes("required action") ||
|
|
||||||
(combined.includes("email") && combined.includes("verified"))
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
code: "email_verification_required",
|
|
||||||
message:
|
|
||||||
"Tu dois confirmer ton email avant de te connecter. Vérifie ta boîte mail (et le spam), puis réessaie.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bad credentials
|
|
||||||
if (
|
|
||||||
combined.includes("invalid user credentials") ||
|
|
||||||
(combined.includes("invalid_grant") && !combined.includes("not fully set up"))
|
|
||||||
) {
|
|
||||||
return { code: "invalid_credentials", message: "Mauvais username ou password." };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback
|
|
||||||
if (desc) return { code: "login_failed", message: desc };
|
|
||||||
if (err) return { code: "login_failed", message: err };
|
|
||||||
if (raw && raw.length < 220) return { code: "login_failed", message: raw };
|
|
||||||
|
|
||||||
return { code: "login_failed", message: status ? `Login failed (${status}).` : "Login failed." };
|
|
||||||
}'
|
|
||||||
|
|
||||||
echo "✅ Wrote: src/auth/authErrors.js"
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# 2) Replace: src/auth/AuthContext.jsx
|
|
||||||
# -------------------------
|
|
||||||
write_file "src/auth/AuthContext.jsx" \
|
|
||||||
'import React, { createContext, useContext, useEffect, useRef, useState } from "react";
|
|
||||||
import { parseAuthError } from "./authErrors";
|
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
|
||||||
|
|
||||||
// Backend API (same origin)
|
|
||||||
const API_BASE = "/api";
|
|
||||||
|
|
||||||
const TOKEN_KEY = "sociowire:access_token";
|
|
||||||
const REFRESH_KEY = "sociowire:refresh_token";
|
|
||||||
const USERNAME_KEY = "sociowire:username";
|
|
||||||
|
|
||||||
function decodeJwtPayload(token) {
|
|
||||||
try {
|
|
||||||
const parts = token.split(".");
|
|
||||||
if (parts.length < 2) return null;
|
|
||||||
const json = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"));
|
|
||||||
return JSON.parse(json);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function willExpireSoon(token, withinSeconds = 90) {
|
|
||||||
const p = decodeJwtPayload(token);
|
|
||||||
if (!p || !p.exp) return true;
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
return p.exp - now <= withinSeconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function backendRefresh(refreshToken) {
|
|
||||||
const res = await fetch(`${API_BASE}/refresh`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`${res.status} ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(text || "{}");
|
|
||||||
if (!data.access_token) throw new Error("No access_token in refresh response");
|
|
||||||
return {
|
|
||||||
accessToken: data.access_token,
|
|
||||||
refreshToken: data.refresh_token || refreshToken,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function backendWhoami(accessToken) {
|
|
||||||
const res = await fetch(`${API_BASE}/whoami`, {
|
|
||||||
method: "GET",
|
|
||||||
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
if (!res.ok) return { ok: false, status: res.status, text };
|
|
||||||
try {
|
|
||||||
const j = JSON.parse(text || "{}");
|
|
||||||
return { ok: true, status: res.status, json: j, text };
|
|
||||||
} catch {
|
|
||||||
return { ok: true, status: res.status, json: {}, text };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function backendResendVerify(payload) {
|
|
||||||
const res = await fetch(`${API_BASE}/resend-verify`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
return { ok: res.ok, status: res.status, text };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
|
||||||
const [booting, setBooting] = useState(true);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const [authenticated, setAuthenticated] = useState(false);
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [token, setToken] = useState(null);
|
|
||||||
|
|
||||||
const [lastError, setLastError] = useState("");
|
|
||||||
const [lastErrorCode, setLastErrorCode] = useState("");
|
|
||||||
|
|
||||||
const usernameRef = useRef("");
|
|
||||||
|
|
||||||
function persistAuth({ accessToken, refreshToken, user }) {
|
|
||||||
setToken(accessToken);
|
|
||||||
setAuthenticated(true);
|
|
||||||
setUsername(user || "");
|
|
||||||
usernameRef.current = user || "";
|
|
||||||
try {
|
|
||||||
localStorage.setItem(TOKEN_KEY, accessToken);
|
|
||||||
if (refreshToken) localStorage.setItem(REFRESH_KEY, refreshToken);
|
|
||||||
if (user) localStorage.setItem(USERNAME_KEY, user);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAuth(msg = "", code = "") {
|
|
||||||
setAuthenticated(false);
|
|
||||||
setToken(null);
|
|
||||||
setUsername("");
|
|
||||||
usernameRef.current = "";
|
|
||||||
setLastError(msg);
|
|
||||||
setLastErrorCode(code);
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
localStorage.removeItem(REFRESH_KEY);
|
|
||||||
localStorage.removeItem(USERNAME_KEY);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function trySilentRefreshIfNeeded() {
|
|
||||||
try {
|
|
||||||
const storedToken = localStorage.getItem(TOKEN_KEY) || "";
|
|
||||||
const storedRefresh = localStorage.getItem(REFRESH_KEY) || "";
|
|
||||||
const storedUser = localStorage.getItem(USERNAME_KEY) || "";
|
|
||||||
|
|
||||||
if (!storedToken) return false;
|
|
||||||
|
|
||||||
// If token still valid, confirm with whoami (prevents stale local token)
|
|
||||||
if (!willExpireSoon(storedToken, 30)) {
|
|
||||||
const me = await backendWhoami(storedToken);
|
|
||||||
if (me.ok && me.json?.authenticated) {
|
|
||||||
setToken(storedToken);
|
|
||||||
setUsername(me.json.username || storedUser);
|
|
||||||
usernameRef.current = me.json.username || storedUser;
|
|
||||||
setAuthenticated(true);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expired or rejected -> refresh
|
|
||||||
if (!storedRefresh) return false;
|
|
||||||
|
|
||||||
const { accessToken, refreshToken } = await backendRefresh(storedRefresh);
|
|
||||||
|
|
||||||
const me = await backendWhoami(accessToken);
|
|
||||||
const finalUser = (me.ok && me.json?.authenticated && (me.json.username || storedUser)) || storedUser;
|
|
||||||
|
|
||||||
persistAuth({ accessToken, refreshToken, user: finalUser });
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[AUTH] silent refresh failed:", e);
|
|
||||||
clearAuth("Session expired. Please login again.", "session_expired");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await trySilentRefreshIfNeeded();
|
|
||||||
} finally {
|
|
||||||
setBooting(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!authenticated) return;
|
|
||||||
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const t = localStorage.getItem(TOKEN_KEY) || "";
|
|
||||||
const rt = localStorage.getItem(REFRESH_KEY) || "";
|
|
||||||
if (!t || !rt) return;
|
|
||||||
|
|
||||||
if (!willExpireSoon(t, 90)) return;
|
|
||||||
|
|
||||||
const { accessToken, refreshToken } = await backendRefresh(rt);
|
|
||||||
const me = await backendWhoami(accessToken);
|
|
||||||
const u =
|
|
||||||
(me.ok && me.json?.authenticated && (me.json.username || "")) ||
|
|
||||||
localStorage.getItem(USERNAME_KEY) ||
|
|
||||||
usernameRef.current ||
|
|
||||||
"";
|
|
||||||
|
|
||||||
persistAuth({ accessToken, refreshToken, user: u });
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[AUTH] auto refresh failed:", e);
|
|
||||||
clearAuth("Session expired. Please login again.", "session_expired");
|
|
||||||
}
|
|
||||||
}, 60_000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [authenticated]);
|
|
||||||
|
|
||||||
async function login(user, pass) {
|
|
||||||
setLastError("");
|
|
||||||
setLastErrorCode("");
|
|
||||||
|
|
||||||
const u = (user || "").trim();
|
|
||||||
if (!u || !pass) {
|
|
||||||
setLastError("Username and password required.");
|
|
||||||
setLastErrorCode("missing_fields");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/login`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ username: u, password: pass }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
if (!res.ok) {
|
|
||||||
const nice = parseAuthError(text, res.status);
|
|
||||||
setLastError(nice.message);
|
|
||||||
setLastErrorCode(nice.code);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(text || "{}");
|
|
||||||
const accessToken = data.access_token;
|
|
||||||
const refreshToken = data.refresh_token;
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
setLastError("No access token in response.");
|
|
||||||
setLastErrorCode("token_missing");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// confirm + fetch real username
|
|
||||||
const me = await backendWhoami(accessToken);
|
|
||||||
const finalUser =
|
|
||||||
(me.ok && me.json?.authenticated && (me.json.username || "")) || u;
|
|
||||||
|
|
||||||
persistAuth({ accessToken, refreshToken, user: finalUser });
|
|
||||||
setLastError("");
|
|
||||||
setLastErrorCode("");
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[AUTH] network error", e);
|
|
||||||
clearAuth("Network error during login.", "network_error");
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resendVerifyEmail(usernameOrEmail) {
|
|
||||||
const v = String(usernameOrEmail || "").trim();
|
|
||||||
if (!v) {
|
|
||||||
setLastError("Entre ton username ou email, puis clique renvoyer.");
|
|
||||||
setLastErrorCode("missing_fields");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await backendResendVerify({ value: v });
|
|
||||||
if (!res.ok) {
|
|
||||||
setLastError(`Resend failed (${res.status})`);
|
|
||||||
setLastErrorCode("resend_failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setLastError("Email de confirmation renvoyé. Vérifie ta boîte mail (et le spam).");
|
|
||||||
setLastErrorCode("verify_sent");
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setLastError("Network error while resending email.");
|
|
||||||
setLastErrorCode("network_error");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
clearAuth("", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider
|
|
||||||
value={{
|
|
||||||
booting,
|
|
||||||
loading,
|
|
||||||
authenticated,
|
|
||||||
username,
|
|
||||||
token,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
lastError,
|
|
||||||
lastErrorCode,
|
|
||||||
needsEmailVerification: lastErrorCode === "email_verification_required",
|
|
||||||
resendVerifyEmail,
|
|
||||||
clearAuthError: () => {
|
|
||||||
setLastError("");
|
|
||||||
setLastErrorCode("");
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
const ctx = useContext(AuthContext);
|
|
||||||
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
'
|
|
||||||
|
|
||||||
echo "✅ Replaced: src/auth/AuthContext.jsx"
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
|
||||||
# 3) Patch: src/components/Layout/TopBar.jsx
|
|
||||||
# -------------------------
|
|
||||||
backup_file "src/components/Layout/TopBar.jsx"
|
|
||||||
|
|
||||||
# Replace file fully (safe, based on YOUR current content with minimal edits)
|
|
||||||
write_file "src/components/Layout/TopBar.jsx" \
|
|
||||||
'import React, { useState, useEffect } from "react";
|
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
|
||||||
import { registerUser } from "../../api/client";
|
|
||||||
|
|
||||||
const THEME_LABELS = {
|
|
||||||
dark: "Dark",
|
|
||||||
blue: "Blue",
|
|
||||||
light: "Light",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TopBar({ theme = "dark", onChangeTheme }) {
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
authenticated,
|
|
||||||
username,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
lastError,
|
|
||||||
lastErrorCode,
|
|
||||||
needsEmailVerification,
|
|
||||||
resendVerifyEmail,
|
|
||||||
clearAuthError,
|
|
||||||
} = useAuth();
|
|
||||||
|
|
||||||
const [showAuth, setShowAuth] = useState(false);
|
|
||||||
const [mode, setMode] = useState("login");
|
|
||||||
|
|
||||||
const [loginUser, setLoginUser] = useState("");
|
|
||||||
const [loginPass, setLoginPass] = useState("");
|
|
||||||
|
|
||||||
const [firstName, setFirstName] = useState("");
|
|
||||||
const [lastName, setLastName] = useState("");
|
|
||||||
const [regUser, setRegUser] = useState("");
|
|
||||||
const [regEmail, setRegEmail] = useState("");
|
|
||||||
const [regPass, setRegPass] = useState("");
|
|
||||||
const [signupError, setSignupError] = useState("");
|
|
||||||
const [signupInfo, setSignupInfo] = useState("");
|
|
||||||
const [signupLoading, setSignupLoading] = useState(false);
|
|
||||||
|
|
||||||
let authLabel = "AUTH: anon";
|
|
||||||
if (loading) authLabel = "AUTH: loading…";
|
|
||||||
else if (authenticated) authLabel = `AUTH: user=${username || "?"}`;
|
|
||||||
|
|
||||||
const openModal = (nextMode) => {
|
|
||||||
setMode(nextMode);
|
|
||||||
setShowAuth(true);
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
if (clearAuthError) clearAuthError();
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setShowAuth(false);
|
|
||||||
setLoginPass("");
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
if (clearAuthError) clearAuthError();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (authenticated) setShowAuth(false);
|
|
||||||
}, [authenticated]);
|
|
||||||
|
|
||||||
const handleLoginSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
await login(loginUser, loginPass);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSignupSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSignupError("");
|
|
||||||
setSignupInfo("");
|
|
||||||
|
|
||||||
if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) {
|
|
||||||
setSignupError("First name, last name, username, email and password are required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSignupLoading(true);
|
|
||||||
|
|
||||||
await registerUser({
|
|
||||||
username: regUser.trim(),
|
|
||||||
email: regEmail.trim(),
|
|
||||||
password: regPass,
|
|
||||||
firstName: firstName.trim(),
|
|
||||||
lastName: lastName.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// IMPORTANT: do NOT auto-login now, because user must verify email first
|
|
||||||
setSignupInfo("Compte créé ✅. Va confirmer ton email (check spam). Ensuite reviens te connecter.");
|
|
||||||
setMode("login");
|
|
||||||
setLoginUser(regUser.trim());
|
|
||||||
setLoginPass("");
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setSignupError(err.message || "Registration error.");
|
|
||||||
} finally {
|
|
||||||
setSignupLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resendFromLogin = async () => {
|
|
||||||
// prefer email if user typed it, else username
|
|
||||||
const v = (loginUser || "").trim() || (regEmail || "").trim();
|
|
||||||
await resendVerifyEmail(v);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<header className="topbar">
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
||||||
<div className="logo-circle">SW</div>
|
|
||||||
<div className="title-block">
|
|
||||||
<div className="main-title">SOCIOWIRE.com</div>
|
|
||||||
<div className="sub-title">Wired to life</div>
|
|
||||||
<div style={{ fontSize: "0.65rem", opacity: 0.7 }}>
|
|
||||||
{authLabel}
|
|
||||||
{lastError && (
|
|
||||||
<span style={{ color: "#f97373", marginLeft: "0.3rem" }}>
|
|
||||||
({lastError})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="topbar-right">
|
|
||||||
<div className="theme-switch">
|
|
||||||
{["dark", "blue", "light"].map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
type="button"
|
|
||||||
className={"theme-dot" + (theme === t ? " theme-dot-active" : "")}
|
|
||||||
title={THEME_LABELS[t]}
|
|
||||||
onClick={() => onChangeTheme && onChangeTheme(t)}
|
|
||||||
>
|
|
||||||
{THEME_LABELS[t][0]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{authenticated ? (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
|
|
||||||
<span style={{ fontSize: "0.8rem" }}>{username}</span>
|
|
||||||
<button className="btn-primary" onClick={logout}>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: "flex", gap: "0.3rem" }}>
|
|
||||||
<button type="button" className="btn-primary" onClick={() => openModal("login")}>
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn-secondary" onClick={() => openModal("signup")}>
|
|
||||||
Sign up
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{showAuth && (
|
|
||||||
<div className="auth-modal-backdrop" onClick={closeModal}>
|
|
||||||
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="auth-modal-header">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={"auth-tab" + (mode === "login" ? " auth-tab-active" : "")}
|
|
||||||
onClick={() => setMode("login")}
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={"auth-tab" + (mode === "signup" ? " auth-tab-active" : "")}
|
|
||||||
onClick={() => setMode("signup")}
|
|
||||||
>
|
|
||||||
Sign up
|
|
||||||
</button>
|
|
||||||
<button type="button" className="auth-close" onClick={closeModal}>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mode === "login" ? (
|
|
||||||
<form className="auth-form" onSubmit={handleLoginSubmit}>
|
|
||||||
<label>
|
|
||||||
Username
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={loginUser}
|
|
||||||
onChange={(e) => setLoginUser(e.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={loginPass}
|
|
||||||
onChange={(e) => setLoginPass(e.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={loading}>
|
|
||||||
{loading ? "..." : "Sign in"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{lastError && (
|
|
||||||
<div className="auth-error" style={{ marginTop: "0.6rem" }}>
|
|
||||||
{lastError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{needsEmailVerification && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: "0.6rem",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
background: "rgba(30, 64, 175, 0.25)",
|
|
||||||
border: "1px solid rgba(191, 219, 254, 0.5)",
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: "0.5rem 0.6rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontWeight: 800, marginBottom: 6 }}>
|
|
||||||
⚠️ Confirme ton email
|
|
||||||
</div>
|
|
||||||
<div style={{ opacity: 0.95 }}>
|
|
||||||
Ouvre ton email et clique le lien de confirmation (check spam).
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary"
|
|
||||||
style={{ marginTop: 10 }}
|
|
||||||
onClick={resendFromLogin}
|
|
||||||
disabled={loading}
|
|
||||||
title="Renvoyer l'email de confirmation"
|
|
||||||
>
|
|
||||||
Resend verification email
|
|
||||||
</button>
|
|
||||||
{lastErrorCode === "verify_sent" && (
|
|
||||||
<div style={{ marginTop: 8, opacity: 0.95 }}>
|
|
||||||
✅ Envoyé. Refresh ta boîte mail.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form className="auth-form" onSubmit={handleSignupSubmit}>
|
|
||||||
<div className="auth-row">
|
|
||||||
<label>
|
|
||||||
First name
|
|
||||||
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Last name
|
|
||||||
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
Username
|
|
||||||
<input type="text" value={regUser} onChange={(e) => setRegUser(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Email
|
|
||||||
<input type="email" value={regEmail} onChange={(e) => setRegEmail(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{signupError && <div className="auth-error">{signupError}</div>}
|
|
||||||
|
|
||||||
{signupInfo && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: "0.25rem",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
color: "#dbeafe",
|
|
||||||
background: "rgba(34, 197, 94, 0.16)",
|
|
||||||
border: "1px solid rgba(34, 197, 94, 0.35)",
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: "0.5rem 0.6rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{signupInfo}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
|
||||||
{signupLoading ? "..." : "Create account"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
'
|
|
||||||
|
|
||||||
echo "✅ Replaced: src/components/Layout/TopBar.jsx"
|
|
||||||
echo ""
|
|
||||||
echo "DONE. Now run:"
|
|
||||||
echo " chmod +x patch_frontend_verify_email.sh"
|
|
||||||
echo " ./patch_frontend_verify_email.sh"
|
|
||||||
echo ""
|
|
||||||
echo "Then:"
|
|
||||||
echo " npm run dev"
|
|
||||||
echo "And test: signup -> should NOT auto-login; it tells you to verify email."
|
|
||||||
echo "Login before verify -> friendly message + resend button."
|
|
||||||
echo ""
|
|
||||||
echo "Backups saved in: $BK"
|
|
||||||
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { getEmailVerifiedFromClaims } from "./emailVerified";
|
||||||
import { clearAuth, guessUsernameFromToken, jwtExpSeconds, loadAuth, saveAuth } from "./authStorage";
|
import { clearAuth, guessUsernameFromToken, jwtExpSeconds, loadAuth, saveAuth } from "./authStorage";
|
||||||
|
import { parseAuthError } from "./authErrors";
|
||||||
|
|
||||||
const AuthCtx = createContext(null);
|
const AuthCtx = createContext(null);
|
||||||
|
|
||||||
|
|
@ -7,6 +9,10 @@ function apiBase() {
|
||||||
return (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
return (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function keycloakBase() {
|
||||||
|
return (import.meta?.env?.VITE_KEYCLOAK_BASE_URL || "https://auth.sociowire.com").replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
async function apiFetch(path, opts = {}) {
|
async function apiFetch(path, opts = {}) {
|
||||||
const url = apiBase() + path;
|
const url = apiBase() + path;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
|
|
@ -20,7 +26,6 @@ async function apiFetch(path, opts = {}) {
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
// tolerant whoami: accepts {authenticated:true} OR {username:""} OR {user:""} OR just ok+token
|
|
||||||
async function whoami(accessToken) {
|
async function whoami(accessToken) {
|
||||||
const res = await apiFetch("/api/whoami", {
|
const res = await apiFetch("/api/whoami", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
|
@ -29,11 +34,7 @@ async function whoami(accessToken) {
|
||||||
|
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
let json = null;
|
let json = null;
|
||||||
try {
|
try { json = text ? JSON.parse(text) : null; } catch {}
|
||||||
json = text ? JSON.parse(text) : null;
|
|
||||||
} catch {
|
|
||||||
json = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uname =
|
const uname =
|
||||||
(json && (json.username || json.user || json.preferred_username)) ||
|
(json && (json.username || json.user || json.preferred_username)) ||
|
||||||
|
|
@ -55,12 +56,7 @@ async function refreshTokens(refreshToken) {
|
||||||
|
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
let json = null;
|
let json = null;
|
||||||
try {
|
try { json = text ? JSON.parse(text) : null; } catch {}
|
||||||
json = text ? JSON.parse(text) : null;
|
|
||||||
} catch {
|
|
||||||
json = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: res.ok, status: res.status, json, text };
|
return { ok: res.ok, status: res.status, json, text };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,16 +66,27 @@ function nowSec() {
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const [status, setStatus] = useState("loading"); // loading | anon | auth
|
const [status, setStatus] = useState("loading"); // loading | anon | auth
|
||||||
const [user, setUser] = useState(null); // { username }
|
const [user, setUser] = useState(null);
|
||||||
const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... }
|
const [tokens, setTokens] = useState(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [errorCode, setErrorCode] = useState("");
|
||||||
|
const [info, setInfo] = useState("");
|
||||||
const refreshTimer = useRef(null);
|
const refreshTimer = useRef(null);
|
||||||
|
|
||||||
function setAnon(msg = "") {
|
function clearErrorUI() {
|
||||||
|
setError("");
|
||||||
|
setErrorCode("");
|
||||||
|
}
|
||||||
|
function clearInfoUI() {
|
||||||
|
setInfo("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAnon(msg = "", code = "") {
|
||||||
setStatus("anon");
|
setStatus("anon");
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setTokens(null);
|
setTokens(null);
|
||||||
setError(msg || "");
|
setError(msg || "");
|
||||||
|
setErrorCode(code || "");
|
||||||
clearAuth();
|
clearAuth();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +96,7 @@ export function AuthProvider({ children }) {
|
||||||
const u = usernameGuess || guessUsernameFromToken(t?.access_token) || "";
|
const u = usernameGuess || guessUsernameFromToken(t?.access_token) || "";
|
||||||
setUser(u ? { username: u } : null);
|
setUser(u ? { username: u } : null);
|
||||||
setStatus("auth");
|
setStatus("auth");
|
||||||
setError("");
|
clearErrorUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureFreshSession(existing) {
|
async function ensureFreshSession(existing) {
|
||||||
|
|
@ -120,7 +127,7 @@ export function AuthProvider({ children }) {
|
||||||
if (w.ok && w.authenticated) {
|
if (w.ok && w.authenticated) {
|
||||||
setUser(w.username ? { username: w.username } : null);
|
setUser(w.username ? { username: w.username } : null);
|
||||||
setStatus("auth");
|
setStatus("auth");
|
||||||
setError("");
|
clearErrorUI();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,7 +166,7 @@ export function AuthProvider({ children }) {
|
||||||
|
|
||||||
async function restore() {
|
async function restore() {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
setError("");
|
clearErrorUI();
|
||||||
const saved = loadAuth();
|
const saved = loadAuth();
|
||||||
if (!saved?.access_token) {
|
if (!saved?.access_token) {
|
||||||
setAnon("");
|
setAnon("");
|
||||||
|
|
@ -176,7 +183,8 @@ export function AuthProvider({ children }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(username, password) {
|
async function login(username, password) {
|
||||||
setError("");
|
clearInfoUI();
|
||||||
|
clearErrorUI();
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
|
|
||||||
const res = await apiFetch("/api/login", {
|
const res = await apiFetch("/api/login", {
|
||||||
|
|
@ -186,14 +194,11 @@ export function AuthProvider({ children }) {
|
||||||
|
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
let j = null;
|
let j = null;
|
||||||
try {
|
try { j = text ? JSON.parse(text) : null; } catch {}
|
||||||
j = text ? JSON.parse(text) : null;
|
|
||||||
} catch {
|
|
||||||
j = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok || !j?.access_token) {
|
if (!res.ok || !j?.access_token) {
|
||||||
setAnon(j?.error_description || j?.error || text || "Login failed");
|
const parsed = parseAuthError(text || j?.error_description || j?.error || "", res.status);
|
||||||
|
setAnon(parsed.message, parsed.code);
|
||||||
return { ok: false, status: res.status, json: j, text };
|
return { ok: false, status: res.status, json: j, text };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,6 +214,7 @@ export function AuthProvider({ children }) {
|
||||||
|
|
||||||
setUser(w.username ? { username: w.username } : null);
|
setUser(w.username ? { username: w.username } : null);
|
||||||
setStatus("auth");
|
setStatus("auth");
|
||||||
|
clearErrorUI();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,6 +224,19 @@ export function AuthProvider({ children }) {
|
||||||
setAnon("");
|
setAnon("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ IMPORTANT: arriving on /auth/verified should CLEAR old errors (stale banner)
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const p = window.location.pathname || "";
|
||||||
|
if (p.startsWith("/auth/verified") || p.startsWith("/auth/verif")) {
|
||||||
|
clearErrorUI();
|
||||||
|
setInfo("✅ Email confirmé. Connecte-toi pour continuer.");
|
||||||
|
// optional: clean URL so refresh doesn't re-trigger forever
|
||||||
|
try { window.history.replaceState({}, "", "/"); } catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
restore();
|
restore();
|
||||||
const onStorage = (e) => {
|
const onStorage = (e) => {
|
||||||
|
|
@ -228,37 +247,44 @@ export function AuthProvider({ children }) {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Backward-compatible fields expected by TopBar/MapView
|
// Optional helper: open Keycloak account
|
||||||
|
function openKeycloak() {
|
||||||
|
try { window.open(keycloakBase(), "_blank", "noopener,noreferrer"); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const value = useMemo(() => {
|
const value = useMemo(() => {
|
||||||
const loading = status === "loading";
|
const loading = status === "loading";
|
||||||
const authenticated = status === "auth";
|
const authenticated = status === "auth";
|
||||||
const username = user?.username || "";
|
const username = user?.username || "";
|
||||||
const token = tokens?.access_token || "";
|
const token = tokens?.access_token || "";
|
||||||
|
|
||||||
return {
|
const needsEmailVerification = errorCode === "email_verification_required";
|
||||||
// internal
|
|
||||||
status,
|
return {
|
||||||
user,
|
status, user, tokens,
|
||||||
tokens,
|
|
||||||
error,
|
|
||||||
|
|
||||||
// expected by UI
|
|
||||||
loading,
|
loading,
|
||||||
authenticated,
|
authenticated,
|
||||||
username,
|
username,
|
||||||
token,
|
token,
|
||||||
|
|
||||||
lastError: error || "",
|
lastError: error || "",
|
||||||
lastInfo: "",
|
lastErrorCode: errorCode || "",
|
||||||
needsEmailVerification: false,
|
lastInfo: info || "",
|
||||||
resendVerificationEmail: async () => {},
|
|
||||||
|
clearError: clearErrorUI,
|
||||||
|
clearInfo: clearInfoUI,
|
||||||
|
setInfo,
|
||||||
|
setError, // keep for compatibility
|
||||||
|
|
||||||
|
needsEmailVerification,
|
||||||
|
openKeycloak,
|
||||||
|
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
restore,
|
restore,
|
||||||
setError,
|
|
||||||
};
|
};
|
||||||
}, [status, user, tokens, error]);
|
}, [status, user, tokens, error, errorCode, info]);
|
||||||
|
|
||||||
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
||||||
}
|
}
|
||||||
|
|
@ -266,3 +292,13 @@ export function AuthProvider({ children }) {
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
return useContext(AuthCtx);
|
return useContext(AuthCtx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SW_AUTH_DEBUG_EMAIL */
|
||||||
|
try {
|
||||||
|
// Helps debug: look for email_verified in token
|
||||||
|
// Toggle by setting: localStorage.SW_AUTH_DEBUG_EMAIL="1"
|
||||||
|
if (typeof window !== "undefined" && localStorage.getItem("SW_AUTH_DEBUG_EMAIL") === "1") {
|
||||||
|
console.log("[SW AUTH] tokenParsed:", tokenParsed);
|
||||||
|
console.log("[SW AUTH] email_verified:", tokenParsed?.email_verified, "emailVerified:", tokenParsed?.emailVerified);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,268 @@
|
||||||
|
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { clearAuth, guessUsernameFromToken, jwtExpSeconds, loadAuth, saveAuth } from "./authStorage";
|
||||||
|
|
||||||
|
const AuthCtx = createContext(null);
|
||||||
|
|
||||||
|
function apiBase() {
|
||||||
|
return (import.meta?.env?.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path, opts = {}) {
|
||||||
|
const url = apiBase() + path;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
credentials: "include",
|
||||||
|
...opts,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(opts.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tolerant whoami: accepts {authenticated:true} OR {username:""} OR {user:""} OR just ok+token
|
||||||
|
async function whoami(accessToken) {
|
||||||
|
const res = await apiFetch("/api/whoami", {
|
||||||
|
method: "GET",
|
||||||
|
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
let json = null;
|
||||||
|
try {
|
||||||
|
json = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
json = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uname =
|
||||||
|
(json && (json.username || json.user || json.preferred_username)) ||
|
||||||
|
guessUsernameFromToken(accessToken) ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
const authed =
|
||||||
|
!!res.ok &&
|
||||||
|
(json?.authenticated === true || json?.ok === true || !!json?.username || !!json?.user || !!uname);
|
||||||
|
|
||||||
|
return { ok: !!res.ok, status: res.status, json, text, authenticated: authed, username: uname };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTokens(refreshToken) {
|
||||||
|
const res = await apiFetch("/api/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
let json = null;
|
||||||
|
try {
|
||||||
|
json = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
json = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: res.ok, status: res.status, json, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowSec() {
|
||||||
|
return Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [status, setStatus] = useState("loading"); // loading | anon | auth
|
||||||
|
const [user, setUser] = useState(null); // { username }
|
||||||
|
const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... }
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const refreshTimer = useRef(null);
|
||||||
|
|
||||||
|
function setAnon(msg = "") {
|
||||||
|
setStatus("anon");
|
||||||
|
setUser(null);
|
||||||
|
setTokens(null);
|
||||||
|
setError(msg || "");
|
||||||
|
clearAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAuthFromTokens(t, usernameGuess = "") {
|
||||||
|
setTokens(t);
|
||||||
|
saveAuth({ ...t, obtained_at: Date.now() });
|
||||||
|
const u = usernameGuess || guessUsernameFromToken(t?.access_token) || "";
|
||||||
|
setUser(u ? { username: u } : null);
|
||||||
|
setStatus("auth");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureFreshSession(existing) {
|
||||||
|
if (!existing?.access_token) return { ok: false, reason: "no_access_token" };
|
||||||
|
|
||||||
|
const exp = jwtExpSeconds(existing.access_token);
|
||||||
|
const skew = 60;
|
||||||
|
const isExpiredOrSoon = !exp || exp <= nowSec() + skew;
|
||||||
|
|
||||||
|
if (isExpiredOrSoon) {
|
||||||
|
if (!existing.refresh_token) return { ok: false, reason: "expired_no_refresh" };
|
||||||
|
|
||||||
|
const rr = await refreshTokens(existing.refresh_token);
|
||||||
|
if (!rr.ok || !rr.json?.access_token) {
|
||||||
|
return { ok: false, reason: `refresh_failed_${rr.status}`, detail: rr.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = { ...existing, ...rr.json };
|
||||||
|
if (!merged.refresh_token) merged.refresh_token = existing.refresh_token;
|
||||||
|
|
||||||
|
setAuthFromTokens(merged);
|
||||||
|
existing = merged;
|
||||||
|
} else {
|
||||||
|
setAuthFromTokens(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = await whoami(existing.access_token);
|
||||||
|
if (w.ok && w.authenticated) {
|
||||||
|
setUser(w.username ? { username: w.username } : null);
|
||||||
|
setStatus("auth");
|
||||||
|
setError("");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, reason: `whoami_${w.status}`, detail: w.text || "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAutoRefresh() {
|
||||||
|
if (refreshTimer.current) {
|
||||||
|
clearInterval(refreshTimer.current);
|
||||||
|
refreshTimer.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshTimer.current = setInterval(async () => {
|
||||||
|
const cur = loadAuth();
|
||||||
|
if (!cur?.access_token) return;
|
||||||
|
|
||||||
|
const exp = jwtExpSeconds(cur.access_token);
|
||||||
|
if (!exp) return;
|
||||||
|
|
||||||
|
if (exp <= nowSec() + 90) {
|
||||||
|
if (!cur.refresh_token) {
|
||||||
|
setAnon("Session expirée (pas de refresh_token).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rr = await refreshTokens(cur.refresh_token);
|
||||||
|
if (!rr.ok || !rr.json?.access_token) {
|
||||||
|
setAnon("Session expirée. Please login again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const merged = { ...cur, ...rr.json };
|
||||||
|
if (!merged.refresh_token) merged.refresh_token = cur.refresh_token;
|
||||||
|
setAuthFromTokens(merged);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restore() {
|
||||||
|
setStatus("loading");
|
||||||
|
setError("");
|
||||||
|
const saved = loadAuth();
|
||||||
|
if (!saved?.access_token) {
|
||||||
|
setAnon("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await ensureFreshSession(saved);
|
||||||
|
if (!r.ok) {
|
||||||
|
setAnon("Login desynced. Please login again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleAutoRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
setError("");
|
||||||
|
setStatus("loading");
|
||||||
|
|
||||||
|
const res = await apiFetch("/api/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
let j = null;
|
||||||
|
try {
|
||||||
|
j = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
j = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok || !j?.access_token) {
|
||||||
|
setAnon(j?.error_description || j?.error || text || "Login failed");
|
||||||
|
return { ok: false, status: res.status, json: j, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = { ...j, obtained_at: Date.now() };
|
||||||
|
setAuthFromTokens(t);
|
||||||
|
scheduleAutoRefresh();
|
||||||
|
|
||||||
|
const w = await whoami(t.access_token);
|
||||||
|
if (!w.ok || !w.authenticated) {
|
||||||
|
setAnon("Login desynced. Please login again.");
|
||||||
|
return { ok: false, status: w.status, json: w.json, text: w.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(w.username ? { username: w.username } : null);
|
||||||
|
setStatus("auth");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
if (refreshTimer.current) clearInterval(refreshTimer.current);
|
||||||
|
refreshTimer.current = null;
|
||||||
|
setAnon("");
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restore();
|
||||||
|
const onStorage = (e) => {
|
||||||
|
if (e.key === "sociowire:auth") restore();
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
return () => window.removeEventListener("storage", onStorage);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Backward-compatible fields expected by TopBar/MapView
|
||||||
|
const value = useMemo(() => {
|
||||||
|
const loading = status === "loading";
|
||||||
|
const authenticated = status === "auth";
|
||||||
|
const username = user?.username || "";
|
||||||
|
const token = tokens?.access_token || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
// internal
|
||||||
|
status,
|
||||||
|
user,
|
||||||
|
tokens,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// expected by UI
|
||||||
|
loading,
|
||||||
|
authenticated,
|
||||||
|
username,
|
||||||
|
token,
|
||||||
|
|
||||||
|
lastError: error || "",
|
||||||
|
lastInfo: "",
|
||||||
|
needsEmailVerification: false,
|
||||||
|
resendVerificationEmail: async () => {},
|
||||||
|
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
restore,
|
||||||
|
setError,
|
||||||
|
};
|
||||||
|
}, [status, user, tokens, error]);
|
||||||
|
|
||||||
|
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthCtx);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export function getEmailVerifiedFromClaims(claims) {
|
||||||
|
if (!claims) return false;
|
||||||
|
// Keycloak/OIDC standard:
|
||||||
|
if (typeof claims.email_verified === "boolean") return claims.email_verified;
|
||||||
|
// Some apps mistakenly use camelCase:
|
||||||
|
if (typeof claims.emailVerified === "boolean") return claims.emailVerified;
|
||||||
|
// Rare: string "true"/"false"
|
||||||
|
if (typeof claims.email_verified === "string") return claims.email_verified === "true";
|
||||||
|
if (typeof claims.emailVerified === "string") return claims.emailVerified === "true";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function AuthToast({
|
||||||
|
type = "info",
|
||||||
|
title = "",
|
||||||
|
message = "",
|
||||||
|
actionLabel = "",
|
||||||
|
onAction,
|
||||||
|
onClose,
|
||||||
|
}) {
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
const icon =
|
||||||
|
type === "success" ? "fa-solid fa-circle-check" :
|
||||||
|
type === "error" ? "fa-solid fa-triangle-exclamation" :
|
||||||
|
type === "warn" ? "fa-solid fa-circle-exclamation" :
|
||||||
|
"fa-solid fa-circle-info";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={"sw-toast sw-toast-" + type} role="status">
|
||||||
|
<div className="sw-toast-icon">
|
||||||
|
<i className={icon} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sw-toast-body">
|
||||||
|
{title ? <div className="sw-toast-title">{title}</div> : null}
|
||||||
|
<div className="sw-toast-msg">{message}</div>
|
||||||
|
|
||||||
|
{actionLabel && typeof onAction === "function" ? (
|
||||||
|
<div className="sw-toast-actions">
|
||||||
|
<button className="sw-toast-btn" type="button" onClick={onAction}>
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="sw-toast-x" type="button" onClick={onClose} aria-label="Close">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useAuth } from "../../auth/AuthContext";
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
import { registerUser } from "../../api/client";
|
import { registerUser } from "../../api/client";
|
||||||
|
import AuthToast from "../Auth/AuthToast";
|
||||||
|
|
||||||
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
|
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
|
||||||
|
|
||||||
|
function initialLetter(name = "") {
|
||||||
|
const s = (name || "").trim();
|
||||||
|
return s ? s[0].toUpperCase() : "G";
|
||||||
|
}
|
||||||
|
|
||||||
export default function TopBar({ theme = "dark", onChangeTheme }) {
|
export default function TopBar({ theme = "dark", onChangeTheme }) {
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -12,9 +18,12 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
needsEmailVerification,
|
needsEmailVerification,
|
||||||
resendVerificationEmail,
|
openKeycloak,
|
||||||
lastError,
|
lastError,
|
||||||
|
lastErrorCode,
|
||||||
lastInfo,
|
lastInfo,
|
||||||
|
clearError,
|
||||||
|
clearInfo,
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
|
|
||||||
const [showAuth, setShowAuth] = useState(false);
|
const [showAuth, setShowAuth] = useState(false);
|
||||||
|
|
@ -32,10 +41,6 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
||||||
const [signupInfo, setSignupInfo] = useState("");
|
const [signupInfo, setSignupInfo] = useState("");
|
||||||
const [signupLoading, setSignupLoading] = useState(false);
|
const [signupLoading, setSignupLoading] = useState(false);
|
||||||
|
|
||||||
let authLabel = "AUTH: anon";
|
|
||||||
if (loading) authLabel = "AUTH: loading…";
|
|
||||||
else if (authenticated) authLabel = `AUTH: user=${username || "?"}`;
|
|
||||||
|
|
||||||
const openModal = (nextMode) => {
|
const openModal = (nextMode) => {
|
||||||
setMode(nextMode);
|
setMode(nextMode);
|
||||||
setShowAuth(true);
|
setShowAuth(true);
|
||||||
|
|
@ -80,7 +85,6 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
||||||
lastName: lastName.trim(),
|
lastName: lastName.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// IMPORTANT: si Verify Email est activé côté Keycloak, on ne tente PAS de login auto.
|
|
||||||
setSignupInfo("Account created. Check your inbox (spam) to verify your email, then login.");
|
setSignupInfo("Account created. Check your inbox (spam) to verify your email, then login.");
|
||||||
setMode("login");
|
setMode("login");
|
||||||
setLoginUser(regUser.trim());
|
setLoginUser(regUser.trim());
|
||||||
|
|
@ -93,57 +97,48 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toast = useMemo(() => {
|
||||||
|
if (lastError) {
|
||||||
|
const t = needsEmailVerification ? "warn" : "error";
|
||||||
|
const title = needsEmailVerification ? "Vérification email requise" : "Connexion impossible";
|
||||||
|
return { type: t, title, message: lastError };
|
||||||
|
}
|
||||||
|
if (lastInfo) {
|
||||||
|
return { type: "success", title: "", message: lastInfo };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [lastError, lastInfo, needsEmailVerification]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Toast area (under topbar) */}
|
||||||
|
{(toast?.message) && (
|
||||||
|
<div className="sw-toast-wrap">
|
||||||
|
<AuthToast
|
||||||
|
type={toast.type}
|
||||||
|
title={toast.title}
|
||||||
|
message={toast.message}
|
||||||
|
onClose={() => {
|
||||||
|
if (lastError) clearError();
|
||||||
|
if (lastInfo) clearInfo();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{needsEmailVerification && (
|
||||||
|
<div style={{ maxWidth: 980, margin: "10px auto 0", display: "flex", gap: 10, justifyContent: "center" }}>
|
||||||
|
<button className="btn-secondary" type="button" onClick={openKeycloak}>
|
||||||
|
Ouvrir Keycloak (Proceed)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.65rem" }}>
|
||||||
<div className="logo-circle">SW</div>
|
<div className="logo-circle">SW</div>
|
||||||
<div className="title-block">
|
<div className="title-block">
|
||||||
<div className="main-title">SOCIOWIRE.com</div>
|
<div className="main-title">SOCIOWIRE.com</div>
|
||||||
<div className="sub-title">Wired to life</div>
|
<div className="sub-title">Wired to life</div>
|
||||||
|
|
||||||
<div style={{ fontSize: "0.65rem", opacity: 0.8 }}>
|
|
||||||
{authLabel}
|
|
||||||
{lastError && (
|
|
||||||
<span style={{ color: "#f97373", marginLeft: "0.35rem" }}>
|
|
||||||
({lastError})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{authenticated && needsEmailVerification && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 6,
|
|
||||||
fontSize: "0.70rem",
|
|
||||||
background: "rgba(250,204,21,0.14)",
|
|
||||||
border: "1px solid rgba(250,204,21,0.55)",
|
|
||||||
padding: "6px 8px",
|
|
||||||
borderRadius: 12,
|
|
||||||
display: "flex",
|
|
||||||
gap: 8,
|
|
||||||
alignItems: "center",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontWeight: 800 }}>Verify email required.</span>
|
|
||||||
<span style={{ opacity: 0.9 }}>Posting is locked until verification.</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary"
|
|
||||||
onClick={resendVerificationEmail}
|
|
||||||
style={{ padding: "0.30rem 0.70rem" }}
|
|
||||||
>
|
|
||||||
Resend email
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!needsEmailVerification && lastInfo && (
|
|
||||||
<div style={{ marginTop: 6, fontSize: "0.70rem", opacity: 0.85 }}>
|
|
||||||
{lastInfo}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -163,14 +158,32 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{authenticated ? (
|
{authenticated ? (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.45rem" }}>
|
||||||
<span style={{ fontSize: "0.8rem" }}>{username}</span>
|
<div className="sw-userchip" title={username}>
|
||||||
|
<div className="sw-avatar">
|
||||||
|
<span>{initialLetter(username)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="sw-usertext">
|
||||||
|
<div className="sw-userline">Connecté</div>
|
||||||
|
<div className="sw-userhandle">@{username || "user"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button className="btn-primary" onClick={logout}>
|
<button className="btn-primary" onClick={logout}>
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: "flex", gap: "0.3rem" }}>
|
<div style={{ display: "flex", gap: "0.35rem", alignItems: "center" }}>
|
||||||
|
<div className="sw-userchip" title="Guest">
|
||||||
|
<div className="sw-avatar">
|
||||||
|
<i className="fa-solid fa-user" />
|
||||||
|
</div>
|
||||||
|
<div className="sw-usertext">
|
||||||
|
<div className="sw-userline">{loading ? "Loading…" : "Guest"}</div>
|
||||||
|
<div className="sw-userhandle">anon</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="button" className="btn-primary" onClick={() => openModal("login")}>
|
<button type="button" className="btn-primary" onClick={() => openModal("login")}>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -228,7 +241,6 @@ 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}>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
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,
|
||||||
|
needsEmailVerification,
|
||||||
|
resendVerificationEmail,
|
||||||
|
lastError,
|
||||||
|
lastInfo,
|
||||||
|
} = useAuth();
|
||||||
|
|
||||||
|
const [showAuth, setShowAuth] = useState(false);
|
||||||
|
const [mode, setMode] = useState("login");
|
||||||
|
|
||||||
|
const [loginUser, setLoginUser] = useState("");
|
||||||
|
const [loginPass, setLoginPass] = useState("");
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [regUser, setRegUser] = useState("");
|
||||||
|
const [regEmail, setRegEmail] = useState("");
|
||||||
|
const [regPass, setRegPass] = useState("");
|
||||||
|
const [signupError, setSignupError] = useState("");
|
||||||
|
const [signupInfo, setSignupInfo] = useState("");
|
||||||
|
const [signupLoading, setSignupLoading] = useState(false);
|
||||||
|
|
||||||
|
let authLabel = "AUTH: anon";
|
||||||
|
if (loading) authLabel = "AUTH: loading…";
|
||||||
|
else if (authenticated) authLabel = `AUTH: user=${username || "?"}`;
|
||||||
|
|
||||||
|
const openModal = (nextMode) => {
|
||||||
|
setMode(nextMode);
|
||||||
|
setShowAuth(true);
|
||||||
|
setSignupError("");
|
||||||
|
setSignupInfo("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setShowAuth(false);
|
||||||
|
setLoginPass("");
|
||||||
|
setSignupError("");
|
||||||
|
setSignupInfo("");
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authenticated) setShowAuth(false);
|
||||||
|
}, [authenticated]);
|
||||||
|
|
||||||
|
const handleLoginSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await login(loginUser, loginPass);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignupSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSignupError("");
|
||||||
|
setSignupInfo("");
|
||||||
|
|
||||||
|
if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) {
|
||||||
|
setSignupError("First name, last name, username, email and password are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSignupLoading(true);
|
||||||
|
|
||||||
|
await registerUser({
|
||||||
|
username: regUser.trim(),
|
||||||
|
email: regEmail.trim(),
|
||||||
|
password: regPass,
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// IMPORTANT: si Verify Email est activé côté Keycloak, on ne tente PAS de login auto.
|
||||||
|
setSignupInfo("Account created. Check your inbox (spam) to verify your email, then login.");
|
||||||
|
setMode("login");
|
||||||
|
setLoginUser(regUser.trim());
|
||||||
|
setLoginPass("");
|
||||||
|
} 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.8 }}>
|
||||||
|
{authLabel}
|
||||||
|
{lastError && (
|
||||||
|
<span style={{ color: "#f97373", marginLeft: "0.35rem" }}>
|
||||||
|
({lastError})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authenticated && needsEmailVerification && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: "0.70rem",
|
||||||
|
background: "rgba(250,204,21,0.14)",
|
||||||
|
border: "1px solid rgba(250,204,21,0.55)",
|
||||||
|
padding: "6px 8px",
|
||||||
|
borderRadius: 12,
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 800 }}>Verify email required.</span>
|
||||||
|
<span style={{ opacity: 0.9 }}>Posting is locked until verification.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={resendVerificationEmail}
|
||||||
|
style={{ padding: "0.30rem 0.70rem" }}
|
||||||
|
>
|
||||||
|
Resend email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!needsEmailVerification && lastInfo && (
|
||||||
|
<div style={{ marginTop: 6, fontSize: "0.70rem", opacity: 0.85 }}>
|
||||||
|
{lastInfo}
|
||||||
|
</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>}
|
||||||
|
{signupInfo && (
|
||||||
|
<div className="auth-error" style={{ background: "rgba(34,197,94,0.14)", borderColor: "rgba(34,197,94,0.55)", color: "#d1fae5" }}>
|
||||||
|
{signupInfo}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
||||||
|
{signupLoading ? "..." : "Create account"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,390 @@
|
||||||
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import { registerUser } from "../../api/client";
|
||||||
|
|
||||||
|
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
|
||||||
|
const LAST_SIGNUP_USER_KEY = "sociowire:lastSignupUser";
|
||||||
|
|
||||||
|
function normalizeAuthMessage(raw) {
|
||||||
|
const msg = String(raw || "").trim();
|
||||||
|
if (!msg) return "";
|
||||||
|
|
||||||
|
const lower = msg.toLowerCase();
|
||||||
|
|
||||||
|
// Keycloak classic when email verification required
|
||||||
|
if (
|
||||||
|
lower.includes("not fully set up") ||
|
||||||
|
lower.includes("verify") ||
|
||||||
|
lower.includes("verification") ||
|
||||||
|
lower.includes("required action") ||
|
||||||
|
(lower.includes("email") && lower.includes("verified"))
|
||||||
|
) {
|
||||||
|
return "Tu dois confirmer ton email avant de te connecter. Vérifie ta boîte mail (et le spam), puis réessaie.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad credentials
|
||||||
|
if (
|
||||||
|
lower.includes("invalid user credentials") ||
|
||||||
|
lower.includes("bad credentials") ||
|
||||||
|
(lower.includes("invalid_grant") && !lower.includes("not fully set up"))
|
||||||
|
) {
|
||||||
|
return "Mauvais username ou mot de passe.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback (shorten very long raw blobs)
|
||||||
|
if (msg.length > 240) return msg.slice(0, 240) + "…";
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TopBar({ theme = "dark", onChangeTheme }) {
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
authenticated,
|
||||||
|
username,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
needsEmailVerification,
|
||||||
|
resendVerificationEmail,
|
||||||
|
lastError,
|
||||||
|
lastInfo,
|
||||||
|
} = useAuth();
|
||||||
|
|
||||||
|
const [showAuth, setShowAuth] = useState(false);
|
||||||
|
const [mode, setMode] = useState("login");
|
||||||
|
|
||||||
|
const [loginUser, setLoginUser] = useState("");
|
||||||
|
const [loginPass, setLoginPass] = useState("");
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [regUser, setRegUser] = useState("");
|
||||||
|
const [regEmail, setRegEmail] = useState("");
|
||||||
|
const [regPass, setRegPass] = useState("");
|
||||||
|
const [signupError, setSignupError] = useState("");
|
||||||
|
const [signupInfo, setSignupInfo] = useState("");
|
||||||
|
const [signupLoading, setSignupLoading] = useState(false);
|
||||||
|
|
||||||
|
const isVerifiedPage =
|
||||||
|
typeof window !== "undefined" && window.location?.pathname === "/auth/verified";
|
||||||
|
|
||||||
|
const prettyError = useMemo(() => normalizeAuthMessage(lastError), [lastError]);
|
||||||
|
|
||||||
|
const openModal = (nextMode) => {
|
||||||
|
setMode(nextMode);
|
||||||
|
setShowAuth(true);
|
||||||
|
setSignupError("");
|
||||||
|
setSignupInfo("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setShowAuth(false);
|
||||||
|
setLoginPass("");
|
||||||
|
setSignupError("");
|
||||||
|
setSignupInfo("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// close modal automatically on auth
|
||||||
|
useEffect(() => {
|
||||||
|
if (authenticated) setShowAuth(false);
|
||||||
|
}, [authenticated]);
|
||||||
|
|
||||||
|
// ✅ If user lands on /auth/verified (after clicking email link),
|
||||||
|
// open login modal + prefill username from last signup.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVerifiedPage) return;
|
||||||
|
|
||||||
|
setMode("login");
|
||||||
|
setShowAuth(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lastU = localStorage.getItem(LAST_SIGNUP_USER_KEY) || "";
|
||||||
|
if (lastU && !loginUser) setLoginUser(lastU);
|
||||||
|
} catch {}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isVerifiedPage]);
|
||||||
|
|
||||||
|
// ✅ After successful login, if still on /auth/verified, cleanly go back home
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authenticated) return;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const path = window.location?.pathname || "";
|
||||||
|
if (path === "/auth/verified") {
|
||||||
|
try {
|
||||||
|
window.history.replaceState({}, "", "/");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, [authenticated]);
|
||||||
|
|
||||||
|
const handleLoginSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await login(loginUser, loginPass);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignupSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSignupError("");
|
||||||
|
setSignupInfo("");
|
||||||
|
|
||||||
|
if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) {
|
||||||
|
setSignupError("First name, last name, username, email and password are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSignupLoading(true);
|
||||||
|
|
||||||
|
await registerUser({
|
||||||
|
username: regUser.trim(),
|
||||||
|
email: regEmail.trim(),
|
||||||
|
password: regPass,
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store last signup username so /auth/verified can prefill login.
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LAST_SIGNUP_USER_KEY, regUser.trim());
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// IMPORTANT: si Verify Email est activé côté Keycloak, on ne tente PAS de login auto.
|
||||||
|
setSignupInfo("Compte créé ✅ Vérifie ton email (spam inclus), puis reviens te connecter.");
|
||||||
|
setMode("login");
|
||||||
|
setLoginUser(regUser.trim());
|
||||||
|
setLoginPass("");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setSignupError(err.message || "Registration error.");
|
||||||
|
} finally {
|
||||||
|
setSignupLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthPill = () => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<span className="auth-pill auth-pill--loading">
|
||||||
|
<i className="fa-solid fa-spinner fa-spin" />
|
||||||
|
Loading…
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (authenticated) {
|
||||||
|
return (
|
||||||
|
<span className="auth-pill auth-pill--ok" title="Connecté">
|
||||||
|
<i className="fa-solid fa-circle-check" />
|
||||||
|
Connecté
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="auth-pill auth-pill--anon" title="Invité">
|
||||||
|
<i className="fa-regular fa-user" />
|
||||||
|
Guest
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* ✅ Clean auth row */}
|
||||||
|
<div className="auth-strip">
|
||||||
|
<AuthPill />
|
||||||
|
{authenticated && username && (
|
||||||
|
<span className="auth-pill" title={username}>
|
||||||
|
<i className="fa-solid fa-at" />
|
||||||
|
{username}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ✅ Verified page hint */}
|
||||||
|
{isVerifiedPage && !authenticated && (
|
||||||
|
<div className="auth-banner auth-banner--success">
|
||||||
|
<span style={{ fontWeight: 900 }}>✅ Email confirmé.</span>
|
||||||
|
<span style={{ opacity: 0.92 }}>Connecte-toi pour continuer.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email verification banner (if you later wire it from backend) */}
|
||||||
|
{authenticated && needsEmailVerification && (
|
||||||
|
<div className="auth-banner auth-banner--info">
|
||||||
|
<span style={{ fontWeight: 900 }}>
|
||||||
|
<i className="fa-solid fa-triangle-exclamation" style={{ marginRight: 6 }} />
|
||||||
|
Verify email required.
|
||||||
|
</span>
|
||||||
|
<span style={{ opacity: 0.9 }}>Posting is locked until verification.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={resendVerificationEmail}
|
||||||
|
style={{ padding: "0.30rem 0.70rem" }}
|
||||||
|
>
|
||||||
|
Resend email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ✅ Nice error banner */}
|
||||||
|
{prettyError && (
|
||||||
|
<div className="auth-banner auth-banner--error">
|
||||||
|
<i className="fa-solid fa-circle-xmark" />
|
||||||
|
<span>{prettyError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Optional lastInfo from AuthContext */}
|
||||||
|
{!needsEmailVerification && lastInfo && (
|
||||||
|
<div className="auth-banner auth-banner--info">
|
||||||
|
<i className="fa-solid fa-circle-info" />
|
||||||
|
<span>{lastInfo}</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.45rem" }}>
|
||||||
|
<div className="user-chip" title={username || ""}>
|
||||||
|
<span className="user-avatar">
|
||||||
|
<i className="fa-solid fa-user-astronaut" />
|
||||||
|
</span>
|
||||||
|
<span className="user-name">{username || "user"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>}
|
||||||
|
{signupInfo && (
|
||||||
|
<div
|
||||||
|
className="auth-error"
|
||||||
|
style={{
|
||||||
|
background: "rgba(34,197,94,0.14)",
|
||||||
|
borderColor: "rgba(34,197,94,0.55)",
|
||||||
|
color: "#d1fae5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{signupInfo}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
||||||
|
{signupLoading ? "..." : "Create account"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,379 @@
|
||||||
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import { registerUser } from "../../api/client";
|
||||||
|
|
||||||
|
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
|
||||||
|
const LAST_SIGNUP_USER_KEY = "sociowire:lastSignupUser";
|
||||||
|
|
||||||
|
const KC_BASE =
|
||||||
|
(import.meta?.env?.VITE_KEYCLOAK_BASE_URL) || "https://auth.sociowire.com";
|
||||||
|
const KC_REALM =
|
||||||
|
(import.meta?.env?.VITE_KEYCLOAK_REALM) || "sociowire";
|
||||||
|
const KC_CLIENT =
|
||||||
|
(import.meta?.env?.VITE_KEYCLOAK_CLIENT_ID) || "sociowire";
|
||||||
|
|
||||||
|
function isEmailVerifyError(raw) {
|
||||||
|
const msg = String(raw || "").toLowerCase();
|
||||||
|
return (
|
||||||
|
msg.includes("not fully set up") ||
|
||||||
|
msg.includes("verify") ||
|
||||||
|
msg.includes("verification") ||
|
||||||
|
msg.includes("required action") ||
|
||||||
|
(msg.includes("email") && msg.includes("verified"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAuthMessage(raw) {
|
||||||
|
const msg = String(raw || "").trim();
|
||||||
|
if (!msg) return "";
|
||||||
|
|
||||||
|
const lower = msg.toLowerCase();
|
||||||
|
|
||||||
|
if (isEmailVerifyError(lower)) {
|
||||||
|
return "Ton compte est créé, mais l’email n’est pas confirmé. Clique “Vérifier mon email” (Keycloak), puis utilise le lien reçu par email.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lower.includes("invalid user credentials") ||
|
||||||
|
lower.includes("bad credentials") ||
|
||||||
|
lower.includes("invalid_grant")
|
||||||
|
) {
|
||||||
|
return "Mauvais username ou mot de passe.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.length > 220) return msg.slice(0, 220) + "…";
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildKeycloakAuthUrl() {
|
||||||
|
const redirectUri = `${window.location.origin}/auth/verified`;
|
||||||
|
const url =
|
||||||
|
`${KC_BASE}/realms/${encodeURIComponent(KC_REALM)}` +
|
||||||
|
`/protocol/openid-connect/auth` +
|
||||||
|
`?client_id=${encodeURIComponent(KC_CLIENT)}` +
|
||||||
|
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||||
|
`&response_type=code&scope=openid`;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TopBar({ theme = "dark", onChangeTheme }) {
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
authenticated,
|
||||||
|
username,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
resendVerificationEmail,
|
||||||
|
lastError,
|
||||||
|
lastInfo,
|
||||||
|
} = useAuth();
|
||||||
|
|
||||||
|
const [showAuth, setShowAuth] = useState(false);
|
||||||
|
const [mode, setMode] = useState("login");
|
||||||
|
|
||||||
|
const [loginUser, setLoginUser] = useState("");
|
||||||
|
const [loginPass, setLoginPass] = useState("");
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [regUser, setRegUser] = useState("");
|
||||||
|
const [regEmail, setRegEmail] = useState("");
|
||||||
|
const [regPass, setRegPass] = useState("");
|
||||||
|
const [signupError, setSignupError] = useState("");
|
||||||
|
const [signupInfo, setSignupInfo] = useState("");
|
||||||
|
const [signupLoading, setSignupLoading] = useState(false);
|
||||||
|
|
||||||
|
const prettyError = useMemo(() => normalizeAuthMessage(lastError), [lastError]);
|
||||||
|
const showVerifyHelp = useMemo(() => isEmailVerifyError(lastError), [lastError]);
|
||||||
|
|
||||||
|
const isVerifiedPage =
|
||||||
|
typeof window !== "undefined" && window.location?.pathname === "/auth/verified";
|
||||||
|
|
||||||
|
const openModal = (nextMode) => {
|
||||||
|
setMode(nextMode);
|
||||||
|
setShowAuth(true);
|
||||||
|
setSignupError("");
|
||||||
|
setSignupInfo("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setShowAuth(false);
|
||||||
|
setLoginPass("");
|
||||||
|
setSignupError("");
|
||||||
|
setSignupInfo("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ If error happens, open modal so errors are not in topbar
|
||||||
|
useEffect(() => {
|
||||||
|
if (prettyError) {
|
||||||
|
setMode("login");
|
||||||
|
setShowAuth(true);
|
||||||
|
}
|
||||||
|
}, [prettyError]);
|
||||||
|
|
||||||
|
// ✅ On /auth/verified: open login + prefill username
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVerifiedPage) return;
|
||||||
|
setMode("login");
|
||||||
|
setShowAuth(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lastU = localStorage.getItem(LAST_SIGNUP_USER_KEY) || "";
|
||||||
|
if (lastU && !loginUser) setLoginUser(lastU);
|
||||||
|
} catch {}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isVerifiedPage]);
|
||||||
|
|
||||||
|
// ✅ After login on /auth/verified, go home
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authenticated) return;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const path = window.location?.pathname || "";
|
||||||
|
if (path === "/auth/verified") {
|
||||||
|
try { window.history.replaceState({}, "", "/"); } catch {}
|
||||||
|
}
|
||||||
|
}, [authenticated]);
|
||||||
|
|
||||||
|
const handleLoginSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await login(loginUser, loginPass);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignupSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSignupError("");
|
||||||
|
setSignupInfo("");
|
||||||
|
|
||||||
|
if (!firstName.trim() || !lastName.trim() || !regUser.trim() || !regEmail.trim() || !regPass) {
|
||||||
|
setSignupError("First name, last name, username, email and password are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSignupLoading(true);
|
||||||
|
|
||||||
|
await registerUser({
|
||||||
|
username: regUser.trim(),
|
||||||
|
email: regEmail.trim(),
|
||||||
|
password: regPass,
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try { localStorage.setItem(LAST_SIGNUP_USER_KEY, regUser.trim()); } catch {}
|
||||||
|
|
||||||
|
// ✅ Do NOT auto-login (verify email flow)
|
||||||
|
setSignupInfo("Compte créé ✅ Vérifie ton email (spam inclus), puis connecte-toi.");
|
||||||
|
setMode("login");
|
||||||
|
setLoginUser(regUser.trim());
|
||||||
|
setLoginPass("");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setSignupError(err.message || "Registration error.");
|
||||||
|
} finally {
|
||||||
|
setSignupLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const NoticeBox = ({ variant, children }) => (
|
||||||
|
<div className={"sw-notice sw-notice--" + variant}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="topbar">
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
|
||||||
|
<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>
|
||||||
|
</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.45rem" }}>
|
||||||
|
{/* ✅ one single user chip */}
|
||||||
|
<div className="user-chip" title={username || ""}>
|
||||||
|
<span className="user-avatar">
|
||||||
|
<i className="fa-solid fa-user-astronaut" />
|
||||||
|
</span>
|
||||||
|
<span className="user-name">{username || "user"}</span>
|
||||||
|
<span className="user-badge">
|
||||||
|
<i className="fa-solid fa-circle-check" />
|
||||||
|
Connecté
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* ✅ Verified page success message */}
|
||||||
|
{isVerifiedPage && !authenticated && (
|
||||||
|
<NoticeBox variant="success">
|
||||||
|
<b>✅ Email confirmé.</b> Connecte-toi pour continuer.
|
||||||
|
</NoticeBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ✅ Any error/info now lives INSIDE modal */}
|
||||||
|
{prettyError && (
|
||||||
|
<NoticeBox variant="error">
|
||||||
|
<i className="fa-solid fa-circle-xmark" style={{ marginRight: 8 }} />
|
||||||
|
{prettyError}
|
||||||
|
</NoticeBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!prettyError && lastInfo && (
|
||||||
|
<NoticeBox variant="info">
|
||||||
|
<i className="fa-solid fa-circle-info" style={{ marginRight: 8 }} />
|
||||||
|
{lastInfo}
|
||||||
|
</NoticeBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ✅ Email verify helper buttons */}
|
||||||
|
{showVerifyHelp && (
|
||||||
|
<NoticeBox variant="warn">
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
<div style={{ fontWeight: 900 }}>
|
||||||
|
<i className="fa-solid fa-triangle-exclamation" style={{ marginRight: 8 }} />
|
||||||
|
Vérification email requise
|
||||||
|
</div>
|
||||||
|
<div style={{ opacity: 0.92, lineHeight: 1.25 }}>
|
||||||
|
Ton login “modal” ne peut pas compléter l’action Keycloak. Ouvre la page Keycloak,
|
||||||
|
clique “proceed”, puis clique le lien reçu par email.
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
|
||||||
|
<a className="btn-secondary" href={buildKeycloakAuthUrl()}>
|
||||||
|
Vérifier mon email (Keycloak)
|
||||||
|
</a>
|
||||||
|
{typeof resendVerificationEmail === "function" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={resendVerificationEmail}
|
||||||
|
>
|
||||||
|
Renvoyer l’email
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NoticeBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 && <NoticeBox variant="error">{signupError}</NoticeBox>}
|
||||||
|
{signupInfo && <NoticeBox variant="success">{signupInfo}</NoticeBox>}
|
||||||
|
|
||||||
|
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
|
||||||
|
{signupLoading ? "..." : "Create account"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import React from "react";
|
||||||
|
import { decodeJwt, readAccessToken } from "../../lib/jwt";
|
||||||
|
|
||||||
|
export default function AuthPill() {
|
||||||
|
const token = readAccessToken();
|
||||||
|
const claims = decodeJwt(token);
|
||||||
|
const username = claims?.preferred_username || claims?.name || "Guest";
|
||||||
|
const emailVerified =
|
||||||
|
typeof claims?.email_verified === "boolean" ? claims.email_verified :
|
||||||
|
(typeof claims?.emailVerified === "boolean" ? claims.emailVerified : null);
|
||||||
|
|
||||||
|
const initial = (username?.[0] || "G").toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 34, height: 34, borderRadius: 999,
|
||||||
|
background: "rgba(255,255,255,0.12)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.18)",
|
||||||
|
display: "grid", placeItems: "center",
|
||||||
|
fontWeight: 900
|
||||||
|
}}>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", lineHeight: 1.05 }}>
|
||||||
|
<div style={{ fontWeight: 900, fontSize: 14 }}>
|
||||||
|
{username}
|
||||||
|
{emailVerified === true ? <span style={{ marginLeft: 8, fontSize: 12, opacity: 0.85 }}>✅</span> : null}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.75 }}>
|
||||||
|
{token ? "Connecté" : "Invité"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function toast({ title, message, kind = "info", ms = 4500 } = {}) {
|
||||||
|
window.dispatchEvent(new CustomEvent("sw:toast", { detail: { title, message, kind, ms } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToastHost() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onToast = (e) => {
|
||||||
|
const id = Math.random().toString(16).slice(2);
|
||||||
|
const it = { id, ...e.detail };
|
||||||
|
setItems((p) => [it, ...p].slice(0, 3));
|
||||||
|
setTimeout(() => setItems((p) => p.filter((x) => x.id !== id)), it.ms || 4500);
|
||||||
|
};
|
||||||
|
window.addEventListener("sw:toast", onToast);
|
||||||
|
return () => window.removeEventListener("sw:toast", onToast);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!items.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 14,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
zIndex: 999999,
|
||||||
|
width: "min(560px, calc(100vw - 24px))",
|
||||||
|
display: "grid",
|
||||||
|
gap: 10
|
||||||
|
}}>
|
||||||
|
{items.map((t) => (
|
||||||
|
<div key={t.id} style={{
|
||||||
|
backdropFilter: "blur(14px)",
|
||||||
|
background: "rgba(10, 20, 40, 0.75)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.12)",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: "12px 14px",
|
||||||
|
boxShadow: "0 10px 30px rgba(0,0,0,0.35)",
|
||||||
|
color: "white"
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center" }}>
|
||||||
|
<div style={{ fontWeight: 800, letterSpacing: 0.2 }}>
|
||||||
|
{t.title || (t.kind === "error" ? "Erreur" : "Info")}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setItems((p) => p.filter((x) => x.id !== t.id))}
|
||||||
|
style={{
|
||||||
|
border: "0",
|
||||||
|
background: "rgba(255,255,255,0.10)",
|
||||||
|
color: "white",
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 10,
|
||||||
|
cursor: "pointer"
|
||||||
|
}}>✕</button>
|
||||||
|
</div>
|
||||||
|
{t.message ? <div style={{ marginTop: 6, opacity: 0.9, lineHeight: 1.25 }}>{t.message}</div> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
export function decodeJwt(token) {
|
||||||
|
try {
|
||||||
|
if (!token || token.split(".").length < 2) return null;
|
||||||
|
const payload = token.split(".")[1];
|
||||||
|
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
|
||||||
|
return JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAccessToken() {
|
||||||
|
return (
|
||||||
|
localStorage.getItem("kc_token") ||
|
||||||
|
localStorage.getItem("access_token") ||
|
||||||
|
localStorage.getItem("token") ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import "./styles/overlays.css";
|
||||||
import "./styles/posts.css";
|
import "./styles/posts.css";
|
||||||
import "./styles/theme.css";
|
import "./styles/theme.css";
|
||||||
import "./styles/auth-modal.css";
|
import "./styles/auth-modal.css";
|
||||||
|
import "./styles/auth-toast.css";
|
||||||
import "./styles/filters.css";
|
import "./styles/filters.css";
|
||||||
import "./styles/mapMarkers.css";
|
import "./styles/mapMarkers.css";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import "@fortawesome/fontawesome-free/css/all.min.css"; // local via npm (no CDN)
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App.jsx";
|
||||||
|
|
||||||
|
import "./styles/base.css";
|
||||||
|
import "./styles/layout.css";
|
||||||
|
import "./styles/topbar.css";
|
||||||
|
import "./styles/map.css";
|
||||||
|
import "./styles/overlays.css";
|
||||||
|
import "./styles/posts.css";
|
||||||
|
import "./styles/theme.css";
|
||||||
|
import "./styles/auth-modal.css";
|
||||||
|
import "./styles/filters.css";
|
||||||
|
import "./styles/mapMarkers.css";
|
||||||
|
|
||||||
|
import { AuthProvider } from "./auth/AuthContext.jsx";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
:root{
|
||||||
|
--sw-topbar-h: 64px;
|
||||||
|
--sw-safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-toast-wrap{
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: calc(var(--sw-safe-top) + var(--sw-topbar-h) + 8px);
|
||||||
|
z-index: 300; /* under modal, above map */
|
||||||
|
padding: 10px 12px 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-toast{
|
||||||
|
pointer-events: auto;
|
||||||
|
display:flex;
|
||||||
|
gap:10px;
|
||||||
|
align-items:flex-start;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(148,163,184,.45);
|
||||||
|
box-shadow: 0 12px 34px rgba(0,0,0,.45);
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-toast-icon{
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
flex-shrink:0;
|
||||||
|
background: rgba(15,23,42,.55);
|
||||||
|
border: 1px solid rgba(148,163,184,.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-toast-body{ flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.sw-toast-title{
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-toast-msg{
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: .95;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-toast-actions{
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-toast-btn{
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid rgba(148,163,184,.40);
|
||||||
|
background: rgba(15,23,42,.35);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-toast-x{
|
||||||
|
margin-left:auto;
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(148,163,184,.35);
|
||||||
|
background: rgba(15,23,42,.35);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-toast-success{ background: rgba(20, 80, 45, 0.35); border-color: rgba(34,197,94,.45); }
|
||||||
|
.sw-toast-error{ background: rgba(90, 25, 25, 0.35); border-color: rgba(248,113,113,.55); }
|
||||||
|
.sw-toast-warn{ background: rgba(100, 80, 20, 0.28); border-color: rgba(250,204,21,.55); }
|
||||||
|
.sw-toast-info{ background: rgba(15, 23, 42, 0.35); }
|
||||||
|
|
||||||
|
body[data-theme="light"] .sw-toast{
|
||||||
|
background: rgba(255,255,255,.92);
|
||||||
|
color: #0B1220;
|
||||||
|
border-color: rgba(148,163,184,.55);
|
||||||
|
box-shadow: 0 12px 24px rgba(0,0,0,.12);
|
||||||
|
}
|
||||||
|
body[data-theme="light"] .sw-toast-icon{ background: rgba(255,255,255,.95); }
|
||||||
|
body[data-theme="light"] .sw-toast-x{ background: rgba(255,255,255,.95); }
|
||||||
|
|
@ -59,3 +59,193 @@
|
||||||
.sub-title{ font-size:.7rem; }
|
.sub-title{ font-size:.7rem; }
|
||||||
.topbar-right{ width:100%; justify-content:flex-start; gap:.45rem; }
|
.topbar-right{ width:100%; justify-content:flex-start; gap:.45rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SW_AUTH_UI:BEGIN */
|
||||||
|
.auth-strip{
|
||||||
|
margin-top: 6px;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
gap: .45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-pill{
|
||||||
|
display:inline-flex;
|
||||||
|
align-items:center;
|
||||||
|
gap: .35rem;
|
||||||
|
padding: .18rem .55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .70rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
border: 1px solid rgba(148,163,184,.55);
|
||||||
|
background: rgba(2,6,23,.25);
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
.auth-pill i{ font-size: .85rem; opacity:.95; }
|
||||||
|
|
||||||
|
.auth-pill--ok{
|
||||||
|
border-color: rgba(34,197,94,.65);
|
||||||
|
background: rgba(34,197,94,.16);
|
||||||
|
}
|
||||||
|
.auth-pill--loading{
|
||||||
|
border-color: rgba(56,189,248,.65);
|
||||||
|
background: rgba(56,189,248,.14);
|
||||||
|
}
|
||||||
|
.auth-pill--anon{
|
||||||
|
border-color: rgba(148,163,184,.55);
|
||||||
|
background: rgba(2,6,23,.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-banner{
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
display:flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items:center;
|
||||||
|
flex-wrap:wrap;
|
||||||
|
}
|
||||||
|
.auth-banner--error{
|
||||||
|
background: rgba(248,113,113,0.14);
|
||||||
|
border: 1px solid rgba(248,113,113,0.55);
|
||||||
|
color: #ffe4e6;
|
||||||
|
}
|
||||||
|
.auth-banner--info{
|
||||||
|
background: rgba(56,189,248,0.14);
|
||||||
|
border: 1px solid rgba(56,189,248,0.55);
|
||||||
|
color: #e0f2fe;
|
||||||
|
}
|
||||||
|
.auth-banner--success{
|
||||||
|
background: rgba(34,197,94,0.14);
|
||||||
|
border: 1px solid rgba(34,197,94,0.55);
|
||||||
|
color: #d1fae5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip{
|
||||||
|
display:inline-flex;
|
||||||
|
align-items:center;
|
||||||
|
gap: .45rem;
|
||||||
|
padding: .22rem .55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(2,6,23,.25);
|
||||||
|
border: 1px solid rgba(148,163,184,.55);
|
||||||
|
}
|
||||||
|
.user-avatar{
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:center;
|
||||||
|
background: rgba(56,189,248,0.18);
|
||||||
|
border: 1px solid rgba(56,189,248,0.35);
|
||||||
|
}
|
||||||
|
.user-avatar i{ font-size: 14px; }
|
||||||
|
|
||||||
|
.user-name{
|
||||||
|
font-size: .80rem;
|
||||||
|
font-weight: 950;
|
||||||
|
color: #f9fafb;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow:hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme readability */
|
||||||
|
body[data-theme="light"] .auth-pill{
|
||||||
|
background: rgba(255,255,255,0.82);
|
||||||
|
border-color: rgba(148,163,184,0.85);
|
||||||
|
color: #0b1220;
|
||||||
|
}
|
||||||
|
body[data-theme="light"] .user-chip{
|
||||||
|
background: rgba(255,255,255,0.82);
|
||||||
|
border-color: rgba(148,163,184,0.85);
|
||||||
|
}
|
||||||
|
body[data-theme="light"] .user-name{ color:#0b1220; }
|
||||||
|
body[data-theme="light"] .user-avatar{
|
||||||
|
background: rgba(30,107,255,0.12);
|
||||||
|
border-color: rgba(30,107,255,0.35);
|
||||||
|
color:#0b1220;
|
||||||
|
}
|
||||||
|
/* SW_AUTH_UI:END */
|
||||||
|
|
||||||
|
/* SW_AUTH_UI2:BEGIN */
|
||||||
|
.sw-notice{
|
||||||
|
margin: 10px 14px 0 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
border: 1px solid rgba(148,163,184,.35);
|
||||||
|
background: rgba(2,6,23,.22);
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
.sw-notice--error{
|
||||||
|
background: rgba(248,113,113,0.14);
|
||||||
|
border-color: rgba(248,113,113,0.55);
|
||||||
|
color: #ffe4e6;
|
||||||
|
}
|
||||||
|
.sw-notice--info{
|
||||||
|
background: rgba(56,189,248,0.14);
|
||||||
|
border-color: rgba(56,189,248,0.55);
|
||||||
|
color: #e0f2fe;
|
||||||
|
}
|
||||||
|
.sw-notice--success{
|
||||||
|
background: rgba(34,197,94,0.14);
|
||||||
|
border-color: rgba(34,197,94,0.55);
|
||||||
|
color: #d1fae5;
|
||||||
|
}
|
||||||
|
.sw-notice--warn{
|
||||||
|
background: rgba(251,191,36,0.12);
|
||||||
|
border-color: rgba(251,191,36,0.40);
|
||||||
|
color: #fffbeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* single user chip (no duplicates) */
|
||||||
|
.user-chip{
|
||||||
|
display:inline-flex;
|
||||||
|
align-items:center;
|
||||||
|
gap: .45rem;
|
||||||
|
padding: .22rem .55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(2,6,23,.25);
|
||||||
|
border: 1px solid rgba(148,163,184,.55);
|
||||||
|
}
|
||||||
|
.user-avatar{
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:center;
|
||||||
|
background: rgba(56,189,248,0.18);
|
||||||
|
border: 1px solid rgba(56,189,248,0.35);
|
||||||
|
}
|
||||||
|
.user-avatar i{ font-size: 14px; }
|
||||||
|
.user-name{
|
||||||
|
font-size: .82rem;
|
||||||
|
font-weight: 950;
|
||||||
|
color: #f9fafb;
|
||||||
|
max-width: 180px;
|
||||||
|
overflow:hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.user-badge{
|
||||||
|
display:inline-flex;
|
||||||
|
align-items:center;
|
||||||
|
gap: .35rem;
|
||||||
|
padding: .10rem .45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .70rem;
|
||||||
|
font-weight: 950;
|
||||||
|
border: 1px solid rgba(34,197,94,.55);
|
||||||
|
background: rgba(34,197,94,.14);
|
||||||
|
color: #d1fae5;
|
||||||
|
}
|
||||||
|
/* SW_AUTH_UI2:END */
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
.topbar{
|
||||||
|
position: sticky; top:0; z-index:100;
|
||||||
|
width:100%;
|
||||||
|
padding: .55rem .9rem;
|
||||||
|
display:flex; align-items:center; justify-content:space-between; gap:.6rem;
|
||||||
|
background: linear-gradient(135deg,#071225 0%,#0b2b5f 38%,#0e65c0 72%,#27a8ff 100%);
|
||||||
|
border-bottom-left-radius:18px; border-bottom-right-radius:18px;
|
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,.55), 0 0 18px rgba(56,189,248,.45);
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-circle{
|
||||||
|
width:40px; height:40px; border-radius:50%; flex-shrink:0;
|
||||||
|
background-image:url("/icons/logo-master.png");
|
||||||
|
background-size:cover; background-position:center; background-repeat:no-repeat;
|
||||||
|
box-shadow: 0 0 10px rgba(37,99,235,.8), 0 0 18px rgba(56,189,248,.6);
|
||||||
|
color:transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-block{ display:flex; flex-direction:column; line-height:1.05; min-width:0; }
|
||||||
|
.main-title{ font-size:1.02rem; font-weight:800; letter-spacing:.04em; color:#f9fafb; }
|
||||||
|
.sub-title{ font-size:.74rem; color:#dbeafe; opacity:.95; }
|
||||||
|
|
||||||
|
.topbar-right{ display:flex; align-items:center; gap:.5rem; margin-left:auto; flex-wrap:wrap; }
|
||||||
|
|
||||||
|
.theme-switch{ display:flex; gap:.28rem; }
|
||||||
|
.theme-dot{
|
||||||
|
width:22px; height:22px; border-radius:999px;
|
||||||
|
border:1px solid rgba(148,163,184,.85);
|
||||||
|
background:rgba(15,23,42,.9); color:#e5e7eb; font-size:.65rem;
|
||||||
|
display:flex; align-items:center; justify-content:center; padding:0; cursor:pointer;
|
||||||
|
box-shadow:0 0 6px rgba(15,23,42,.9); transition:transform 0.36s ease;
|
||||||
|
}
|
||||||
|
.theme-dot:hover{ transform:scale(1.06); }
|
||||||
|
.theme-dot-active{ border-color:#facc15; box-shadow:0 0 10px rgba(250,204,21,.9),0 0 14px rgba(56,189,248,.55); }
|
||||||
|
|
||||||
|
.btn-primary, .btn-secondary{
|
||||||
|
padding:.32rem .85rem; font-size:.78rem; border-radius:999px; font-weight:700; cursor:pointer;
|
||||||
|
white-space:nowrap; line-height:1;
|
||||||
|
}
|
||||||
|
.btn-primary{
|
||||||
|
border:1px solid #22c55e;
|
||||||
|
background:radial-gradient(circle at 30% 30%,#22c55e,#16a34a);
|
||||||
|
color:#f9fafb;
|
||||||
|
box-shadow:0 4px 10px rgba(22,163,74,.5),0 0 8px rgba(22,163,74,.6);
|
||||||
|
}
|
||||||
|
.btn-primary:disabled{ opacity:.6; cursor:default; }
|
||||||
|
.btn-secondary{
|
||||||
|
border:1px solid #bfdbfe;
|
||||||
|
background:rgba(15,23,42,.25);
|
||||||
|
color:#e0f2fe;
|
||||||
|
box-shadow:0 3px 10px rgba(15,23,42,.6);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover{ background:rgba(15,23,42,.45); }
|
||||||
|
|
||||||
|
@media (max-width:640px){
|
||||||
|
.topbar{ padding:.5rem .7rem; border-bottom-left-radius:16px; border-bottom-right-radius:16px; }
|
||||||
|
.main-title{ font-size:1rem; }
|
||||||
|
.sub-title{ font-size:.7rem; }
|
||||||
|
.topbar-right{ width:100%; justify-content:flex-start; gap:.45rem; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
.topbar{
|
||||||
|
position: sticky; top:0; z-index:100;
|
||||||
|
width:100%;
|
||||||
|
padding: .55rem .9rem;
|
||||||
|
display:flex; align-items:center; justify-content:space-between; gap:.6rem;
|
||||||
|
background: linear-gradient(135deg,#071225 0%,#0b2b5f 38%,#0e65c0 72%,#27a8ff 100%);
|
||||||
|
border-bottom-left-radius:18px; border-bottom-right-radius:18px;
|
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,.55), 0 0 18px rgba(56,189,248,.45);
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-circle{
|
||||||
|
width:40px; height:40px; border-radius:50%; flex-shrink:0;
|
||||||
|
background-image:url("/icons/logo-master.png");
|
||||||
|
background-size:cover; background-position:center; background-repeat:no-repeat;
|
||||||
|
box-shadow: 0 0 10px rgba(37,99,235,.8), 0 0 18px rgba(56,189,248,.6);
|
||||||
|
color:transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-block{ display:flex; flex-direction:column; line-height:1.05; min-width:0; }
|
||||||
|
.main-title{ font-size:1.02rem; font-weight:800; letter-spacing:.04em; color:#f9fafb; }
|
||||||
|
.sub-title{ font-size:.74rem; color:#dbeafe; opacity:.95; }
|
||||||
|
|
||||||
|
.topbar-right{ display:flex; align-items:center; gap:.5rem; margin-left:auto; flex-wrap:wrap; }
|
||||||
|
|
||||||
|
.theme-switch{ display:flex; gap:.28rem; }
|
||||||
|
.theme-dot{
|
||||||
|
width:22px; height:22px; border-radius:999px;
|
||||||
|
border:1px solid rgba(148,163,184,.85);
|
||||||
|
background:rgba(15,23,42,.9); color:#e5e7eb; font-size:.65rem;
|
||||||
|
display:flex; align-items:center; justify-content:center; padding:0; cursor:pointer;
|
||||||
|
box-shadow:0 0 6px rgba(15,23,42,.9); transition:transform 0.36s ease;
|
||||||
|
}
|
||||||
|
.theme-dot:hover{ transform:scale(1.06); }
|
||||||
|
.theme-dot-active{ border-color:#facc15; box-shadow:0 0 10px rgba(250,204,21,.9),0 0 14px rgba(56,189,248,.55); }
|
||||||
|
|
||||||
|
.btn-primary, .btn-secondary{
|
||||||
|
padding:.32rem .85rem; font-size:.78rem; border-radius:999px; font-weight:700; cursor:pointer;
|
||||||
|
white-space:nowrap; line-height:1;
|
||||||
|
}
|
||||||
|
.btn-primary{
|
||||||
|
border:1px solid #22c55e;
|
||||||
|
background:radial-gradient(circle at 30% 30%,#22c55e,#16a34a);
|
||||||
|
color:#f9fafb;
|
||||||
|
box-shadow:0 4px 10px rgba(22,163,74,.5),0 0 8px rgba(22,163,74,.6);
|
||||||
|
}
|
||||||
|
.btn-primary:disabled{ opacity:.6; cursor:default; }
|
||||||
|
.btn-secondary{
|
||||||
|
border:1px solid #bfdbfe;
|
||||||
|
background:rgba(15,23,42,.25);
|
||||||
|
color:#e0f2fe;
|
||||||
|
box-shadow:0 3px 10px rgba(15,23,42,.6);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover{ background:rgba(15,23,42,.45); }
|
||||||
|
|
||||||
|
@media (max-width:640px){
|
||||||
|
.topbar{ padding:.5rem .7rem; border-bottom-left-radius:16px; border-bottom-right-radius:16px; }
|
||||||
|
.main-title{ font-size:1rem; }
|
||||||
|
.sub-title{ font-size:.7rem; }
|
||||||
|
.topbar-right{ width:100%; justify-content:flex-start; gap:.45rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SW_AUTH_UI:BEGIN */
|
||||||
|
.auth-strip{
|
||||||
|
margin-top: 6px;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
gap: .45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-pill{
|
||||||
|
display:inline-flex;
|
||||||
|
align-items:center;
|
||||||
|
gap: .35rem;
|
||||||
|
padding: .18rem .55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .70rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
border: 1px solid rgba(148,163,184,.55);
|
||||||
|
background: rgba(2,6,23,.25);
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
.auth-pill i{ font-size: .85rem; opacity:.95; }
|
||||||
|
|
||||||
|
.auth-pill--ok{
|
||||||
|
border-color: rgba(34,197,94,.65);
|
||||||
|
background: rgba(34,197,94,.16);
|
||||||
|
}
|
||||||
|
.auth-pill--loading{
|
||||||
|
border-color: rgba(56,189,248,.65);
|
||||||
|
background: rgba(56,189,248,.14);
|
||||||
|
}
|
||||||
|
.auth-pill--anon{
|
||||||
|
border-color: rgba(148,163,184,.55);
|
||||||
|
background: rgba(2,6,23,.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-banner{
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
display:flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items:center;
|
||||||
|
flex-wrap:wrap;
|
||||||
|
}
|
||||||
|
.auth-banner--error{
|
||||||
|
background: rgba(248,113,113,0.14);
|
||||||
|
border: 1px solid rgba(248,113,113,0.55);
|
||||||
|
color: #ffe4e6;
|
||||||
|
}
|
||||||
|
.auth-banner--info{
|
||||||
|
background: rgba(56,189,248,0.14);
|
||||||
|
border: 1px solid rgba(56,189,248,0.55);
|
||||||
|
color: #e0f2fe;
|
||||||
|
}
|
||||||
|
.auth-banner--success{
|
||||||
|
background: rgba(34,197,94,0.14);
|
||||||
|
border: 1px solid rgba(34,197,94,0.55);
|
||||||
|
color: #d1fae5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip{
|
||||||
|
display:inline-flex;
|
||||||
|
align-items:center;
|
||||||
|
gap: .45rem;
|
||||||
|
padding: .22rem .55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(2,6,23,.25);
|
||||||
|
border: 1px solid rgba(148,163,184,.55);
|
||||||
|
}
|
||||||
|
.user-avatar{
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:center;
|
||||||
|
background: rgba(56,189,248,0.18);
|
||||||
|
border: 1px solid rgba(56,189,248,0.35);
|
||||||
|
}
|
||||||
|
.user-avatar i{ font-size: 14px; }
|
||||||
|
|
||||||
|
.user-name{
|
||||||
|
font-size: .80rem;
|
||||||
|
font-weight: 950;
|
||||||
|
color: #f9fafb;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow:hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme readability */
|
||||||
|
body[data-theme="light"] .auth-pill{
|
||||||
|
background: rgba(255,255,255,0.82);
|
||||||
|
border-color: rgba(148,163,184,0.85);
|
||||||
|
color: #0b1220;
|
||||||
|
}
|
||||||
|
body[data-theme="light"] .user-chip{
|
||||||
|
background: rgba(255,255,255,0.82);
|
||||||
|
border-color: rgba(148,163,184,0.85);
|
||||||
|
}
|
||||||
|
body[data-theme="light"] .user-name{ color:#0b1220; }
|
||||||
|
body[data-theme="light"] .user-avatar{
|
||||||
|
background: rgba(30,107,255,0.12);
|
||||||
|
border-color: rgba(30,107,255,0.35);
|
||||||
|
color:#0b1220;
|
||||||
|
}
|
||||||
|
/* SW_AUTH_UI:END */
|
||||||
Loading…
Reference in New Issue