feat: Settings page — profile + link/unlink providers via SuperOAuth API
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 18s
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 18s
- Store SuperOAuth access_token in localStorage for profile API calls - Settings page: profile info, linked providers, link/unlink buttons - Navbar: settings icon replaces logout button
This commit is contained in:
@@ -76,9 +76,9 @@ export default function Navbar({ navData, toggleRain, setToggleRain }) {
|
|||||||
{user ? (
|
{user ? (
|
||||||
<div className="auth-nav">
|
<div className="auth-nav">
|
||||||
<span className="auth-nickname">{user.nickname}</span>
|
<span className="auth-nickname">{user.nickname}</span>
|
||||||
<button className="auth-btn" onClick={logout} type="button">
|
<Link className="mainLink" to="/settings" title="Paramètres">
|
||||||
Déconnexion
|
⚙
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Link className="mainLink" to="/login">
|
<Link className="mainLink" to="/login">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Login from "./pages/Login";
|
|||||||
import AuthCallback from "./pages/AuthCallback";
|
import AuthCallback from "./pages/AuthCallback";
|
||||||
import { AuthProvider } from "./context/AuthContext";
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
import Achievements from "./pages/Achievements";
|
import Achievements from "./pages/Achievements";
|
||||||
|
import Settings from "./pages/Settings";
|
||||||
import Legal from "./pages/Legal";
|
import Legal from "./pages/Legal";
|
||||||
import Cookie from "./pages/Cookie";
|
import Cookie from "./pages/Cookie";
|
||||||
|
|
||||||
@@ -36,6 +37,10 @@ const router = createBrowserRouter([
|
|||||||
path: "/cookies",
|
path: "/cookies",
|
||||||
element: <Cookie />,
|
element: <Cookie />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/settings",
|
||||||
|
element: <Settings />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/login",
|
path: "/login",
|
||||||
element: <Login />,
|
element: <Login />,
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export default function AuthCallback() {
|
|||||||
exchangeCode(code, verifier, redirectUri)
|
exchangeCode(code, verifier, redirectUri)
|
||||||
.then((tokens) => {
|
.then((tokens) => {
|
||||||
clearVerifier();
|
clearVerifier();
|
||||||
|
// Store SuperOAuth access_token for profile API calls (short-lived, 15min)
|
||||||
|
localStorage.setItem("clkz_oauth_token", tokens.access_token);
|
||||||
return apiFetch("/auth/session", {
|
return apiFetch("/auth/session", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
225
Frontend/src/pages/Settings.jsx
Normal file
225
Frontend/src/pages/Settings.jsx
Normal file
@@ -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 (
|
||||||
|
<section>
|
||||||
|
<div className="containererror">
|
||||||
|
<p className="message">Connecte-toi pour accéder aux paramètres.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="containererror">
|
||||||
|
<p className="message">Chargement du profil...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedNames = new Set(linkedProviders.map((p) => p.provider));
|
||||||
|
const canUnlink = linkedProviders.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="containererror" style={{ maxWidth: 500 }}>
|
||||||
|
<h1>Paramètres</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: "#ef4444", fontSize: 13, marginBottom: 16 }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Profile info */}
|
||||||
|
{profile && (
|
||||||
|
<div style={{ marginBottom: 24, textAlign: "left" }}>
|
||||||
|
<p style={{ fontSize: 14, color: "#9ca3af", margin: "4px 0" }}>
|
||||||
|
<strong>Pseudo :</strong> {profile.nickname}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 14, color: "#9ca3af", margin: "4px 0" }}>
|
||||||
|
<strong>Email :</strong> {profile.email || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Linked providers */}
|
||||||
|
<h2 style={{ fontSize: 18, marginBottom: 12 }}>Comptes liés</h2>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{PROVIDERS.map((provider) => {
|
||||||
|
const linked = linkedNames.has(provider);
|
||||||
|
const isLoading = actionLoading === provider;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={provider}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: linked ? "#1a2a1a" : "#1a1a2a",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${linked ? "#2a4a2a" : "#2a2a4a"}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 14 }}>
|
||||||
|
{EMOJIS[provider]} {provider.charAt(0).toUpperCase() + provider.slice(1)}
|
||||||
|
{linked && (
|
||||||
|
<span style={{ color: "#4ade80", fontSize: 12, marginLeft: 8 }}>
|
||||||
|
✓ lié
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{linked ? (
|
||||||
|
<button
|
||||||
|
className="btn-return"
|
||||||
|
style={{ fontSize: 12, padding: "4px 10px", opacity: canUnlink ? 1 : 0.4 }}
|
||||||
|
disabled={!canUnlink || isLoading}
|
||||||
|
onClick={() => handleUnlink(provider)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isLoading ? "..." : "Délier"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn-return"
|
||||||
|
style={{ fontSize: 12, padding: "4px 10px" }}
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => handleLink(provider)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isLoading ? "..." : "Lier"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<button
|
||||||
|
className="btn-return"
|
||||||
|
style={{ marginTop: 24, width: "100%" }}
|
||||||
|
onClick={logout}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user