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 */}
+
+
+
+ );
+}