Implement user login and add tablo page

This commit is contained in:
Arthur Belleville 2025-03-23 15:21:02 +01:00
parent 707d9aa5ac
commit 9b78fea02d
No known key found for this signature in database
25 changed files with 423 additions and 262 deletions

View file

@ -5,7 +5,7 @@ import inspect
from typing import Annotated, Dict, List, Optional
from contextlib import contextmanager
from .auth import get_supabase
from app.routers.auth import get_supabase, router as auth_router
from fastapi import FastAPI, Depends, HTTPException, status, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
@ -17,7 +17,6 @@ from pydantic_core.core_schema import FieldValidationInfo
from dotenv import load_dotenv
from supabase import Client
from .auth import get_current_user
from uuid import uuid4
from datetime import datetime
@ -34,6 +33,8 @@ app.add_middleware(
expose_headers=["X-Error-Code", "X-Error-Message"]
)
app.include_router(auth_router, prefix="/auth")
# Security
security = HTTPBearer()
@ -54,222 +55,9 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
})
return JSONResponse(status_code=422, content=jsonable_encoder(custom_errors))
# ======================================
# 🚀 MODELS
# ======================================
class UserCreate(BaseModel):
email: EmailStr
first_name: str
last_name: str
password: str
confirm_password: str
business_name: str
@field_validator("email")
def email_must_contain_at_symbol(cls, v):
if '@' not in v:
raise ValueError("Entrer un email valide")
return v
@field_validator("password")
def password_must_contain_at_least_8_characters(cls, v):
if len(v) < 8:
raise ValueError("Le mot de passe doit contenir au moins 8 caractères")
return v
@field_validator("business_name")
def business_name_must_contain_at_least_3_characters(cls, v):
if len(v) < 3:
raise ValueError("Le nom de la société doit contenir au moins 3 caractères")
return v
@field_validator('confirm_password', mode='before')
def passwords_match(cls, v, info: FieldValidationInfo):
if 'password' in info.data and v != info.data['password']:
raise ValueError('Les mots de passe ne correspondent pas')
return v
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserOut(BaseModel):
email: EmailStr
business_name: str
class GameState(BaseModel):
id: str
word: str
guessed_letters: List[str] = []
attempts_left: int
status: str
wrong_guesses: List[str] = []
correct_guesses: List[str] = []
created_by: str
created_at: str
hints: List[str] = []
class PublicGameState(BaseModel):
id: str
masked_word: str
guessed_letters: List[str] = []
attempts_left: int
status: str
wrong_guesses: List[str] = []
correct_guesses: List[str] = []
created_by: str
created_at: str
hints: List[str] = []
class PublicGameResponse(BaseModel):
game_id: str
status: str
created_by: str
created_at: str
attempts_left: int
class LetterGuess(BaseModel):
letter: str
class CreateGame(BaseModel):
word: str
class HintRequest(BaseModel):
hint: str
class RefreshToken(BaseModel):
refresh_token: str
class RefreshResponse(BaseModel):
access_token: str
refresh_token: str
expires_at: int
user: dict
# ======================================
# 🔥 AUTH ROUTES
# ======================================
@app.post("/auth/register")
async def register(user: UserCreate, supabase: Client = Depends(get_supabase)):
try:
return supabase.auth.sign_up({
"email": user.email,
"password": user.password,
"options": {"data": {"first_name": user.first_name, "last_name": user.last_name, "business_name": user.business_name}}
})
except Exception as e:
headers = {}
if e.code == "user_already_exists":
headers = {"X-Error-Code": e.code, "X-Error-Message": "Cette adresse email est déjà utilisée"}
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@app.post("/auth/login")
async def login(credentials: UserLogin, supabase: Client = Depends(get_supabase)):
try:
print("Login attempt for:", credentials.email) # Debug log
response = supabase.auth.sign_in_with_password({
"email": credentials.email.strip(),
"password": credentials.password.strip()
})
print("Login response:", response)
return {
"access_token": response.session.access_token,
"token_type": "bearer"
}
except Exception as e:
headers = {}
if e.code == "invalid_credentials":
headers = {"X-Error-Code": e.code, "X-Error-Message": "Email ou mot de passe incorrect"}
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers=headers
)
@app.get("/auth/login/google")
async def login_with_google(supabase: Client = Depends(get_supabase)):
try:
response = supabase.auth.sign_in_with_oauth({
"provider": "google",
"options": {
"redirect_to": "https://mhcafqvzbrrwvahpvvzd.supabase.co/auth/v1/callback"
}
})
return {"auth_url": response.url}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@app.post("/auth/logout")
async def logout(user=Depends(get_current_user), supabase: Client = Depends(get_supabase)):
try:
supabase.auth.sign_out()
return {"message": "Successfully logged out"}
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@app.get("/me", response_model=UserOut)
async def get_me(
user = Depends(get_current_user), # Now properly imported
supabase: Client = Depends(get_supabase)
):
try:
# Get user details from public.users table
db_user = supabase.table("users").select("*").eq("id", user.user.id).execute().data[0]
return {
"username": db_user["username"],
"email": user.user.email,
"business_name": db_user["business_name"]
}
except IndexError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found in database"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@app.post("/auth/refresh", response_model=RefreshResponse)
async def refresh_token(refresh_request: RefreshToken, supabase: Client = Depends(get_supabase)):
"""Refresh the access token using a valid refresh token."""
try:
# Validate the refresh token and get new tokens
response = supabase.auth.refresh_session(refresh_request.refresh_token)
# Extract user data
user_data = {
"id": response.user.id,
"email": response.user.email,
"first_name": response.user.user_metadata.get("first_name", "Unknown"),
"last_name": response.user.user_metadata.get("last_name", "Unknown"),
"business_name": response.user.user_metadata.get("business_name", "Unknown")
}
# Return the new tokens and user data
return {
"access_token": response.session.access_token,
"refresh_token": response.session.refresh_token,
"expires_at": int(response.session.expires_at),
"user": user_data
}
except Exception as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Failed to refresh token: {str(e)}")
# ======================================
# 🔍 HEALTH CHECK ROUTES
# ======================================
@app.get("/ping")
async def ping():
"""Health check endpoint that returns a success status."""

