end of phase 3
This commit is contained in:
parent
4b7415e1c3
commit
4fbbe77658
.gitea/workflows
be
Dockerfile
alembic/versions
563ee77c5214_add_invite_table_and_relationships.py69b0c1432084_add_invite_table_and_relationships.py6f80b82dbdf8_add_invite_table_and_relationships.pyd90ab7116920_add_invite_table_and_relationships.pyf42efe4f4bca_add_invite_table_and_relationships.py
app
requirements.txtfe/src
84
.gitea/workflows/ci.yml
Normal file
84
.gitea/workflows/ci.yml
Normal 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."
|
@ -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"]
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
@ -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 ###
|
71
be/app/api/dependencies.py
Normal file
71
be/app/api/dependencies.py
Normal 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
|
@ -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"])
|
91
be/app/api/v1/endpoints/auth.py
Normal file
91
be/app/api/v1/endpoints/auth.py
Normal 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")
|
196
be/app/api/v1/endpoints/groups.py
Normal file
196
be/app/api/v1/endpoints/groups.py
Normal 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")
|
59
be/app/api/v1/endpoints/invites.py
Normal file
59
be/app/api/v1/endpoints/invites.py
Normal 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.")
|
30
be/app/api/v1/endpoints/users.py
Normal file
30
be/app/api/v1/endpoints/users.py
Normal 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))
|
92
be/app/api/v1/test_auth.py
Normal file
92
be/app/api/v1/test_auth.py
Normal 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"]
|
65
be/app/api/v1/test_users.py
Normal file
65
be/app/api/v1/test_users.py
Normal 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
|
@ -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
0
be/app/core/__init__.py
Normal file
110
be/app/core/security.py
Normal file
110
be/app/core/security.py
Normal 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
|
86
be/app/core/test_security.py
Normal file
86
be/app/core/test_security.py
Normal 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
0
be/app/crud/__init__.py
Normal file
123
be/app/crud/group.py
Normal file
123
be/app/crud/group.py
Normal 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
69
be/app/crud/invite.py
Normal 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
28
be/app/crud/user.py
Normal 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
|
@ -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",
|
||||
|
132
be/app/models.py
132
be/app/models.py
@ -1,6 +1,8 @@
|
||||
# app/models.py
|
||||
import enum
|
||||
from datetime import datetime
|
||||
import secrets # For generating invite codes
|
||||
from datetime import datetime, timedelta, timezone # For invite expiry
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
Integer,
|
||||
@ -10,15 +12,20 @@ from sqlalchemy import (
|
||||
Boolean,
|
||||
Enum as SAEnum, # Renamed to avoid clash with Python's enum
|
||||
UniqueConstraint,
|
||||
Index, # Added for invite code index
|
||||
DDL,
|
||||
event,
|
||||
DDL
|
||||
delete, # Added for potential cascade delete if needed (though FK handles it)
|
||||
func, # Added for func.count()
|
||||
text as sa_text # For raw SQL in index definition if needed
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func # For server_default=func.now()
|
||||
# Removed func import as it's imported above
|
||||
# from sqlalchemy.sql import func # For server_default=func.now()
|
||||
|
||||
from app.database import Base # Import Base from database setup
|
||||
from .database import Base # Import Base from database setup
|
||||
|
||||
# Define Enum for User Roles in Groups
|
||||
# --- Enums ---
|
||||
class UserRoleEnum(enum.Enum):
|
||||
owner = "owner"
|
||||
member = "member"
|
||||
@ -29,23 +36,24 @@ class User(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String, nullable=False)
|
||||
name = Column(String, index=True, nullable=True) # Allow nullable name initially
|
||||
password_hash = Column(String, nullable=False) # Column name used in CRUD
|
||||
name = Column(String, index=True, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
# --- Relationships ---
|
||||
# Groups created by this user
|
||||
created_groups = relationship("Group", back_populates="creator")
|
||||
# Association object for group membership
|
||||
group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan")
|
||||
created_groups = relationship("Group", back_populates="creator") # Links to Group.creator
|
||||
|
||||
# Items added by this user (Add later when Item model is defined)
|
||||
# Association object for group membership (many-to-many)
|
||||
group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan") # Links to UserGroup.user
|
||||
|
||||
# Invites created by this user (one-to-many)
|
||||
created_invites = relationship("Invite", back_populates="creator") # Links to Invite.creator
|
||||
|
||||
# Optional relationships for items/lists (Add later)
|
||||
# added_items = relationship("Item", foreign_keys="[Item.added_by_id]", back_populates="added_by_user")
|
||||
# Items completed by this user (Add later)
|
||||
# completed_items = relationship("Item", foreign_keys="[Item.completed_by_id]", back_populates="completed_by_user")
|
||||
# Expense shares for this user (Add later)
|
||||
# expense_shares = relationship("ExpenseShare", back_populates="user")
|
||||
# Lists created by this user (Add later)
|
||||
# created_lists = relationship("List", foreign_keys="[List.created_by_id]", back_populates="creator")
|
||||
|
||||
|
||||
@ -55,54 +63,78 @@ class Group(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True, nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # FK to User table
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
# The user who created this group
|
||||
creator = relationship("User", back_populates="created_groups")
|
||||
# Association object for group membership
|
||||
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan")
|
||||
# --- Relationships ---
|
||||
# The user who created this group (many-to-one)
|
||||
creator = relationship("User", back_populates="created_groups") # Links to User.created_groups
|
||||
|
||||
# Association object for group membership (one-to-many)
|
||||
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan") # Links to UserGroup.group
|
||||
|
||||
# Invites belonging to this group (one-to-many)
|
||||
invites = relationship("Invite", back_populates="group", cascade="all, delete-orphan") # Links to Invite.group
|
||||
|
||||
# Lists belonging to this group (Add later)
|
||||
# lists = relationship("List", back_populates="group")
|
||||
|
||||
# --- UserGroup Association Model ---
|
||||
|
||||
# --- UserGroup Association Model (Many-to-Many link) ---
|
||||
class UserGroup(Base):
|
||||
__tablename__ = "user_groups"
|
||||
__table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),) # Ensure user cannot be in same group twice
|
||||
# Ensure a user cannot be in the same group twice
|
||||
__table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True) # Surrogate primary key
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False)
|
||||
role = Column(SAEnum(UserRoleEnum), nullable=False, default=UserRoleEnum.member)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) # FK to User
|
||||
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group
|
||||
role = Column(SAEnum(UserRoleEnum, name="userroleenum", create_type=True), nullable=False, default=UserRoleEnum.member) # Use Enum, ensure type is created
|
||||
joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# Relationships back to User and Group
|
||||
user = relationship("User", back_populates="group_associations")
|
||||
group = relationship("Group", back_populates="member_associations")
|
||||
# --- Relationships ---
|
||||
# Link back to User (many-to-one from the perspective of this table row)
|
||||
user = relationship("User", back_populates="group_associations") # Links to User.group_associations
|
||||
|
||||
# Link back to Group (many-to-one from the perspective of this table row)
|
||||
group = relationship("Group", back_populates="member_associations") # Links to Group.member_associations
|
||||
|
||||
|
||||
# --- Add other models below when needed ---
|
||||
# --- Invite Model ---
|
||||
class Invite(Base):
|
||||
__tablename__ = "invites"
|
||||
# Ensure unique codes *within active invites* using a partial index (PostgreSQL specific)
|
||||
# If not using PostgreSQL or need simpler logic, a simple unique=True on 'code' works,
|
||||
# but doesn't allow reusing old codes once deactivated.
|
||||
__table_args__ = (
|
||||
Index(
|
||||
'ix_invites_active_code',
|
||||
'code',
|
||||
unique=True,
|
||||
postgresql_where=sa_text('is_active = true') # Partial index condition
|
||||
),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
# Generate a secure random code by default
|
||||
code = Column(String, unique=False, index=True, nullable=False, default=lambda: secrets.token_urlsafe(16)) # Index helps lookup, uniqueness handled by partial index
|
||||
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group
|
||||
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # FK to User
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
# Set default expiry (e.g., 7 days from creation)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + timedelta(days=7))
|
||||
is_active = Column(Boolean, default=True, nullable=False) # To mark as used/invalid
|
||||
|
||||
# --- Relationships ---
|
||||
# Link back to the Group this invite is for (many-to-one)
|
||||
group = relationship("Group", back_populates="invites") # Links to Group.invites
|
||||
|
||||
# Link back to the User who created the invite (many-to-one)
|
||||
creator = relationship("User", back_populates="created_invites") # Links to User.created_invites
|
||||
|
||||
|
||||
# --- Models for Lists, Items, Expenses (Add later) ---
|
||||
# class List(Base): ...
|
||||
# class Item(Base): ...
|
||||
# class Expense(Base): ...
|
||||
# class ExpenseShare(Base): ...
|
||||
|
||||
# Optional: Trigger for automatically creating an 'owner' UserGroup entry when a Group is created.
|
||||
# This requires importing event and DDL. It's advanced and DB-specific, might be simpler to handle in application logic.
|
||||
# Example for PostgreSQL (might need adjustment):
|
||||
# group_owner_trigger = DDL("""
|
||||
# CREATE OR REPLACE FUNCTION add_group_owner()
|
||||
# RETURNS TRIGGER AS $$
|
||||
# BEGIN
|
||||
# INSERT INTO user_groups (user_id, group_id, role, joined_at)
|
||||
# VALUES (NEW.created_by_id, NEW.id, 'owner', NOW());
|
||||
# RETURN NEW;
|
||||
# END;
|
||||
# $$ LANGUAGE plpgsql;
|
||||
#
|
||||
# CREATE TRIGGER trg_add_group_owner
|
||||
# AFTER INSERT ON groups
|
||||
# FOR EACH ROW EXECUTE FUNCTION add_group_owner();
|
||||
# """)
|
||||
# event.listen(Group.__table__, 'after_create', group_owner_trigger)
|
||||
# class ExpenseShare(Base): ...
|
11
be/app/schemas/auth.py
Normal file
11
be/app/schemas/auth.py
Normal 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
24
be/app/schemas/group.py
Normal 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
20
be/app/schemas/invite.py
Normal 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)
|
5
be/app/schemas/message.py
Normal file
5
be/app/schemas/message.py
Normal 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
34
be/app/schemas/user.py
Normal 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
|
@ -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]
|
@ -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 };
|
4
fe/src/lib/schemas/auth.ts
Normal file
4
fe/src/lib/schemas/auth.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface Token {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
9
fe/src/lib/schemas/group.ts
Normal file
9
fe/src/lib/schemas/group.ts
Normal 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
|
||||
}
|
5
fe/src/lib/schemas/invite.ts
Normal file
5
fe/src/lib/schemas/invite.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface InviteCodePublic {
|
||||
code: string;
|
||||
expires_at: string; // Date as string from JSON
|
||||
group_id: number;
|
||||
}
|
3
fe/src/lib/schemas/message.ts
Normal file
3
fe/src/lib/schemas/message.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface Message {
|
||||
detail: string;
|
||||
}
|
6
fe/src/lib/schemas/user.ts
Normal file
6
fe/src/lib/schemas/user.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface UserPublic {
|
||||
id: number;
|
||||
email: string;
|
||||
name?: string | null;
|
||||
created_at: string;
|
||||
}
|
119
fe/src/lib/stores/authStore.ts
Normal file
119
fe/src/lib/stores/authStore.ts
Normal 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;
|
||||
}
|
49
fe/src/routes/(app)/+layout.svelte
Normal file
49
fe/src/routes/(app)/+layout.svelte
Normal 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>
|
38
fe/src/routes/(app)/+layout.ts
Normal file
38
fe/src/routes/(app)/+layout.ts
Normal 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
|
||||
};
|
||||
};
|
137
fe/src/routes/(app)/dashboard/+page.svelte
Normal file
137
fe/src/routes/(app)/dashboard/+page.svelte
Normal 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>
|
48
fe/src/routes/(app)/dashboard/+page.ts
Normal file
48
fe/src/routes/(app)/dashboard/+page.ts
Normal 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
|
||||
};
|
||||
}
|
||||
};
|
189
fe/src/routes/(app)/groups/[groupId]/+page.svelte
Normal file
189
fe/src/routes/(app)/groups/[groupId]/+page.svelte
Normal 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}
|
55
fe/src/routes/(app)/groups/[groupId]/+page.ts
Normal file
55
fe/src/routes/(app)/groups/[groupId]/+page.ts
Normal 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.');
|
||||
}
|
||||
}
|
||||
};
|
@ -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>
|
||||
|
@ -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>
|
138
fe/src/routes/join/+page.svelte
Normal file
138
fe/src/routes/join/+page.svelte
Normal 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>
|
19
fe/src/routes/join/+page.ts
Normal file
19
fe/src/routes/join/+page.ts
Normal 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
|
||||
};
|
||||
};
|
150
fe/src/routes/login/+page.svelte
Normal file
150
fe/src/routes/login/+page.svelte
Normal 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>
|
118
fe/src/routes/signup/+page.svelte
Normal file
118
fe/src/routes/signup/+page.svelte
Normal 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>
|
Loading…
Reference in New Issue
Block a user