This commit is contained in:
Your Name 2025-12-20 03:23:36 -05:00
parent 2157222e17
commit 3e34dbde20
9 changed files with 1801 additions and 95 deletions

View File

@ -181,7 +181,7 @@ export function AuthProvider({ children }) {
async function refreshProfile(accessTokenOverride) { async function refreshProfile(accessTokenOverride) {
const at = accessTokenOverride || tokens?.access_token; const at = accessTokenOverride || tokens?.access_token;
if (!at) return { ok: False }; if (!at) return { ok: false };
// /api/me is the truth; token claim is fallback only // /api/me is the truth; token claim is fallback only
const mr = await me(at); const mr = await me(at);
@ -414,6 +414,8 @@ export function AuthProvider({ children }) {
setError, setError,
setLastInfo, setLastInfo,
setNeedsEmailVerification, setNeedsEmailVerification,
clearError: () => setError(""),
clearInfo: () => setLastInfo(""),
}; };
}, [status, user, tokens, error, lastInfo, needsEmailVerification]); }, [status, user, tokens, error, lastInfo, needsEmailVerification]);

View File

@ -0,0 +1,425 @@
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import {
clearAuth,
guessUsernameFromToken,
jwtExpSeconds,
loadAuth,
saveAuth,
decodeJwtPayload,
} from "./authStorage";
import { parseAuthError } from "./authErrors";
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: {
...(opts.headers || {}),
},
});
return res;
}
function tokenEmailVerified(accessToken) {
try {
const p = decodeJwtPayload(accessToken);
if (!p) return null;
if (typeof p.email_verified === "boolean") return p.email_verified;
if (typeof p.emailVerified === "boolean") return p.emailVerified;
return null;
} catch {
return null;
}
}
// tolerant whoami: accepts {authenticated:true} OR {username:""} OR {user:""} OR just ok+token
async function whoami(accessToken) {
const res = await apiFetch("/api/whoami", {
method: "GET",
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 };
}
// NEW: /api/me is the source of truth for email_verified
async function me(accessToken) {
const res = await apiFetch("/api/me", {
method: "GET",
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
});
const text = await res.text().catch(() => "");
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
// Try multiple shapes
const u = (json && (json.user || json.profile || json.me)) || json || null;
const emailVerified =
u && typeof u.email_verified === "boolean"
? u.email_verified
: u && typeof u.emailVerified === "boolean"
? u.emailVerified
: null;
return {
ok: !!res.ok,
status: res.status,
json,
text,
emailVerified,
};
}
async function refreshTokens(refreshToken) {
const res = await apiFetch("/api/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
const text = await res.text().catch(() => "");
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: res.ok, status: res.status, json, text };
}
async function resendVerification(accessToken) {
const res = await apiFetch("/api/resend-verification", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
body: JSON.stringify({}),
});
const text = await res.text().catch(() => "");
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: res.ok, status: res.status, json, text };
}
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 [lastInfo, setLastInfo] = useState("");
const [needsEmailVerification, setNeedsEmailVerification] = useState(false);
const refreshTimer = useRef(null);
function setAnon(msg = "") {
setStatus("anon");
setUser(null);
setTokens(null);
setError(msg || "");
setLastInfo("");
setNeedsEmailVerification(false);
clearAuth();
}
function setAuthFromTokens(t, usernameGuess = "") {
setTokens(t);
saveAuth({ ...t, obtained_at: Date.now() });
const u = usernameGuess || guessUsernameFromToken(t?.access_token) || "";
setUser(u ? { username: u } : null);
setStatus("auth");
setError("");
}
async function refreshProfile(accessTokenOverride) {
const at = accessTokenOverride || tokens?.access_token;
if (!at) return { ok: False };
// /api/me is the truth; token claim is fallback only
const mr = await me(at);
let verified = mr.emailVerified
if (typeof verified !== "boolean") {
const tv = tokenEmailVerified(at);
if (typeof tv === "boolean") verified = tv;
}
if (typeof verified === "boolean") {
setNeedsEmailVerification(!verified);
return { ok: true, verified };
}
// If cannot determine, do not block UI aggressively
setNeedsEmailVerification(false);
return { ok: mr.ok, verified: null };
}
async function ensureFreshSession(existing) {
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("");
// ✅ Update email verification state from backend
await refreshProfile(existing.access_token);
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);
// keep verification fresh too
await refreshProfile(merged.access_token);
} else {
// lightweight: refresh verification occasionally without spamming
// (every 30s interval already runs; but this call is only parsing token unless needed)
const tv = tokenEmailVerified(cur.access_token);
if (typeof tv === "boolean") setNeedsEmailVerification(!tv);
}
}, 30000);
}
async function restore() {
setStatus("loading");
setError("");
setLastInfo("");
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("");
setLastInfo("");
setStatus("loading");
const res = await apiFetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const text = await res.text().catch(() => "");
let j = null;
try {
j = text ? JSON.parse(text) : null;
} catch {
j = null;
}
if (!res.ok || !j?.access_token) {
const parsed = parseAuthError(text || "", res.status);
setNeedsEmailVerification(parsed.code === "email_verification_required");
setAnon(parsed.message || j?.error_description || j?.error || text || "Login failed");
return { ok: false, status: res.status, json: j, text };
}
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");
// ✅ update verified state from backend
await refreshProfile(t.access_token);
return { ok: true };
}
function logout() {
if (refreshTimer.current) clearInterval(refreshTimer.current);
refreshTimer.current = null;
setAnon("");
}
async function resendVerificationEmail() {
const at = tokens?.access_token || "";
if (!at) {
setError("Not logged in.");
return { ok: false };
}
setError("");
setLastInfo("");
const r = await resendVerification(at);
if (!r.ok) {
setError(r.json?.error || r.json?.message || r.text || `Resend failed (${r.status})`);
return { ok: false, status: r.status };
}
setLastInfo("Verification email sent. Check inbox/spam, then refresh session (logout/login).");
return { ok: true };
}
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: lastInfo || "",
needsEmailVerification: !!needsEmailVerification,
resendVerificationEmail,
login,
logout,
restore,
refreshProfile,
setError,
setLastInfo,
setNeedsEmailVerification,
};
}, [status, user, tokens, error, lastInfo, needsEmailVerification]);
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}
export function useAuth() {
return useContext(AuthCtx);
}

