diff --git a/Frontend/src/components/navbar.jsx b/Frontend/src/components/navbar.jsx index 12e1a32..3895b26 100755 --- a/Frontend/src/components/navbar.jsx +++ b/Frontend/src/components/navbar.jsx @@ -76,9 +76,9 @@ export default function Navbar({ navData, toggleRain, setToggleRain }) { {user ? (
{user.nickname} - + + ⚙ +
) : ( diff --git a/Frontend/src/main.jsx b/Frontend/src/main.jsx index d0bb50c..0a0123d 100755 --- a/Frontend/src/main.jsx +++ b/Frontend/src/main.jsx @@ -8,6 +8,7 @@ import Login from "./pages/Login"; import AuthCallback from "./pages/AuthCallback"; import { AuthProvider } from "./context/AuthContext"; import Achievements from "./pages/Achievements"; +import Settings from "./pages/Settings"; import Legal from "./pages/Legal"; import Cookie from "./pages/Cookie"; @@ -36,6 +37,10 @@ const router = createBrowserRouter([ path: "/cookies", element: , }, + { + path: "/settings", + element: , + }, { path: "/login", element: , diff --git a/Frontend/src/pages/AuthCallback.jsx b/Frontend/src/pages/AuthCallback.jsx index dec186e..fb84ddc 100644 --- a/Frontend/src/pages/AuthCallback.jsx +++ b/Frontend/src/pages/AuthCallback.jsx @@ -40,6 +40,8 @@ export default function AuthCallback() { exchangeCode(code, verifier, redirectUri) .then((tokens) => { clearVerifier(); + // Store SuperOAuth access_token for profile API calls (short-lived, 15min) + localStorage.setItem("clkz_oauth_token", tokens.access_token); return apiFetch("/auth/session", { method: "POST", body: JSON.stringify({ diff --git a/Frontend/src/pages/Settings.jsx b/Frontend/src/pages/Settings.jsx new file mode 100644 index 0000000..d1d7e76 --- /dev/null +++ b/Frontend/src/pages/Settings.jsx @@ -0,0 +1,225 @@ +import { useEffect, useState } from "react"; +import { useAuth } from "../context/AuthContext"; +import "../scss/pages.scss"; + +const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || ""; +const PROVIDERS = ["discord", "github", "google", "twitch"]; +const EMOJIS = { discord: "🎮", github: "🐙", google: "🌐", twitch: "🎬" }; + +function getOAuthToken() { + return localStorage.getItem("clkz_oauth_token"); +} + +async function oauthFetch(path, options = {}) { + const token = getOAuthToken(); + if (!token) throw new Error("Not authenticated with SuperOAuth"); + + const res = await fetch(`${OAUTH_URL}/api/v1${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...options.headers, + }, + }); + + if (res.status === 401) { + localStorage.removeItem("clkz_oauth_token"); + throw new Error("SuperOAuth token expired — re-login required"); + } + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message || `HTTP ${res.status}`); + } + + return res.json(); +} + +export default function Settings() { + const { user, logout } = useAuth(); + const [profile, setProfile] = useState(null); + const [linkedProviders, setLinkedProviders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [actionLoading, setActionLoading] = useState(null); + + // Handle ?linked= or ?error= from link callback + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const linked = params.get("linked"); + const err = params.get("error"); + if (linked) { + window.history.replaceState({}, "", "/settings"); + } + if (err) { + setError(err); + window.history.replaceState({}, "", "/settings"); + } + }, []); + + useEffect(() => { + if (!user) return; + fetchProfile(); + }, [user]); + + async function fetchProfile() { + setLoading(true); + setError(null); + try { + const data = await oauthFetch("/user/profile"); + setProfile(data.data.user); + setLinkedProviders(data.data.linkedProviders); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + } + + async function handleLink(provider) { + setActionLoading(provider); + setError(null); + try { + const data = await oauthFetch(`/oauth/${provider}/link`, { + method: "POST", + body: JSON.stringify({ + returnUrl: `${window.location.origin}/settings`, + }), + }); + // Redirect to OAuth provider + window.location.href = data.data.authUrl; + } catch (e) { + setError(e.message); + setActionLoading(null); + } + } + + async function handleUnlink(provider) { + if (!confirm(`Délier ${provider} ?`)) return; + setActionLoading(provider); + setError(null); + try { + await oauthFetch(`/oauth/${provider}/unlink`, { method: "DELETE" }); + await fetchProfile(); + } catch (e) { + setError(e.message); + } finally { + setActionLoading(null); + } + } + + if (!user) { + return ( +
+
+

Connecte-toi pour accéder aux paramètres.

+
+
+ ); + } + + if (loading) { + return ( +
+
+

Chargement du profil...

+
+
+ ); + } + + const linkedNames = new Set(linkedProviders.map((p) => p.provider)); + const canUnlink = linkedProviders.length > 1; + + return ( +
+
+

Paramètres

+ + {error && ( +

+ {error} +

+ )} + + {/* Profile info */} + {profile && ( +
+

+ Pseudo : {profile.nickname} +

+

+ Email : {profile.email || "—"} +

+
+ )} + + {/* Linked providers */} +

Comptes liés

+
+ {PROVIDERS.map((provider) => { + const linked = linkedNames.has(provider); + const isLoading = actionLoading === provider; + + return ( +
+ + {EMOJIS[provider]} {provider.charAt(0).toUpperCase() + provider.slice(1)} + {linked && ( + + ✓ lié + + )} + + + {linked ? ( + + ) : ( + + )} +
+ ); + })} +
+ + {/* Logout */} + +
+
+ ); +}