feat(sprint1-step4): SuperOAuth login frontend

- AuthContext : fix exports, x-auth-token header, loginWithOAuth(), suppression axios/jwt-decode
- Login.jsx : redirect SuperOAuth Discord avec tenantId=clickerz
- AuthCallback.jsx : extraction token query param, flow OAuth complet
- .env.sample : ajout VITE_SUPEROAUTH_URL
- Mode invité préservé (pas de route guard)
This commit is contained in:
2026-03-20 13:40:33 +01:00
parent a52746ed0c
commit d215e9a33e
4 changed files with 127 additions and 25 deletions

View File

@@ -3,5 +3,5 @@
# Backend API URL (call it in React with import.meta.env.VITE_BACKEND_URL) # Backend API URL (call it in React with import.meta.env.VITE_BACKEND_URL)
VITE_BACKEND_URL=http://localhost:3310 VITE_BACKEND_URL=http://localhost:3310
# Other Environment Variables (if needed) # SuperOAuth URL (OAuth login provider)
# VITE_OTHER_VARIABLE=value VITE_SUPEROAUTH_URL=https://superoauth.tetardtek.com

View File

@@ -6,8 +6,8 @@ import React, {
useEffect, useEffect,
} from "react"; } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import axios from "axios"; const decodeJwtPayload = (token) =>
import { jwtDecode } from "jwt-decode"; JSON.parse(atob(token.split(".")[1]));
const AuthContext = createContext(); const AuthContext = createContext();
@@ -21,13 +21,19 @@ import React, {
if (jwtToken) { if (jwtToken) {
try { try {
const decodedPayload = jwtDecode(jwtToken); const decodedPayload = decodeJwtPayload(jwtToken);
const res = await axios.get( const res = await fetch(
`${import.meta.env.VITE_BACKEND_URL}/api/users/${decodedPayload.user}` `${import.meta.env.VITE_BACKEND_URL}/api/users/${decodedPayload.user}`,
{
headers: { "x-auth-token": jwtToken },
}
); );
setUser(res.data); if (!res.ok) throw new Error("Failed to fetch user");
const data = await res.json();
setUser(data);
} catch (error) { } catch (error) {
console.error("Error fetching user data:", error); console.error("Error fetching user data:", error);
localStorage.removeItem("token");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -39,11 +45,26 @@ import React, {
fetchData(); fetchData();
}, []); }, []);
const loginWithOAuth = async (token) => {
const res = await fetch(
`${import.meta.env.VITE_BACKEND_URL}/api/auth/callback?code=${encodeURIComponent(token)}`
);
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || "OAuth login failed");
}
localStorage.setItem("token", data.token);
setUser(data.user);
return data.user;
};
const logout = () => { const logout = () => {
localStorage.removeItem("token"); localStorage.removeItem("token");
setUser(null); setUser(null);
}; };
const editUser = async (updatedFields) => { const editUser = async (updatedFields) => {
try { try {
const response = await fetch( const response = await fetch(
@@ -52,7 +73,7 @@ import React, {
method: "PUT", method: "PUT",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`, "x-auth-token": localStorage.getItem("token"),
}, },
body: JSON.stringify(updatedFields), body: JSON.stringify(updatedFields),
} }
@@ -114,6 +135,7 @@ import React, {
user, user,
loading, loading,
logout, logout,
loginWithOAuth,
editUser, editUser,
sendPasswordResetEmail, sendPasswordResetEmail,
setUser: (newUser) => { setUser: (newUser) => {
@@ -140,3 +162,5 @@ import React, {
} }
return context; return context;
}; };
export { AuthProvider, useAuth };

View File

@@ -0,0 +1,46 @@
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams, Link } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import "../scss/pages.scss";
export default function AuthCallback() {
const [searchParams] = useSearchParams();
const { loginWithOAuth } = useAuth();
const navigate = useNavigate();
const [error, setError] = useState(null);
useEffect(() => {
const token = searchParams.get("token");
if (!token) {
setError("Token manquant dans l'URL.");
return;
}
loginWithOAuth(token)
.then(() => navigate("/", { replace: true }))
.catch((err) => setError(err.message || "Erreur de connexion."));
}, []);
if (error) {
return (
<section>
<div className="containererror">
<h1>Erreur de connexion</h1>
<p className="message">{error}</p>
<Link className="btn-return" to="/login">
Retour au login
</Link>
</div>
</section>
);
}
return (
<section>
<div className="containererror">
<p className="message">Connexion en cours...</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,32 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import "../scss/pages.scss";
const SUPEROAUTH_URL = import.meta.env.VITE_SUPEROAUTH_URL;
export default function Login() {
const { user } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (user) navigate("/", { replace: true });
}, [user, navigate]);
const handleLogin = () => {
const callbackUrl = `${window.location.origin}/callback`;
window.location.href = `${SUPEROAUTH_URL}/api/v1/oauth/discord?redirectUrl=${encodeURIComponent(callbackUrl)}&tenantId=clickerz`;
};
return (
<section>
<div className="containererror">
<h1>Connexion</h1>
<p className="message">Connecte-toi pour sauvegarder ta progression.</p>
<button className="btn-return" onClick={handleLogin} type="button">
Se connecter avec SuperOAuth
</button>
</div>
</section>
);
}