View file

Binary file not shown.

126
backend/app/routers/auth.py Normal file
View file

@ -0,0 +1,126 @@
from fastapi import Depends, HTTPException, status
from fastapi.routing import APIRouter
from fastapi.encoders import jsonable_encoder
from fastapi.security import OAuth2PasswordBearer
from supabase import Client
from app.config import settings
from jose import JWTError, jwt
import os
from app.routers.helpers import get_supabase, get_current_user_required, get_current_user_optional
from app.schemas.user import UserCreate, UserLogin, UserOut
from app.schemas.token import RefreshResponse, RefreshToken
router = APIRouter(tags=["auth"])
@router.post("/register")
async def register(user: UserCreate, supabase: Client = Depends(get_supabase)):
try:
return supabase.auth.sign_up({
"email": user.email,
"password": user.password,
"options": {"data": {"first_name": user.first_name, "last_name": user.last_name, "business_name": user.business_name}}
})
except Exception as e:
headers = {}
if e.code == "user_already_exists":
headers = {"X-Error-Code": e.code, "X-Error-Message": "Cette adresse email est déjà utilisée"}
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.post("/login")
async def login(credentials: UserLogin, supabase: Client = Depends(get_supabase)):
try:
print("Login attempt for:", credentials.email) # Debug log
response = supabase.auth.sign_in_with_password({
"email": credentials.email.strip(),
"password": credentials.password.strip()
})
print("Login response:", response)
return {
"access_token": response.session.access_token,
"token_type": "bearer"
}
except Exception as e:
headers = {}
if e.code == "invalid_credentials":
headers = {"X-Error-Code": e.code, "X-Error-Message": "Email ou mot de passe incorrect"}
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers=headers
)
@router.get("/login/google")
async def login_with_google(supabase: Client = Depends(get_supabase)):
try:
response = supabase.auth.sign_in_with_oauth({
"provider": "google",
"options": {
"redirect_to": "https://mhcafqvzbrrwvahpvvzd.supabase.co/auth/v1/callback"
}
})
return {"auth_url": response.url}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.post("/logout")
async def logout(user=Depends(get_current_user_required), supabase: Client = Depends(get_supabase)):
try:
supabase.auth.sign_out()
return {"message": "Successfully logged out"}
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/users/me")
async def get_me(
user = Depends(get_current_user_required),
supabase: Client = Depends(get_supabase)
):
try:
return {
"user": jsonable_encoder(user)
}
except IndexError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found in database"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.post("/refresh", response_model=RefreshResponse)
async def refresh_token(refresh_request: RefreshToken, supabase: Client = Depends(get_supabase)):
"""Refresh the access token using a valid refresh token."""
try:
# Validate the refresh token and get new tokens
response = supabase.auth.refresh_session(refresh_request.refresh_token)
# Extract user data
user_data = {
"id": response.user.id,
"email": response.user.email,
"first_name": response.user.user_metadata.get("first_name", "Unknown"),
"last_name": response.user.user_metadata.get("last_name", "Unknown"),
"business_name": response.user.user_metadata.get("business_name", "Unknown")
}
# Return the new tokens and user data
return {
"access_token": response.session.access_token,
"refresh_token": response.session.refresh_token,
"expires_at": int(response.session.expires_at),
"user": user_data
}
except Exception as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Failed to refresh token: {str(e)}")