View File

@ -4,12 +4,66 @@ import { registerUser } from "../../api/client";
import AuthToast from "../Auth/AuthToast"; import AuthToast from "../Auth/AuthToast";
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" }; const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
const LAST_SIGNUP_KEY = "sociowire:lastSignup";
function initialLetter(name = "") { function initialLetter(name = "") {
const s = (name || "").trim(); const s = (name || "").trim();
return s ? s[0].toUpperCase() : "G"; return s ? s[0].toUpperCase() : "G";
} }
function truthyParam(v) {
if (!v) return false;
const x = String(v).toLowerCase().trim();
return x === "1" || x === "true" || x === "yes" || x === "ok";
}
function readLastSignup() {
try {
const raw = localStorage.getItem(LAST_SIGNUP_KEY);
if (!raw) return null;
const j = JSON.parse(raw);
if (!j || typeof j !== "object") return null;
return {
username: (j.username || "").toString().trim(),
email: (j.email || "").toString().trim(),
};
} catch {
return null;
}
}
function parseMergedParams() {
// Support both:
// - /?verified=1&email=...
// - /#/something?verified=1&email=...
const search = window.location.search || "";
const hash = window.location.hash || "";
const qs = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
let hashQs = "";
if (hash.includes("?")) hashQs = hash.slice(hash.indexOf("?") + 1);
const hs = new URLSearchParams(hashQs);
const get = (k) => {
const a = qs.get(k);
if (a != null && a !== "") return a;
const b = hs.get(k);
return b != null ? b : null;
};
const hasAny = (...keys) => any(keys, qs, hs)
return { qs, hs, get, hash };
}
function any(keys, qs, hs){
for (const k of keys){
if (qs.has(k) || hs.has(k)) return true;
}
return false;
}
export default function TopBar({ theme = "dark", onChangeTheme }) { export default function TopBar({ theme = "dark", onChangeTheme }) {
const { const {
loading, loading,
@ -18,9 +72,7 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
login, login,
logout, logout,
needsEmailVerification, needsEmailVerification,
openKeycloak,
lastError, lastError,
lastErrorCode,
lastInfo, lastInfo,
clearError, clearError,
clearInfo, clearInfo,
@ -45,16 +97,17 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
setMode(nextMode); setMode(nextMode);
setShowAuth(true); setShowAuth(true);
setSignupError(""); setSignupError("");
setSignupInfo(""); // keep signupInfo (so verify/signup notice stays visible)
}; };
const closeModal = () => { const closeModal = () => {
setShowAuth(false); setShowAuth(false);
setLoginPass(""); setLoginPass("");
setSignupError(""); setSignupError("");
setSignupInfo(""); // keep signupInfo
}; };
// Close auth modal automatically when logged in
useEffect(() => { useEffect(() => {
if (authenticated) setShowAuth(false); if (authenticated) setShowAuth(false);
}, [authenticated]); }, [authenticated]);
@ -62,21 +115,47 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
/* SW_URL_NOTICE:BEGIN */ /* SW_URL_NOTICE:BEGIN */
useEffect(() => { useEffect(() => {
try { try {
const qs = new URLSearchParams(window.location.search || ""); const { get, qs, hs, hash } = parseMergedParams();
const verified = qs.get("verified") || qs.get("email_verified");
const signup = qs.get("signup");
const notice = qs.get("notice");
if (verified === "1" || verified === "true") { const verified =
setSignupInfo("✅ Email confirmé. Tu peux te connecter."); get("verified") ||
get("email_verified") ||
get("emailVerified") ||
get("kc_verified");
const signup = get("signup");
const notice = get("notice");
const email =
get("email") ||
get("user_email") ||
get("mail");
const uname =
get("username") ||
get("user") ||
get("preferred_username") ||
get("login");
const last = readLastSignup();
if (truthyParam(verified)) {
const who = (email || uname || last?.email || last?.username || "").trim();
setSignupInfo(`✅ Email confirmé${who ? ` (${who})` : ""}. Tu peux te connecter.`);
setMode("login"); setMode("login");
setShowAuth(true); setShowAuth(true);
} else if (signup === "1" || signup === "true") {
setSignupInfo("✅ Compte créé. Vérifie tes emails (et le spam), puis connecte-toi."); const prefill = (uname || email || last?.username || last?.email || "").trim();
if (prefill) setLoginUser(prefill);
} else if (truthyParam(signup)) {
const who = (email || uname || last?.email || last?.username || "").trim();
setSignupInfo(`✅ Compte créé${who ? ` (${who})` : ""}. Vérifie tes emails (et le spam), puis connecte-toi.`);
setMode("login"); setMode("login");
setShowAuth(true); setShowAuth(true);
const prefill = (uname || email || last?.username || last?.email || "").trim();
if (prefill) setLoginUser(prefill);
} else if (notice) { } else if (notice) {
// allow custom notice (URL-encoded)
const msg = decodeURIComponent(notice); const msg = decodeURIComponent(notice);
if (msg && msg.trim()) { if (msg && msg.trim()) {
setSignupInfo(msg.trim()); setSignupInfo(msg.trim());
@ -85,9 +164,14 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
} }
} }
// clean URL (remove params without reload) // clean URL (remove params without reload) both search and hash query
if (verified || signup || notice) { const touched =
const clean = window.location.pathname + (window.location.hash || ""); qs.has("verified") || qs.has("email_verified") || qs.has("emailVerified") || qs.has("signup") || qs.has("notice") ||
hs.has("verified") || hs.has("email_verified") || hs.has("emailVerified") || hs.has("signup") || hs.has("notice");
if (touched) {
const cleanHash = hash.includes("?") ? hash.split("?")[0] : hash;
const clean = window.location.pathname + cleanHash;
window.history.replaceState({}, "", clean); window.history.replaceState({}, "", clean);
} }
} catch (e) { } catch (e) {
@ -96,7 +180,6 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
}, []); }, []);
/* SW_URL_NOTICE:END */ /* SW_URL_NOTICE:END */
const handleLoginSubmit = async (e) => { const handleLoginSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
await login(loginUser, loginPass); await login(loginUser, loginPass);
@ -115,17 +198,25 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
try { try {
setSignupLoading(true); setSignupLoading(true);
const u = regUser.trim();
const em = regEmail.trim();
await registerUser({ await registerUser({
username: regUser.trim(), username: u,
email: regEmail.trim(), email: em,
password: regPass, password: regPass,
firstName: firstName.trim(), firstName: firstName.trim(),
lastName: lastName.trim(), lastName: lastName.trim(),
}); });
setSignupInfo("Account created. Check your inbox (spam) to verify your email, then login."); // Remember for the "verified" return
try {
localStorage.setItem(LAST_SIGNUP_KEY, JSON.stringify({ username: u, email: em }));
} catch {}
setSignupInfo(`✅ Compte créé (${em}). Vérifie tes emails (spam aussi), puis connecte-toi.`);
setMode("login"); setMode("login");
setLoginUser(regUser.trim()); setLoginUser(u || em);
setLoginPass(""); setLoginPass("");
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -150,29 +241,23 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
return ( return (
<> <>
{/* Toast area (under topbar) */} {/* Toast area (under topbar) */}
{(toast?.message) && ( {toast?.message ? (
<div className="sw-toast-wrap"> <div className="sw-toast-wrap">
<AuthToast <AuthToast
type={toast.type} type={toast.type}
title={toast.title} title={toast.title}
message={toast.message} message={toast.message}
onClose={() => { onClose={() => {
if (lastError) clearError(); if (lastError && typeof clearError === "function") clearError();
if (lastInfo) clearInfo(); if (lastInfo && typeof clearInfo === "function") 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>
)} ) : null}
</div>
)}
<header className="topbar"> <header className="topbar">
<div style={{ display: "flex", alignItems: "center", gap: "0.65rem" }}> <div className="brand-stack">
<div className="brand-toprow">
<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>
@ -180,7 +265,8 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
</div> </div>
</div> </div>
<div className="topbar-right"> {/* Theme dots UNDER the brand (prevents topbar wrapping / overflow) */}
<div className="brand-under">
<div className="theme-switch"> <div className="theme-switch">
{["dark", "blue", "light"].map((t) => ( {["dark", "blue", "light"].map((t) => (
<button <button
@ -194,24 +280,21 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
</button> </button>
))} ))}
</div> </div>
</div>
</div>
<div className="topbar-right">
{authenticated ? ( {authenticated ? (
<div style={{ display: "flex", alignItems: "center", gap: "0.45rem" }}>
<div className="sw-userchip" title={username}> <div className="sw-userchip" title={username}>
<div className="sw-avatar"> <div className="sw-avatar">
<span>{initialLetter(username)}</span> <span>{initialLetter(username)}</span>
</div> </div>
<div className="sw-usertext"> <div className="sw-usertext">
<div className="sw-userline">Connecté</div> <div className="sw-userline">Online</div>
<div className="sw-userhandle">@{username || "user"}</div> <div className="sw-userhandle">@{username || "user"}</div>
</div> </div>
</div> </div>
<button className="btn-primary" onClick={logout}>
Logout
</button>
</div>
) : ( ) : (
<div style={{ display: "flex", gap: "0.35rem", alignItems: "center" }}>
<div className="sw-userchip" title="Guest"> <div className="sw-userchip" title="Guest">
<div className="sw-avatar"> <div className="sw-avatar">
<i className="fa-solid fa-user" /> <i className="fa-solid fa-user" />
@ -221,19 +304,22 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
<div className="sw-userhandle">anon</div> <div className="sw-userhandle">anon</div>
</div> </div>
</div> </div>
<button type="button" className="btn-primary" onClick={() => openModal("login")}>
Login
</button>
<button type="button" className="btn-secondary" onClick={() => openModal("signup")}>
Sign up
</button>
</div>
)} )}
{/* Single button: Logon / Logoff (same spot, same style) */}
<button
className="btn-primary"
type="button"
onClick={() => (authenticated ? logout() : openModal("login"))}
disabled={loading}
title={authenticated ? "Logoff" : "Logon"}
>
{authenticated ? "Logoff" : "Logon"}
</button>
</div> </div>
</header> </header>
{showAuth && ( {showAuth ? (
<div className="auth-modal-backdrop" onClick={closeModal}> <div className="auth-modal-backdrop" onClick={closeModal}>
<div className="auth-modal" onClick={(e) => e.stopPropagation()}> <div className="auth-modal" onClick={(e) => e.stopPropagation()}>
<div className="auth-modal-header"> <div className="auth-modal-header">
@ -256,18 +342,18 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
</button> </button>
</div> </div>
{/* SW_MODAL_NOTICE:BEGIN */} {/* Pretty notices inside modal */}
{signupInfo ? ( {signupInfo ? <div className="auth-notice auth-notice-success">{signupInfo}</div> : null}
<div className="auth-notice auth-notice-success"> {mode === "login" && lastError ? (
{signupInfo} <div className={"auth-notice " + (needsEmailVerification ? "auth-notice-warn" : "auth-notice-error")}>
{lastError}
</div> </div>
) : null} ) : null}
{/* SW_MODAL_NOTICE:END */}
{mode === "login" ? ( {mode === "login" ? (
<form className="auth-form" onSubmit={handleLoginSubmit}> <form className="auth-form" onSubmit={handleLoginSubmit}>
<label> <label>
Username Username or email
<input <input
type="text" type="text"
value={loginUser} value={loginUser}
@ -287,6 +373,12 @@ 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>
{needsEmailVerification ? (
<div className="auth-notice auth-notice-warn" style={{ marginTop: 10 }}>
Ton compte demande une vérification email. Confirme ton email, puis réessaie de te connecter.
</div>
) : null}
</form> </form>
) : ( ) : (
<form className="auth-form" onSubmit={handleSignupSubmit}> <form className="auth-form" onSubmit={handleSignupSubmit}>
@ -313,12 +405,7 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} /> <input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
</label> </label>
{signupError && <div className="auth-error">{signupError}</div>} {signupError ? <div className="auth-notice auth-notice-error">{signupError}</div> : null}
{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}> <button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
{signupLoading ? "..." : "Create account"} {signupLoading ? "..." : "Create account"}
@ -327,7 +414,7 @@ export default function TopBar({ theme = "dark", onChangeTheme }) {
)} )}
</div> </div>
</div> </div>
)} ) : null}
</> </>
); );
} }

View File

@ -0,0 +1,392 @@
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "../../auth/AuthContext";
import { registerUser } from "../../api/client";
import AuthToast from "../Auth/AuthToast";
const THEME_LABELS = { dark: "Dark", blue: "Blue", light: "Light" };
const LAST_EMAIL_KEY = "sociowire:lastEmail";
function initialLetter(name = "") {
const s = (name || "").trim();
return s ? s[0].toUpperCase() : "G";
}
function looksLikeEmail(s = "") {
return typeof s === "string" && /.+@.+\..+/.test(s.trim());
}
export default function TopBar({ theme = "dark", onChangeTheme }) {
const {
loading,
authenticated,
username,
login,
logout,
needsEmailVerification,
resendVerificationEmail,
lastError,
lastInfo,
setError,
setLastInfo,
} = useAuth();
const clearError = () => setError && setError("");
const clearInfo = () => setLastInfo && setLastInfo("");
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 openModal = (nextMode) => {
setMode(nextMode);
setShowAuth(true);
setSignupError("");
setSignupInfo("");
// Prefill login with last known email (nice for email-verified return)
if (nextMode === "login") {
try {
const last = localStorage.getItem(LAST_EMAIL_KEY) || "";
if (!loginUser.trim() && last.trim()) setLoginUser(last.trim());
} catch {}
}
};
const closeModal = () => {
setShowAuth(false);
setLoginPass("");
setSignupError("");
setSignupInfo("");
};
useEffect(() => {
if (authenticated) setShowAuth(false);
}, [authenticated]);
/* SW_URL_NOTICE:BEGIN */
useEffect(() => {
try {
const qs = new URLSearchParams(window.location.search || "");
const verified = qs.get("verified") || qs.get("email_verified");
const signup = qs.get("signup");
const notice = qs.get("notice");
const email = (qs.get("email") || qs.get("user") || qs.get("username") || "").trim();
if (email) {
try { localStorage.setItem(LAST_EMAIL_KEY, email); } catch {}
// If user uses email to login, prefill it
if (!loginUser.trim()) setLoginUser(email);
if (!regEmail.trim()) setRegEmail(email);
}
if (verified === "1" || verified === "true") {
setSignupInfo(`✅ Email confirmé${email ? ` (${email})` : ""}. Tu peux te connecter.`);
setMode("login");
setShowAuth(true);
} else if (signup === "1" || signup === "true") {
setSignupInfo("✅ Compte créé. Vérifie tes emails (et le spam), puis connecte-toi.");
setMode("login");
setShowAuth(true);
} else if (notice) {
const msg = decodeURIComponent(notice);
if (msg && msg.trim()) {
setSignupInfo(msg.trim());
setMode("login");
setShowAuth(true);
}
}
// clean URL (remove params without reload)
if (verified || signup || notice || email) {
const clean = window.location.pathname + (window.location.hash || "");
window.history.replaceState({}, "", clean);
}
} catch (e) {
// ignore
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* SW_URL_NOTICE:END */
const handleLoginSubmit = async (e) => {
e.preventDefault();
// store email-like login for next time
try {
if (looksLikeEmail(loginUser)) localStorage.setItem(LAST_EMAIL_KEY, loginUser.trim());
} catch {}
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_EMAIL_KEY, regEmail.trim()); } catch {}
setSignupInfo("✅ Account created. Check your inbox (spam) to verify your email, then login.");
setMode("login");
// Some people login with email, some with username => prefer email if present
setLoginUser((regEmail.trim() || regUser.trim()));
setLoginPass("");
} catch (err) {
console.error(err);
setSignupError(err.message || "Registration error.");
} finally {
setSignupLoading(false);
}
};
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]);
const authComboLabel = authenticated ? "Log off" : (loading ? "..." : "Log on");
const authLine = authenticated ? "Online" : "Guest";
const authHandle = authenticated ? `@${username || "user"}` : "anon";
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 className="sw-toast-subactions">
<button
className="btn-secondary"
type="button"
onClick={() => openModal("login")}
>
Ouvrir Login
</button>
<button
className="btn-secondary"
type="button"
onClick={() => {
// Works only if already authenticated (token present); otherwise just tells user what to do.
if (authenticated) resendVerificationEmail && resendVerificationEmail();
else setLastInfo && setLastInfo("Va voir ta boîte email (et le spam) et clique le lien de confirmation, puis reviens ici.");
}}
>
Resend email
</button>
</div>
)}
</div>
)}
<header className="topbar">
<div className="topbar-left">
<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>
{/* ONE unified auth button (guest online / log on / log off) */}
<button
type="button"
className={"sw-auth-combo" + (authenticated ? " sw-auth-combo--on" : " sw-auth-combo--off")}
onClick={() => {
if (authenticated) logout();
else openModal("login");
}}
title={authenticated ? "Click to log off" : "Click to log on"}
>
<div className="sw-avatar">
{authenticated ? <span>{initialLetter(username)}</span> : <i className="fa-solid fa-user" />}
</div>
<div className="sw-usertext">
<div className="sw-userline">{authLine}</div>
<div className="sw-userhandle">{authHandle}</div>
</div>
<div className="sw-auth-action">{authComboLabel}</div>
</button>
{!authenticated && (
<button type="button" className="btn-secondary" onClick={() => openModal("signup")}>
Sign up
</button>
)}
</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>
{/* Nice notices (modal) */}
{signupInfo ? <div className="auth-notice auth-notice-success">{signupInfo}</div> : null}
{mode === "login" && needsEmailVerification ? (
<div className="auth-notice auth-notice-warn">
⚠️ Ton compte doit confirmer lemail avant de se connecter. Vérifie ta boîte mail (et le spam),
clique le lien, puis reviens ici.
</div>
) : null}
{mode === "login" && lastError ? (
<div className="auth-notice auth-notice-error">{lastError}</div>
) : null}
{mode === "login" && lastInfo ? (
<div className="auth-notice auth-notice-success">{lastInfo}</div>
) : null}
{mode === "login" ? (
<form className="auth-form" onSubmit={handleLoginSubmit}>
<label>
Username / Email
<input
type="text"
value={loginUser}
onChange={(e) => setLoginUser(e.target.value)}
autoComplete="username"
inputMode={looksLikeEmail(loginUser) ? "email" : "text"}
/>
</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)}
autoComplete="email"
inputMode="email"
/>
</label>
<label>
Password
<input type="password" value={regPass} onChange={(e) => setRegPass(e.target.value)} />
</label>
{signupError ? <div className="auth-notice auth-notice-error">{signupError}</div> : null}
{signupInfo ? <div className="auth-notice auth-notice-success">{signupInfo}</div> : null}
<button type="submit" className="btn-primary btn-full" disabled={signupLoading}>
{signupLoading ? "..." : "Create account"}
</button>
</form>
)}
</div>
</div>
)}
</>
);
}

