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('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 (
-
+
+
+
+
+ {t('general.words.or')}
-
-
+
+
+
+
+
+
+
+
+ {t('routes.publicLayout.signUp.alreadyHaveAccount')}{' '}
+
+
+ {t('general.actions.login')}!
+
+
+
+
)
}