diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..ced9dd9 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,84 @@ +# When you push to the develop branch or open/update a pull request targeting main, Gitea will: +# Trigger the "CI Checks" workflow. +# Execute the checks job on a runner. +# Run each step sequentially. +# If any of the linter/formatter check commands (black --check, ruff check, npm run lint) exit with a non-zero status code (indicating an error or check failure), the step and the entire job will fail. +# You will see the status (success/failure) associated with your commit or pull request in the Gitea interface. + +name: CI Checks + +# Define triggers for the workflow +on: + push: + branches: + - develop # Run on pushes to the develop branch + pull_request: + branches: + - main # Run on pull requests targeting the main branch + +jobs: + checks: + name: Linters and Formatters + runs-on: ubuntu-latest # Use a standard Linux runner environment + + steps: + - name: Checkout code + uses: actions/checkout@v4 # Fetches the repository code + + # --- Backend Checks (Python/FastAPI) --- + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' # Match your project/Dockerfile version + cache: 'pip' # Cache pip dependencies based on requirements.txt + cache-dependency-path: 'be/requirements.txt' # Specify path for caching + + - name: Install Backend Dependencies and Tools + working-directory: ./be # Run command within the 'be' directory + run: | + pip install --upgrade pip + pip install -r requirements.txt + pip install black ruff # Install formatters/linters for CI check + + - name: Run Black Formatter Check (Backend) + working-directory: ./be + run: black --check --diff . + + - name: Run Ruff Linter (Backend) + working-directory: ./be + run: ruff check . + + # --- Frontend Checks (SvelteKit/Node.js) --- + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' # Or specify your required Node.js version (e.g., 'lts/*') + cache: 'npm' # Or 'pnpm' / 'yarn' depending on your package manager + cache-dependency-path: 'fe/package-lock.json' # Adjust lockfile name if needed + + - name: Install Frontend Dependencies + working-directory: ./fe # Run command within the 'fe' directory + run: npm install # Or 'pnpm install' / 'yarn install' + + - name: Run ESLint and Prettier Check (Frontend) + working-directory: ./fe + # Assuming you have a 'lint' script in fe/package.json that runs both + # Example package.json script: "lint": "prettier --check . && eslint ." + run: npm run lint + # If no combined script, run separately: + # run: | + # npm run format -- --check # Or 'npx prettier --check .' + # npm run lint # Or 'npx eslint .' + + # - name: Run Frontend Type Check (Optional but recommended) + # working-directory: ./fe + # # Assuming you have a 'check' script: "check": "svelte-kit sync && svelte-check ..." + # run: npm run check + + # - name: Run Placeholder Tests (Optional) + # run: | + # # Add commands to run backend tests if available + # # Add commands to run frontend tests (e.g., npm test in ./fe) if available + # echo "No tests configured yet." \ No newline at end of file diff --git a/be/Dockerfile b/be/Dockerfile index a2f5925..a6e5d5d 100644 --- a/be/Dockerfile +++ b/be/Dockerfile @@ -32,4 +32,4 @@ EXPOSE 8000 # The default command for production (can be overridden in docker-compose for development) # Note: Make sure 'app.main:app' correctly points to your FastAPI app instance # relative to the WORKDIR (/app). If your main.py is directly in /app, this is correct. -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["uvicorn", "app.main:app", "--host", "localhost", "--port", "8000"] \ No newline at end of file diff --git a/be/alembic/versions/563ee77c5214_add_invite_table_and_relationships.py b/be/alembic/versions/563ee77c5214_add_invite_table_and_relationships.py new file mode 100644 index 0000000..504b65f --- /dev/null +++ b/be/alembic/versions/563ee77c5214_add_invite_table_and_relationships.py @@ -0,0 +1,32 @@ +"""Add invite table and relationships + +Revision ID: 563ee77c5214 +Revises: 69b0c1432084 +Create Date: 2025-03-30 18:51:19.926810 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '563ee77c5214' +down_revision: Union[str, None] = '69b0c1432084' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/be/alembic/versions/69b0c1432084_add_invite_table_and_relationships.py b/be/alembic/versions/69b0c1432084_add_invite_table_and_relationships.py new file mode 100644 index 0000000..eaa707c --- /dev/null +++ b/be/alembic/versions/69b0c1432084_add_invite_table_and_relationships.py @@ -0,0 +1,32 @@ +"""Add invite table and relationships + +Revision ID: 69b0c1432084 +Revises: 6f80b82dbdf8 +Create Date: 2025-03-30 18:50:48.072504 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '69b0c1432084' +down_revision: Union[str, None] = '6f80b82dbdf8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/be/alembic/versions/6f80b82dbdf8_add_invite_table_and_relationships.py b/be/alembic/versions/6f80b82dbdf8_add_invite_table_and_relationships.py new file mode 100644 index 0000000..4d998f7 --- /dev/null +++ b/be/alembic/versions/6f80b82dbdf8_add_invite_table_and_relationships.py @@ -0,0 +1,32 @@ +"""Add invite table and relationships + +Revision ID: 6f80b82dbdf8 +Revises: f42efe4f4bca +Create Date: 2025-03-30 18:49:26.968637 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '6f80b82dbdf8' +down_revision: Union[str, None] = 'f42efe4f4bca' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/be/alembic/versions/d90ab7116920_add_invite_table_and_relationships.py b/be/alembic/versions/d90ab7116920_add_invite_table_and_relationships.py new file mode 100644 index 0000000..081e9a8 --- /dev/null +++ b/be/alembic/versions/d90ab7116920_add_invite_table_and_relationships.py @@ -0,0 +1,32 @@ +"""Add invite table and relationships + +Revision ID: d90ab7116920 +Revises: 563ee77c5214 +Create Date: 2025-03-30 18:57:39.047729 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd90ab7116920' +down_revision: Union[str, None] = '563ee77c5214' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/be/alembic/versions/f42efe4f4bca_add_invite_table_and_relationships.py b/be/alembic/versions/f42efe4f4bca_add_invite_table_and_relationships.py new file mode 100644 index 0000000..c22ebd1 --- /dev/null +++ b/be/alembic/versions/f42efe4f4bca_add_invite_table_and_relationships.py @@ -0,0 +1,49 @@ +"""Add invite table and relationships + +Revision ID: f42efe4f4bca +Revises: 85a3c075e73a +Create Date: 2025-03-30 18:41:50.854172 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f42efe4f4bca' +down_revision: Union[str, None] = '85a3c075e73a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('invites', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(), nullable=False), + sa.Column('group_id', sa.Integer(), nullable=False), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_invites_active_code', 'invites', ['code'], unique=True, postgresql_where=sa.text('is_active = true')) + op.create_index(op.f('ix_invites_code'), 'invites', ['code'], unique=True) + op.create_index(op.f('ix_invites_id'), 'invites', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_invites_id'), table_name='invites') + op.drop_index(op.f('ix_invites_code'), table_name='invites') + op.drop_index('ix_invites_active_code', table_name='invites', postgresql_where=sa.text('is_active = true')) + op.drop_table('invites') + # ### end Alembic commands ### diff --git a/be/app/api/dependencies.py b/be/app/api/dependencies.py new file mode 100644 index 0000000..98d24e2 --- /dev/null +++ b/be/app/api/dependencies.py @@ -0,0 +1,71 @@ +# app/api/dependencies.py +import logging +from typing import Optional + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession +from jose import JWTError + +from app.database import get_db +from app.core.security import verify_access_token +from app.crud import user as crud_user +from app.models import User as UserModel # Import the SQLAlchemy model + +logger = logging.getLogger(__name__) + +# Define the OAuth2 scheme +# tokenUrl should point to your login endpoint relative to the base path +# It's used by Swagger UI for the "Authorize" button flow. +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") # Corrected path + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db) +) -> UserModel: + """ + Dependency to get the current user based on the JWT token. + + - Extracts token using OAuth2PasswordBearer. + - Verifies the token (signature, expiry). + - Fetches the user from the database based on the token's subject (email). + - Raises HTTPException 401 if any step fails. + + Returns: + The authenticated user's database model instance. + """ + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + payload = verify_access_token(token) + if payload is None: + logger.warning("Token verification failed (invalid, expired, or malformed).") + raise credentials_exception + + email: Optional[str] = payload.get("sub") + if email is None: + logger.error("Token payload missing 'sub' (subject/email).") + raise credentials_exception # Token is malformed + + # Fetch user from database + user = await crud_user.get_user_by_email(db, email=email) + if user is None: + logger.warning(f"User corresponding to token subject not found: {email}") + # Could happen if user deleted after token issuance + raise credentials_exception # Treat as invalid credentials + + logger.debug(f"Authenticated user retrieved: {user.email} (ID: {user.id})") + return user + +# Optional: Dependency for getting the *active* current user +# You might add an `is_active` flag to your User model later +# async def get_current_active_user( +# current_user: UserModel = Depends(get_current_user) +# ) -> UserModel: +# if not current_user.is_active: # Assuming an is_active attribute +# logger.warning(f"Authentication attempt by inactive user: {current_user.email}") +# raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user") +# return current_user \ No newline at end of file diff --git a/be/app/api/v1/api.py b/be/app/api/v1/api.py index c8b9f7c..5616c14 100644 --- a/be/app/api/v1/api.py +++ b/be/app/api/v1/api.py @@ -1,12 +1,19 @@ # app/api/v1/api.py from fastapi import APIRouter -from app.api.v1.endpoints import health # Import the health endpoint router +from app.api.v1.endpoints import health +from app.api.v1.endpoints import auth +from app.api.v1.endpoints import users +from app.api.v1.endpoints import groups +from app.api.v1.endpoints import invites api_router_v1 = APIRouter() -# Include endpoint routers here, adding the desired prefix for v1 -api_router_v1.include_router(health.router) # The path "/health" is defined inside health.router +api_router_v1.include_router(health.router) # Path /health defined inside +api_router_v1.include_router(auth.router, prefix="/auth", tags=["Authentication"]) +api_router_v1.include_router(users.router, prefix="/users", tags=["Users"]) +api_router_v1.include_router(groups.router, prefix="/groups", tags=["Groups"]) +api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"]) # Add other v1 endpoint routers here later # e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"]) \ No newline at end of file diff --git a/be/app/api/v1/endpoints/auth.py b/be/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..74de631 --- /dev/null +++ b/be/app/api/v1/endpoints/auth.py @@ -0,0 +1,91 @@ +# app/api/v1/endpoints/auth.py +import logging +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.schemas.user import UserCreate, UserPublic +from app.schemas.auth import Token +from app.crud import user as crud_user +from app.core.security import verify_password, create_access_token + +logger = logging.getLogger(__name__) +router = APIRouter() + +@router.post( + "/signup", + response_model=UserPublic, # Return public user info, not the password hash + status_code=status.HTTP_201_CREATED, # Indicate resource creation + summary="Register New User", + description="Creates a new user account.", + tags=["Authentication"] +) +async def signup( + user_in: UserCreate, + db: AsyncSession = Depends(get_db) +): + """ + Handles user registration. + - Validates input data. + - Checks if email already exists. + - Hashes the password. + - Stores the new user in the database. + """ + logger.info(f"Signup attempt for email: {user_in.email}") + existing_user = await crud_user.get_user_by_email(db, email=user_in.email) + if existing_user: + logger.warning(f"Signup failed: Email already registered - {user_in.email}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered.", + ) + + try: + created_user = await crud_user.create_user(db=db, user_in=user_in) + logger.info(f"User created successfully: {created_user.email} (ID: {created_user.id})") + # Note: UserPublic schema automatically excludes the hashed password + return created_user + except Exception as e: + logger.error(f"Error during user creation for {user_in.email}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred during user creation.", + ) + + + +@router.post( + "/login", + response_model=Token, + summary="User Login", + description="Authenticates a user and returns an access token.", + tags=["Authentication"] +) +async def login( + form_data: OAuth2PasswordRequestForm = Depends(), # Use standard form for username/password + db: AsyncSession = Depends(get_db) +): + """ + Handles user login. + - Finds user by email (provided in 'username' field of form). + - Verifies the provided password against the stored hash. + - Generates and returns a JWT access token upon successful authentication. + """ + logger.info(f"Login attempt for user: {form_data.username}") + user = await crud_user.get_user_by_email(db, email=form_data.username) + + # Check if user exists and password is correct + # Use the correct attribute name 'password_hash' from the User model + if not user or not verify_password(form_data.password, user.password_hash): # <-- CORRECTED LINE + logger.warning(f"Login failed: Invalid credentials for user {form_data.username}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, # Standard header for 401 + ) + + # Generate JWT + access_token = create_access_token(subject=user.email) # Use email as subject + logger.info(f"Login successful, token generated for user: {user.email}") + return Token(access_token=access_token, token_type="bearer") \ No newline at end of file diff --git a/be/app/api/v1/endpoints/groups.py b/be/app/api/v1/endpoints/groups.py new file mode 100644 index 0000000..2380e35 --- /dev/null +++ b/be/app/api/v1/endpoints/groups.py @@ -0,0 +1,196 @@ +# app/api/v1/endpoints/groups.py +import logging +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.api.dependencies import get_current_user +from app.models import User as UserModel, UserRoleEnum # Import model and enum +from app.schemas.group import GroupCreate, GroupPublic +from app.schemas.invite import InviteCodePublic +from app.schemas.message import Message # For simple responses +from app.crud import group as crud_group +from app.crud import invite as crud_invite + +logger = logging.getLogger(__name__) +router = APIRouter() + +@router.post( + "", # Route relative to prefix "/groups" + response_model=GroupPublic, + status_code=status.HTTP_201_CREATED, + summary="Create New Group", + tags=["Groups"] +) +async def create_group( + group_in: GroupCreate, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """Creates a new group, adding the creator as the owner.""" + logger.info(f"User {current_user.email} creating group: {group_in.name}") + created_group = await crud_group.create_group(db=db, group_in=group_in, creator_id=current_user.id) + # Load members explicitly if needed for the response (optional here) + # created_group = await crud_group.get_group_by_id(db, created_group.id) + return created_group + + +@router.get( + "", # Route relative to prefix "/groups" + response_model=List[GroupPublic], + summary="List User's Groups", + tags=["Groups"] +) +async def read_user_groups( + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """Retrieves all groups the current user is a member of.""" + logger.info(f"Fetching groups for user: {current_user.email}") + groups = await crud_group.get_user_groups(db=db, user_id=current_user.id) + return groups + + +@router.get( + "/{group_id}", + response_model=GroupPublic, + summary="Get Group Details", + tags=["Groups"] +) +async def read_group( + group_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """Retrieves details for a specific group, including members, if the user is part of it.""" + logger.info(f"User {current_user.email} requesting details for group ID: {group_id}") + # Check if user is a member first + is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id) + if not is_member: + logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member of this group") + + group = await crud_group.get_group_by_id(db=db, group_id=group_id) + if not group: + logger.error(f"Group {group_id} requested by member {current_user.email} not found (data inconsistency?)") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") + + # Manually construct the members list with UserPublic schema if needed + # Pydantic v2's from_attributes should handle this if relationships are loaded + # members_public = [UserPublic.model_validate(assoc.user) for assoc in group.member_associations] + # return GroupPublic.model_validate(group, update={"members": members_public}) + return group # Rely on Pydantic conversion and eager loading + + +@router.post( + "/{group_id}/invites", + response_model=InviteCodePublic, + summary="Create Group Invite", + tags=["Groups", "Invites"] +) +async def create_group_invite( + group_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """Generates a new invite code for the group. Requires owner/admin role (MVP: owner only).""" + logger.info(f"User {current_user.email} attempting to create invite for group {group_id}") + user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id) + + # --- Permission Check (MVP: Owner only) --- + if user_role != UserRoleEnum.owner: + logger.warning(f"Permission denied: User {current_user.email} (role: {user_role}) cannot create invite for group {group_id}") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only group owners can create invites") + + # Check if group exists (implicitly done by role check, but good practice) + group = await crud_group.get_group_by_id(db, group_id) + if not group: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") + + invite = await crud_invite.create_invite(db=db, group_id=group_id, creator_id=current_user.id) + if not invite: + logger.error(f"Failed to generate unique invite code for group {group_id}") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not generate invite code") + + logger.info(f"Invite code created for group {group_id} by user {current_user.email}") + return invite + +@router.delete( + "/{group_id}/leave", + response_model=Message, + summary="Leave Group", + tags=["Groups"] +) +async def leave_group( + group_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """Removes the current user from the specified group.""" + logger.info(f"User {current_user.email} attempting to leave group {group_id}") + user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id) + + if user_role is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="You are not a member of this group") + + # --- MVP: Prevent owner leaving if they are the last member/owner --- + if user_role == UserRoleEnum.owner: + member_count = await crud_group.get_group_member_count(db, group_id) + # More robust check: count owners. For now, just check member count. + if member_count <= 1: + logger.warning(f"Owner {current_user.email} attempted to leave group {group_id} as last member.") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Owner cannot leave the group as the last member. Delete the group or transfer ownership.") + + # Proceed with removal + deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=current_user.id) + + if not deleted: + # Should not happen if role check passed, but handle defensively + logger.error(f"Failed to remove user {current_user.email} from group {group_id} despite being a member.") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to leave group") + + logger.info(f"User {current_user.email} successfully left group {group_id}") + return Message(detail="Successfully left the group") + +# --- Optional: Remove Member Endpoint --- +@router.delete( + "/{group_id}/members/{user_id_to_remove}", + response_model=Message, + summary="Remove Member From Group (Owner Only)", + tags=["Groups"] +) +async def remove_group_member( + group_id: int, + user_id_to_remove: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """Removes a specified user from the group. Requires current user to be owner.""" + logger.info(f"Owner {current_user.email} attempting to remove user {user_id_to_remove} from group {group_id}") + owner_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id) + + # --- Permission Check --- + if owner_role != UserRoleEnum.owner: + logger.warning(f"Permission denied: User {current_user.email} (role: {owner_role}) cannot remove members from group {group_id}") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only group owners can remove members") + + # Prevent owner removing themselves via this endpoint + if current_user.id == user_id_to_remove: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Owner cannot remove themselves using this endpoint. Use 'Leave Group' instead.") + + # Check if target user is actually in the group + target_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=user_id_to_remove) + if target_role is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User to remove is not a member of this group") + + # Proceed with removal + deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=user_id_to_remove) + + if not deleted: + logger.error(f"Owner {current_user.email} failed to remove user {user_id_to_remove} from group {group_id}.") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to remove member") + + logger.info(f"Owner {current_user.email} successfully removed user {user_id_to_remove} from group {group_id}") + return Message(detail="Successfully removed member from the group") \ No newline at end of file diff --git a/be/app/api/v1/endpoints/invites.py b/be/app/api/v1/endpoints/invites.py new file mode 100644 index 0000000..e403629 --- /dev/null +++ b/be/app/api/v1/endpoints/invites.py @@ -0,0 +1,59 @@ +# app/api/v1/endpoints/invites.py +import logging +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.api.dependencies import get_current_user +from app.models import User as UserModel, UserRoleEnum +from app.schemas.invite import InviteAccept +from app.schemas.message import Message +from app.crud import invite as crud_invite +from app.crud import group as crud_group + +logger = logging.getLogger(__name__) +router = APIRouter() + +@router.post( + "/accept", # Route relative to prefix "/invites" + response_model=Message, + summary="Accept Group Invite", + tags=["Invites"] +) +async def accept_invite( + invite_in: InviteAccept, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """Allows an authenticated user to accept an invite using its code.""" + code = invite_in.code + logger.info(f"User {current_user.email} attempting to accept invite code: {code}") + + # Find the active, non-expired invite + invite = await crud_invite.get_active_invite_by_code(db=db, code=code) + if not invite: + logger.warning(f"Invite code '{code}' not found, expired, or already used.") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite code is invalid or expired") + + group_id = invite.group_id + + # Check if user is already in the group + is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id) + if is_member: + logger.info(f"User {current_user.email} is already a member of group {group_id}. Invite '{code}' still deactivated.") + # Deactivate invite even if already member, to prevent reuse + await crud_invite.deactivate_invite(db=db, invite=invite) + return Message(detail="You are already a member of this group.") + + # Add user to the group as a member + added = await crud_group.add_user_to_group(db=db, group_id=group_id, user_id=current_user.id, role=UserRoleEnum.member) + if not added: + # Should not happen if is_member check was correct, but handle defensively + logger.error(f"Failed to add user {current_user.email} to group {group_id} via invite '{code}' despite not being a member.") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not join group.") + + # Deactivate the invite (single-use) + await crud_invite.deactivate_invite(db=db, invite=invite) + + logger.info(f"User {current_user.email} successfully joined group {group_id} using invite '{code}'.") + return Message(detail="Successfully joined the group.") \ No newline at end of file diff --git a/be/app/api/v1/endpoints/users.py b/be/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..b0e9946 --- /dev/null +++ b/be/app/api/v1/endpoints/users.py @@ -0,0 +1,30 @@ +# app/api/v1/endpoints/users.py +import logging +from fastapi import APIRouter, Depends, HTTPException + +from app.api.dependencies import get_current_user # Import the dependency +from app.schemas.user import UserPublic # Import the response schema +from app.models import User as UserModel # Import the DB model for type hinting + +logger = logging.getLogger(__name__) +router = APIRouter() + +@router.get( + "/me", + response_model=UserPublic, # Use the public schema to avoid exposing hash + summary="Get Current User", + description="Retrieves the details of the currently authenticated user.", + tags=["Users"] +) +async def read_users_me( + current_user: UserModel = Depends(get_current_user) # Apply the dependency +): + """ + Returns the data for the user associated with the current valid access token. + """ + logger.info(f"Fetching details for current user: {current_user.email}") + # The 'current_user' object is the SQLAlchemy model instance returned by the dependency. + # Pydantic's response_model will automatically convert it using UserPublic schema. + return current_user + +# Add other user-related endpoints here later (e.g., update user, list users (admin)) \ No newline at end of file diff --git a/be/app/api/v1/test_auth.py b/be/app/api/v1/test_auth.py new file mode 100644 index 0000000..131f4f8 --- /dev/null +++ b/be/app/api/v1/test_auth.py @@ -0,0 +1,92 @@ +# Example: be/tests/api/v1/test_auth.py +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import verify_password +from app.crud.user import get_user_by_email +from app.schemas.user import UserPublic # Import for response validation +from app.schemas.auth import Token # Import for response validation + +pytestmark = pytest.mark.asyncio + +async def test_signup_success(client: AsyncClient, db: AsyncSession): + email = "testsignup@example.com" + password = "testpassword123" + response = await client.post( + "/api/v1/auth/signup", + json={"email": email, "password": password, "name": "Test Signup"}, + ) + assert response.status_code == 201 + data = response.json() + assert data["email"] == email + assert data["name"] == "Test Signup" + assert "id" in data + assert "created_at" in data + # Verify password hash is NOT returned + assert "password" not in data + assert "hashed_password" not in data + + # Verify user exists in DB + user_db = await get_user_by_email(db, email=email) + assert user_db is not None + assert user_db.email == email + assert verify_password(password, user_db.hashed_password) + + +async def test_signup_email_exists(client: AsyncClient, db: AsyncSession): + # Create user first + email = "testexists@example.com" + password = "testpassword123" + await client.post( + "/api/v1/auth/signup", + json={"email": email, "password": password}, + ) + + # Attempt signup again with same email + response = await client.post( + "/api/v1/auth/signup", + json={"email": email, "password": "anotherpassword"}, + ) + assert response.status_code == 400 + assert "Email already registered" in response.json()["detail"] + + +async def test_login_success(client: AsyncClient, db: AsyncSession): + email = "testlogin@example.com" + password = "testpassword123" + # Create user first via signup + signup_res = await client.post( + "/api/v1/auth/signup", json={"email": email, "password": password} + ) + assert signup_res.status_code == 201 + + # Attempt login + login_payload = {"username": email, "password": password} + response = await client.post("/api/v1/auth/login", data=login_payload) # Use data for form encoding + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + # Optionally verify the token itself here using verify_access_token + + +async def test_login_wrong_password(client: AsyncClient, db: AsyncSession): + email = "testloginwrong@example.com" + password = "testpassword123" + await client.post( + "/api/v1/auth/signup", json={"email": email, "password": password} + ) + + login_payload = {"username": email, "password": "wrongpassword"} + response = await client.post("/api/v1/auth/login", data=login_payload) + assert response.status_code == 401 + assert "Incorrect email or password" in response.json()["detail"] + assert "WWW-Authenticate" in response.headers + assert response.headers["WWW-Authenticate"] == "Bearer" + +async def test_login_user_not_found(client: AsyncClient, db: AsyncSession): + login_payload = {"username": "nosuchuser@example.com", "password": "anypassword"} + response = await client.post("/api/v1/auth/login", data=login_payload) + assert response.status_code == 401 + assert "Incorrect email or password" in response.json()["detail"] \ No newline at end of file diff --git a/be/app/api/v1/test_users.py b/be/app/api/v1/test_users.py new file mode 100644 index 0000000..6b4d970 --- /dev/null +++ b/be/app/api/v1/test_users.py @@ -0,0 +1,65 @@ +# Example: be/tests/api/v1/test_users.py +import pytest +from httpx import AsyncClient + +from app.schemas.user import UserPublic # For response validation +from app.core.security import create_access_token + +pytestmark = pytest.mark.asyncio + +# Helper function to get a valid token +async def get_auth_headers(client: AsyncClient, email: str, password: str) -> dict: + """Logs in a user and returns authorization headers.""" + login_payload = {"username": email, "password": password} + response = await client.post("/api/v1/auth/login", data=login_payload) + response.raise_for_status() # Raise exception for non-2xx status + token_data = response.json() + return {"Authorization": f"Bearer {token_data['access_token']}"} + +async def test_read_users_me_success(client: AsyncClient): + # 1. Create user + email = "testme@example.com" + password = "password123" + signup_res = await client.post( + "/api/v1/auth/signup", json={"email": email, "password": password, "name": "Test Me"} + ) + assert signup_res.status_code == 201 + user_data = UserPublic(**signup_res.json()) # Validate signup response + + # 2. Get token + headers = await get_auth_headers(client, email, password) + + # 3. Request /users/me + response = await client.get("/api/v1/users/me", headers=headers) + assert response.status_code == 200 + me_data = response.json() + assert me_data["email"] == email + assert me_data["name"] == "Test Me" + assert me_data["id"] == user_data.id # Check ID matches signup + assert "password" not in me_data + assert "hashed_password" not in me_data + + +async def test_read_users_me_no_token(client: AsyncClient): + response = await client.get("/api/v1/users/me") # No headers + assert response.status_code == 401 # Handled by OAuth2PasswordBearer + assert response.json()["detail"] == "Not authenticated" # Default detail from OAuth2PasswordBearer + +async def test_read_users_me_invalid_token(client: AsyncClient): + headers = {"Authorization": "Bearer invalid-token-string"} + response = await client.get("/api/v1/users/me", headers=headers) + assert response.status_code == 401 + assert response.json()["detail"] == "Could not validate credentials" # Detail from our dependency + +async def test_read_users_me_expired_token(client: AsyncClient): + # Create a short-lived token manually (or adjust settings temporarily) + email = "testexpired@example.com" + # Assume create_access_token allows timedelta override + expired_token = create_access_token(subject=email, expires_delta=timedelta(seconds=-10)) + headers = {"Authorization": f"Bearer {expired_token}"} + + response = await client.get("/api/v1/users/me", headers=headers) + assert response.status_code == 401 + assert response.json()["detail"] == "Could not validate credentials" + +# Add test case for valid token but user deleted from DB if needed \ No newline at end of file diff --git a/be/app/config.py b/be/app/config.py index 1b9f8a4..6201d25 100644 --- a/be/app/config.py +++ b/be/app/config.py @@ -8,6 +8,12 @@ load_dotenv() class Settings(BaseSettings): DATABASE_URL: str | None = None + # --- JWT Settings --- + # Generate a strong secret key using: openssl rand -hex 32 + SECRET_KEY: str = "a_very_insecure_default_secret_key_replace_me" # !! MUST BE CHANGED IN PRODUCTION !! + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # Default token lifetime: 30 minutes + class Config: env_file = ".env" env_file_encoding = 'utf-8' @@ -15,10 +21,17 @@ class Settings(BaseSettings): settings = Settings() -# Basic validation to ensure DATABASE_URL is set +# Validation for critical settings if settings.DATABASE_URL is None: - print("Error: DATABASE_URL environment variable not set.") - # Consider raising an exception for clearer failure + print("Warning: DATABASE_URL environment variable not set.") # raise ValueError("DATABASE_URL environment variable not set.") -# else: # Optional: Log the URL being used (without credentials ideally) for debugging - # print(f"DATABASE_URL loaded: {settings.DATABASE_URL[:settings.DATABASE_URL.find('@')] if '@' in settings.DATABASE_URL else 'URL structure unexpected'}") + +# CRITICAL: Check if the default secret key is being used +if settings.SECRET_KEY == "a_very_insecure_default_secret_key_replace_me": + print("*" * 80) + print("WARNING: Using default insecure SECRET_KEY. Please generate a strong key and set it in the environment variables!") + print("Use: openssl rand -hex 32") + print("*" * 80) + # Consider raising an error in a production environment check + # if os.getenv("ENVIRONMENT") == "production": + # raise ValueError("Default SECRET_KEY is not allowed in production!") \ No newline at end of file diff --git a/be/app/core/__init__.py b/be/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/be/app/core/security.py b/be/app/core/security.py new file mode 100644 index 0000000..64ce3f7 --- /dev/null +++ b/be/app/core/security.py @@ -0,0 +1,110 @@ +# app/core/security.py +from datetime import datetime, timedelta, timezone +from typing import Any, Union, Optional + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.config import settings # Import settings from config + +# --- Password Hashing --- + +# Configure passlib context +# Using bcrypt as the default hashing scheme +# 'deprecated="auto"' will automatically upgrade hashes if needed on verification +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verifies a plain text password against a hashed password. + + Args: + plain_password: The password attempt. + hashed_password: The stored hash from the database. + + Returns: + True if the password matches the hash, False otherwise. + """ + try: + return pwd_context.verify(plain_password, hashed_password) + except Exception: + # Handle potential errors during verification (e.g., invalid hash format) + return False + +def hash_password(password: str) -> str: + """ + Hashes a plain text password using the configured context (bcrypt). + + Args: + password: The plain text password to hash. + + Returns: + The resulting hash string. + """ + return pwd_context.hash(password) + + +# --- JSON Web Tokens (JWT) --- + +def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str: + """ + Creates a JWT access token. + + Args: + subject: The subject of the token (e.g., user ID or email). + expires_delta: Optional timedelta object for token expiry. If None, + uses ACCESS_TOKEN_EXPIRE_MINUTES from settings. + + Returns: + The encoded JWT access token string. + """ + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + + # Data to encode in the token payload + to_encode = {"exp": expire, "sub": str(subject)} + + encoded_jwt = jwt.encode( + to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM + ) + return encoded_jwt + +def verify_access_token(token: str) -> Optional[dict]: + """ + Verifies a JWT access token and returns its payload if valid. + + Args: + token: The JWT token string to verify. + + Returns: + The decoded token payload (dict) if the token is valid and not expired, + otherwise None. + """ + try: + # Decode the token. This also automatically verifies: + # - Signature (using SECRET_KEY and ALGORITHM) + # - Expiration ('exp' claim) + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + return payload + except JWTError as e: + # Handles InvalidSignatureError, ExpiredSignatureError, etc. + print(f"JWT Error: {e}") # Log the error for debugging + return None + except Exception as e: + # Handle other potential unexpected errors during decoding + print(f"Unexpected error decoding JWT: {e}") + return None + +# You might add a function here later to extract the 'sub' (subject/user id) +# specifically, often used in dependency injection for authentication. +# def get_subject_from_token(token: str) -> Optional[str]: +# payload = verify_access_token(token) +# if payload: +# return payload.get("sub") +# return None \ No newline at end of file diff --git a/be/app/core/test_security.py b/be/app/core/test_security.py new file mode 100644 index 0000000..3b0af5e --- /dev/null +++ b/be/app/core/test_security.py @@ -0,0 +1,86 @@ +# Example: be/tests/core/test_security.py +import pytest +from datetime import timedelta +from jose import jwt, JWTError +import time + +from app.core.security import ( + hash_password, + verify_password, + create_access_token, + verify_access_token, +) +from app.config import settings # Import settings for testing JWT config + +# --- Password Hashing Tests --- + +def test_hash_password_returns_string(): + password = "testpassword" + hashed = hash_password(password) + assert isinstance(hashed, str) + assert password != hashed # Ensure it's not plain text + +def test_verify_password_correct(): + password = "correct_password" + hashed = hash_password(password) + assert verify_password(password, hashed) is True + +def test_verify_password_incorrect(): + hashed = hash_password("correct_password") + assert verify_password("wrong_password", hashed) is False + +def test_verify_password_invalid_hash_format(): + # Passlib's verify handles many format errors gracefully + assert verify_password("any_password", "invalid_hash_string") is False + + +# --- JWT Tests --- + +def test_create_access_token(): + subject = "testuser@example.com" + token = create_access_token(subject=subject) + assert isinstance(token, str) + + # Decode manually for basic check (verification done in verify_access_token tests) + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + assert payload["sub"] == subject + assert "exp" in payload + assert isinstance(payload["exp"], int) + +def test_verify_access_token_valid(): + subject = "test_subject_valid" + token = create_access_token(subject=subject) + payload = verify_access_token(token) + assert payload is not None + assert payload["sub"] == subject + +def test_verify_access_token_invalid_signature(): + subject = "test_subject_invalid_sig" + token = create_access_token(subject=subject) + # Attempt to verify with a wrong key + wrong_key = settings.SECRET_KEY + "wrong" + with pytest.raises(JWTError): # Decoding with wrong key should raise JWTError internally + jwt.decode(token, wrong_key, algorithms=[settings.ALGORITHM]) + # Our verify function should catch this and return None + assert verify_access_token(token + "tamper") is None # Tampering token often invalidates sig + # Note: Testing verify_access_token directly returning None for wrong key is tricky + # as the error happens *during* jwt.decode. We rely on it catching JWTError. + +def test_verify_access_token_expired(): + # Create a token that expires almost immediately + subject = "test_subject_expired" + expires_delta = timedelta(seconds=-1) # Expired 1 second ago + token = create_access_token(subject=subject, expires_delta=expires_delta) + + # Wait briefly just in case of timing issues, though negative delta should guarantee expiry + time.sleep(0.1) + + # Decoding expired token raises ExpiredSignatureError internally + with pytest.raises(JWTError): # Specifically ExpiredSignatureError, but JWTError catches it + jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + + # Our verify function should catch this and return None + assert verify_access_token(token) is None + +def test_verify_access_token_malformed(): + assert verify_access_token("this.is.not.a.valid.token") is None \ No newline at end of file diff --git a/be/app/crud/__init__.py b/be/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/be/app/crud/group.py b/be/app/crud/group.py new file mode 100644 index 0000000..409dfa4 --- /dev/null +++ b/be/app/crud/group.py @@ -0,0 +1,123 @@ +# app/crud/group.py +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import selectinload # For eager loading members +from typing import Optional, List + +from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel +from app.schemas.group import GroupCreate +from app.models import UserRoleEnum # Import enum + +# --- Keep existing functions: get_user_by_email, create_user --- +# (These are actually user CRUD, should ideally be in user.py, but keep for now if working) +from app.core.security import hash_password +from app.schemas.user import UserCreate # Assuming create_user uses this + +async def get_user_by_email(db: AsyncSession, email: str) -> Optional[UserModel]: + result = await db.execute(select(UserModel).filter(UserModel.email == email)) + return result.scalars().first() + +async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel: + _hashed_password = hash_password(user_in.password) + db_user = UserModel( + email=user_in.email, + password_hash=_hashed_password, # Use correct keyword argument + name=user_in.name + ) + db.add(db_user) + await db.commit() + await db.refresh(db_user) + return db_user +# --- End User CRUD --- + + +# --- Group CRUD --- +async def create_group(db: AsyncSession, group_in: GroupCreate, creator_id: int) -> GroupModel: + """Creates a group and adds the creator as the owner.""" + db_group = GroupModel(name=group_in.name, created_by_id=creator_id) + db.add(db_group) + await db.flush() # Flush to get the db_group.id for the UserGroup entry + + # Add creator as owner + db_user_group = UserGroupModel( + user_id=creator_id, + group_id=db_group.id, + role=UserRoleEnum.owner # Use the Enum member + ) + db.add(db_user_group) + + await db.commit() + await db.refresh(db_group) + return db_group + +async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]: + """Gets all groups a user is a member of.""" + result = await db.execute( + select(GroupModel) + .join(UserGroupModel) + .where(UserGroupModel.user_id == user_id) + .options(selectinload(GroupModel.member_associations)) # Optional: preload associations if needed often + ) + return result.scalars().all() + +async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupModel]: + """Gets a single group by its ID, optionally loading members.""" + # Use selectinload to eager load members and their user details + result = await db.execute( + select(GroupModel) + .where(GroupModel.id == group_id) + .options( + selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user) + ) + ) + return result.scalars().first() + +async def is_user_member(db: AsyncSession, group_id: int, user_id: int) -> bool: + """Checks if a user is a member of a specific group.""" + result = await db.execute( + select(UserGroupModel.id) # Select just one column for existence check + .where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id) + .limit(1) + ) + return result.scalar_one_or_none() is not None + +async def get_user_role_in_group(db: AsyncSession, group_id: int, user_id: int) -> Optional[UserRoleEnum]: + """Gets the role of a user in a specific group.""" + result = await db.execute( + select(UserGroupModel.role) + .where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id) + ) + role = result.scalar_one_or_none() + return role # Will be None if not a member, or the UserRoleEnum value + +async def add_user_to_group(db: AsyncSession, group_id: int, user_id: int, role: UserRoleEnum = UserRoleEnum.member) -> Optional[UserGroupModel]: + """Adds a user to a group if they aren't already a member.""" + # Check if already exists + existing = await db.execute( + select(UserGroupModel).where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id) + ) + if existing.scalar_one_or_none(): + return None # Indicate user already in group + + db_user_group = UserGroupModel(user_id=user_id, group_id=group_id, role=role) + db.add(db_user_group) + await db.commit() + await db.refresh(db_user_group) + return db_user_group + +async def remove_user_from_group(db: AsyncSession, group_id: int, user_id: int) -> bool: + """Removes a user from a group.""" + result = await db.execute( + delete(UserGroupModel) + .where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id) + .returning(UserGroupModel.id) # Optional: check if a row was actually deleted + ) + await db.commit() + return result.scalar_one_or_none() is not None # True if deletion happened + +async def get_group_member_count(db: AsyncSession, group_id: int) -> int: + """Counts the number of members in a group.""" + result = await db.execute( + select(func.count(UserGroupModel.id)).where(UserGroupModel.group_id == group_id) + ) + return result.scalar_one() \ No newline at end of file diff --git a/be/app/crud/invite.py b/be/app/crud/invite.py new file mode 100644 index 0000000..ed64438 --- /dev/null +++ b/be/app/crud/invite.py @@ -0,0 +1,69 @@ +# app/crud/invite.py +import secrets +from datetime import datetime, timedelta, timezone +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import delete # Import delete statement +from typing import Optional + +from app.models import Invite as InviteModel + +# Invite codes should be reasonably unique, but handle potential collision +MAX_CODE_GENERATION_ATTEMPTS = 5 + +async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expires_in_days: int = 7) -> Optional[InviteModel]: + """Creates a new invite code for a group.""" + expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days) + code = None + attempts = 0 + + # Generate a unique code, retrying if a collision occurs (highly unlikely but safe) + while attempts < MAX_CODE_GENERATION_ATTEMPTS: + attempts += 1 + potential_code = secrets.token_urlsafe(16) + # Check if an *active* invite with this code already exists + existing = await db.execute( + select(InviteModel.id).where(InviteModel.code == potential_code, InviteModel.is_active == True).limit(1) + ) + if existing.scalar_one_or_none() is None: + code = potential_code + break + + if code is None: + # Failed to generate a unique code after several attempts + return None + + db_invite = InviteModel( + code=code, + group_id=group_id, + created_by_id=creator_id, + expires_at=expires_at, + is_active=True + ) + db.add(db_invite) + await db.commit() + await db.refresh(db_invite) + return db_invite + +async def get_active_invite_by_code(db: AsyncSession, code: str) -> Optional[InviteModel]: + """Gets an active and non-expired invite by its code.""" + now = datetime.now(timezone.utc) + result = await db.execute( + select(InviteModel).where( + InviteModel.code == code, + InviteModel.is_active == True, + InviteModel.expires_at > now + ) + ) + return result.scalars().first() + +async def deactivate_invite(db: AsyncSession, invite: InviteModel) -> InviteModel: + """Marks an invite as inactive (used).""" + invite.is_active = False + db.add(invite) # Add to session to track change + await db.commit() + await db.refresh(invite) + return invite + +# Optional: Function to periodically delete old, inactive invites +# async def cleanup_old_invites(db: AsyncSession, older_than_days: int = 30): ... \ No newline at end of file diff --git a/be/app/crud/user.py b/be/app/crud/user.py new file mode 100644 index 0000000..c126a0d --- /dev/null +++ b/be/app/crud/user.py @@ -0,0 +1,28 @@ +# app/crud/user.py +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from typing import Optional + +from app.models import User as UserModel # Alias to avoid name clash +from app.schemas.user import UserCreate +from app.core.security import hash_password + +async def get_user_by_email(db: AsyncSession, email: str) -> Optional[UserModel]: + """Fetches a user from the database by email.""" + result = await db.execute(select(UserModel).filter(UserModel.email == email)) + return result.scalars().first() + +async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel: + """Creates a new user record in the database.""" + _hashed_password = hash_password(user_in.password) # Keep local var name if you like + # Create SQLAlchemy model instance - explicitly map fields + db_user = UserModel( + email=user_in.email, + # Use the correct keyword argument matching the model column name + password_hash=_hashed_password, + name=user_in.name + ) + db.add(db_user) + await db.commit() + await db.refresh(db_user) # Refresh to get DB-generated values like ID, created_at + return db_user \ No newline at end of file diff --git a/be/app/main.py b/be/app/main.py index fd27fcd..0efd641 100644 --- a/be/app/main.py +++ b/be/app/main.py @@ -29,7 +29,7 @@ app = FastAPI( # but restrict it as soon as possible. # SvelteKit default dev port is 5173 origins = [ - "http://localhost:5173", + "http://localhost:5174", "http://localhost:8000", # Allow requests from the API itself (e.g., Swagger UI) # Add your deployed frontend URL here later # "https://your-frontend-domain.com", diff --git a/be/app/models.py b/be/app/models.py index 72d93d4..20fcd54 100644 --- a/be/app/models.py +++ b/be/app/models.py @@ -1,6 +1,8 @@ # app/models.py import enum -from datetime import datetime +import secrets # For generating invite codes +from datetime import datetime, timedelta, timezone # For invite expiry + from sqlalchemy import ( Column, Integer, @@ -10,15 +12,20 @@ from sqlalchemy import ( Boolean, Enum as SAEnum, # Renamed to avoid clash with Python's enum UniqueConstraint, + Index, # Added for invite code index + DDL, event, - DDL + delete, # Added for potential cascade delete if needed (though FK handles it) + func, # Added for func.count() + text as sa_text # For raw SQL in index definition if needed ) from sqlalchemy.orm import relationship -from sqlalchemy.sql import func # For server_default=func.now() +# Removed func import as it's imported above +# from sqlalchemy.sql import func # For server_default=func.now() -from app.database import Base # Import Base from database setup +from .database import Base # Import Base from database setup -# Define Enum for User Roles in Groups +# --- Enums --- class UserRoleEnum(enum.Enum): owner = "owner" member = "member" @@ -29,23 +36,24 @@ 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) - name = Column(String, index=True, nullable=True) # Allow nullable name initially + password_hash = Column(String, nullable=False) # Column name used in CRUD + name = Column(String, index=True, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) - # Relationships + # --- Relationships --- # Groups created by this user - created_groups = relationship("Group", back_populates="creator") - # Association object for group membership - group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan") + created_groups = relationship("Group", back_populates="creator") # Links to Group.creator - # Items added by this user (Add later when Item model is defined) + # Association object for group membership (many-to-many) + group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan") # Links to UserGroup.user + + # Invites created by this user (one-to-many) + created_invites = relationship("Invite", back_populates="creator") # Links to Invite.creator + + # Optional relationships for items/lists (Add later) # added_items = relationship("Item", foreign_keys="[Item.added_by_id]", back_populates="added_by_user") - # Items completed by this user (Add later) # completed_items = relationship("Item", foreign_keys="[Item.completed_by_id]", back_populates="completed_by_user") - # Expense shares for this user (Add later) # expense_shares = relationship("ExpenseShare", back_populates="user") - # Lists created by this user (Add later) # created_lists = relationship("List", foreign_keys="[List.created_by_id]", back_populates="creator") @@ -55,54 +63,78 @@ class Group(Base): id = Column(Integer, primary_key=True, index=True) name = Column(String, index=True, nullable=False) - created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # FK to User table created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) - # Relationships - # The user who created this group - creator = relationship("User", back_populates="created_groups") - # Association object for group membership - member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan") + # --- Relationships --- + # The user who created this group (many-to-one) + creator = relationship("User", back_populates="created_groups") # Links to User.created_groups + + # Association object for group membership (one-to-many) + member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan") # Links to UserGroup.group + + # Invites belonging to this group (one-to-many) + invites = relationship("Invite", back_populates="group", cascade="all, delete-orphan") # Links to Invite.group + # Lists belonging to this group (Add later) # lists = relationship("List", back_populates="group") -# --- UserGroup Association Model --- + +# --- UserGroup Association Model (Many-to-Many link) --- class UserGroup(Base): __tablename__ = "user_groups" - __table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),) # Ensure user cannot be in same group twice + # Ensure a user cannot be in the same group twice + __table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),) id = Column(Integer, primary_key=True, index=True) # Surrogate primary key - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) - role = Column(SAEnum(UserRoleEnum), nullable=False, default=UserRoleEnum.member) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) # FK to User + group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group + role = Column(SAEnum(UserRoleEnum, name="userroleenum", create_type=True), nullable=False, default=UserRoleEnum.member) # Use Enum, ensure type is created joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) - # Relationships back to User and Group - user = relationship("User", back_populates="group_associations") - group = relationship("Group", back_populates="member_associations") + # --- Relationships --- + # Link back to User (many-to-one from the perspective of this table row) + user = relationship("User", back_populates="group_associations") # Links to User.group_associations + + # Link back to Group (many-to-one from the perspective of this table row) + group = relationship("Group", back_populates="member_associations") # Links to Group.member_associations -# --- Add other models below when needed --- +# --- Invite Model --- +class Invite(Base): + __tablename__ = "invites" + # Ensure unique codes *within active invites* using a partial index (PostgreSQL specific) + # If not using PostgreSQL or need simpler logic, a simple unique=True on 'code' works, + # but doesn't allow reusing old codes once deactivated. + __table_args__ = ( + Index( + 'ix_invites_active_code', + 'code', + unique=True, + postgresql_where=sa_text('is_active = true') # Partial index condition + ), + ) + + id = Column(Integer, primary_key=True, index=True) + # Generate a secure random code by default + code = Column(String, unique=False, index=True, nullable=False, default=lambda: secrets.token_urlsafe(16)) # Index helps lookup, uniqueness handled by partial index + group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group + created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # FK to User + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + # Set default expiry (e.g., 7 days from creation) + expires_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + timedelta(days=7)) + is_active = Column(Boolean, default=True, nullable=False) # To mark as used/invalid + + # --- Relationships --- + # Link back to the Group this invite is for (many-to-one) + group = relationship("Group", back_populates="invites") # Links to Group.invites + + # Link back to the User who created the invite (many-to-one) + creator = relationship("User", back_populates="created_invites") # Links to User.created_invites + + +# --- Models for Lists, Items, Expenses (Add later) --- # class List(Base): ... # class Item(Base): ... # class Expense(Base): ... -# class ExpenseShare(Base): ... - -# Optional: Trigger for automatically creating an 'owner' UserGroup entry when a Group is created. -# This requires importing event and DDL. It's advanced and DB-specific, might be simpler to handle in application logic. -# Example for PostgreSQL (might need adjustment): -# group_owner_trigger = DDL(""" -# CREATE OR REPLACE FUNCTION add_group_owner() -# RETURNS TRIGGER AS $$ -# BEGIN -# INSERT INTO user_groups (user_id, group_id, role, joined_at) -# VALUES (NEW.created_by_id, NEW.id, 'owner', NOW()); -# RETURN NEW; -# END; -# $$ LANGUAGE plpgsql; -# -# CREATE TRIGGER trg_add_group_owner -# AFTER INSERT ON groups -# FOR EACH ROW EXECUTE FUNCTION add_group_owner(); -# """) -# event.listen(Group.__table__, 'after_create', group_owner_trigger) \ No newline at end of file +# class ExpenseShare(Base): ... \ No newline at end of file diff --git a/be/app/schemas/auth.py b/be/app/schemas/auth.py new file mode 100644 index 0000000..d3a76fb --- /dev/null +++ b/be/app/schemas/auth.py @@ -0,0 +1,11 @@ +# app/schemas/auth.py +from pydantic import BaseModel, EmailStr + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" # Default token type + +# Optional: If you preferred not to use OAuth2PasswordRequestForm +# class UserLogin(BaseModel): +# email: EmailStr +# password: str \ No newline at end of file diff --git a/be/app/schemas/group.py b/be/app/schemas/group.py new file mode 100644 index 0000000..00c227b --- /dev/null +++ b/be/app/schemas/group.py @@ -0,0 +1,24 @@ +# app/schemas/group.py +from pydantic import BaseModel, ConfigDict +from datetime import datetime +from typing import Optional, List + +from .user import UserPublic # Import UserPublic to represent members + +# Properties to receive via API on creation +class GroupCreate(BaseModel): + name: str + +# Properties to return to client +class GroupPublic(BaseModel): + id: int + name: str + created_by_id: int + created_at: datetime + members: Optional[List[UserPublic]] = None # Include members only in detailed view + + model_config = ConfigDict(from_attributes=True) + +# Properties stored in DB (if needed, often GroupPublic is sufficient) +# class GroupInDB(GroupPublic): +# pass \ No newline at end of file diff --git a/be/app/schemas/invite.py b/be/app/schemas/invite.py new file mode 100644 index 0000000..63dcb79 --- /dev/null +++ b/be/app/schemas/invite.py @@ -0,0 +1,20 @@ +# app/schemas/invite.py +from pydantic import BaseModel +from datetime import datetime + +# Properties to receive when accepting an invite +class InviteAccept(BaseModel): + code: str + +# Properties to return when an invite is created +class InviteCodePublic(BaseModel): + code: str + expires_at: datetime + group_id: int + +# Properties for internal use/DB (optional) +# class Invite(InviteCodePublic): +# id: int +# created_by_id: int +# is_active: bool = True +# model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/be/app/schemas/message.py b/be/app/schemas/message.py new file mode 100644 index 0000000..04b9e0e --- /dev/null +++ b/be/app/schemas/message.py @@ -0,0 +1,5 @@ +# app/schemas/message.py +from pydantic import BaseModel + +class Message(BaseModel): + detail: str \ No newline at end of file diff --git a/be/app/schemas/user.py b/be/app/schemas/user.py new file mode 100644 index 0000000..4bf247a --- /dev/null +++ b/be/app/schemas/user.py @@ -0,0 +1,34 @@ +# app/schemas/user.py +from pydantic import BaseModel, EmailStr, ConfigDict +from datetime import datetime +from typing import Optional + +# Shared properties +class UserBase(BaseModel): + email: EmailStr + name: Optional[str] = None + +# Properties to receive via API on creation +class UserCreate(UserBase): + password: str + +# Properties to receive via API on update (optional, add later if needed) +# class UserUpdate(UserBase): +# password: Optional[str] = None + +# Properties stored in DB +class UserInDBBase(UserBase): + id: int + hashed_password: str + created_at: datetime + model_config = ConfigDict(from_attributes=True) # Use orm_mode in Pydantic v1 + +# Additional properties to return via API (excluding password) +class UserPublic(UserBase): + id: int + created_at: datetime + model_config = ConfigDict(from_attributes=True) + +# Full user model including hashed password (for internal use/reading from DB) +class User(UserInDBBase): + pass \ No newline at end of file diff --git a/be/requirements.txt b/be/requirements.txt index cc940dc..c6625a7 100644 --- a/be/requirements.txt +++ b/be/requirements.txt @@ -5,4 +5,7 @@ asyncpg>=0.27.0 # Async PostgreSQL driver psycopg2-binary>=2.9.0 # Often needed by Alembic even if app uses asyncpg alembic>=1.9.0 # Database migrations pydantic-settings>=2.0.0 # For loading settings from .env -python-dotenv>=1.0.0 # To load .env file for scripts/alembic \ No newline at end of file +python-dotenv>=1.0.0 # To load .env file for scripts/alembic +passlib[bcrypt]>=1.7.4 +python-jose[cryptography]>=3.3.0 +pydantic[email] \ No newline at end of file diff --git a/fe/src/lib/apiClient.ts b/fe/src/lib/apiClient.ts index 3c1bdef..8b91a34 100644 --- a/fe/src/lib/apiClient.ts +++ b/fe/src/lib/apiClient.ts @@ -1,163 +1,278 @@ // src/lib/apiClient.ts -import { error } from '@sveltejs/kit'; // SvelteKit's error helper + +// Import necessary modules/types +import { browser } from '$app/environment'; // For checks if needed +import { error } from '@sveltejs/kit'; // Can be used for throwing errors in load functions +import { authStore, logout, getCurrentToken } from './stores/authStore'; // Import store and helpers // --- Configuration --- -// Get the base URL from environment variables provided by Vite/SvelteKit -// Ensure VITE_API_BASE_URL is set in your .env file (e.g., VITE_API_BASE_URL=http://localhost:8000/api) +// Read base URL from Vite environment variables +// Ensure VITE_API_BASE_URL is set in your fe/.env file (e.g., VITE_API_BASE_URL=http://localhost:8000/api) const BASE_URL = import.meta.env.VITE_API_BASE_URL; -if (!BASE_URL) { - console.error('VITE_API_BASE_URL is not defined. Please set it in your .env file.'); - // In a real app, you might throw an error here or have a default, - // but logging is often sufficient during development. +// Initial check for configuration during module load (optional but good practice) +if (!BASE_URL && browser) { // Only log error in browser, avoid during SSR build if possible + console.error( + 'VITE_API_BASE_URL is not defined. Please set it in your .env file. API calls may fail.' + ); } +// --- Custom Error Class for API Client --- export class ApiClientError extends Error { - status: number; - errorData: unknown; + status: number; // HTTP status code + errorData: unknown; // Parsed error data from response body (if any) constructor(message: string, status: number, errorData: unknown = null) { - super(message); - this.name = 'ApiClientError'; + super(message); // Pass message to the base Error class + this.name = 'ApiClientError'; // Custom error name this.status = status; this.errorData = errorData; - // --- Corrected Conditional Check --- - // Check if the static method exists on the Error constructor object + // Attempt to capture a cleaner stack trace in V8 environments (Node, Chrome) + // Conditionally check if the non-standard captureStackTrace exists if (typeof (Error as any).captureStackTrace === 'function') { // Call it if it exists, casting Error to 'any' to bypass static type check - (Error as any).captureStackTrace(this, ApiClientError); + (Error as any).captureStackTrace(this, ApiClientError); // Pass 'this' and the constructor } - // else { - // Optional: Fallback if captureStackTrace is not available - // You might assign the stack from a new error instance, - // though `super(message)` often handles basic stack creation. - // this.stack = new Error(message).stack; - // } - // --- End Corrected Check --- } } -// --- Core Fetch Function --- +// --- Request Options Interface --- +// Extends standard RequestInit but omits 'body' as we handle it separately interface RequestOptions extends Omit { - // Can add custom options here if needed later + // Can add custom options here later, e.g.: + // skipAuth?: boolean; // To bypass adding the Authorization header } -async function request( // Generic type T for expected response data +// --- Core Request Function --- +// Uses generics to allow specifying the expected successful response data type +async function request( method: string, - path: string, // Relative path (e.g., /v1/health) - data?: unknown, // Optional request body data - options: RequestOptions = {} // Optional fetch options (headers, etc.) + path: string, // Relative path to the API endpoint (e.g., /v1/users/me) + bodyData?: unknown, // Optional data for the request body (can be object, FormData, URLSearchParams, etc.) + options: RequestOptions = {} // Optional fetch options (headers, credentials, mode, etc.) ): Promise { + // Runtime check for BASE_URL, in case it wasn't set or available during initial load if (!BASE_URL) { - // Or use SvelteKit's error helper for server-side/universal loads - // error(500, 'API Base URL is not configured.'); - throw new Error('API Base URL (VITE_API_BASE_URL) is not configured.'); + // Depending on context (load function vs. component event), choose how to handle + // error(500, 'API Base URL is not configured.'); // Use in load functions + throw new Error('API Base URL (VITE_API_BASE_URL) is not configured.'); // Throw for component events } - // Construct the full URL, handling potential leading/trailing slashes + // Construct the full URL safely const cleanBase = BASE_URL.replace(/\/$/, ''); // Remove trailing slash from base const cleanPath = path.replace(/^\//, ''); // Remove leading slash from path const url = `${cleanBase}/${cleanPath}`; - // Default headers + // Initialize headers, setting Accept to JSON by default const headers = new Headers({ Accept: 'application/json', - ...options.headers // Spread custom headers from options + ...options.headers // Spread custom headers provided in options early }); - // Fetch options + // --- Prepare Request Body and Set Content-Type --- + let processedBody: BodyInit | null = null; + + if (bodyData !== undefined && bodyData !== null) { + if (bodyData instanceof URLSearchParams) { + // Handle URL-encoded form data + headers.set('Content-Type', 'application/x-www-form-urlencoded'); + processedBody = bodyData; + } else if (bodyData instanceof FormData) { + // Handle FormData (multipart/form-data) + // Let the browser set the Content-Type with the correct boundary + // Important: DO NOT set 'Content-Type' manually for FormData + // headers.delete('Content-Type'); // Ensure no manual Content-Type is set + processedBody = bodyData; + } else if (typeof bodyData === 'object') { + // Handle plain JavaScript objects as JSON + headers.set('Content-Type', 'application/json'); + try { + processedBody = JSON.stringify(bodyData); + } catch (e) { + console.error("Failed to stringify JSON body data:", bodyData, e); + throw new Error("Invalid JSON body data provided."); + } + } else { + // Handle other primitives (string, number, boolean) - default to sending as JSON stringified + // Adjust this logic if you need to send plain text or other formats + headers.set('Content-Type', 'application/json'); + try { + processedBody = JSON.stringify(bodyData) + } catch (e) { + console.error("Failed to stringify primitive body data:", bodyData, e); + throw new Error("Invalid body data provided."); + } + } + } + // --- End Body Preparation --- + + // --- Add Authorization Header --- + const currentToken = getCurrentToken(); // Get token synchronously from auth store + // Add header if token exists and Authorization wasn't manually set in options.headers + if (currentToken && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${currentToken}`); + } + // --- End Authorization Header --- + + // Assemble final fetch options const fetchOptions: RequestInit = { method: method.toUpperCase(), headers, - ...options // Spread other custom options (credentials, mode, cache, etc.) + body: processedBody, // Use the potentially processed body + credentials: options.credentials ?? 'same-origin', // Default credentials policy + mode: options.mode ?? 'cors', // Default mode + cache: options.cache ?? 'default', // Default cache policy + ...options // Spread remaining options, potentially overriding defaults if needed }; - // Add body and Content-Type header if data is provided - if (data !== undefined && data !== null) { - headers.set('Content-Type', 'application/json'); - fetchOptions.body = JSON.stringify(data); - } - - // Add credentials option if needed for cookies/auth later - // fetchOptions.credentials = 'include'; - + // --- Execute Fetch and Handle Response --- try { + // Optional: Log request details for debugging + // console.debug(`API Request: ${fetchOptions.method} ${url}`, { headers: Object.fromEntries(headers.entries()), body: bodyData }); const response = await fetch(url, fetchOptions); + // Optional: Log response status + // console.debug(`API Response Status: ${response.status} for ${fetchOptions.method} ${url}`); - // Check if the response is successful (status code 200-299) + // Check if the response status code indicates failure (not 2xx) if (!response.ok) { let errorJson: unknown = null; + // Attempt to parse error details from the response body try { - // Try to parse error details from the response body errorJson = await response.json(); + // console.debug(`API Error Response Body:`, errorJson); } catch (e) { - // Ignore if response body isn't valid JSON - console.warn('API Error response was not valid JSON.', response.status, response.statusText) + // Ignore if response body isn't valid JSON or empty + console.warn(`API Error response for ${response.status} was not valid JSON or empty.`); } - // Throw a custom error with status and potentially parsed error data - throw new ApiClientError( + + // Create the custom error object + const errorToThrow = new ApiClientError( `API request failed: ${response.status} ${response.statusText}`, response.status, errorJson ); + + // --- Global 401 (Unauthorized) Handling --- + // If the server returns 401, assume the token is invalid/expired + // and automatically log the user out by clearing the auth store. + if (response.status === 401) { + console.warn(`API Client: Received 401 Unauthorized for ${method} ${path}. Logging out.`); + // Calling logout clears the token from store & localStorage + logout(); + // Optional: Trigger a redirect to login page. Often better handled + // by calling code or root layout based on application structure. + // import { goto } from '$app/navigation'; + // if (browser) await goto('/login?sessionExpired=true'); + } + // --- End Global 401 Handling --- + + // Throw the error regardless, so the calling code knows the request failed + throw errorToThrow; } - // Handle successful responses with no content (e.g., 204 No Content) + // Handle successful responses with no content (e.g., 204 No Content for DELETE) if (response.status === 204) { - // Type assertion needed because Promise expects a value, - // but 204 has no body. We return null. Adjust T if needed. + // Assert type as T, assuming T can accommodate null or void if needed return null as T; } - // Parse successful JSON response + // Parse successful JSON response body const responseData = await response.json(); - return responseData as T; // Assert the type based on the generic T + // Assert the response data matches the expected generic type T + return responseData as T; } catch (err) { - // Handle network errors or errors thrown above - console.error(`API Client request error: ${method} ${path}`, err); + // Handle network errors (fetch throws TypeError) or errors thrown above + console.error(`API Client request error during ${method} ${path}:`, err); - // Re-throw the error so calling code can handle it + // Ensure logout is called even if the caught error is a 401 ApiClientError + // This handles cases where parsing a non-ok response might fail but status was 401 + if (err instanceof ApiClientError && err.status === 401) { + console.warn(`API Client: Caught ApiClientError 401 for ${method} ${path}. Ensuring logout.`); + // Ensure logout state is cleared even if error originated elsewhere + logout(); + } + + // Re-throw the error so the calling code can handle it appropriately // If it's already our custom error, re-throw it directly if (err instanceof ApiClientError) { throw err; } - // Otherwise, wrap network or other errors + + // Otherwise, wrap network or other unexpected errors in our custom error type throw new ApiClientError( `Network or unexpected error during API request: ${err instanceof Error ? err.message : String(err)}`, - 0, // Use 0 or a specific code for network errors - err + 0, // Use 0 or a specific code (e.g., -1) for non-HTTP errors + err // Include the original error object as data ); } } -// --- Helper Methods --- +// --- Convenience Methods (GET, POST, PUT, DELETE, PATCH) --- +// Provide simple wrappers around the core 'request' function export const apiClient = { + /** + * Performs a GET request. + * @template T The expected type of the response data. + * @param path API endpoint path (e.g., '/v1/users/me'). + * @param options Optional fetch request options. + * @returns Promise resolving to the parsed JSON response body of type T. + */ get: (path: string, options: RequestOptions = {}): Promise => { return request('GET', path, undefined, options); }, + /** + * Performs a POST request. + * @template T The expected type of the response data. + * @param path API endpoint path (e.g., '/v1/auth/signup'). + * @param data Request body data (object, FormData, URLSearchParams). + * @param options Optional fetch request options. + * @returns Promise resolving to the parsed JSON response body of type T. + */ post: (path: string, data: unknown, options: RequestOptions = {}): Promise => { return request('POST', path, data, options); }, + /** + * Performs a PUT request. + * @template T The expected type of the response data. + * @param path API endpoint path. + * @param data Request body data. + * @param options Optional fetch request options. + * @returns Promise resolving to the parsed JSON response body of type T. + */ put: (path: string, data: unknown, options: RequestOptions = {}): Promise => { return request('PUT', path, data, options); }, + /** + * Performs a DELETE request. + * @template T The expected type of the response data (often null or void). + * @param path API endpoint path. + * @param options Optional fetch request options. + * @returns Promise resolving to the parsed JSON response body (often null for 204). + */ delete: (path: string, options: RequestOptions = {}): Promise => { - // Note: DELETE requests might have a body, but often don't. Adjust if needed. + // DELETE requests might or might not have a body depending on API design return request('DELETE', path, undefined, options); }, + /** + * Performs a PATCH request. + * @template T The expected type of the response data. + * @param path API endpoint path. + * @param data Request body data (usually partial updates). + * @param options Optional fetch request options. + * @returns Promise resolving to the parsed JSON response body of type T. + */ patch: (path: string, data: unknown, options: RequestOptions = {}): Promise => { return request('PATCH', path, data, options); } - // Can add other methods (HEAD, OPTIONS) if necessary }; -// Default export can sometimes be convenient, but named export is clear -// export default apiClient; \ No newline at end of file +// Optional: Export the error class as well if needed externally +// export { ApiClientError }; \ No newline at end of file diff --git a/fe/src/lib/schemas/auth.ts b/fe/src/lib/schemas/auth.ts new file mode 100644 index 0000000..b902ca0 --- /dev/null +++ b/fe/src/lib/schemas/auth.ts @@ -0,0 +1,4 @@ +export interface Token { + access_token: string; + token_type: string; +} \ No newline at end of file diff --git a/fe/src/lib/schemas/group.ts b/fe/src/lib/schemas/group.ts new file mode 100644 index 0000000..b7836d4 --- /dev/null +++ b/fe/src/lib/schemas/group.ts @@ -0,0 +1,9 @@ +import type { UserPublic } from "./user"; + +export interface GroupPublic { + id: number; + name: string; + created_by_id: number; + created_at: string; + members?: UserPublic[] | null; // Ensure this is included +} diff --git a/fe/src/lib/schemas/invite.ts b/fe/src/lib/schemas/invite.ts new file mode 100644 index 0000000..4d873a2 --- /dev/null +++ b/fe/src/lib/schemas/invite.ts @@ -0,0 +1,5 @@ +export interface InviteCodePublic { + code: string; + expires_at: string; // Date as string from JSON + group_id: number; +} \ No newline at end of file diff --git a/fe/src/lib/schemas/message.ts b/fe/src/lib/schemas/message.ts new file mode 100644 index 0000000..68c783d --- /dev/null +++ b/fe/src/lib/schemas/message.ts @@ -0,0 +1,3 @@ +export interface Message { + detail: string; +} \ No newline at end of file diff --git a/fe/src/lib/schemas/user.ts b/fe/src/lib/schemas/user.ts new file mode 100644 index 0000000..e467ca9 --- /dev/null +++ b/fe/src/lib/schemas/user.ts @@ -0,0 +1,6 @@ +export interface UserPublic { + id: number; + email: string; + name?: string | null; + created_at: string; +} diff --git a/fe/src/lib/stores/authStore.ts b/fe/src/lib/stores/authStore.ts new file mode 100644 index 0000000..35526ea --- /dev/null +++ b/fe/src/lib/stores/authStore.ts @@ -0,0 +1,119 @@ +// src/lib/stores/authStore.ts +import { writable, get } from 'svelte/store'; +import { browser } from '$app/environment'; // Import browser check + +// --- Define Types --- + +// You should ideally have a shared UserPublic type or define it here +// matching the backend UserPublic schema +interface UserPublic { + id: number; + email: string; + name?: string | null; + created_at: string; // Date might be string in JSON +} + +interface AuthState { + isAuthenticated: boolean; + user: UserPublic | null; + token: string | null; +} + +// --- Store Initialization --- + +const AUTH_TOKEN_KEY = 'authToken'; // Key for localStorage + +const initialAuthState: AuthState = { + isAuthenticated: false, + user: null, + token: null +}; + +// Create the writable store +export const authStore = writable(initialAuthState); + +// --- Persistence Logic --- + +// Load initial state from localStorage (only in browser) +if (browser) { + const storedToken = localStorage.getItem(AUTH_TOKEN_KEY); + if (storedToken) { + // Token exists, tentatively set state. + // We don't know if it's *valid* yet, nor do we have user data. + // A call to /users/me on app load could validate & fetch user data. + authStore.update((state) => ({ + ...state, + token: storedToken, + // Keep isAuthenticated false until token is validated/user fetched + // Or set to true tentatively if you prefer optimistic UI + isAuthenticated: true // Optimistic: assume token might be valid + })); + console.log('AuthStore: Loaded token from localStorage.'); + } +} + +// Subscribe to store changes to persist the token (only in browser) +authStore.subscribe((state) => { + if (browser) { + if (state.token) { + // Save token to localStorage when it exists + localStorage.setItem(AUTH_TOKEN_KEY, state.token); + console.log('AuthStore: Token saved to localStorage.'); + } else { + // Remove token from localStorage when it's null (logout) + localStorage.removeItem(AUTH_TOKEN_KEY); + console.log('AuthStore: Token removed from localStorage.'); + } + } +}); + + +// --- Action Functions --- + +/** + * Updates the auth store upon successful login. + * @param token The JWT access token. + * @param userData The public user data received from the login/signup or /users/me endpoint. + */ +export function login(token: string, userData: UserPublic): void { + authStore.set({ + isAuthenticated: true, + user: userData, + token: token + }); + console.log('AuthStore: User logged in.', userData); +} + +/** + * Resets the auth store to its initial state (logged out). + */ +export function logout(): void { + authStore.set(initialAuthState); + console.log('AuthStore: User logged out.'); +} + +/** + * Updates only the user information in the store, keeping auth state. + * Useful after fetching fresh user data from /users/me. + * @param userData The updated public user data. + */ +export function updateUser(userData: UserPublic): void { + authStore.update(state => { + if (state.isAuthenticated) { + return { ...state, user: userData }; + } + return state; // No change if not authenticated + }); + console.log('AuthStore: User data updated.', userData); +} + + +// --- Helper to get token synchronously (use with caution) --- +/** + * Gets the current token synchronously from the store. + * Primarily intended for use within the apiClient where reactivity isn't needed. + * @returns The current token string or null. + */ +export function getCurrentToken(): string | null { + return get(authStore).token; +} \ No newline at end of file diff --git a/fe/src/routes/(app)/+layout.svelte b/fe/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..f00b016 --- /dev/null +++ b/fe/src/routes/(app)/+layout.svelte @@ -0,0 +1,49 @@ + + + + + +
+
+
+ App Dashboard +
+ {#if data.user} + Welcome, {data.user.name || data.user.email}! + {/if} + +
+
+
+ +
+ + +
+ + + +
diff --git a/fe/src/routes/(app)/+layout.ts b/fe/src/routes/(app)/+layout.ts new file mode 100644 index 0000000..71f01bd --- /dev/null +++ b/fe/src/routes/(app)/+layout.ts @@ -0,0 +1,38 @@ +// src/routes/(app)/+layout.ts +import { redirect } from '@sveltejs/kit'; +import { browser } from '$app/environment'; +import { get } from 'svelte/store'; // Import get for synchronous access in load +import { authStore } from '$lib/stores/authStore'; +import type { LayoutLoad } from './$types'; // Import generated types for load function + +export const load: LayoutLoad = ({ url }) => { + // IMPORTANT: localStorage/authStore logic relies on the browser. + // This check prevents errors during SSR or prerendering. + if (!browser) { + // On the server, we cannot reliably check auth state stored in localStorage. + // You might implement server-side session checking here later if needed. + // For now, we allow server rendering to proceed, the client-side check + // or a subsequent navigation check will handle redirection if necessary. + return {}; // Proceed with loading on server + } + + // Get the current auth state synchronously + const authState = get(authStore); + + console.log('(app) layout load: Checking auth state', authState); + + // If not authenticated in the browser, redirect to login + if (!authState.isAuthenticated) { + console.log('(app) layout load: User not authenticated, redirecting to login.'); + // Construct the redirect URL, preserving the original path the user tried to access + const redirectTo = `/login?redirectTo=${encodeURIComponent(url.pathname + url.search)}`; + throw redirect(307, redirectTo); // Use 307 Temporary Redirect + } + + // If authenticated, allow the layout and page to load. + // We could return user data here if needed by the layout/pages. + console.log('(app) layout load: User authenticated, proceeding.'); + return { + user: authState.user // Optionally pass user data to the layout/pages + }; +}; \ No newline at end of file diff --git a/fe/src/routes/(app)/dashboard/+page.svelte b/fe/src/routes/(app)/dashboard/+page.svelte new file mode 100644 index 0000000..4a46733 --- /dev/null +++ b/fe/src/routes/(app)/dashboard/+page.svelte @@ -0,0 +1,137 @@ + + + +
+

Your Groups

+ + +
+

Create New Group

+
+
+ + +
+ +
+ {#if createError} +

{createError}

+ {/if} +
+ + +
+

My Groups

+ + {#if loadError} + + + {:else if displayedGroups.length === 0} + +

You are not a member of any groups yet. Create one above!

+ {:else} + +
    + {#each displayedGroups as group (group.id)} +
  • +
    + {group.name} + + + View + +
    +

    + ID: {group.id} | Created: {new Date(group.created_at).toLocaleDateString()} +

    +
  • + {/each} +
+ {/if} +
+
diff --git a/fe/src/routes/(app)/dashboard/+page.ts b/fe/src/routes/(app)/dashboard/+page.ts new file mode 100644 index 0000000..b275e2d --- /dev/null +++ b/fe/src/routes/(app)/dashboard/+page.ts @@ -0,0 +1,48 @@ +// src/routes/(app)/dashboard/+page.ts +import { error } from '@sveltejs/kit'; +import { apiClient, ApiClientError } from '$lib/apiClient'; +import type { GroupPublic } from '$lib/schemas/group'; // Import the Group type +import type { PageLoad } from './$types'; // SvelteKit's type for load functions + +// Define the expected shape of the data returned by this load function +export interface DashboardLoadData { + groups: GroupPublic[]; + error?: string | null; // Optional error message property +} + +export const load: PageLoad = async ({ fetch }) => { + // Note: SvelteKit's 'fetch' is recommended inside load functions + // as it handles credentials and relative paths better during SSR/CSR. + // However, our apiClient uses the global fetch but includes auth logic. + // For consistency, we can continue using apiClient here. + console.log('Dashboard page load: Fetching groups...'); + try { + const groups = await apiClient.get('/v1/groups'); // apiClient adds auth header + console.log('Dashboard page load: Groups fetched successfully', groups); + return { + groups: groups ?? [], // Return empty array if API returns null/undefined + error: null + }; + } catch (err) { + console.error('Dashboard page load: Failed to fetch groups:', err); + let errorMessage = 'Failed to load groups.'; + if (err instanceof ApiClientError) { + // Specific API error handling (authStore's 401 handling should have run) + errorMessage = `Failed to load groups (Status: ${err.status}). Please try again later.`; + // If it was a 401, the layout guard should ideally redirect before this load runs, + // but handle defensively. + if (err.status === 401) { + errorMessage = "Your session may have expired. Please log in again." + // Redirect could also happen here, but layout guard is primary place + // throw redirect(307, '/login?sessionExpired=true'); + } + } else if (err instanceof Error) { + errorMessage = `Network or client error: ${err.message}`; + } + // Return empty list and the error message + return { + groups: [], + error: errorMessage + }; + } +}; \ No newline at end of file diff --git a/fe/src/routes/(app)/groups/[groupId]/+page.svelte b/fe/src/routes/(app)/groups/[groupId]/+page.svelte new file mode 100644 index 0000000..2e9ec30 --- /dev/null +++ b/fe/src/routes/(app)/groups/[groupId]/+page.svelte @@ -0,0 +1,189 @@ + + + +{#if data.group} +
+

Group: {data.group.name}

+

+ ID: {data.group.id} | Created: {new Date(data.group.created_at).toLocaleDateString()} +

+ + +
+ +

Members

+ {#if data.group.members && data.group.members.length > 0} +
    + {#each data.group.members as member (member.id)} +
  • + {member.name || member.email} + ID: {member.id} +
  • + {/each} +
+ {:else} +

No members found (or data not loaded).

+ {/if} +
+ + + {#if isOwner} +
+ +

Invite Members

+ +
+ {/if} + + +
+

Group Actions

+ {#if leaveError} +

{leaveError}

+ {/if} + + {#if isOwner} +

Owners may have restrictions on leaving.

+ {/if} + +
+ + + +
+{:else} +

Group data could not be loaded.

+{/if} diff --git a/fe/src/routes/(app)/groups/[groupId]/+page.ts b/fe/src/routes/(app)/groups/[groupId]/+page.ts new file mode 100644 index 0000000..1a28cfa --- /dev/null +++ b/fe/src/routes/(app)/groups/[groupId]/+page.ts @@ -0,0 +1,55 @@ +// src/routes/(app)/groups/[groupId]/+page.ts +import { error } from '@sveltejs/kit'; +import { apiClient, ApiClientError } from '$lib/apiClient'; +import type { GroupPublic } from '$lib/schemas/group'; +import type { PageLoad } from './$types'; // SvelteKit's type for load functions + +// Define the expected shape of the data returned +export interface GroupDetailPageLoadData { + group: GroupPublic; // The fetched group data +} + +export const load: PageLoad = async ({ params, fetch }) => { + const groupId = params.groupId; // Get groupId from the URL parameter + console.log(`Group Detail page load: Fetching data for group ID: ${groupId}`); + + // Basic validation (optional but good) + if (!groupId || isNaN(parseInt(groupId, 10))) { + throw error(400, 'Invalid Group ID'); // Use SvelteKit's error helper + } + + try { + // Fetch the specific group details using the apiClient + // The backend endpoint GET /api/v1/groups/{group_id} should include members + const groupData = await apiClient.get(`/v1/groups/${groupId}`); + + if (!groupData) { + // Should not happen if API call was successful, but check defensively + throw error(404, 'Group not found'); + } + + console.log('Group Detail page load: Data fetched successfully', groupData); + return { + group: groupData + }; + } catch (err) { + console.error(`Group Detail page load: Failed to fetch group ${groupId}:`, err); + if (err instanceof ApiClientError) { + if (err.status === 404) { + throw error(404, 'Group not found'); + } + if (err.status === 403) { + // User is authenticated (layout guard passed) but not member of this group + throw error(403, 'Forbidden: You are not a member of this group'); + } + // For other API errors (like 500) + throw error(err.status || 500, `API Error: ${err.message}`); + } else if (err instanceof Error) { + // Network or other client errors + throw error(500, `Failed to load group data: ${err.message}`); + } else { + // Unknown error + throw error(500, 'An unexpected error occurred while loading group data.'); + } + } +}; \ No newline at end of file diff --git a/fe/src/routes/+layout.svelte b/fe/src/routes/+layout.svelte index 2cfb68c..00d2d3d 100644 --- a/fe/src/routes/+layout.svelte +++ b/fe/src/routes/+layout.svelte @@ -1,41 +1,56 @@ +
- -
-
-

Shared Lists App

- - -
-
+ + {#if !$page.route.id?.startsWith('/(app)')} +
+
+ Shared Lists App - + +
+
+ {/if} + + +
-
- -
-

© {new Date().getFullYear()} Shared Lists App. All rights reserved.

-
+ + {#if !$page.route.id?.startsWith('/(app)')} +
+

© {new Date().getFullYear()} Shared Lists App. All rights reserved.

+
+ {/if}
- - diff --git a/fe/src/routes/+page.svelte b/fe/src/routes/+page.svelte index e69de29..e11d895 100644 --- a/fe/src/routes/+page.svelte +++ b/fe/src/routes/+page.svelte @@ -0,0 +1,100 @@ + + + + +
+ +
+

Welcome to Shared Lists!

+

+ Your go-to app for managing household shopping lists, capturing items via OCR, and splitting + costs easily. +

+
+ + +
+

System Status

+ {#if errorMessage} +

{errorMessage}

+ {/if} +

+ API Reachable: + + {apiStatus} + +

+

+ Database Connection: + + {dbStatus} + +

+
+ + + +
diff --git a/fe/src/routes/join/+page.svelte b/fe/src/routes/join/+page.svelte new file mode 100644 index 0000000..9b389ca --- /dev/null +++ b/fe/src/routes/join/+page.svelte @@ -0,0 +1,138 @@ + + + +
+

Join a Group

+ +
+ {#if successMessage} + + {/if} + {#if errorMessage} + + {/if} + +
+ + +
+ + +
+ +

+ ← Back to Dashboard +

+
diff --git a/fe/src/routes/join/+page.ts b/fe/src/routes/join/+page.ts new file mode 100644 index 0000000..71214c6 --- /dev/null +++ b/fe/src/routes/join/+page.ts @@ -0,0 +1,19 @@ +// src/routes/join/+page.ts +import type { PageLoad } from './$types'; + +// Define the shape of data passed to the page component +export interface JoinPageLoadData { + codeFromUrl?: string | null; // Code extracted from URL, if present +} + +export const load: PageLoad = ({ url }) => { + // Check if a 'code' query parameter exists in the URL + const code = url.searchParams.get('code'); + + console.log(`Join page load: Checking for code in URL. Found: ${code}`); + + // Return the code (or null if not found) so the page component can access it + return { + codeFromUrl: code + }; +}; \ No newline at end of file diff --git a/fe/src/routes/login/+page.svelte b/fe/src/routes/login/+page.svelte new file mode 100644 index 0000000..b66cde9 --- /dev/null +++ b/fe/src/routes/login/+page.svelte @@ -0,0 +1,150 @@ + + +
+

Log In

+ +
+ {#if signupSuccessMessage} + + {/if} + {#if errorMessage} + + {/if} + +
+ + +
+ +
+ + +
+ + +
+

+ Don't have an account? + Sign Up +

+
diff --git a/fe/src/routes/signup/+page.svelte b/fe/src/routes/signup/+page.svelte new file mode 100644 index 0000000..663c6c2 --- /dev/null +++ b/fe/src/routes/signup/+page.svelte @@ -0,0 +1,118 @@ + + + +
+

Create Account

+ +
+ {#if errorMessage} + + {/if} + +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +

+ Already have an account? + Log In +

+