12 SCSS files (1167 lines) replaced by centralized index.css with Tailwind v4 @theme tokens, @layer components, and utility classes. Game panel design system (gp-*) preserved as CSS components. Inline styles in Settings/Login/MilestoneBar converted to Tailwind utilities. sass removed from dependencies. Build clean, 53 tests pass.
214 lines
6.0 KiB
JavaScript
214 lines
6.0 KiB
JavaScript
import { useEffect, useState } from "react";
|
|
import { useAuth } from "../context/AuthContext";
|
|
|
|
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`,
|
|
}),
|
|
});
|
|
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 max-w-[500px]">
|
|
<h1>Paramètres</h1>
|
|
|
|
{error && (
|
|
<p className="text-red-500 text-[13px] mb-4">{error}</p>
|
|
)}
|
|
|
|
{/* Profile info */}
|
|
{profile && (
|
|
<div className="mb-6 text-left">
|
|
<p className="text-sm text-gray-400 my-1">
|
|
<strong>Pseudo :</strong> {profile.nickname}
|
|
</p>
|
|
<p className="text-sm text-gray-400 my-1">
|
|
<strong>Email :</strong> {profile.email || "—"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Linked providers */}
|
|
<h2 className="text-lg mb-3">Comptes liés</h2>
|
|
<div className="flex flex-col gap-2">
|
|
{PROVIDERS.map((provider) => {
|
|
const linked = linkedNames.has(provider);
|
|
const isLoading = actionLoading === provider;
|
|
|
|
return (
|
|
<div
|
|
key={provider}
|
|
className={`flex items-center justify-between px-3 py-2 rounded-lg border ${
|
|
linked
|
|
? "bg-[#1a2a1a] border-[#2a4a2a]"
|
|
: "bg-[#1a1a2a] border-[#2a2a4a]"
|
|
}`}
|
|
>
|
|
<span className="text-sm">
|
|
{EMOJIS[provider]} {provider.charAt(0).toUpperCase() + provider.slice(1)}
|
|
{linked && (
|
|
<span className="text-green-400 text-xs ml-2">✓ lié</span>
|
|
)}
|
|
</span>
|
|
|
|
{linked ? (
|
|
<button
|
|
className="btn-return text-xs! py-1! px-2.5!"
|
|
disabled={!canUnlink || isLoading}
|
|
onClick={() => handleUnlink(provider)}
|
|
type="button"
|
|
style={{ opacity: canUnlink ? 1 : 0.4 }}
|
|
>
|
|
{isLoading ? "..." : "Délier"}
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="btn-return text-xs! py-1! px-2.5!"
|
|
disabled={isLoading}
|
|
onClick={() => handleLink(provider)}
|
|
type="button"
|
|
>
|
|
{isLoading ? "..." : "Lier"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Logout */}
|
|
<button
|
|
className="btn-return mt-6 w-full!"
|
|
onClick={logout}
|
|
type="button"
|
|
>
|
|
Déconnexion
|
|
</button>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|