Implement user login and add tablo page
This commit is contained in:
parent
707d9aa5ac
commit
9b78fea02d
25 changed files with 423 additions and 262 deletions
Binary file not shown.
|
|
@ -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."""
|
||||
|
|
|
|||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
BIN
backend/app/routers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/routers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/auth.cpython-312.pyc
Normal file
BIN
backend/app/routers/__pycache__/auth.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/routers/__pycache__/helpers.cpython-312.pyc
Normal file
BIN
backend/app/routers/__pycache__/helpers.cpython-312.pyc
Normal file
Binary file not shown.
126
backend/app/routers/auth.py
Normal file
126
backend/app/routers/auth.py
Normal 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)}")
|
||||
|
||||
|
|
@ -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
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
BIN
backend/app/schemas/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/app/schemas/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/token.cpython-312.pyc
Normal file
BIN
backend/app/schemas/__pycache__/token.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/user.cpython-312.pyc
Normal file
BIN
backend/app/schemas/__pycache__/user.cpython-312.pyc
Normal file
Binary file not shown.
11
backend/app/schemas/token.py
Normal file
11
backend/app/schemas/token.py
Normal 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
|
||||
|
||||
43
backend/app/schemas/user.py
Normal file
43
backend/app/schemas/user.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
18
ui/src/components/ProtectedRoute.tsx
Normal file
18
ui/src/components/ProtectedRoute.tsx
Normal 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}</>;
|
||||
};
|
||||
15
ui/src/components/SignOutButton.tsx
Normal file
15
ui/src/components/SignOutButton.tsx
Normal 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>;
|
||||
};
|
||||
90
ui/src/contexts/AuthContext.tsx
Normal file
90
ui/src/contexts/AuthContext.tsx
Normal 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;
|
||||
};
|
||||
|
|
@ -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) => {
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
38
ui/src/pages/tablo.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in a new issue