View File

@ -96,3 +96,80 @@ body[data-theme="light"] .auth-form input {
.auth-modal { padding:.95rem .9rem 1.1rem; } .auth-modal { padding:.95rem .9rem 1.1rem; }
.auth-row { flex-direction:column; } .auth-row { flex-direction:column; }
} }
/* SW_AUTH_NOTICE:BEGIN */
.auth-notice{
margin: .2rem 0 .65rem;
padding: .55rem .7rem;
border-radius: 14px;
font-size: .82rem;
font-weight: 800;
line-height: 1.2;
border: 1px solid rgba(148,163,184,.35);
background: rgba(2,6,23,.22);
color: #e5e7eb;
}
.auth-notice-success{
background: rgba(34,197,94,0.14);
border-color: rgba(34,197,94,0.55);
color: #d1fae5;
}
.auth-notice-error{
background: rgba(248,113,113,0.14);
border-color: rgba(248,113,113,0.55);
color: #ffe4e6;
}
.auth-notice-warn{
background: rgba(251,191,36,0.12);
border-color: rgba(251,191,36,0.40);
color: #fffbeb;
}
body[data-theme="light"] .auth-notice{
background: rgba(255,255,255,0.92);
border-color: rgba(148,163,184,0.75);
color: #0b1220;
}
body[data-theme="light"] .auth-notice-success{ color:#0b1220; }
body[data-theme="light"] .auth-notice-error{ color:#0b1220; }
body[data-theme="light"] .auth-notice-warn{ color:#0b1220; }
/* SW_AUTH_NOTICE:END */
/* SW_AUTH_MODAL_NOTICE_POLISH:BEGIN */
.auth-notice{
margin: 0 0 .65rem 0;
padding: .65rem .75rem;
border-radius: 14px;
font-size: .82rem;
font-weight: 800;
line-height: 1.2;
border: 1px solid rgba(148,163,184,.35);
background: rgba(2,6,23,.22);
color: #e5e7eb;
}
.auth-notice-success{
background: rgba(34,197,94,0.14);
border-color: rgba(34,197,94,0.55);
color: #d1fae5;
}
.auth-notice-error{
background: rgba(248,113,113,0.14);
border-color: rgba(248,113,113,0.55);
color: #ffe4e6;
}
.auth-notice-warn{
background: rgba(251,191,36,0.12);
border-color: rgba(251,191,36,0.40);
color: #fffbeb;
}
.auth-notice-info{
background: rgba(56,189,248,0.14);
border-color: rgba(56,189,248,0.55);
color: #e0f2fe;
}
/* SW_AUTH_MODAL_NOTICE_POLISH:END */

View File

@ -0,0 +1,139 @@
.auth-modal-backdrop {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
background: rgba(2, 6, 23, 0.65);
backdrop-filter: blur(8px);
}
.auth-modal {
width: min(640px, 95vw);
border-radius: 22px;
padding: 1.1rem 1.25rem 1.35rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.85);
border: 1px solid rgba(148, 163, 184, 0.7);
}
body[data-theme="dark"] .auth-modal {
background: radial-gradient(circle at top left, #0f172a, #020617 70%);
}
body[data-theme="blue"] .auth-modal {
background: radial-gradient(circle at top left, #0ea5e9, #020617 65%);
border-color: rgba(96, 165, 250, 0.9);
}
body[data-theme="light"] .auth-modal {
background: radial-gradient(circle at top left, #ffffff, #e5e7eb 70%);
color: #020617;
border-color: rgba(148, 163, 184, 0.7);
}
.auth-modal-header { display:flex; align-items:center; gap:.5rem; margin-bottom:.9rem; }
.auth-tab {
padding: .45rem .95rem;
border-radius: 999px;
border: 1px solid transparent;
background: transparent;
font-size: .85rem;
color: #e5e7eb;
cursor: pointer;
}
.auth-tab-active {
background: linear-gradient(135deg, #1d4ed8, #38bdf8);
border-color: rgba(191, 219, 254, 0.9);
color: #f9fafb;
}
body[data-theme="light"] .auth-tab { color:#0f172a; }
body[data-theme="light"] .auth-tab-active { color:#f9fafb; }
.auth-close {
margin-left:auto;
width:32px; height:32px;
border-radius:999px;
border:1px solid rgba(148, 163, 184, 0.6);
background: rgba(15, 23, 42, 0.85);
color:#e5e7eb;
font-size:.8rem;
cursor:pointer;
}
body[data-theme="light"] .auth-close { background:#e5e7eb; color:#020617; }
.auth-form { display:flex; flex-direction:column; gap:.7rem; }
.auth-row { display:flex; gap:.6rem; }
.auth-row label { flex:1; }
.auth-form label { display:flex; flex-direction:column; font-size:.8rem; gap:.25rem; }
.auth-form input {
border-radius: 999px;
border: 1px solid rgba(51, 65, 85, 0.9);
background: rgba(15, 23, 42, 0.96);
color: #e5e7eb;
padding: .55rem .85rem;
font-size: .85rem;
}
body[data-theme="light"] .auth-form input {
background:#f9fafb;
color:#020617;
border-color: rgba(148, 163, 184, 0.9);
}
.btn-full { width:100%; margin-top:.4rem; }
.auth-error {
margin-top:.25rem;
font-size:.8rem;
color:#fecaca;
background: rgba(127, 29, 29, 0.55);
border-radius:.5rem;
padding:.4rem .6rem;
border: 1px solid rgba(248, 113, 113, 0.7);
}
@media (max-width:480px){
.auth-modal { padding:.95rem .9rem 1.1rem; }
.auth-row { flex-direction:column; }
}
/* SW_AUTH_NOTICE:BEGIN */
.auth-notice{
margin: .2rem 0 .65rem;
padding: .55rem .7rem;
border-radius: 14px;
font-size: .82rem;
font-weight: 800;
line-height: 1.2;
border: 1px solid rgba(148,163,184,.35);
background: rgba(2,6,23,.22);
color: #e5e7eb;
}
.auth-notice-success{
background: rgba(34,197,94,0.14);
border-color: rgba(34,197,94,0.55);
color: #d1fae5;
}
.auth-notice-error{
background: rgba(248,113,113,0.14);
border-color: rgba(248,113,113,0.55);
color: #ffe4e6;
}
.auth-notice-warn{
background: rgba(251,191,36,0.12);
border-color: rgba(251,191,36,0.40);
color: #fffbeb;
}
body[data-theme="light"] .auth-notice{
background: rgba(255,255,255,0.92);
border-color: rgba(148,163,184,0.75);
color: #0b1220;
}
body[data-theme="light"] .auth-notice-success{ color:#0b1220; }
body[data-theme="light"] .auth-notice-error{ color:#0b1220; }
body[data-theme="light"] .auth-notice-warn{ color:#0b1220; }
/* SW_AUTH_NOTICE:END */

View File

@ -91,3 +91,14 @@ body[data-theme="light"] .sw-toast{
} }
body[data-theme="light"] .sw-toast-icon{ background: rgba(255,255,255,.95); } 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); } body[data-theme="light"] .sw-toast-x{ background: rgba(255,255,255,.95); }
/* SW_TOAST_SUBACTIONS:BEGIN */
.sw-toast-subactions{
max-width: 980px;
margin: 10px auto 0;
display:flex;
gap:10px;
justify-content:center;
pointer-events: auto;
}
/* SW_TOAST_SUBACTIONS:END */

View File

@ -340,3 +340,151 @@ body[data-theme="light"] .sw-avatar{
} }
/* SW_TOPBAR_POLISH:END */ /* SW_TOPBAR_POLISH:END */
/* SW_TOPBAR_OVERFLOW_FIX:BEGIN */
.topbar{
flex-wrap: wrap; /* ✅ allow wrap instead of overflowing right */
row-gap: .45rem;
}
.topbar-left{
display:flex;
align-items:center;
gap: .65rem;
min-width: 0; /* ✅ allow shrinking */
}
.title-block{ min-width:0; }
.main-title, .sub-title{
max-width: 58vw;
overflow:hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-right{
min-width: 0;
max-width: 100%;
flex: 1 1 auto;
justify-content: flex-end;
flex-wrap: wrap; /* ✅ wrap buttons instead of clipping */
gap: .45rem;
}
@media (max-width:640px){
.main-title, .sub-title{ max-width: 86vw; }
.topbar-right{
justify-content: flex-start;
width: 100%;
}
}
/* Unified auth button (guest online / log on / log off) */
.sw-auth-combo{
display:flex;
align-items:center;
gap:.5rem;
padding:.25rem .55rem;
border-radius: 999px;
border: 1px solid rgba(148,163,184,.55);
background: rgba(2,6,23,.22);
color: #e5e7eb;
cursor:pointer;
max-width: 100%;
box-shadow: 0 4px 14px rgba(0,0,0,.22);
}
.sw-auth-combo .sw-usertext{
min-width: 0;
}
.sw-auth-combo .sw-userhandle{
max-width: 120px;
overflow:hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sw-auth-action{
margin-left: .25rem;
padding: .22rem .55rem;
border-radius: 999px;
font-weight: 950;
font-size: .70rem;
border: 1px solid rgba(56,189,248,.35);
background: rgba(56,189,248,.14);
color: #dbeafe;
white-space: nowrap;
}
.sw-auth-combo--on .sw-auth-action{
border-color: rgba(34,197,94,.45);
background: rgba(34,197,94,.16);
color: #d1fae5;
}
/* SW_TOPBAR_OVERFLOW_FIX:END */
/* SW_TOPBAR_ONE_LINE_FIX:BEGIN */
/* Keep the header on ONE ROW (no wrapping to a second line) */
.topbar{
flex-wrap: nowrap;
align-items: center;
}
/* Left brand stack (logo + title, with theme dots underneath) */
.brand-stack{
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.brand-toprow{
display: flex;
align-items: center;
gap: .65rem;
min-width: 0;
}
.brand-under{
display: flex;
align-items: center;
gap: .5rem;
padding-left: 46px; /* aligns under title block visually */
}
/* Prevent title from pushing the right side off-screen */
.title-block{ min-width: 0; }
.main-title{
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: min(360px, 48vw);
}
.sub-title{
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: min(360px, 48vw);
}
/* Right side never wraps */
.topbar-right{
flex-wrap: nowrap !important;
white-space: nowrap;
min-width: 0;
max-width: 52vw;
overflow: hidden;
}
/* Make user handle truncate instead of forcing width */
.sw-userhandle{ max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sw-userchip{ min-width: 0; }
@media (max-width: 640px){
.main-title, .sub-title{ max-width: 62vw; }
.topbar-right{ max-width: 45vw; }
.brand-under{ padding-left: 0; }
}
/* SW_TOPBAR_ONE_LINE_FIX:END */

View File

@ -0,0 +1,425 @@
.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 */
/* 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 */
/* SW_TOPBAR_POLISH:BEGIN */
/* Make right side look like a clean “glass dock” */
.topbar-right{
padding: .28rem .35rem;
border-radius: 999px;
background: rgba(2,6,23,.18);
border: 1px solid rgba(255,255,255,.14);
backdrop-filter: blur(10px);
}
/* User chip used by TopBar.jsx (missing styles before) */
.sw-userchip{
display:flex;
align-items:center;
gap:.5rem;
padding:.28rem .55rem;
border-radius: 999px;
background: rgba(2,6,23,.22);
border: 1px solid rgba(148,163,184,.45);
box-shadow: 0 4px 14px rgba(0,0,0,.25);
}
.sw-avatar{
width: 30px;
height: 30px;
border-radius: 999px;
display:grid;
place-items:center;
background: rgba(56,189,248,0.18);
border: 1px solid rgba(56,189,248,0.35);
color: #e5e7eb;
font-weight: 950;
overflow:hidden;
}
.sw-avatar i{ font-size: 14px; }
.sw-usertext{
display:flex;
flex-direction:column;
line-height: 1.05;
min-width: 0;
}
.sw-userline{
font-size: .72rem;
font-weight: 950;
color: #f9fafb;
}
.sw-userhandle{
font-size: .68rem;
font-weight: 800;
color: #dbeafe;
opacity: .92;
max-width: 160px;
overflow:hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Mobile: dock becomes full width and nice */
@media (max-width:640px){
.topbar-right{
width:100%;
border-radius: 16px;
justify-content: flex-start;
}
}
/* Light theme readability */
body[data-theme="light"] .topbar-right{
background: rgba(255,255,255,.55);
border-color: rgba(148,163,184,.55);
}
body[data-theme="light"] .sw-userchip{
background: rgba(255,255,255,.80);
border-color: rgba(148,163,184,.70);
color: #0b1220;
}
body[data-theme="light"] .sw-userline,
body[data-theme="light"] .sw-userhandle{
color:#0b1220;
}
body[data-theme="light"] .sw-avatar{
background: rgba(30,107,255,0.10);
border-color: rgba(30,107,255,0.35);
color:#0b1220;
}
/* SW_TOPBAR_POLISH:END */
/* SW_TOPBAR_OVERFLOW_FIX:BEGIN */
.topbar{
flex-wrap: wrap; /* ✅ allow wrap instead of overflowing right */
row-gap: .45rem;
}
.topbar-left{
display:flex;
align-items:center;
gap: .65rem;
min-width: 0; /* ✅ allow shrinking */
}
.title-block{ min-width:0; }
.main-title, .sub-title{
max-width: 58vw;
overflow:hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-right{
min-width: 0;
max-width: 100%;
flex: 1 1 auto;
justify-content: flex-end;
flex-wrap: wrap; /* ✅ wrap buttons instead of clipping */
gap: .45rem;
}
@media (max-width:640px){
.main-title, .sub-title{ max-width: 86vw; }
.topbar-right{
justify-content: flex-start;
width: 100%;
}
}
/* Unified auth button (guest online / log on / log off) */
.sw-auth-combo{
display:flex;
align-items:center;
gap:.5rem;
padding:.25rem .55rem;
border-radius: 999px;
border: 1px solid rgba(148,163,184,.55);
background: rgba(2,6,23,.22);
color: #e5e7eb;
cursor:pointer;
max-width: 100%;
box-shadow: 0 4px 14px rgba(0,0,0,.22);
}
.sw-auth-combo .sw-usertext{
min-width: 0;
}
.sw-auth-combo .sw-userhandle{
max-width: 120px;
overflow:hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sw-auth-action{
margin-left: .25rem;
padding: .22rem .55rem;
border-radius: 999px;
font-weight: 950;
font-size: .70rem;
border: 1px solid rgba(56,189,248,.35);
background: rgba(56,189,248,.14);
color: #dbeafe;
white-space: nowrap;
}
.sw-auth-combo--on .sw-auth-action{
border-color: rgba(34,197,94,.45);
background: rgba(34,197,94,.16);
color: #d1fae5;
}
/* SW_TOPBAR_OVERFLOW_FIX:END */