diff --git a/backend/.env.example b/backend/.env.example index 6a4bf87..d051cdb 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -10,6 +10,8 @@ FIRST_SUPERUSER=admin@example.com FIRST_SUPERUSER_PASSWORD=changethis USERS_OPEN_REGISTRATION=True +# Frontend +FRONTEND_URL="http://localhost:5173" # Postgres POSTGRES_SERVER=localhost @@ -24,3 +26,15 @@ AI_API_KEY="dummy_api_key" COLLECTION_GENERATION_PROMPT="I want to generate flashcards on a specific topic for efficient studying. Please create a set of flashcards covering key concepts, definitions, important details, and examples, with a focus on progressively building understanding of the topic. The flashcards should aim to provide a helpful learning experience by using structured explanations, real-world examples and formatting. Each flashcard should follow this format: Front (Question/Prompt): A clear and concise question or term to test recall, starting with introductory concepts and moving toward more complex details. Back (Answer): If the front is a concept or topic, provide a detailed explanation, broken down into clear paragraphs with easy-to-understand language. If possible, include a real-world example, analogy or illustrative diagrams to make the concept more memorable and relatable. If the front is a vocabulary word (for language learning), provide a direct translation in the target language. Optional Hint: A short clue to aid recall, especially for more complex concepts. Important: Use valid Markdown format for the back of the flashcard." CARD_GENERATION_PROMPT="I want to generate a flashcard on a specific topic. The contents of the flashcard should provide helpful information that aim to help the learner retain the concepts given. The flashcard must follow this format: Front (Question/Prompt): A clear and concise question or term to test recall. Back (Answer): If the front is a concept or topic, provide a detailed explanation, broken down into clear paragraphs with easy-to-understand language. If possible, include a real-world example, analogy or illustrative diagrams to make the concept more memorable and relatable. If the front is a vocabulary word (for language learning), provide a direct translation in the target language. Important: Use valid Markdown format for the back of the flashcard." + +# Auth0 Configuration +AUTH0_DOMAIN=auth0-domain +AUTH0_CLIENT_ID=auth0-client-id +AUTH0_CLIENT_SECRET=auth0-client-secret +AUTH0_CALLBACK_URL=auth0-callback-url + +ALLOWED_REDIRECT_ORIGINS=http://localhost:5173 + +# Session Configuration +SECRET_KEY=secret-key + diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e11b525..0e2265c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,9 @@ dependencies = [ "fastapi-pagination>=0.12.34", "bcrypt==4.0.1", "google-genai>=1.5.0", + "starlette (>=0.46.2,<0.47.0)", + "itsdangerous (>=2.2.0,<3.0.0)", + "authlib (>=1.5.2,<2.0.0)", ] [tool.uv] diff --git a/backend/src/alembic/versions/1425c896d3ef_add_auth0_id_field_to_users_table.py b/backend/src/alembic/versions/1425c896d3ef_add_auth0_id_field_to_users_table.py new file mode 100644 index 0000000..870dae8 --- /dev/null +++ b/backend/src/alembic/versions/1425c896d3ef_add_auth0_id_field_to_users_table.py @@ -0,0 +1,38 @@ +"""Add auth0_id field to Users table + +Revision ID: 1425c896d3ef +Revises: cb16ae472c1e +Create Date: 2025-05-10 00:12:00.358973 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '1425c896d3ef' +down_revision = 'cb16ae472c1e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('auth0_id', sa.String(), nullable=True)) + + op.alter_column('user', 'hashed_password', + existing_type=sa.VARCHAR(), + nullable=True) + op.create_index(op.f('ix_user_auth0_id'), 'user', ['auth0_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_user_auth0_id'), table_name='user') + op.drop_column('user', 'auth0_id') + op.alter_column('user', 'hashed_password', + existing_type=sa.VARCHAR(), + nullable=False) + # ### end Alembic commands ### diff --git a/backend/src/auth/api.py b/backend/src/auth/api.py index 2ee4849..f4448f9 100644 --- a/backend/src/auth/api.py +++ b/backend/src/auth/api.py @@ -1,7 +1,7 @@ from datetime import timedelta from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.security import OAuth2PasswordRequestForm from src.auth.schemas import Token @@ -15,7 +15,9 @@ @router.post("/tokens") def login_access_token( - session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] + request: Request, + session: SessionDep, + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], ) -> Token: user = services.authenticate( session=session, email=form_data.username, password=form_data.password @@ -24,9 +26,12 @@ def login_access_token( raise HTTPException(status_code=400, detail="Incorrect email or password") elif not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - return Token( - access_token=services.create_access_token( - user.id, expires_delta=access_token_expires - ) - ) + + # Create token + token = services.create_access_token(user.id, expires_delta=access_token_expires) + + request.session["user_id"] = str(user.id) + + return Token(access_token=token) diff --git a/backend/src/auth/auth0_api.py b/backend/src/auth/auth0_api.py new file mode 100644 index 0000000..28c0d32 --- /dev/null +++ b/backend/src/auth/auth0_api.py @@ -0,0 +1,68 @@ +from authlib.integrations.starlette_client import OAuth +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import RedirectResponse +from sqlmodel import Session + +from src.auth.services import get_or_create_user_by_email # Fix the import path +from src.core.config import settings +from src.core.db import get_db + +router = APIRouter(tags=["auth"]) + +oauth = OAuth() +oauth.register( + name="auth0", + client_id=settings.AUTH0_CLIENT_ID, + client_secret=settings.AUTH0_CLIENT_SECRET, + server_metadata_url=f"https://{settings.AUTH0_DOMAIN}/.well-known/openid-configuration", + client_kwargs={"scope": "openid profile email"}, +) + + +@router.get("/login", name="auth0_login") +async def login(request: Request, redirect_to: str = "/collections"): + request.session["redirect_to"] = redirect_to + redirect_uri = request.url_for("auth0_callback") + return await oauth.auth0.authorize_redirect( + request, redirect_uri, prompt="select_account", connection="google-oauth2" + ) + + +@router.get("/callback", name="auth0_callback") +async def auth0_callback(request: Request, db: Session = Depends(get_db)): + # Exchange code for token + token = await oauth.auth0.authorize_access_token(request) + + # Extract user info + user_info = token.get("userinfo") + if not user_info: + user_info = await oauth.auth0.userinfo(token=token) + + if not user_info or "email" not in user_info: + raise HTTPException(status_code=400, detail="Invalid user info from Auth0") + + # Create or get user in local DB + db_user = get_or_create_user_by_email( + session=db, + email=user_info["email"], + defaults={ + "auth0_id": user_info["sub"], + "full_name": user_info.get("name"), + "is_active": True, + }, + ) + + # Store user in session + request.session["user_id"] = str(db_user.id) + + # Determine redirect target + redirect_to = request.session.pop("redirect_to", "/collections") + redirect_url = f"{settings.FRONTEND_URL}{redirect_to}" + + return RedirectResponse(url=redirect_url) + + +@router.get("/logout", name="auth0_logout") +async def logout(request: Request): + request.session.clear() + return {"detail": "Logged out"} diff --git a/backend/src/auth/services.py b/backend/src/auth/services.py index 9b98387..fac64dd 100644 --- a/backend/src/auth/services.py +++ b/backend/src/auth/services.py @@ -1,18 +1,20 @@ +import uuid from datetime import datetime, timedelta, timezone from typing import Annotated, Any import jwt -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request from fastapi.security import OAuth2PasswordBearer from jwt.exceptions import InvalidTokenError from passlib.context import CryptContext from pydantic import ValidationError -from sqlmodel import Session +from sqlmodel import Session, select from src.auth.schemas import TokenPayload from src.core.config import settings from src.core.db import get_db from src.users.models import User +from src.users.schemas import UserPublic ALGORITHM = "HS256" @@ -23,21 +25,64 @@ TokenDep = Annotated[str, Depends(reusable_oauth2)] -def get_current_user(session: SessionDep, token: TokenDep) -> User: +def get_user_from_session(request: Request, session: SessionDep) -> User: + user_id = request.session.get("user_id") + if not user_id: + raise HTTPException(status_code=401, detail="Not authenticated (no session)") + + from src.users.services import get_user_by_id + + try: + user_uuid = uuid.UUID(user_id) + user = get_user_by_id(session=session, user_id=user_uuid) + if not user or not user.is_active: + raise HTTPException(status_code=401, detail="Invalid session user") + return UserPublic.model_validate(user) + except (ValueError, TypeError): + raise HTTPException(status_code=401, detail="Invalid user ID in session") + + +def get_user_from_token( + session: SessionDep, + token: Annotated[str, Depends(reusable_oauth2)], +) -> User: try: payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) token_data = TokenPayload(**payload) + + user = session.get(User, token_data.sub) + + if not user or not user.is_active: + raise HTTPException(status_code=401, detail="Invalid user") + + return user + except (InvalidTokenError, ValidationError): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Could not validate credentials", - ) - user = session.get(User, token_data.sub) - if not user: - raise HTTPException(status_code=404, detail="User not found") - if not user.is_active: - raise HTTPException(status_code=400, detail="Inactive user") - return user + raise HTTPException(status_code=403, detail="Invalid token") + + +def get_current_user( + request: Request, + session: SessionDep, + token: Annotated[ + str | None, + Depends( + OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/tokens", auto_error=False + ) + ), + ] = None, +) -> User: + # Check for token-based authentication first + if token: + return get_user_from_token(session, token) + + # Check for session-based authentication + if request.session.get("user_id"): + return get_user_from_session(request, session) + + # No valid authentication method found + raise HTTPException(status_code=401, detail="Not authenticated") CurrentUser = Annotated[User, Depends(get_current_user)] @@ -53,8 +98,15 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None: db_user = get_user_by_email(session=session, email=email) if not db_user: return None + + # Auth0 users may not have a password + if not db_user.hashed_password: + # Return None for users without a password when using password authentication + return None + if not verify_password(password, db_user.hashed_password): return None + return db_user @@ -67,3 +119,18 @@ def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: def get_password_hash(password: str) -> str: return pwd_context.hash(password) + + +def get_or_create_user_by_email( + session: Session, + email: str, + defaults: dict | None = None, +) -> User: + user = session.exec(select(User).where(User.email == email)).first() + if user: + return user + user = User(email=email, **defaults) + session.add(user) + session.commit() + session.refresh(user) + return user diff --git a/backend/src/core/config.py b/backend/src/core/config.py index bd66a54..e1aa8aa 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -30,9 +30,12 @@ class Settings(BaseSettings): SECRET_KEY: str = secrets.token_urlsafe(32) # 60 minutes * 24 hours * 8 days = 8 days ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 + AUTH_EXPIRE_MINUTES: int = 60 * 24 * 8 DOMAIN: str = "localhost" ENVIRONMENT: Literal["local", "staging", "production"] = "local" + FRONTEND_URL: str + BACKEND_CORS_ORIGINS: Annotated[ list[AnyUrl] | str, BeforeValidator(parse_cors) ] = [] @@ -46,6 +49,14 @@ class Settings(BaseSettings): POSTGRES_PASSWORD: str POSTGRES_DB: str = "" + AUTH0_CLIENT_ID: str + AUTH0_CLIENT_SECRET: str + AUTH0_DOMAIN: str + + ALLOWED_REDIRECT_ORIGINS: Annotated[ + list[str] | str, BeforeValidator(parse_cors) + ] = [] + @computed_field # type: ignore[misc] @property def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: @@ -91,6 +102,7 @@ def _enforce_non_default_secrets(self) -> Self: self._check_default_secret( "FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD ) + self._check_default_secret("AUTH0_CLIENT_SECRET", self.AUTH0_CLIENT_SECRET) return self diff --git a/backend/src/main.py b/backend/src/main.py index 91ba021..516cafc 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -2,6 +2,7 @@ from fastapi.routing import APIRoute from fastapi_pagination import add_pagination from starlette.middleware.cors import CORSMiddleware +from starlette.middleware.sessions import SessionMiddleware from src.core.config import settings from src.routers import api_router @@ -29,5 +30,14 @@ def custom_generate_unique_id(route: APIRoute) -> str: allow_headers=["*"], ) +# Setup session middleware +app.add_middleware( + SessionMiddleware, + secret_key=settings.SECRET_KEY, + same_site="lax", # adjust for production + https_only=False, + max_age=settings.AUTH_EXPIRE_MINUTES * 60, +) + app.include_router(api_router, prefix=settings.API_V1_STR) add_pagination(app) diff --git a/backend/src/routers.py b/backend/src/routers.py index 1908455..6718ed5 100644 --- a/backend/src/routers.py +++ b/backend/src/routers.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from src.auth.api import router as auth_router +from src.auth.auth0_api import router as auth0_router from src.flashcards.api import router as flashcards_router from src.stats.api import router as stats_router from src.users.api import router as user_router @@ -11,3 +12,4 @@ api_router.include_router(user_router, prefix="/users", tags=["users"]) api_router.include_router(flashcards_router, tags=["flashcards"]) api_router.include_router(stats_router, tags=["stats"]) +api_router.include_router(auth0_router, prefix="/auth0", tags=["auth0"]) diff --git a/backend/src/users/models.py b/backend/src/users/models.py index 528005a..717114d 100644 --- a/backend/src/users/models.py +++ b/backend/src/users/models.py @@ -11,7 +11,8 @@ class User(UserBase, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - hashed_password: str + auth0_id: str | None = Field(default=None, index=True) + hashed_password: str | None = Field(default=None) collections: list["Collection"] = Relationship( back_populates="user", cascade_delete=True, diff --git a/backend/src/users/schemas.py b/backend/src/users/schemas.py index 5da1ce6..d2c5e39 100644 --- a/backend/src/users/schemas.py +++ b/backend/src/users/schemas.py @@ -16,7 +16,8 @@ class UserUpdate(UserBase): class UserCreate(UserBase): - password: str = Field(min_length=8, max_length=40) + password: str | None = Field(default=None, min_length=8, max_length=40) + auth0_id: str | None = None class UserRegister(SQLModel): @@ -26,3 +27,4 @@ class UserRegister(SQLModel): class UserPublic(UserBase): id: uuid.UUID + auth0_id: str | None = None diff --git a/backend/src/users/services.py b/backend/src/users/services.py index 474948e..2509552 100644 --- a/backend/src/users/services.py +++ b/backend/src/users/services.py @@ -9,9 +9,15 @@ def create_user(*, session: Session, user_create: UserCreate) -> User: - db_obj = User.model_validate( - user_create, update={"hashed_password": get_password_hash(user_create.password)} - ) + # Prepare update data + update_data = {} + + # Only hash password if it's provided + if user_create.password: + update_data["hashed_password"] = get_password_hash(user_create.password) + + # Create user object + db_obj = User.model_validate(user_create, update=update_data) session.add(db_obj) session.commit() session.refresh(db_obj) diff --git a/backend/tests/flashcards/collection/test_api.py b/backend/tests/flashcards/collection/test_api.py index d2bdff0..2fd59b4 100644 --- a/backend/tests/flashcards/collection/test_api.py +++ b/backend/tests/flashcards/collection/test_api.py @@ -338,13 +338,13 @@ def test_different_user_delete( test_collection: dict[str, Any], ): collection_id = test_collection["id"] + rsp = client.delete( f"{settings.API_V1_STR}/collections/{collection_id}", headers=superuser_token_headers, ) assert rsp.status_code == 404 - # Verity the data still exists verify_rsp = client.get( f"{settings.API_V1_STR}/collections/{collection_id}", diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 304e480..1e08b45 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -150,6 +150,7 @@ "github": "GitHub", "medium": "Medium", "noCardsAdded": "No cards added yet.", + "or": "or", "password": "Password", "statistics": "Statistics", "totalCards": "Total Cards", diff --git a/frontend/public/locales/es/translation.json b/frontend/public/locales/es/translation.json index 62f764e..6202d36 100644 --- a/frontend/public/locales/es/translation.json +++ b/frontend/public/locales/es/translation.json @@ -150,6 +150,7 @@ "github": "GitHub", "medium": "Medio", "noCardsAdded": "No se han agregado tarjetas aún.", + "or": "o", "password": "Contraseña", "statistics": "Estadísticas", "totalCards": "Total de Tarjetas", diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index 39a694c..79d7eb0 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -150,6 +150,7 @@ "github": "GitHub", "medium": "Gemiddeld", "noCardsAdded": "Nog geen kaarten toegevoegd.", + "or": "of", "password": "Wachtwoord", "statistics": "Statistieken", "totalCards": "Totaal Aantal Kaarten", diff --git a/frontend/src/assets/google-icon.svg b/frontend/src/assets/google-icon.svg new file mode 100644 index 0000000..b4eafe7 --- /dev/null +++ b/frontend/src/assets/google-icon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/components/commonUI/Drawer.tsx b/frontend/src/components/commonUI/Drawer.tsx index 1b420e6..67d93b7 100644 --- a/frontend/src/components/commonUI/Drawer.tsx +++ b/frontend/src/components/commonUI/Drawer.tsx @@ -1,4 +1,5 @@ import Logo from '@/assets/Logo.svg' +import { UsersService } from '@/client' import type { Collection } from '@/client/types.gen' import { useColorMode } from '@/components/ui/color-mode' import { @@ -13,7 +14,7 @@ import { import useAuth from '@/hooks/useAuth' import { getCollections } from '@/services/collections' import { HStack, IconButton, Image, List, Spinner, Text, VStack } from '@chakra-ui/react' -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { Link, useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { FiLogOut, FiMoon, FiSun } from 'react-icons/fi' @@ -54,14 +55,23 @@ function Drawer({ isOpen, setIsOpen }: { isOpen: boolean; setIsOpen: (open: bool const { t } = useTranslation() const { logout } = useAuth() const { colorMode, toggleColorMode } = useColorMode() - const queryClient = useQueryClient() - const currentUser = queryClient.getQueryData<{ email: string }>(['currentUser']) - const flashcardsService = { getCollections } const navigate = useNavigate() + const { data: currentUser } = useQuery({ + queryKey: ['currentUser'], + queryFn: async () => { + try { + return await UsersService.readUserMe() + } catch (error) { + return null + } + }, + }) + + // Fetch collections data const { data, isLoading } = useQuery({ queryKey: ['collections'], - queryFn: flashcardsService.getCollections, + queryFn: getCollections, placeholderData: (prevData) => prevData, }) diff --git a/frontend/src/components/commonUI/GoogleAuthButton.tsx b/frontend/src/components/commonUI/GoogleAuthButton.tsx new file mode 100644 index 0000000..873b131 --- /dev/null +++ b/frontend/src/components/commonUI/GoogleAuthButton.tsx @@ -0,0 +1,35 @@ +import GoogleIcon from '@/assets/google-icon.svg' +import { Button, type ButtonProps, HStack, Image, Text } from '@chakra-ui/react' +import { forwardRef } from 'react' + +interface GoogleAuthButtonProps extends ButtonProps { + action: 'login' | 'signup' +} + +export const GoogleAuthButton = forwardRef( + ({ action, ...props }, ref) => ( + + ), +) + +GoogleAuthButton.displayName = 'GoogleAuthButton' diff --git a/frontend/src/hooks/useAuthContext.tsx b/frontend/src/hooks/useAuthContext.tsx index 2ecfa75..8879b5f 100644 --- a/frontend/src/hooks/useAuthContext.tsx +++ b/frontend/src/hooks/useAuthContext.tsx @@ -1,3 +1,4 @@ +import { UsersService } from '@/client' import type React from 'react' import { createContext, useContext, useEffect, useState } from 'react' @@ -8,7 +9,7 @@ interface AuthContextType { isGuest: boolean isLoggedIn: boolean setGuestMode: (value: boolean) => void - logout: () => void + logout: () => Promise } const AuthContext = createContext(undefined) @@ -21,6 +22,23 @@ function AuthProvider({ children }: { children: React.ReactNode }) { localStorage.getItem(GUEST_MODE_KEY) === 'true', ) + useEffect(() => { + const checkUser = async () => { + if (localStorage.getItem(GUEST_MODE_KEY) === 'true') { + setIsLoggedIn(true) + return + } + + try { + const user = await UsersService.readUserMe() + setIsLoggedIn(Boolean(user)) + } catch (error) { + console.error('Error fetching user:', error) + } + } + checkUser() + }, []) + useEffect(() => { const handleStorage = (e: StorageEvent) => { if (e.key === GUEST_MODE_KEY || e.key === ACCESS_TOKEN_KEY) { @@ -45,8 +63,25 @@ function AuthProvider({ children }: { children: React.ReactNode }) { setIsLoggedIn(value || Boolean(localStorage.getItem(ACCESS_TOKEN_KEY))) } - const logout = () => { - localStorage.removeItem(ACCESS_TOKEN_KEY) + const logout = async () => { + const isGuest = localStorage.getItem('guest_mode') === 'true' + const hasToken = Boolean(localStorage.getItem('access_token')) + + try { + if (isGuest) { + localStorage.removeItem('guest_mode') + } else if (hasToken) { + localStorage.removeItem('access_token') + } + + await fetch(`${import.meta.env.VITE_API_URL}/api/v1/auth0/logout`, { + method: 'GET', + credentials: 'include', + }) + } catch (e) { + console.error('Logout request failed', e) + } + setGuestMode(false) setIsLoggedIn(false) } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 5b0e7bd..51b3006 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -12,6 +12,7 @@ import { routeTree } from './routeTree.gen' import { system } from './theme' OpenAPI.BASE = import.meta.env.VITE_API_URL +OpenAPI.WITH_CREDENTIALS = true OpenAPI.TOKEN = async () => { return localStorage.getItem('access_token') || '' } diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx index feba07d..4b61ec9 100644 --- a/frontend/src/routes/_layout.tsx +++ b/frontend/src/routes/_layout.tsx @@ -1,3 +1,4 @@ +import { UsersService } from '@/client' import Navbar from '@/components/commonUI/Navbar' import { Toaster } from '@/components/ui/toaster' import { Container } from '@chakra-ui/react' @@ -6,14 +7,18 @@ import { Outlet, createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/_layout')({ component: Layout, beforeLoad: async () => { - // NOTE: Direct localStorage access is used here because React context is not available in router guards. - // For all React components, use useAuthContext() from './hooks/useAuthContext' instead. const isGuest = localStorage.getItem('guest_mode') === 'true' - const isLoggedIn = Boolean(localStorage.getItem('access_token')) || isGuest - if (!isLoggedIn) { - throw redirect({ - to: '/login', - }) + if (isGuest) { + return + } + try { + const user = await UsersService.readUserMe() + if (!user) { + throw redirect({ to: '/login' }) + } + } catch (err) { + console.error('Failed to check auth state:', err) + throw redirect({ to: '/login' }) } }, }) diff --git a/frontend/src/routes/_publicLayout/login.tsx b/frontend/src/routes/_publicLayout/login.tsx index d9cfbc3..e1fdffa 100644 --- a/frontend/src/routes/_publicLayout/login.tsx +++ b/frontend/src/routes/_publicLayout/login.tsx @@ -1,25 +1,27 @@ import Logo from '@/assets/Logo.svg' +import { UsersService } from '@/client' import useAuth from '@/hooks/useAuth' -import { Container, Field, Fieldset, Image, Text } from '@chakra-ui/react' +import { Box, Container, Field, Fieldset, HStack, Image, Text, VStack } from '@chakra-ui/react' import { Link, createFileRoute, redirect } from '@tanstack/react-router' import { type SubmitHandler, useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import type { Body_login_login_access_token as AccessToken } from '../../client' import { DefaultButton } from '../../components/commonUI/Button' +import { GoogleAuthButton } from '../../components/commonUI/GoogleAuthButton' import { DefaultInput } from '../../components/commonUI/Input' import { emailPattern } from '../../utils' export const Route = createFileRoute('/_publicLayout/login')({ component: Login, beforeLoad: async () => { - // NOTE: Direct localStorage access is used here because React context is not available in router guards. - // For all React components, use useAuthContext() from './hooks/useAuthContext' instead. - const isGuest = localStorage.getItem('guest_mode') === 'true' - const isLoggedIn = Boolean(localStorage.getItem('access_token')) || isGuest - if (isLoggedIn) { - throw redirect({ - to: '/collections', - }) + try { + const user = await UsersService.readUserMe() + const isGuest = localStorage.getItem('guest_mode') === 'true' + if (user || isGuest) { + throw redirect({ to: '/collections' }) + } + } catch (error) { + // Continue to login page if user is not authenticated } }, }) @@ -42,9 +44,7 @@ function Login() { const onSubmit: SubmitHandler = async (data) => { if (isSubmitting) return - resetError() - try { await loginMutation.mutateAsync(data) } catch { @@ -52,6 +52,10 @@ function Login() { } } + const handleGoogleLogin = () => { + window.location.href = `${import.meta.env.VITE_API_URL}/api/v1/auth0/login?redirect_to=/collections` + } + return ( -
- - - - {t('general.words.email')} - - {errors.username && ( - - {errors.username.message} - - )} + + + + + - {t('general.words.password')} + {t('general.words.email')} - {error && ( + {errors.username && ( - {error} + {errors.username.message} )} + + {t('general.words.password')} + + {error && ( + + {error} + + )} + - - - - {t('general.actions.login')} - - -
- - {t('routes.publicLayout.login.dontHaveAccount')}{' '} - - - {t('general.actions.signUp')} + + + {t('general.actions.login')} + + + + + + + + {t('general.words.or')} - - + + + + + + + + {t('routes.publicLayout.login.dontHaveAccount')}{' '} + + + {t('general.actions.signUp')} + + + +
) } diff --git a/frontend/src/routes/_publicLayout/signup.tsx b/frontend/src/routes/_publicLayout/signup.tsx index d7671b9..ef8048c 100644 --- a/frontend/src/routes/_publicLayout/signup.tsx +++ b/frontend/src/routes/_publicLayout/signup.tsx @@ -1,10 +1,21 @@ import Logo from '@/assets/Logo.svg' import useAuth from '@/hooks/useAuth' -import { Button, Container, Field, Fieldset, Image, Text } from '@chakra-ui/react' +import { + Box, + Button, + Container, + Field, + Fieldset, + HStack, + Image, + Text, + VStack, +} from '@chakra-ui/react' import { Link, createFileRoute, redirect } from '@tanstack/react-router' import { type SubmitHandler, useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import type { UserRegister } from '../../client' +import { GoogleAuthButton } from '../../components/commonUI/GoogleAuthButton' import { DefaultInput } from '../../components/commonUI/Input' import PasswordInput from '../../components/commonUI/PasswordInput' import { confirmPasswordRules, emailPattern, passwordRules } from '../../utils' @@ -52,6 +63,10 @@ function SignUp() { signUpMutation.mutate(data) } + const handleGoogleSignup = () => { + window.location.href = `${import.meta.env.VITE_API_URL}/api/v1/auth0/login?redirect_to=/collections` + } + return ( Logo -
- - - - {t('general.words.email')} - - {errors.email && ( - - {errors.email.message} - - )} - {error && ( - - {error} - - )} - - - {t('general.words.password')} - - {errors.password && ( - - {errors.password.message} - - )} - + + + + + + {t('general.words.email')} + + {errors.email && ( + + {errors.email.message} + + )} + {error && ( + + {error} + + )} + + + + {t('general.words.password')} + + {errors.password && ( + + {errors.password.message} + + )} + - - {t('general.actions.confirmPassword')} - - {errors.confirm_password && ( - - {errors.confirm_password.message} - - )} - - - - - - - {t('routes.publicLayout.signUp.alreadyHaveAccount')}{' '} - - - {t('general.actions.login')}! + + {t('general.actions.confirmPassword')} + + {errors.confirm_password && ( + + {errors.confirm_password.message} + + )} + + + +
+ + + + + + {t('general.words.or')} - - + + + + + + + + + {t('routes.publicLayout.signUp.alreadyHaveAccount')}{' '} + + + {t('general.actions.login')}! + + + +
) }