View file

@ -1,11 +1,10 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from typing import Optional
from supabase import Client
from .config import settings
from jose import JWTError, jwt
import os
from app.config import settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login", auto_error=False)
def get_supabase() -> Client:
from supabase import create_client
@ -26,21 +25,28 @@ def get_supabase() -> Client:
return create_client(url, key)
# Updated current user dependency
async def get_current_user(
async def get_user_from_token(
token: str = Depends(oauth2_scheme),
supabase: Client = Depends(get_supabase)
):
try:
# Get user from Supabase auth
return supabase.auth.get_user(token)
except Exception as e:
return None
def get_current_user_required(user: Optional[dict] = Depends(get_user_from_token)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
# Get user from Supabase auth
user = supabase.auth.get_user(token)
if not user:
raise credentials_exception
return user
except Exception as e:
raise credentials_exception
if not user:
raise credentials_exception
return user
def get_current_user_optional(
user: Optional[dict] = Depends(get_user_from_token)
) -> Optional[dict]:
return user

View file

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,11 @@
from pydantic import BaseModel
class RefreshToken(BaseModel):
refresh_token: str
class RefreshResponse(BaseModel):
access_token: str
refresh_token: str
expires_at: int
user: dict

View file

@ -0,0 +1,43 @@
from pydantic import BaseModel, EmailStr, field_validator, Field, SecretStr
from pydantic_core.core_schema import FieldValidationInfo
class UserCreate(BaseModel):
email: EmailStr
first_name: str
last_name: str
password: str
confirm_password: str
business_name: str
@field_validator("email")
def email_must_contain_at_symbol(cls, v):
if '@' not in v:
raise ValueError("Entrer un email valide")
return v
@field_validator("password")
def password_must_contain_at_least_8_characters(cls, v):
if len(v) < 8:
raise ValueError("Le mot de passe doit contenir au moins 8 caractères")
return v
@field_validator("business_name")
def business_name_must_contain_at_least_3_characters(cls, v):
if len(v) < 3:
raise ValueError("Le nom de la société doit contenir au moins 3 caractères")
return v
@field_validator('confirm_password', mode='before')
def passwords_match(cls, v, info: FieldValidationInfo):
if 'password' in info.data and v != info.data['password']:
raise ValueError('Les mots de passe ne correspondent pas')
return v
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserOut(BaseModel):
email: EmailStr
business_name: str

View file

@ -48,6 +48,7 @@
"@tanstack/react-query": "^5.69.0",
"@types/react-router-dom": "^5.3.3",
"axios": "^1.8.4",
"jwt-decode": "^4.0.0",
"react-router-dom": "^7.3.0",
"react-stately": "^3.36.1",
"ts-pattern": "^5.6.2"

View file

@ -23,6 +23,9 @@ importers:
axios:
specifier: ^1.8.4
version: 1.8.4
jwt-decode:
specifier: ^4.0.0
version: 4.0.0
react-router-dom:
specifier: ^7.3.0
version: 7.3.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -2073,6 +2076,10 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@ -5295,6 +5302,8 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
jwt-decode@4.0.0: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1

View file

@ -2,39 +2,52 @@ import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import { LoginPage } from "./pages/login";
import { SignUpPage } from "./pages/signup";
import { ThemeProvider } from "./contexts/ThemeContext";
import { AuthProvider } from "./contexts/AuthContext";
import { twMerge } from "tailwind-merge";
import { ResetPasswordPage } from "./pages/reset-password";
import { LandingPage } from "./pages/landing";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { TabloPage } from "./pages/tablo";
export const App = () => {
return (
<ThemeProvider>
<Router>
<div
className={twMerge(
"min-h-screen bg-gradient-to-br from-emerald-100 via-green-100 to-white",
"dark:bg-gradient-to-br dark:from-[#0a1f0a] dark:via-[#051505] dark:to-black"
)}
>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignUpPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
</Routes>
<style>
{`
@keyframes slide {
0% { transform: translateX(-100vw); }
100% { transform: translateX(100vw); }
}
.animate-slide {
animation: slide 24s linear infinite;
}
`}
</style>
</div>
</Router>
<AuthProvider>
<Router>
<div
className={twMerge(
"min-h-screen bg-gradient-to-br from-emerald-100 via-green-100 to-white",
"dark:bg-gradient-to-br dark:from-[#0a1f0a] dark:via-[#051505] dark:to-black"
)}
>
<Routes>
<Route
path="/"
element={
<ProtectedRoute>
<TabloPage />
</ProtectedRoute>
}
/>
<Route path="/landing" element={<LandingPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignUpPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
</Routes>
<style>
{`
@keyframes slide {
0% { transform: translateX(-100vw); }
100% { transform: translateX(100vw); }
}
.animate-slide {
animation: slide 24s linear infinite;
}
`}
</style>
</div>
</Router>
</AuthProvider>
</ThemeProvider>
);
};

View file

@ -1,5 +1,5 @@
import "./login-with-google.css";
import { useLoginWithGoogle } from "../../hooks/useAuth";
import { useLoginWithGoogle } from "../../hooks/auth";
export function LoginWithGoogle() {
const { mutate: loginWithGoogle } = useLoginWithGoogle();

View file

@ -0,0 +1,18 @@
import { Navigate } from "react-router-dom";
import { ReactNode } from "react";
import { useAuth } from "../contexts/AuthContext";
interface ProtectedRouteProps {
children: ReactNode;
}
export const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
// Redirect to login page if user is not authenticated
return <Navigate to="/landing" replace />;
}
// If authenticated, render the protected component
return <>{children}</>;
};

View file

@ -0,0 +1,15 @@
import { useAuth } from "../contexts/AuthContext";
import { useNavigate } from "react-router-dom";
import { Button } from "../ui-library/button";
export const SignOutButton = () => {
const { logout } = useAuth();
const navigate = useNavigate();
const handleSignOut = () => {
logout();
navigate("/landing");
};
return <Button onPress={handleSignOut}>Se déconnecter</Button>;
};

View file

@ -0,0 +1,90 @@
import { jwtDecode } from "jwt-decode";
import { createContext, useContext, useState, ReactNode } from "react";
interface UserMetadata {
email: string;
first_name: string;
last_name: string;
business_name: string;
}
interface AuthContextType {
isAuthenticated: boolean;
login: (token: string) => void;
logout: () => void;
user: UserMetadata | null;
}
type SupabaseToken = {
iss: string;
sub: string;
exp: number;
iat: number;
email: string;
phone: string;
user_metadata: UserMetadata;
role: string;
aal: string;
session_id: string;
is_anonymous: boolean;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider = ({ children }: AuthProviderProps) => {
const [isAuthenticated, setIsAuthenticated] = useState(() => {
// Check if there's a token in localStorage on initial load
return !!localStorage.getItem("auth_token");
});
const decodeToken = (token: string) => {
try {
const decoded = jwtDecode(token) as SupabaseToken;
return decoded;
} catch (error) {
console.error("Error decoding token:", error);
return null;
}
};
const [user, setUser] = useState<UserMetadata | null>(() => {
const token = localStorage.getItem("auth_token");
if (!token) {
return null;
}
return decodeToken(token)?.user_metadata ?? null;
});
const login = (token: string) => {
localStorage.setItem("auth_token", token);
setIsAuthenticated(true);
const dcdToken = decodeToken(token);
if (dcdToken) {
setUser(dcdToken.user_metadata);
}
};
const logout = () => {
localStorage.removeItem("auth_token");
setIsAuthenticated(false);
setUser(null);
};
return (
<AuthContext.Provider value={{ isAuthenticated, login, logout, user }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};

View file

@ -4,6 +4,8 @@ import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { match } from "ts-pattern";
import { toast } from "../ui-library/toast/toast-queue";
import { useAuth } from "../contexts/AuthContext";
interface SignUpData {
email: string;
password: string;
@ -81,6 +83,7 @@ export function useSignUp() {
export function useLoginEmail() {
const navigate = useNavigate();
const { login } = useAuth();
const [errors, setErrors] = useState<Record<string, string>>({});
const { mutate, isPending } = useMutation<
unknown,
@ -94,10 +97,10 @@ export function useLoginEmail() {
>({
mutationFn: async (data: LoginData) => {
const response = await api.post("/auth/login", data);
login(response.data.access_token);
return response.data;
},
onSuccess: (data) => {
console.log("data", data);
onSuccess: () => {
navigate("/");
},
onError: (error) => {

View file

@ -1,10 +1,10 @@
import { Button } from "../ui-library/button";
import { twMerge } from "tailwind-merge";
import { useNavigate } from "react-router-dom";
import { LoginWithGoogle } from "../components/BrandButtons/LoginWIthGoogle";
import { LoginWithGoogle } from "../components/BrandButtons/LoginWithGoogle";
import { useState } from "react";
import { Label, Input, TextField, FieldError } from "../ui-library/field";
import { useLoginEmail } from "../hooks/useAuth";
import { useLoginEmail } from "../hooks/auth";
import { Form } from "../ui-library/form";
export function LoginPage() {

View file

@ -1,10 +1,10 @@
import { Button } from "../ui-library/button";
import { twMerge } from "tailwind-merge";
import { useNavigate } from "react-router-dom";
import { LoginWithGoogle } from "../components/BrandButtons/LoginWIthGoogle";
import { LoginWithGoogle } from "../components/BrandButtons/LoginWithGoogle";
import { useState } from "react";
import { Label, Input, TextField, FieldError } from "../ui-library/field";
import { useSignUp } from "../hooks/useAuth";
import { useSignUp } from "../hooks/auth";
import { Form } from "../ui-library/form";
import { Text } from "../ui-library/text";

38
ui/src/pages/tablo.tsx Normal file
View file

@ -0,0 +1,38 @@
import { useAuth } from "../contexts/AuthContext";
import { SignOutButton } from "../components/SignOutButton";
export const TabloPage = () => {
const { isAuthenticated, user } = useAuth();
return (
<div className="min-h-screen">
<header className="bg-white dark:bg-gray-800 shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Tablo
</h1>
<SignOutButton />
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Tableau de bord
</h1>
<div className="text-sm text-gray-600 dark:text-gray-300">
{isAuthenticated ? "Connected" : "Not connected"}
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p className="text-gray-600 dark:text-gray-300">
Bienvenue sur votre tableau de bord {user?.first_name}{" "}
{user?.last_name}
</p>
</div>
</div>
</main>
</div>
);
};