From 9b78fea02d5e597130e57e78714a5c847ad1a1c8 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 23 Mar 2025 15:21:02 +0100 Subject: [PATCH] Implement user login and add tablo page --- backend/app/__pycache__/main.cpython-312.pyc | Bin 13629 -> 2880 bytes backend/app/main.py | 218 +----------------- backend/app/routers/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 183 bytes .../routers/__pycache__/auth.cpython-312.pyc | Bin 0 -> 6935 bytes .../__pycache__/helpers.cpython-312.pyc | Bin 0 -> 1965 bytes backend/app/routers/auth.py | 126 ++++++++++ backend/app/{auth.py => routers/helpers.py} | 34 +-- backend/app/schemas/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 183 bytes .../schemas/__pycache__/token.cpython-312.pyc | Bin 0 -> 740 bytes .../schemas/__pycache__/user.cpython-312.pyc | Bin 0 -> 2632 bytes backend/app/schemas/token.py | 11 + backend/app/schemas/user.py | 43 ++++ ui/package.json | 1 + ui/pnpm-lock.yaml | 9 + ui/src/App.tsx | 65 +++--- .../BrandButtons/LoginWIthGoogle.tsx | 2 +- ui/src/components/ProtectedRoute.tsx | 18 ++ ui/src/components/SignOutButton.tsx | 15 ++ ui/src/contexts/AuthContext.tsx | 90 ++++++++ ui/src/hooks/{useAuth.ts => auth.ts} | 7 +- ui/src/pages/login.tsx | 4 +- ui/src/pages/signup.tsx | 4 +- ui/src/pages/tablo.tsx | 38 +++ 25 files changed, 423 insertions(+), 262 deletions(-) create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/routers/__pycache__/auth.cpython-312.pyc create mode 100644 backend/app/routers/__pycache__/helpers.cpython-312.pyc create mode 100644 backend/app/routers/auth.py rename backend/app/{auth.py => routers/helpers.py} (64%) create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/schemas/__pycache__/token.cpython-312.pyc create mode 100644 backend/app/schemas/__pycache__/user.cpython-312.pyc create mode 100644 backend/app/schemas/token.py create mode 100644 backend/app/schemas/user.py create mode 100644 ui/src/components/ProtectedRoute.tsx create mode 100644 ui/src/components/SignOutButton.tsx create mode 100644 ui/src/contexts/AuthContext.tsx rename ui/src/hooks/{useAuth.ts => auth.ts} (96%) create mode 100644 ui/src/pages/tablo.tsx diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index 062418e39807067806bf7af13d5c6dc6ed69037b..c9569099995ac48ae815974a1aac40ad2c6b4612 100644 GIT binary patch delta 843 zcmZvaOH30%7{_Oq?E~5_eNjs3gJ&cY-1Cwlnt%z( zJ~B*`Fe%wjMraDAB=?h1nuck~10+MUFiUeVC;I>yqvLR#PQVE|2`A~)E}Wv%aGK^} zUJ8O_h8AE!a)=mo7S2l6$r(Ba=Oho3A}zs^flfcv!|W-7Bpd`+)TwrvnSHr9yk%4yRR5y{Eb#i8mbet@b+rUZklUm z3p-xcu(vUL9rP3xe$#t-upPWryvA6A6|Xc9Zu2MJwMg4jHtp@ZhT?de3~$u$9r|jD z`cvV{{&t`s5;MasRsP=p)Ke2^@QwcG{6YEAjN_xY)iN#o60nP6=U6PjmPB#1W_`w6)h=a+@ literal 13629 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 UR-whJMT3FegB80dMo-!P2NLfb3;+NC diff --git a/backend/app/main.py b/backend/app/main.py index 89aa2bb..75d965e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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.""" diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/__pycache__/__init__.cpython-312.pyc b/backend/app/routers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e50baeb8c8c6f4650338aca1b4a719a42fcc1ecc GIT binary patch literal 183 zcmX@j%ge<81dsmQPY2PDK?FMZ%mNgd&QQsq$>_I|p@<2{`wUX^D@;GMIJKx)Ke4DJ zqqImbDK#f2wJZ}z=)2@6m*%GCl@#k202SoxSCk|s<>c!Y=a&{Gr|KsqCTFMSrRXOX z6zCV_mzIFch>y?A%PfhH*DI*J#bJ}1pHiBWYFESxw1yFgi$RQ!%#4hTMa)1J00$~E AH~;_u literal 0 HcmV?d00001 diff --git a/backend/app/routers/__pycache__/auth.cpython-312.pyc b/backend/app/routers/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..61493de107b88d82a411ee8a293915ac4759a3d6 GIT binary patch literal 6935 zcmb_gYit`=cAf`^?}sFcB1MT3?Z}SkN}}zxT*;3X)FT@wvE;~dldwn#iZha^@Z~#0 z$rhw+Hfx~HudGp^vC$Ov_D^HjZPWb~peSJ3Ss;roP|ngQ>8Xu)(f(-vQFed^O@8#8 zOAbkCsogEM7npm`+opbIl91bf3#o~Rsq;xRMzvGKuj5Xr<2FEZTGa@6h zDJIQE*)$jB(tMO>v5!j`(n3_AG@ml2O;Ho24JmWl617lTNLkaisEyLbls)Z;Iw);Q zIn%DFE8P}tqwnTad$b*7T2dWpchsHkjCQ6yQ4bxnrn=JJs5kA4`qJIeZrW!{`O`ho zo^)@tmt{Cc5=8qFd)IM?t*$&8kOC{G_3boT(W!I599DAcZ;PzxlDuM@WLq?d?N@|5 zwJFj5J>+!gGk;BvTk_T9bT-Kef}F|5I&@0(>?sG=!iil?GWPGW7Vi!jojcwdJLKr9 zIMLT6@r6BB)xAT;Zi7%w2n3PFm zMcUUn8_6rnN6*D&c`ZxC2`Nr!zi~2^lrjp;GRu;pBr{8Lh||okyfHsbNS0{)%9=6* zORz6VN=(k@;tO$E(z@|2ktYPE#`3a6Vnn)_PZCLNF_fi?ji*40^#brt5-F}ong!|C zvP;Q~Wq?BY2*RU?HzEELgRL*E+*5D$DtGs(%NOe~)xeeg#5 zvFgF97pnkNnx$cr25NDrFhsziPBhurvBQUB6OpOd-1O@grq9o7ysVIrfq0S4hit5* znU-OL5|L$`9E9>eb73?LIUFa-a-NJVNU4;xnuIDml}+T+V0>~o2MpQpB?as~J1l4O zBq42o4`R}0mPv)EVnTo+C%(G>e_oK}Z zH%qPo)iqG|bblPZ7A^aFZ#r%`%D%o&mB0VN?H_zO|1aRudR&T^NbPj3mu(TO=X+?qvIbQFL)09(sro);^5z` z{$#Z$d};-!bq^9A$yZ!0#O|DAjYmiz%bahSal z>5B9)TYjXsdMp#e{MJCkFfqvA9~9u_{xA;%>&9@30*x&J5Cy+28BfVOAOcO9Ld54y z5TVo@Dwrgibu**@I~LsxScH=nAhBYq@-^43C9NPx?JBw>ZVev4X$C+@-B>rdrhNcp zG_9Fy1wSiwplK{eG>F0{9EG7e-~bFoHhF*{!v?njss*+ z_i{orE>vA1r`h9)1Ok#mJ&lz%G39bj(%i|+YCM$`V@>L_-an0i70l*?=`3K56h!2b zf=O_>;1?4L*$-P-cb}DCkA35Ee-Td^|PqITE`t6S*+|^6cE1==4-bAYtUAzyo+t z{T1X7un(6bBXA)A1<5c_nm~<*oCF#XQ2r%UHN5B&oR7_pf^+|^S4#t<>cHqj^YN$d zo{vvoJ6&=gSKY_Uo?yvyQ1u)vd4^QaP|-6|_PtQ@4XM7NlJBVMJ6iM|+ZOmSKnlLo zcGV0hVR06m{kMD%&4(&RCUBtCJEZmwm3qh3-tn@3KQOmhD-3V3e)F}NB8pjX{surv zs9oaK?>~0-Jnr3J>K#*i$I9NJvbXPRlfmUgWO9C6v4Dv0uO0~};;9rm zD-1bvB)ggf|3`wb9?gvYDwZcH@&deu%vDd^vBM~`$fNPdM3ujx_kC3FN5ScynVFxS zn~A&@J3l@5C)0DW>AAVtIfCv3tPl-=oWKfoUq*1LD}6gv`l|I+H%q2qd<+@0P}K~; zE?CRDzJE`3f3NHdK5YvW`lA(|^EtN#VH7l5K+Ue$nf9LRFW-Fa#%l#vs9+9#^Vrt; zj4=pSR8C)C$#+`yoqp`@`tij-KmN7AyQ*6LZG{Jx?_@-b57@|y?3Ll8%s(LUQ%huk z|HpwyKfl#40J$~5L+`o?6%(BP4*ynH$)AFlx=Q}eA+YX=ApHd8tf?ISZdz(8hu@$@ zsRzKVf?ma;^EIg)|65efFr%5$)nIMidp=KrdNH3$T@J!#mn0EjSgC4?sj4YvswvpJ zQ#A-$IX(Z|d?Wm7asI~YUnFOMA0PR3sA`99z!=8d_L1X5N6Fl$n)@D_gXOmFLeH^+ zYqVe|a|Vo&24Fh>PEH2|#Xj^3V&p4u-*U zI4!k4*nfrP=?5#a4VS;c=uvy^*y>>n92?vpCOwsg4q;gPQeBMwBkmc)!UPcGP^~ zvDnP)eC(y!3o}zy-I#!RR8=joIt1%ed&k4hfm`#% z&O>1D=$AP3OGd`twZpQHsJ^J*4IsQ)>?0WNJBqbSVeXjvR|CRYdB3 z2~q&?K1z1yCr<<^AkBo@tKO(?dV0iu9;8>@&m2ykF zF+G^a*If>69Q$ZMBam67>rstnp%PJA)xqtI>j_;1RuVk26GGT3vKc*s%+{m$!>ar6 zXZ@ukGwP9Z7f%xxD+_422MWq2d*!d`bN~gky78d+BaVG zpD1?*wk-l8vVe$erJ{5DRpqFFuVLPnJT0+#bmayoGYBY_|QoTW&$A>a_Sku zw1vGgWuIzizG!2C{-WJ7ZREf3zc|hEKVt=;e`e&NKXgztEyDd)98?4&$tKt1s`ZcH zWen&kK`hkT^xoPntxTx*s&~s0+^{B<%LK8xda4-JYgV|{fgn1wm|P+Vi<)623m(vh zTnK&|mxW6+WUAf$j4Vqjye1+Tc@a$S2?COh9}W(H~k z#EpY^VW=6a(ltXa3ppuXCeaL$ynz&k$~1JMo|9_YZRR<2GS!sQd3PERf5r6u8)Ny3Sx}jUub7i6a}w%bSvszF zT{l0p?0et%lr{ZX&kuX9{%Mizel+&S_w2>7v)hcFooBE6E42M|^%-rS^9<*&a5z-y zWjHgP_GXdodUSH)-l^irbK8uUbzXm~g3V_KpJ9V@ycG_p%2Ax+|DpftY?1AK6h8jh zMln3K%{W-=t;J{9Aa9_;0aXd=;@>K=-bY=5Lf;=2yT*S0M&Uxdcy?i%5nv7XUV=+8 zXmOga!Xa64>fd;^$ohU|>nON~inifL-oEP_MeoqI(9T+)Iy;|Xi_^L*98i^m`q!eL ty58?8vSW{4dA%_APVto(?nq`o?Zqw0>^%rhu*Iq4+psB_B};Gw{|m>#h)N6z-m%{r{OHiy{0a#R9SlSy%~P#3}?Vlrlt<6tV}mCNrI6!tBo4Jwph! zYenQBC%GkF6mC$!MXw&bd)ZX1B2I-emLBw$bvaP#!S`l&W5lv+ru%ij*WK@Z?|c1S zG8rS#zR@;jzE%nO87JKq>Iki0Kv*IsF{w?;v`EWRQ7X$tnc|vcD`mB)2C{62%Hd)- zkQF;pjuxYVtlF`1ycnlMB1||m@uO(IxU&8n@TYWdddP|lHD2D z#97KrfVE;KZ>q&~pighTnsFA8|2U# z+hP?LR|Rujt1=_5$8^}1x1AZQ@@7u*Bd&9WRVKOZqrxdM&K>TDICDK-G1@S+dj$p= z{?;sXkPQmRLpJIJ5T+)vr8;%H86$I%_NMK9UD~D(vc6qQNL`jljnt%?+|A86KrzZO zQ4m7X;7oT}$!NzEf|$IjKBWuBQ()msj^I%g65aKZ2Vwm)yTTT7N?^-1w3AUS!>Yj0 zhM@g1nV*E?L|*6atjC9^m~FGG7IgWuj^UL7OOdaFfs?=H>QlCJR5%_tSbj=307Wye zSF3sMcrKnX%WO0qt}ghA8RnL{Uhx;;Hgr@Rhi;KPQ4Y02L`^+Ska*wY*x*WRaPiD~ zthXyRVYt5Bl!gWRt35XkeVg9sz&Ev_I#Ve zWIoqwq)X;;k|i#l7PJF|$Li8$D7U)wk+jem=jlATCSNA=^pQMI@6%Bru7dRPBN(s; zQ2Nm=?&f5^6AlY}_9+v1Jz&mv!07jNH&?+1^J#j zLLaFG`73#Z`U<*ebc!8}2|)q}B!3%(CE{-B+%1&nwot(h?S{o*$`*a7Jm{cWr!^v^ z8odD{&{!S4zVD3V+2&Biafhzzwq*jBhk(*1kjm0+ab3G~>C#cGXxVi;E&K6y z*_RB*gk_Vtx@G&}S*Dx7FaQ=^;_!SDe+Rx7r4z@FmnKI~Pfnb__}*K^b7ylB9}2YF zE-bO`t0FMAZYBmW6Nbk*xDlkV#My_Q1&;E=V0H-p4sqA_XURAaAv`RJIYxZp71z7kEEx{TJ8>ZU8o-z67;DPWUbm??br<{tC3{ zFyy~}Ih}`&zYJDbO9HD3ND_jJJNJ#|`uy;;4)9eij(2T?O6#l-3kLC8?gB?Y{E)j) zg_n|t(HaYk<68#LW3@VLIF)H@rv1z$9i)1pB#c>BWH#6kqsm7_X)sc6b zBuceA=kHD29c`i5+)4Ii8`+bqyI*a}GS${%y|+)?8s0>CLrs2ae*D2x739G6zX7n> B-x&Y^ literal 0 HcmV?d00001 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..4e1e4ca --- /dev/null +++ b/backend/app/routers/auth.py @@ -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)}") + \ No newline at end of file diff --git a/backend/app/auth.py b/backend/app/routers/helpers.py similarity index 64% rename from backend/app/auth.py rename to backend/app/routers/helpers.py index c235e2c..f9c2c34 100644 --- a/backend/app/auth.py +++ b/backend/app/routers/helpers.py @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/__pycache__/__init__.cpython-312.pyc b/backend/app/schemas/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cd596f318f1ec0d8da5c59c381c6e4c3954bb944 GIT binary patch literal 183 zcmX@j%ge<81jhgFr-SInAOanHW&w&!XQ*V*Wb|9fP{ah}eFmxd6{a6roLW?@pIB6q zQCg&zl$w*1T9yeU^j-3kOLJ56N{aOhfC}>UD@qcRa`JVH^Gl18Q}vS)le1IvQuGrG z3iOMUGg5OCi}mB!4O1ma>4<0CU8BV!RWkOcsT CZZTT` literal 0 HcmV?d00001 diff --git a/backend/app/schemas/__pycache__/token.cpython-312.pyc b/backend/app/schemas/__pycache__/token.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..32a062308437c4e03d957bec626cad0260a525ef GIT binary patch literal 740 zcmZuv&1(}u6rb6h-J~&Tf~Bb^@wx}QH$f;Kq#y`JsP>Y}Fxh#U#r<+-R+_Wzy|?}i z#s5T)^$-RG!IL*3@#4uhy9p?CV1MuX&3nJuFM~mk;JF@sfBPBb4`*youu)b=s9X_A zB+W=pr<4+pJR{OOC(;M{$#3(fUgKrx4gT@^i!kc^bSV+FJ5Pm%7o~(OX^UFibResH zXkQT^Q!2^SJ0;))lhob6@FL&#PvDILJ%3%kgTf9}YjNYuNfg*F=Y`0DbKB!QFJ+xM zz0djkT4bAuuZ^-p&P7p_MwqlLH0Rf3KqEA z;|deX_{@k|RzB2atrCc5BEb=4EUGHj$sBT_<7TSEYM~sD4Y)OR7vX{|_r_n@SPizJ z+v#|O;oIqGi18dT5FkZZ`gP_ z+WYu&+1ovT-WIw1NyKcoT1ZitG*OP5mZ_%K|NU+EsC~^R>Hza7e)<8zFP~ESjoe=| WN)Ij{uL+8E$msAg{Y_9bm;D8iyRNeU literal 0 HcmV?d00001 diff --git a/backend/app/schemas/__pycache__/user.cpython-312.pyc b/backend/app/schemas/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fdd8914d21b933623aac90d589c40cf633be146f GIT binary patch literal 2632 zcmcIl&5smC6tDi8ov;0xWe0}`T01ohzF(32k7EQ$UPc=Iwim~1*4GvG~CH?Ey?^V5i^{Zc1 zec#)gCeSASzIWlcM#yjY(Q5E5TEmCe4Pp~pa)~FEB~LEPo>EpkwXAwtS(7j(y9uwS z+#?a094EGNjo2!cR+TtKIVrpvcoSXTl<@X|H`(P)FQkg;`#7+uMX4hur>6s(x>0h{ zGaYw2WKsXDLtWdLH(kdz!+=HFF?2+!X=*VVLYJZx4GG`7aUUn}Px!Ne6_*pE+WcsI z2gVITNm;T^sH?6UQ^rIGYfsi{wRHpQ}#BaW=JEAB41H@ z)?qv}eAA;*$~9YNvSM=nLBQ;2zzTfm;~8yF?@X0DKIL4DvYI~?zs3c{r|~iXh(cPD z<|UR1n-eA=gw`ZJg4i@EDpQg4)?$9r4;f{8)z?K?x>!64>y(Bis?tfu0=6PYiskZ1 zn&AMhOcu^n+2{;)T{`aol#T{g)uVpMOBLu4ls*j288_I&gDSIVX~wiJ zQQt0^l}d?QbMQ-EszRpG%0jeR6kvE&paP~InvQRnp}`ltnZR8f=`c9@ZymExAm0RV zh5VA~TQ)zL_$jx!wq<`kH&)Azt@RIFPc|S*p3dGlL~f7{YfBx;NS9#lz-V*pJeAS= zNf=*!9pDtzy&%+Ws^jWW-42{k$5~O|VY*p`sN-{eT(?YSTH)=RjB=#er9K;i@oWP? zlWn#U>}Xd@22U{LQdG~lVaOlY7h5kdE{8we3UGz|p6R=O`LnU*!=Jt(2%N6x z&epPL1%a#pUH%^ek=^Pc03gs0JS5OHbskvG?UnHM%CmUf*Ucl_4C9|9aPK3#=NT;S z>t^v#7mF`C_$8>}8B~_*x%0K``3{v)VusEJ40oOC!I>JV4jzhar9mpR@oBsdAojO> z5v<4CJYidwq@}0EYzdd`(t+oynDs%w#hs@pM-B6ih$zjK?6D4^Q^jnQG{h-s7*X0VfYqu?!Oj}S<*Mo8 zscRTah5S*!VVJ%jgk}i05r;ks4;jN-mf<2Ytb}btz}?M809J_D0%BGSZ)yRUjeidC zFSYeV_En*UYXu>4+K+wxFWw84f#5?ddj^)O8m z!^`Emrndvzx&lUB+tCi}>;!7VBRA*nY6pa;w)5a0q?vVG^p0!AGA(%84VMO6V>`g} z?%4&Us75JVu2aDU$7dNBBXlygl3=gkM-kyw1muD3LfDP42LYuQ(}iow5O%=B@wjf$ z)i-$Ublefc|FPB{to2Maj4K_0gi~xU;N#`IhCT$WpZ5Z^${EVvRr7H@7@sO;qGV;k zHvQ1C7?KxHR0t)3pFIFkq16TMFh)lajQFOD)OgRcMp literal 0 HcmV?d00001 diff --git a/backend/app/schemas/token.py b/backend/app/schemas/token.py new file mode 100644 index 0000000..9de02d8 --- /dev/null +++ b/backend/app/schemas/token.py @@ -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 + diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..f2809cd --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 diff --git a/ui/package.json b/ui/package.json index 5a768d1..ded9fc5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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" diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 716fecd..1d03a8c 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -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 diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 6caa8a9..5a8dc9c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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 ( - -
- - } /> - } /> - } /> - } /> - - -
-
+ + +
+ + + + + } + /> + } /> + } /> + } /> + } /> + + +
+
+
); }; diff --git a/ui/src/components/BrandButtons/LoginWIthGoogle.tsx b/ui/src/components/BrandButtons/LoginWIthGoogle.tsx index 1b82c20..588aefc 100644 --- a/ui/src/components/BrandButtons/LoginWIthGoogle.tsx +++ b/ui/src/components/BrandButtons/LoginWIthGoogle.tsx @@ -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(); diff --git a/ui/src/components/ProtectedRoute.tsx b/ui/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..9c5d0a7 --- /dev/null +++ b/ui/src/components/ProtectedRoute.tsx @@ -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 ; + } + + // If authenticated, render the protected component + return <>{children}; +}; diff --git a/ui/src/components/SignOutButton.tsx b/ui/src/components/SignOutButton.tsx new file mode 100644 index 0000000..7d38512 --- /dev/null +++ b/ui/src/components/SignOutButton.tsx @@ -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 ; +}; diff --git a/ui/src/contexts/AuthContext.tsx b/ui/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..5c5f548 --- /dev/null +++ b/ui/src/contexts/AuthContext.tsx @@ -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(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(() => { + 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 ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +}; diff --git a/ui/src/hooks/useAuth.ts b/ui/src/hooks/auth.ts similarity index 96% rename from ui/src/hooks/useAuth.ts rename to ui/src/hooks/auth.ts index c125a13..8d5ee01 100644 --- a/ui/src/hooks/useAuth.ts +++ b/ui/src/hooks/auth.ts @@ -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>({}); 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) => { diff --git a/ui/src/pages/login.tsx b/ui/src/pages/login.tsx index 9fdc7cb..ed89e81 100644 --- a/ui/src/pages/login.tsx +++ b/ui/src/pages/login.tsx @@ -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() { diff --git a/ui/src/pages/signup.tsx b/ui/src/pages/signup.tsx index ef2fc2e..638a349 100644 --- a/ui/src/pages/signup.tsx +++ b/ui/src/pages/signup.tsx @@ -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"; diff --git a/ui/src/pages/tablo.tsx b/ui/src/pages/tablo.tsx new file mode 100644 index 0000000..fdccffa --- /dev/null +++ b/ui/src/pages/tablo.tsx @@ -0,0 +1,38 @@ +import { useAuth } from "../contexts/AuthContext"; +import { SignOutButton } from "../components/SignOutButton"; + +export const TabloPage = () => { + const { isAuthenticated, user } = useAuth(); + + return ( +
+
+
+

+ Tablo +

+ +
+
+
+
+
+

+ Tableau de bord +

+
+ {isAuthenticated ? "Connected" : "Not connected"} +
+
+ +
+

+ Bienvenue sur votre tableau de bord {user?.first_name}{" "} + {user?.last_name} +

+
+
+
+
+ ); +};