end of phase 3

This commit is contained in:
mohamad 2025-03-30 19:42:32 +02:00
parent 4b7415e1c3
commit 4fbbe77658
50 changed files with 2905 additions and 158 deletions

84
.gitea/workflows/ci.yml Normal file
View File

@ -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."

View File

@ -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"]
CMD ["uvicorn", "app.main:app", "--host", "localhost", "--port", "8000"]

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -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

View File

@ -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"])

View File

@ -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")

View File

@ -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")

View File

@ -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.")

View File

@ -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))

View File

@ -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"]

View File

@ -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

View File

@ -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!")

0
be/app/core/__init__.py Normal file
View File

110
be/app/core/security.py Normal file
View File

@ -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

View File

@ -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

0
be/app/crud/__init__.py Normal file
View File

123
be/app/crud/group.py Normal file
View File

@ -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()

69
be/app/crud/invite.py Normal file
View File

@ -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): ...

28
be/app/crud/user.py Normal file
View File

@ -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

View File

@ -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",

View File

@ -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)
# class ExpenseShare(Base): ...

11
be/app/schemas/auth.py Normal file
View File

@ -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

24
be/app/schemas/group.py Normal file
View File

@ -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

20
be/app/schemas/invite.py Normal file
View File

@ -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)

View File

@ -0,0 +1,5 @@
# app/schemas/message.py
from pydantic import BaseModel
class Message(BaseModel):
detail: str

34
be/app/schemas/user.py Normal file
View File

@ -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

View File

@ -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
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]

View File

