diff --git a/be/alembic/versions/5e8b6dde50fc_update_user_model_for_fastapi_users.py b/be/alembic/versions/5e8b6dde50fc_update_user_model_for_fastapi_users.py new file mode 100644 index 0000000..667c3c8 --- /dev/null +++ b/be/alembic/versions/5e8b6dde50fc_update_user_model_for_fastapi_users.py @@ -0,0 +1,53 @@ +"""update_user_model_for_fastapi_users + +Revision ID: 5e8b6dde50fc +Revises: 7c26d62e8005 +Create Date: 2025-05-13 23:30:02.005611 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5e8b6dde50fc' +down_revision: Union[str, None] = '7c26d62e8005' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # 1. Add columns as nullable or with a default + op.add_column('users', sa.Column('hashed_password', sa.String(), nullable=True)) + op.add_column('users', sa.Column('is_active', sa.Boolean(), nullable=True, server_default=sa.sql.expression.true())) + op.add_column('users', sa.Column('is_superuser', sa.Boolean(), nullable=True, server_default=sa.sql.expression.false())) + op.add_column('users', sa.Column('is_verified', sa.Boolean(), nullable=True, server_default=sa.sql.expression.false())) + + # 2. Set default values for existing rows + op.execute("UPDATE users SET hashed_password = '' WHERE hashed_password IS NULL") + op.execute("UPDATE users SET is_active = true WHERE is_active IS NULL") + op.execute("UPDATE users SET is_superuser = false WHERE is_superuser IS NULL") + op.execute("UPDATE users SET is_verified = false WHERE is_verified IS NULL") + + # 3. Alter columns to be non-nullable + op.alter_column('users', 'hashed_password', nullable=False) + op.alter_column('users', 'is_active', nullable=False) + op.alter_column('users', 'is_superuser', nullable=False) + op.alter_column('users', 'is_verified', nullable=False) + + # 4. Drop the old column + op.drop_column('users', 'password_hash') + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('password_hash', sa.VARCHAR(), autoincrement=False, nullable=False)) + op.drop_column('users', 'is_verified') + op.drop_column('users', 'is_superuser') + op.drop_column('users', 'is_active') + op.drop_column('users', 'hashed_password') + # ### end Alembic commands ### diff --git a/be/app/api/auth/oauth.py b/be/app/api/auth/oauth.py new file mode 100644 index 0000000..cc0eb82 --- /dev/null +++ b/be/app/api/auth/oauth.py @@ -0,0 +1,91 @@ +from fastapi import APIRouter, Depends, Request +from fastapi.responses import RedirectResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import get_async_session +from app.models import User +from app.auth import oauth, fastapi_users +from app.config import settings + +router = APIRouter() + +@router.get('/google/login') +async def google_login(request: Request): + return await oauth.google.authorize_redirect(request, settings.GOOGLE_REDIRECT_URI) + +@router.get('/google/callback') +async def google_callback(request: Request, db: AsyncSession = Depends(get_async_session)): + token_data = await oauth.google.authorize_access_token(request) + user_info = await oauth.google.parse_id_token(request, token_data) + + # Check if user exists + existing_user = (await db.execute(select(User).where(User.email == user_info['email']))).scalar_one_or_none() + + user_to_login = existing_user + if not existing_user: + # Create new user + new_user = User( + email=user_info['email'], + name=user_info.get('name', user_info.get('email')), + is_verified=True, # Email is verified by Google + is_active=True + ) + db.add(new_user) + await db.commit() + await db.refresh(new_user) + user_to_login = new_user + + # Generate JWT token + strategy = fastapi_users._auth_backends[0].get_strategy() + token = await strategy.write_token(user_to_login) + + # Redirect to frontend with token + return RedirectResponse( + url=f"{settings.FRONTEND_URL}/auth/callback?token={token}" + ) + +@router.get('/apple/login') +async def apple_login(request: Request): + return await oauth.apple.authorize_redirect(request, settings.APPLE_REDIRECT_URI) + +@router.get('/apple/callback') +async def apple_callback(request: Request, db: AsyncSession = Depends(get_async_session)): + token_data = await oauth.apple.authorize_access_token(request) + user_info = token_data.get('user', await oauth.apple.userinfo(token=token_data) if hasattr(oauth.apple, 'userinfo') else {}) + if 'email' not in user_info and 'sub' in token_data: + parsed_id_token = await oauth.apple.parse_id_token(request, token_data) if hasattr(oauth.apple, 'parse_id_token') else {} + user_info = {**parsed_id_token, **user_info} + + if 'email' not in user_info: + return RedirectResponse(url=f"{settings.FRONTEND_URL}/auth/callback?error=apple_email_missing") + + # Check if user exists + existing_user = (await db.execute(select(User).where(User.email == user_info['email']))).scalar_one_or_none() + + user_to_login = existing_user + if not existing_user: + # Create new user + name_info = user_info.get('name', {}) + first_name = name_info.get('firstName', '') + last_name = name_info.get('lastName', '') + full_name = f"{first_name} {last_name}".strip() if first_name or last_name else user_info.get('email') + + new_user = User( + email=user_info['email'], + name=full_name, + is_verified=True, # Email is verified by Apple + is_active=True + ) + db.add(new_user) + await db.commit() + await db.refresh(new_user) + user_to_login = new_user + + # Generate JWT token + strategy = fastapi_users._auth_backends[0].get_strategy() + token = await strategy.write_token(user_to_login) + + # Redirect to frontend with token + return RedirectResponse( + url=f"{settings.FRONTEND_URL}/auth/callback?token={token}" + ) \ No newline at end of file diff --git a/be/app/api/v1/endpoints/financials.py b/be/app/api/v1/endpoints/financials.py index cab9ce5..558576b 100644 --- a/be/app/api/v1/endpoints/financials.py +++ b/be/app/api/v1/endpoints/financials.py @@ -436,7 +436,4 @@ async def delete_settlement_record( logger.error(f"Unexpected error deleting settlement {settlement_id}: {str(e)}", exc_info=True) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.") - return Response(status_code=status.HTTP_204_NO_CONTENT) - -# TODO (remaining from original list): -# (None - GET/POST/PUT/DELETE implemented for Expense/Settlement) \ No newline at end of file + return Response(status_code=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/be/app/auth.py b/be/app/auth.py new file mode 100644 index 0000000..6df0042 --- /dev/null +++ b/be/app/auth.py @@ -0,0 +1,90 @@ +from typing import Optional + +from fastapi import Depends, Request +from fastapi_users import BaseUserManager, FastAPIUsers, IntegerIDMixin +from fastapi_users.authentication import ( + AuthenticationBackend, + BearerTransport, + JWTStrategy, + OAuth2PasswordRequestForm, +) +from fastapi_users.db import SQLAlchemyUserDatabase +from sqlalchemy.ext.asyncio import AsyncSession +from authlib.integrations.starlette_client import OAuth +from starlette.config import Config +from starlette.middleware.sessions import SessionMiddleware + +from .database import get_async_session +from .models import User +from .config import settings + +# OAuth2 configuration +config = Config('.env') +oauth = OAuth(config) + +# Google OAuth2 setup +oauth.register( + name='google', + server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', + client_kwargs={ + 'scope': 'openid email profile', + 'redirect_uri': settings.GOOGLE_REDIRECT_URI + } +) + +# Apple OAuth2 setup +oauth.register( + name='apple', + server_metadata_url='https://appleid.apple.com/.well-known/openid-configuration', + client_kwargs={ + 'scope': 'openid email name', + 'redirect_uri': settings.APPLE_REDIRECT_URI + } +) + +class UserManager(IntegerIDMixin, BaseUserManager[User, int]): + reset_password_token_secret = settings.SECRET_KEY + verification_token_secret = settings.SECRET_KEY + + async def on_after_register(self, user: User, request: Optional[Request] = None): + print(f"User {user.id} has registered.") + + async def on_after_forgot_password( + self, user: User, token: str, request: Optional[Request] = None + ): + print(f"User {user.id} has forgot their password. Reset token: {token}") + + async def on_after_request_verify( + self, user: User, token: str, request: Optional[Request] = None + ): + print(f"Verification requested for user {user.id}. Verification token: {token}") + + async def on_after_login( + self, user: User, request: Optional[Request] = None + ): + print(f"User {user.id} has logged in.") + +async def get_user_db(session: AsyncSession = Depends(get_async_session)): + yield SQLAlchemyUserDatabase(session, User) + +async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)): + yield UserManager(user_db) + +bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") + +def get_jwt_strategy() -> JWTStrategy: + return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=3600) + +auth_backend = AuthenticationBackend( + name="jwt", + transport=bearer_transport, + get_strategy=get_jwt_strategy, +) + +fastapi_users = FastAPIUsers[User, int]( + get_user_manager, + [auth_backend], +) + +current_active_user = fastapi_users.current_user(active=True) +current_superuser = fastapi_users.current_user(active=True, superuser=True) \ No newline at end of file diff --git a/be/app/config.py b/be/app/config.py index 13796bd..554e576 100644 --- a/be/app/config.py +++ b/be/app/config.py @@ -4,6 +4,7 @@ from pydantic_settings import BaseSettings from dotenv import load_dotenv import logging import secrets +from typing import List load_dotenv() logger = logging.getLogger(__name__) @@ -13,11 +14,11 @@ class Settings(BaseSettings): GEMINI_API_KEY: str | None = None SENTRY_DSN: str | None = None # Sentry DSN for error tracking - # --- JWT Settings --- + # --- JWT Settings --- (SECRET_KEY is used by FastAPI-Users) SECRET_KEY: str # Must be set via environment variable - ALGORITHM: str = "HS256" - ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # Default token lifetime: 30 minutes - REFRESH_TOKEN_EXPIRE_MINUTES: int = 10080 # Default refresh token lifetime: 7 days + # ALGORITHM: str = "HS256" # Handled by FastAPI-Users strategy + # ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # Handled by FastAPI-Users strategy + # REFRESH_TOKEN_EXPIRE_MINUTES: int = 10080 # Handled by FastAPI-Users strategy # --- OCR Settings --- MAX_FILE_SIZE_MB: int = 10 # Maximum allowed file size for OCR processing @@ -64,6 +65,7 @@ Organic Bananas # Add your deployed frontend URL here later # "https://your-frontend-domain.com", ] + FRONTEND_URL: str = "http://localhost:5173" # URL for the frontend application # --- API Metadata --- API_TITLE: str = "Shared Lists API" @@ -79,14 +81,14 @@ Organic Bananas HEALTH_STATUS_OK: str = "ok" HEALTH_STATUS_ERROR: str = "error" - # --- Auth Settings --- - OAUTH2_TOKEN_URL: str = "/api/v1/auth/login" # Path to login endpoint - TOKEN_TYPE: str = "bearer" # Default token type for OAuth2 - AUTH_HEADER_PREFIX: str = "Bearer" # Prefix for Authorization header - AUTH_HEADER_NAME: str = "WWW-Authenticate" # Name of auth header - AUTH_CREDENTIALS_ERROR: str = "Could not validate credentials" - AUTH_INVALID_CREDENTIALS: str = "Incorrect email or password" - AUTH_NOT_AUTHENTICATED: str = "Not authenticated" + # --- Auth Settings --- (These are largely handled by FastAPI-Users now) + # OAUTH2_TOKEN_URL: str = "/api/v1/auth/login" # FastAPI-Users has its own token URL + # TOKEN_TYPE: str = "bearer" + # AUTH_HEADER_PREFIX: str = "Bearer" + # AUTH_HEADER_NAME: str = "WWW-Authenticate" + # AUTH_CREDENTIALS_ERROR: str = "Could not validate credentials" + # AUTH_INVALID_CREDENTIALS: str = "Incorrect email or password" + # AUTH_NOT_AUTHENTICATED: str = "Not authenticated" # --- HTTP Status Messages --- HTTP_400_DETAIL: str = "Bad Request" @@ -104,6 +106,20 @@ Organic Bananas DB_TRANSACTION_ERROR: str = "Database transaction error" DB_QUERY_ERROR: str = "Database query error" + # OAuth Settings + GOOGLE_CLIENT_ID: str = "" + GOOGLE_CLIENT_SECRET: str = "" + GOOGLE_REDIRECT_URI: str = "http://localhost:8000/auth/google/callback" + + APPLE_CLIENT_ID: str = "" + APPLE_TEAM_ID: str = "" + APPLE_KEY_ID: str = "" + APPLE_PRIVATE_KEY: str = "" + APPLE_REDIRECT_URI: str = "http://localhost:8000/auth/apple/callback" + + # Session Settings + SESSION_SECRET_KEY: str = "your-session-secret-key" # Change this in production + class Config: env_file = ".env" env_file_encoding = 'utf-8' diff --git a/be/app/crud/settlement.py b/be/app/crud/settlement.py index 49dd130..fdb0b37 100644 --- a/be/app/crud/settlement.py +++ b/be/app/crud/settlement.py @@ -162,7 +162,4 @@ async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, ex except Exception as e: await db.rollback() raise InvalidOperationError(f"Failed to delete settlement: {str(e)}") - return None - -# TODO: Implement update_settlement (consider restrictions, versioning) -# TODO: Implement delete_settlement (consider implications on balances) \ No newline at end of file + return None \ No newline at end of file diff --git a/be/app/main.py b/be/app/main.py index 5fc94b3..248abb6 100644 --- a/be/app/main.py +++ b/be/app/main.py @@ -3,14 +3,16 @@ import logging import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.sessions import SessionMiddleware import sentry_sdk from sentry_sdk.integrations.fastapi import FastApiIntegration from app.api.api_router import api_router from app.config import settings from app.core.api_config import API_METADATA, API_TAGS -# Import database and models if needed for startup/shutdown events later -# from . import database, models +from app.auth import fastapi_users, auth_backend +from app.models import User +from app.api.auth.oauth import router as oauth_router # Initialize Sentry sentry_sdk.init( @@ -39,6 +41,12 @@ app = FastAPI( openapi_tags=API_TAGS ) +# Add session middleware for OAuth +app.add_middleware( + SessionMiddleware, + secret_key=settings.SESSION_SECRET_KEY +) + # --- CORS Middleware --- # Define allowed origins. Be specific in production! # Use ["*"] for wide open access during early development if needed, @@ -62,7 +70,37 @@ app.add_middleware( # --- Include API Routers --- -# All API endpoints will be prefixed with /api +# Include FastAPI-Users routes +app.include_router( + fastapi_users.get_auth_router(auth_backend), + prefix="/auth/jwt", + tags=["auth"], +) +app.include_router( + fastapi_users.get_register_router(), + prefix="/auth", + tags=["auth"], +) +app.include_router( + fastapi_users.get_reset_password_router(), + prefix="/auth", + tags=["auth"], +) +app.include_router( + fastapi_users.get_verify_router(), + prefix="/auth", + tags=["auth"], +) +app.include_router( + fastapi_users.get_users_router(), + prefix="/users", + tags=["users"], +) + +# Include OAuth routes +app.include_router(oauth_router, prefix="/auth", tags=["auth"]) + +# Include your API router app.include_router(api_router, prefix=settings.API_PREFIX) # --- End Include API Routers --- @@ -80,18 +118,18 @@ async def read_root(): # --- Application Startup/Shutdown Events (Optional) --- -# @app.on_event("startup") -# async def startup_event(): -# logger.info("Application startup: Connecting to database...") -# # You might perform initial checks or warm-up here -# # await database.engine.connect() # Example check (get_db handles sessions per request) -# logger.info("Application startup complete.") +@app.on_event("startup") +async def startup_event(): + logger.info("Application startup: Connecting to database...") + # You might perform initial checks or warm-up here + # await database.engine.connect() # Example check (get_db handles sessions per request) + logger.info("Application startup complete.") -# @app.on_event("shutdown") -# async def shutdown_event(): -# logger.info("Application shutdown: Disconnecting from database...") -# # await database.engine.dispose() # Close connection pool -# logger.info("Application shutdown complete.") +@app.on_event("shutdown") +async def shutdown_event(): + logger.info("Application shutdown: Disconnecting from database...") + # await database.engine.dispose() # Close connection pool + logger.info("Application shutdown complete.") # --- End Events --- diff --git a/be/app/models.py b/be/app/models.py index 3494fb3..0bfaece 100644 --- a/be/app/models.py +++ b/be/app/models.py @@ -45,8 +45,11 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) email = Column(String, unique=True, index=True, nullable=False) - password_hash = Column(String, nullable=False) + hashed_password = Column(String, nullable=False) name = Column(String, index=True, nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + is_superuser = Column(Boolean, default=False, nullable=False) + is_verified = Column(Boolean, default=False, nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) # --- Relationships --- diff --git a/be/requirements.txt b/be/requirements.txt index ca66a1e..86a3eef 100644 --- a/be/requirements.txt +++ b/be/requirements.txt @@ -10,4 +10,10 @@ passlib[bcrypt]>=1.7.4 python-jose[cryptography]>=3.3.0 pydantic[email] google-generativeai>=0.5.0 -sentry-sdk[fastapi]>=1.39.0 \ No newline at end of file +sentry-sdk[fastapi]>=1.39.0 +python-multipart>=0.0.6 # Required for form data handling +fastapi-users[sqlalchemy]>=12.1.2 +email-validator>=2.0.0 +fastapi-users[oauth]>=12.1.2 +authlib>=1.3.0 +itsdangerous>=2.1.2 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index c292335..9a4c1f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,24 +1,21 @@ -# docker-compose.yml (in project root) -version: '3.8' - services: db: - image: postgres:15 # Use a specific PostgreSQL version + image: postgres:17 # Use a specific PostgreSQL version container_name: postgres_db environment: - POSTGRES_USER: dev_user # Define DB user - POSTGRES_PASSWORD: dev_password # Define DB password - POSTGRES_DB: dev_db # Define Database name + POSTGRES_USER: dev_user # Define DB user + POSTGRES_PASSWORD: dev_password # Define DB password + POSTGRES_DB: dev_db # Define Database name volumes: - postgres_data:/var/lib/postgresql/data # Persist data using a named volume ports: - "5432:5432" # Expose PostgreSQL port to host (optional, for direct access) healthcheck: - test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s + test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s restart: unless-stopped backend: @@ -37,29 +34,29 @@ services: # Uses the service name 'db' as the host, and credentials defined above # IMPORTANT: Use the correct async driver prefix if your app needs it! - DATABASE_URL=postgresql+asyncpg://dev_user:dev_password@db:5432/dev_db + - GEMINI_API_KEY=AIzaSyDKoZBIzUKoeGRtc3m7FtSoqId_nZjfl7M + - SECRET_KEY=zaSyDKoZBIzUKoeGRtc3m7zaSyGRtc3m7zaSyDKoZBIzUKoeGRtc3m7 # Add other environment variables needed by the backend here # - SOME_OTHER_VAR=some_value depends_on: - db: # Wait for the db service to be healthy before starting backend + db: + # Wait for the db service to be healthy before starting backend condition: service_healthy - command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] # Override CMD for development reload + command: [ "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ] # Override CMD for development reload restart: unless-stopped - pgadmin: # Optional service for database administration - image: dpage/pgadmin4:latest - container_name: pgadmin4_server - environment: - PGADMIN_DEFAULT_EMAIL: admin@example.com # Change as needed - PGADMIN_DEFAULT_PASSWORD: admin_password # Change to a secure password - PGADMIN_CONFIG_SERVER_MODE: 'False' # Run in Desktop mode for easier local dev server setup - volumes: - - pgadmin_data:/var/lib/pgadmin # Persist pgAdmin configuration + frontend: + container_name: vite_frontend + build: + context: ./fe + dockerfile: Dockerfile ports: - - "5050:80" # Map container port 80 to host port 5050 + - "80:80" depends_on: - - db # Depends on the database service + - backend restart: unless-stopped -volumes: # Define named volumes for data persistence +volumes: + # Define named volumes for data persistence postgres_data: - pgadmin_data: \ No newline at end of file + pgadmin_data: diff --git a/fe/Dockerfile b/fe/Dockerfile new file mode 100644 index 0000000..e7b4f72 --- /dev/null +++ b/fe/Dockerfile @@ -0,0 +1,31 @@ +# Build stage +FROM node:24-alpine AS build + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built assets from build stage +COPY --from=build /app/dist /usr/share/nginx/html + +# Copy nginx configuration if needed +# COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port 80 +EXPOSE 80 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/fe/src/assets/main.scss b/fe/src/assets/main.scss index a0be802..454dfc0 100644 --- a/fe/src/assets/main.scss +++ b/fe/src/assets/main.scss @@ -1,6 +1,6 @@ // src/assets/main.scss // @import './variables.scss'; // Your custom variables -@import './valerie-ui.scss'; +@use './valerie-ui.scss'; // Example global styles body { @@ -13,6 +13,7 @@ body { a { color: var(--primary-color); text-decoration: none; + &:hover { text-decoration: underline; } @@ -23,4 +24,5 @@ a { margin: 0 auto; padding: 1rem; } + // Add more global utility classes or base styles \ No newline at end of file diff --git a/fe/src/components/ConflictResolutionDialog.vue b/fe/src/components/ConflictResolutionDialog.vue index a8348a6..a847ba5 100644 --- a/fe/src/components/ConflictResolutionDialog.vue +++ b/fe/src/components/ConflictResolutionDialog.vue @@ -1,34 +1,26 @@