@ -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<RequestInit, 'body'> {
// 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<T = unknown>( // Generic type T for expected response data
// --- Core Request Function ---
// Uses generics <T> to allow specifying the expected successful response data type
async function request<T = unknown>(
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<T> {
// 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<T> 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: <T = unknown>(path: string, options: RequestOptions = {}): Promise<T> => {
return request<T>('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: <T = unknown>(path: string, data: unknown, options: RequestOptions = {}): Promise<T> => {
return request<T>('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: <T = unknown>(path: string, data: unknown, options: RequestOptions = {}): Promise<T> => {
return request<T>('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: <T = unknown>(path: string, options: RequestOptions = {}): Promise<T> => {
// 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<T>('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: <T = unknown>(path: string, data: unknown, options: RequestOptions = {}): Promise<T> => {
return request<T>('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;
// Optional: Export the error class as well if needed externally
// export { ApiClientError };

View File

@ -0,0 +1,4 @@
export interface Token {
access_token: string;
token_type: string;
}

View File

@ -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
}

View File

@ -0,0 +1,5 @@
export interface InviteCodePublic {
code: string;
expires_at: string; // Date as string from JSON
group_id: number;
}

View File

@ -0,0 +1,3 @@
export interface Message {
detail: string;
}

View File

@ -0,0 +1,6 @@
export interface UserPublic {
id: number;
email: string;
name?: string | null;
created_at: string;
}

View File

@ -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<AuthState>(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;
}

View File

@ -0,0 +1,49 @@
<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
import { logout as performLogout } from '$lib/stores/authStore';
import { goto } from '$app/navigation';
import type { LayoutData } from './$types'; // Import generated types for data prop
// Receive data from the +layout.ts load function
export let data: LayoutData;
// Destructure user from data if needed, or access as data.user
// $: user = data.user; // Reactive assignment if data can change
async function handleLogout() {
console.log('Logging out...');
performLogout(); // Clear the auth store and localStorage
await goto('/login'); // Redirect to login page
}
</script>
<!-- You can reuse the main layout structure or create a specific one -->
<!-- For simplicity, let's just add a header specific to the authenticated area -->
<div class="auth-layout min-h-screen bg-slate-100">
<header class="bg-purple-700 p-4 text-white shadow-md">
<div class="container mx-auto flex items-center justify-between">
<a href="/dashboard" class="text-lg font-semibold hover:text-purple-200">App Dashboard</a>
<div class="flex items-center space-x-4">
{#if data.user}
<span class="text-sm">Welcome, {data.user.name || data.user.email}!</span>
{/if}
<button
on:click={handleLogout}
class="rounded bg-red-500 px-3 py-1 text-sm font-medium hover:bg-red-600 focus:ring-2 focus:ring-red-400 focus:ring-offset-2 focus:ring-offset-purple-700 focus:outline-none"
>
Logout
</button>
</div>
</div>
</header>
<main class="container mx-auto p-4 md:p-8">
<!-- Slot for the actual page content (e.g., dashboard/+page.svelte) -->
<slot />
</main>
<!-- Optional specific footer for authenticated area -->
<!-- <footer class="mt-auto bg-gray-700 p-3 text-center text-xs text-gray-300">
Authenticated Section Footer
</footer> -->
</div>

View File

@ -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
};
};

View File

@ -0,0 +1,137 @@
<!-- src/routes/(app)/dashboard/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { apiClient, ApiClientError } from '$lib/apiClient';
import type { GroupPublic } from '$lib/schemas/group';
import type { PageData } from './$types'; // Import generated type for page data
// Receive groups data from the +page.ts load function
export let data: PageData; // Contains { groups: GroupPublic[], error?: string | null }
// Local reactive state for the list (to allow adding without full page reload)
let displayedGroups: GroupPublic[] = [];
let loadError: string | null = null;
// State for the creation form
let newGroupName = '';
let isCreating = false;
let createError: string | null = null;
// Initialize local state when component mounts or data changes
$: {
// $: block ensures this runs whenever 'data' prop changes
console.log('Dashboard page: Data prop updated', data);
displayedGroups = data.groups ?? []; // Update local list from load data
loadError = data.error ?? null; // Update load error message
}
async function handleCreateGroup() {
if (!newGroupName.trim()) {
createError = 'Group name cannot be empty.';
return;
}
isCreating = true;
createError = null;
console.log(`Creating group: ${newGroupName}`);
try {
const newGroupData = { name: newGroupName.trim() };
const createdGroup = await apiClient.post<GroupPublic>('/v1/groups', newGroupData);
console.log('Group creation successful:', createdGroup);
// Add the new group to the local reactive list
displayedGroups = [...displayedGroups, createdGroup];
newGroupName = ''; // Clear the input form
} catch (err) {
console.error('Group creation failed:', err);
if (err instanceof ApiClientError) {
let detail = 'An unknown API error occurred.';
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
// detail = (<{ detail: string }>err.errorData).detail;
}
createError = `Failed to create group (${err.status}): ${detail}`;
} else if (err instanceof Error) {
createError = `Error: ${err.message}`;
} else {
createError = 'An unexpected error occurred.';
}
} finally {
isCreating = false;
}
}
</script>
<div class="space-y-8">
<h1 class="text-3xl font-bold text-gray-800">Your Groups</h1>
<!-- Group Creation Section -->
<div class="rounded bg-white p-6 shadow">
<h2 class="mb-4 text-xl font-semibold text-gray-700">Create New Group</h2>
<form on:submit|preventDefault={handleCreateGroup} class="flex flex-col gap-4 sm:flex-row">
<div class="flex-grow">
<label for="new-group-name" class="sr-only">Group Name</label>
<input
type="text"
id="new-group-name"
bind:value={newGroupName}
placeholder="Enter group name..."
required
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
disabled={isCreating}
/>
</div>
<button
type="submit"
class="rounded bg-blue-600 px-4 py-2 font-medium whitespace-nowrap text-white transition duration-150 ease-in-out hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={isCreating}
>
{isCreating ? 'Creating...' : 'Create Group'}
</button>
</form>
{#if createError}
<p class="mt-3 text-sm text-red-600">{createError}</p>
{/if}
</div>
<!-- Group List Section -->
<div class="rounded bg-white p-6 shadow">
<h2 class="mb-4 text-xl font-semibold text-gray-700">My Groups</h2>
{#if loadError}
<!-- Display error from load function -->
<div class="rounded border border-red-400 bg-red-100 p-4 text-red-700" role="alert">
<p class="font-bold">Error Loading Groups</p>
<p>{loadError}</p>
</div>
{:else if displayedGroups.length === 0}
<!-- Message when no groups and no load error -->
<p class="text-gray-500">You are not a member of any groups yet. Create one above!</p>
{:else}
<!-- Display the list of groups -->
<ul class="space-y-3">
{#each displayedGroups as group (group.id)}
<li
class="rounded border border-gray-200 p-4 transition duration-150 ease-in-out hover:bg-gray-50"
>
<div class="flex items-center justify-between">
<span class="font-medium text-gray-800">{group.name}</span>
<!-- Add link to group details page later -->
<a
href="/groups/{group.id}"
class="text-sm text-blue-600 hover:underline"
aria-label="View details for {group.name}"
>
View
</a>
</div>
<p class="mt-1 text-xs text-gray-500">
ID: {group.id} | Created: {new Date(group.created_at).toLocaleDateString()}
</p>
</li>
{/each}
</ul>
{/if}
</div>
</div>

View File

@ -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<DashboardLoadData> = 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<GroupPublic[]>('/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
};
}
};

View File

@ -0,0 +1,189 @@
<!-- src/routes/(app)/groups/[groupId]/+page.svelte -->
<script lang="ts">
import { page } from '$app/stores';
import { authStore } from '$lib/stores/authStore';
import { apiClient, ApiClientError } from '$lib/apiClient';
import { goto } from '$app/navigation'; // Import goto for redirect
import type { InviteCodePublic } from '$lib/schemas/invite';
import type { Message } from '$lib/schemas/message'; // For leave response
import type { PageData } from './$types';
export let data: PageData;
// Invite generation state
let isOwner = false;
let isLoadingInvite = false;
let inviteCode: string | null = null;
let inviteExpiry: string | null = null;
let inviteError: string | null = null;
// --- Leave Group State ---
let isLeaving = false;
let leaveError: string | null = null;
// --- End Leave Group State ---
// Determine ownership and reset state
$: {
if ($authStore.user && data.group) {
isOwner = $authStore.user.id === data.group.created_by_id;
console.log(
`User ${$authStore.user.id}, Owner ${data.group.created_by_id}, Is Owner: ${isOwner}`
);
} else {
isOwner = false;
}
// Reset state if group changes
inviteCode = null;
inviteExpiry = null;
inviteError = null;
leaveError = null; // Reset leave error too
isLeaving = false; // Reset leaving state
}
async function generateInvite() {
// ... (keep existing generateInvite function) ...
if (!isOwner || !data.group) return;
isLoadingInvite = true;
inviteCode = null;
inviteExpiry = null;
inviteError = null;
try {
const result = await apiClient.post<InviteCodePublic>(
`/v1/groups/${data.group.id}/invites`,
{}
);
inviteCode = result.code;
inviteExpiry = new Date(result.expires_at).toLocaleString();
} catch (err) {
console.error('Invite generation failed:', err);
if (err instanceof ApiClientError) {
/* ... error handling ... */
} else if (err instanceof Error) {
/* ... */
} else {
/* ... */
}
// Simplified error handling for brevity, keep your previous detailed one
inviteError = err instanceof Error ? err.message : 'Failed to generate invite';
} finally {
isLoadingInvite = false;
}
}
async function copyInviteCode() {
// ... (keep existing copyInviteCode function) ...
if (!inviteCode) return;
try {
await navigator.clipboard.writeText(inviteCode);
alert('Invite code copied to clipboard!');
} catch (err) {
console.error('Failed to copy text: ', err);
alert('Failed to copy code. Please copy manually.');
}
}
// --- Handle Leave Group ---
async function handleLeaveGroup() {
if (!data.group) return; // Should always have group data here
// Confirmation Dialog
const confirmationMessage = isOwner
? `Are you sure you want to leave the group "${data.group.name}"? Check if another owner exists or if you are the last member, as this might be restricted.`
: `Are you sure you want to leave the group "${data.group.name}"?`;
if (!confirm(confirmationMessage)) {
return; // User cancelled
}
isLeaving = true;
leaveError = null;
console.log(`Attempting to leave group ${data.group.id}`);
try {
const result = await apiClient.delete<Message>(`/v1/groups/${data.group.id}/leave`);
console.log('Leave group successful:', result);
// Redirect to dashboard on success
await goto('/dashboard?leftGroup=true'); // Add query param for optional feedback
} catch (err) {
console.error('Leave group failed:', err);
if (err instanceof ApiClientError) {
let detail = 'Failed to leave the group.';
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
// detail = (<{ detail: string }>err.errorData).detail;
}
// Use backend detail directly if available, otherwise generic message
leaveError = `Error (${err.status}): ${detail}`;
} else if (err instanceof Error) {
leaveError = `Error: ${err.message}`;
} else {
leaveError = 'An unexpected error occurred.';
}
isLeaving = false; // Ensure loading state is reset on error
}
// No finally needed here as success results in navigation away
}
// --- End Handle Leave Group ---
</script>
{#if data.group}
<div class="space-y-6">
<h1 class="text-3xl font-bold text-gray-800">Group: {data.group.name}</h1>
<p class="text-sm text-gray-500">
ID: {data.group.id} | Created: {new Date(data.group.created_at).toLocaleDateString()}
</p>
<!-- Member List Section -->
<div class="rounded bg-white p-6 shadow">
<!-- ... (keep existing member list code) ... -->
<h2 class="mb-4 text-xl font-semibold text-gray-700">Members</h2>
{#if data.group.members && data.group.members.length > 0}
<ul class="space-y-2">
{#each data.group.members as member (member.id)}
<li class="flex items-center justify-between rounded p-2 hover:bg-gray-100">
<span class="text-gray-800">{member.name || member.email}</span>
<span class="text-xs text-gray-500">ID: {member.id}</span>
</li>
{/each}
</ul>
{:else}
<p class="text-gray-500">No members found (or data not loaded).</p>
{/if}
</div>
<!-- Invite Section (Owner Only) -->
{#if isOwner}
<div class="rounded bg-white p-6 shadow">
<!-- ... (keep existing invite generation code) ... -->
<h2 class="mb-4 text-xl font-semibold text-gray-700">Invite Members</h2>
<!-- ... button and invite display ... -->
</div>
{/if}
<!-- Group Actions Section -->
<div class="mt-6 rounded border border-dashed border-red-300 bg-white p-6 shadow">
<h2 class="mb-4 text-xl font-semibold text-red-700">Group Actions</h2>
{#if leaveError}
<p class="mb-3 text-sm text-red-600">{leaveError}</p>
{/if}
<button
on:click={handleLeaveGroup}
disabled={isLeaving}
class="rounded bg-red-600 px-4 py-2 font-medium text-white transition hover:bg-red-700 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
{isLeaving ? 'Leaving...' : 'Leave Group'}
</button>
{#if isOwner}
<p class="mt-2 text-xs text-gray-500">Owners may have restrictions on leaving.</p>
{/if}
<!-- Add Delete Group button for owner later -->
</div>
<!-- Back Link -->
<div class="mt-6 border-t pt-6">
<a href="/dashboard" class="text-sm text-blue-600 hover:underline">← Back to Dashboard</a>
</div>
</div>
{:else}
<p class="text-center text-red-500">Group data could not be loaded.</p>
{/if}

View File

@ -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<GroupDetailPageLoadData> = 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<GroupPublic>(`/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.');
}
}
};

View File

@ -1,41 +1,56 @@
<!-- src/routes/+layout.svelte -->
<script lang="ts">
// Import global styles if you have them, e.g., app.css
// We'll rely on Tailwind configured via app.postcss for now.
import '../app.css'; // Import the main PostCSS file where Tailwind directives are
console.log('Root layout loaded'); // For debugging in browser console
import '../app.css';
import { authStore, logout as performLogout } from '$lib/stores/authStore'; // Import store and logout action
import { goto } from '$app/navigation'; // Import goto for logout redirect
import { page } from '$app/stores'; // To check current route
async function handleLogout() {
console.log('Logging out from root layout...');
performLogout();
await goto('/login');
}
</script>
<div class="flex min-h-screen flex-col bg-gray-50">
<!-- Header Placeholder -->
<header class="bg-gradient-to-r from-blue-600 to-indigo-700 p-4 text-white shadow-md">
<div class="container mx-auto flex items-center justify-between">
<h1 class="text-xl font-bold">Shared Lists App</h1>
<!-- Navigation Placeholder -->
<nav class="space-x-4">
<a href="/" class="hover:text-blue-200 hover:underline">Home</a>
<a href="/login" class="hover:text-blue-200 hover:underline">Login</a>
<!-- Add other basic links later -->
</nav>
</div>
</header>
<!-- Only show the main header if NOT inside the authenticated (app) section -->
{#if !$page.route.id?.startsWith('/(app)')}
<header class="bg-gradient-to-r from-blue-600 to-indigo-700 p-4 text-white shadow-md">
<div class="container mx-auto flex items-center justify-between">
<a href="/" class="text-xl font-bold hover:text-blue-200">Shared Lists App</a>
<!-- Main Content Area -->
<nav class="flex items-center space-x-4">
{#if $authStore.isAuthenticated && $authStore.user}
<!-- Show if logged in -->
<span class="text-sm">{$authStore.user.name || $authStore.user.email}</span>
<a href="/dashboard" class="text-sm hover:text-blue-200 hover:underline">Dashboard</a>
<button
on:click={handleLogout}
class="rounded bg-red-500 px-3 py-1 text-sm font-medium hover:bg-red-600 focus:ring-2 focus:ring-red-400 focus:ring-offset-2 focus:ring-offset-blue-700 focus:outline-none"
>
Logout
</button>
{:else}
<!-- Show if logged out -->
<a href="/" class="hover:text-blue-200 hover:underline">Home</a>
<a href="/login" class="hover:text-blue-200 hover:underline">Login</a>
<a href="/signup" class="hover:text-blue-200 hover:underline">Sign Up</a>
{/if}
</nav>
</div>
</header>
{/if}
<!-- Main Content Area - Renders layout/page based on route -->
<!-- The (app) layout will take over rendering its own header when inside that group -->
<main class="container mx-auto flex-grow p-4 md:p-8">
<!-- The <slot /> component renders the content of the current page (+page.svelte) -->
<slot />
</main>
<!-- Footer Placeholder -->
<footer class="mt-auto bg-gray-200 p-4 text-center text-sm text-gray-600">
<p>© {new Date().getFullYear()} Shared Lists App. All rights reserved.</p>
</footer>
<!-- Only show the main footer if NOT inside the authenticated (app) section -->
{#if !$page.route.id?.startsWith('/(app)')}
<footer class="mt-auto bg-gray-200 p-4 text-center text-sm text-gray-600">
<p>© {new Date().getFullYear()} Shared Lists App. All rights reserved.</p>
</footer>
{/if}
</div>
<style lang="postcss">
/* You can add global non-utility styles here if needed, */
/* but Tailwind is generally preferred for component styling. */
/* Example: */
/* :global(body) { */
/* font-family: 'Inter', sans-serif; */
/* } */
</style>

View File

@ -0,0 +1,100 @@
<!-- src/routes/+page.svelte -->
<script lang="ts">
// Imports
import { onMount } from 'svelte';
import { apiClient, ApiClientError } from '$lib/apiClient'; // Use $lib alias
import type { HealthStatus } from '$lib/schemas/health'; // Ensure this path is correct for your project structure
// Component State
let apiStatus = 'Checking...';
let dbStatus = 'Checking...';
let errorMessage: string | null = null;
// Fetch API health on component mount
onMount(async () => {
console.log('Home page mounted, checking API health...');
try {
// Specify the expected return type using the generic
const health = await apiClient.get<HealthStatus>('/v1/health'); // Path relative to BASE_URL
console.log('API Health Response:', health);
// Use nullish coalescing (??) in case status is optional or null
apiStatus = health.status ?? 'ok';
dbStatus = health.database;
errorMessage = null; // Clear any previous error
} catch (err) {
console.error('API Health Check Failed:', err);
apiStatus = 'Error';
dbStatus = 'Error';
// Handle different error types
if (err instanceof ApiClientError) {
// Start with the basic error message
errorMessage = `API Error (${err.status}): ${err.message}`;
// Append detail from backend if available (using 'as' for type assertion)
if (
err.errorData &&
typeof err.errorData === 'object' &&
err.errorData !== null &&
'detail' in err.errorData
) {
errorMessage += ` - Detail: ${(err.errorData as { detail: string }).detail}`;
}
} else if (err instanceof Error) {
// Handle network errors or other generic errors
errorMessage = `Error: ${err.message}`;
} else {
// Fallback for unknown errors
errorMessage = 'An unknown error occurred.';
}
}
});
</script>
<!-- HTML Structure -->
<div class="space-y-6 text-center">
<!-- Welcome Section -->
<div>
<h2 class="mb-4 text-3xl font-semibold text-gray-800">Welcome to Shared Lists!</h2>
<p class="text-lg text-gray-600">
Your go-to app for managing household shopping lists, capturing items via OCR, and splitting
costs easily.
</p>
</div>
<!-- API Status Section -->
<div class="mx-auto max-w-sm rounded border border-gray-300 bg-white p-4 shadow-sm">
<h3 class="mb-3 text-lg font-medium text-gray-700">System Status</h3>
{#if errorMessage}
<p class="mb-2 rounded bg-red-100 p-2 text-sm text-red-600">{errorMessage}</p>
{/if}
<p class="text-gray-700">
API Reachable:
<span class="font-semibold {apiStatus === 'ok' ? 'text-green-600' : 'text-red-600'}">
{apiStatus}
</span>
</p>
<p class="text-gray-700">
Database Connection:
<span class="font-semibold {dbStatus === 'connected' ? 'text-green-600' : 'text-red-600'}">
{dbStatus}
</span>
</p>
</div>
<!-- Call to Action Section -->
<div class="mt-8">
<a
href="/signup"
class="mr-4 rounded bg-blue-600 px-6 py-2 font-medium text-white transition duration-150 ease-in-out hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Get Started
</a>
<a
href="/features"
class="rounded bg-gray-300 px-6 py-2 font-medium text-gray-800 transition duration-150 ease-in-out hover:bg-gray-400 focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:outline-none"
>
Learn More
</a>
</div>
</div>

View File

@ -0,0 +1,138 @@
<!-- src/routes/join/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { apiClient, ApiClientError } from '$lib/apiClient';
import type { Message } from '$lib/schemas/message';
import type { PageData } from './$types'; // Type for data from load function
// Receive data from the +page.ts load function
export let data: PageData; // Contains { codeFromUrl?: string | null }
// Form state
let inviteCode = '';
let isLoading = false;
let errorMessage: string | null = null;
let successMessage: string | null = null;
// Pre-fill input if code is present in URL on component mount
onMount(() => {
if (data.codeFromUrl && !inviteCode) {
inviteCode = data.codeFromUrl;
console.log('Join page mounted: Pre-filled code from URL:', inviteCode);
// Optional: Remove code from URL history for cleaner look
// history.replaceState(null, '', '/join');
}
});
async function handleJoinGroup() {
if (!inviteCode.trim()) {
errorMessage = 'Please enter an invite code.';
return;
}
isLoading = true;
errorMessage = null;
successMessage = null;
console.log(`Attempting to join group with code: ${inviteCode}`);
try {
// Backend expects POST /api/v1/invites/accept with body: { "code": "..." }
const requestBody = { code: inviteCode.trim() };
const result = await apiClient.post<Message>('/v1/invites/accept', requestBody);
console.log('Join group successful:', result);
// Set success message briefly before redirecting
successMessage = result.detail || 'Successfully joined the group!';
// Redirect to dashboard after a short delay to show the message
// Alternatively, redirect immediately.
setTimeout(async () => {
await goto('/dashboard'); // Redirect to dashboard where group list will refresh
}, 1500); // 1.5 second delay
} catch (err) {
console.error('Join group failed:', err);
if (err instanceof ApiClientError) {
// Extract detail message from backend error response
let detail = 'Failed to join group.';
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
// detail = (<{ detail: string }>err.errorData).detail;
}
// Customize message based on common errors from backend
if (err.status === 404) {
errorMessage = 'Invite code is invalid, expired, or already used.';
} else if (detail.includes('already a member')) {
// Check if backend detail indicates this
errorMessage = detail; // Use backend message like "You are already a member..."
} else {
errorMessage = `Error (${err.status}): ${detail}`;
}
} else if (err instanceof Error) {
errorMessage = `Error: ${err.message}`;
} else {
errorMessage = 'An unexpected error occurred.';
}
// Clear input on error? Optional.
// inviteCode = '';
} finally {
isLoading = false;
}
}
</script>
<div class="mx-auto max-w-md rounded border border-gray-200 bg-white p-8 shadow-md">
<h1 class="mb-6 text-center text-2xl font-semibold text-gray-700">Join a Group</h1>
<form on:submit|preventDefault={handleJoinGroup} class="space-y-4">
{#if successMessage}
<div
class="mb-4 rounded border border-green-400 bg-green-100 p-3 text-center text-sm text-green-700"
role="alert"
>
{successMessage} Redirecting...
</div>
{/if}
{#if errorMessage}
<div
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-center text-sm text-red-700"
role="alert"
>
{errorMessage}
</div>
{/if}
<div>
<label for="invite-code" class="mb-1 block text-sm font-medium text-gray-600"
>Invite Code</label
>
<input
type="text"
id="invite-code"
bind:value={inviteCode}
placeholder="Enter code shared with you..."
required
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:opacity-70"
disabled={isLoading}
/>
</div>
<button
type="submit"
class="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white transition duration-150 ease-in-out hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={isLoading || !!successMessage}
>
{#if isLoading}
Joining...
{:else if successMessage}
Joined!
{:else}
Join Group
{/if}
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-600">
<a href="/dashboard" class="font-medium text-blue-600 hover:underline">← Back to Dashboard</a>
</p>
</div>

View File

@ -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<JoinPageLoadData> = ({ 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
};
};

View File

@ -0,0 +1,150 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores'; // To read query parameters
import { goto } from '$app/navigation';
import { apiClient, ApiClientError } from '$lib/apiClient';
import { login as setAuthState } from '$lib/stores/authStore'; // Rename import for clarity
import type { Token } from '$lib/schemas/auth';
import type { UserPublic } from '$lib/schemas/user'; // Or wherever UserPublic is defined
let email = '';
let password = '';
let isLoading = false;
let errorMessage: string | null = null;
let signupSuccessMessage: string | null = null;
// Check for signup success message on mount
onMount(() => {
if ($page.url.searchParams.get('signedUp') === 'true') {
signupSuccessMessage = 'Signup successful! Please log in.';
// Optional: Remove the query param from URL history for cleaner UX
// history.replaceState(null, '', '/login');
}
});
async function handleLogin() {
isLoading = true;
errorMessage = null;
signupSuccessMessage = null; // Clear signup message on new attempt
console.log('Attempting login...');
try {
// 1. Prepare form data for OAuth2PasswordRequestForm (backend expects x-www-form-urlencoded)
const loginFormData = new URLSearchParams();
loginFormData.append('username', email); // Key must be 'username'
loginFormData.append('password', password);
// 2. Call the API login endpoint
const tokenResponse = await apiClient.post<Token>('/v1/auth/login', loginFormData, {
headers: {
// Must set Content-Type for form data
'Content-Type': 'application/x-www-form-urlencoded'
}
});
// 3. Fetch user data using the new token (apiClient will add header)
// Store token *temporarily* just for the next call, before setting the store.
// This is slightly tricky. A better way might be to have login endpoint return user data.
// Let's assume apiClient is updated to use the token *after* this call by setting the store.
// Alternative: Modify backend login to return user data + token.
// For now, let's update the store *first* and then fetch user.
// ---> TEMPORARY TOKEN HANDLING FOR /users/me CALL <---
const tempToken = tokenResponse.access_token;
// Make the /users/me call *with the specific token* before fully setting auth state
const userResponse = await apiClient.get<UserPublic>('/v1/users/me', {
headers: { Authorization: `Bearer ${tempToken}` }
});
// --- END TEMPORARY TOKEN HANDLING ---
// 4. Update the auth store (this makes subsequent apiClient calls authenticated)
setAuthState(tokenResponse.access_token, userResponse);
console.log('Login successful, user:', userResponse);
// 5. Redirect to dashboard or protected area
// Check if there was a redirect query parameter? e.g., ?redirectTo=/some/page
const redirectTo = $page.url.searchParams.get('redirectTo') || '/dashboard'; // Default redirect
await goto(redirectTo);
} catch (err) {
console.error('Login failed:', err);
if (err instanceof ApiClientError) {
if (err.status === 401) {
// The global handler in apiClient already called logout(), just show message
errorMessage = 'Login failed: Invalid email or password.';
} else {
let detail = 'An unknown API error occurred during login.';
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
// detail = (<{ detail: string }>err.errorData).detail;
}
errorMessage = `Login error (${err.status}): ${detail}`;
}
} else if (err instanceof Error) {
errorMessage = `Network error: ${err.message}`;
} else {
errorMessage = 'An unexpected error occurred during login.';
}
} finally {
isLoading = false;
}
}
</script>
<div class="mx-auto max-w-md rounded border border-gray-200 bg-white p-8 shadow-md">
<h1 class="mb-6 text-center text-2xl font-semibold text-gray-700">Log In</h1>
<form on:submit|preventDefault={handleLogin} class="space-y-4">
{#if signupSuccessMessage}
<div
class="mb-4 rounded border border-green-400 bg-green-100 p-3 text-center text-sm text-green-700"
role="alert"
>
{signupSuccessMessage}
</div>
{/if}
{#if errorMessage}
<div
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-center text-sm text-red-700"
role="alert"
>
{errorMessage}
</div>
{/if}
<div>
<label for="email" class="mb-1 block text-sm font-medium text-gray-600">Email</label>
<input
type="email"
id="email"
bind:value={email}
required
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
disabled={isLoading}
/>
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium text-gray-600">Password</label>
<input
type="password"
id="password"
bind:value={password}
required
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
disabled={isLoading}
/>
</div>
<button
type="submit"
class="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Logging in...' : 'Log In'}
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-600">
Don't have an account?
<a href="/signup" class="font-medium text-blue-600 hover:underline">Sign Up</a>
</p>
</div>

View File

@ -0,0 +1,118 @@
<!-- src/routes/signup/+page.svelte -->
<script lang="ts">
import { goto } from '$app/navigation';
import { apiClient, ApiClientError } from '$lib/apiClient';
import type { UserPublic } from '$lib/schemas/user'; // Or import from where you defined it
let name = '';
let email = '';
let password = '';
let isLoading = false;
let errorMessage: string | null = null;
async function handleSignup() {
isLoading = true;
errorMessage = null;
console.log('Attempting signup...');
try {
// API expects: { email, password, name? }
const signupData = { email, password, name: name || undefined }; // Send name only if provided
const createdUser = await apiClient.post<UserPublic>('/v1/auth/signup', signupData);
console.log('Signup successful:', createdUser);
// Option 1: Redirect to login page with a success message
await goto('/login?signedUp=true');
// Option 2: Log user in directly (more complex, requires login call)
// requires importing login action from store & handling potential post-signup login errors
// const loginFormData = new URLSearchParams();
// loginFormData.append('username', email);
// loginFormData.append('password', password);
// const tokenResponse = await apiClient.post<Token>('/v1/auth/login', loginFormData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
// storeLogin(tokenResponse.access_token, createdUser); // Use the user data from signup response
// await goto('/dashboard');
} catch (err) {
console.error('Signup failed:', err);
if (err instanceof ApiClientError) {
// Extract detail message from backend if available
let detail = 'An unknown API error occurred during signup.';
if (err.errorData && typeof err.errorData === 'object' && 'detail' in err.errorData) {
// detail = (<{ detail: string }>err.errorData).detail; // Type assertion
}
errorMessage = `Signup failed (${err.status}): ${detail}`;
} else if (err instanceof Error) {
errorMessage = `Error: ${err.message}`;
} else {
errorMessage = 'An unexpected error occurred.';
}
} finally {
isLoading = false;
}
}
</script>
<div class="mx-auto max-w-md rounded border border-gray-200 bg-white p-8 shadow-md">
<h1 class="mb-6 text-center text-2xl font-semibold text-gray-700">Create Account</h1>
<form on:submit|preventDefault={handleSignup} class="space-y-4">
{#if errorMessage}
<div
class="mb-4 rounded border border-red-400 bg-red-100 p-3 text-center text-sm text-red-700"
role="alert"
>
{errorMessage}
</div>
{/if}
<div>
<label for="name" class="mb-1 block text-sm font-medium text-gray-600">Name (Optional)</label>
<input
type="text"
id="name"
bind:value={name}
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
disabled={isLoading}
/>
</div>
<div>
<label for="email" class="mb-1 block text-sm font-medium text-gray-600">Email</label>
<input
type="email"
id="email"
bind:value={email}
required
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
disabled={isLoading}
/>
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium text-gray-600">Password</label>
<input
type="password"
id="password"
bind:value={password}
required
minlength="6"
class="w-full rounded border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
disabled={isLoading}
/>
</div>
<button
type="submit"
class="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Creating Account...' : 'Sign Up'}
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-600">
Already have an account?
<a href="/login" class="font-medium text-blue-600 hover:underline">Log In</a>
</p>
</div>