end of phase 3
This commit is contained in:
parent
4b7415e1c3
commit
4fbbe77658
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)
|
# 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
|
# 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.
|
# 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
|
# app/api/v1/api.py
|
||||||
from fastapi import APIRouter
|
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()
|
api_router_v1 = APIRouter()
|
||||||
|
|
||||||
# Include endpoint routers here, adding the desired prefix for v1
|
api_router_v1.include_router(health.router) # Path /health defined inside
|
||||||
api_router_v1.include_router(health.router) # The path "/health" is defined inside health.router
|
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
|
# Add other v1 endpoint routers here later
|
||||||
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
|
# 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):
|
class Settings(BaseSettings):
|
||||||
DATABASE_URL: str | None = None
|
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:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
env_file_encoding = 'utf-8'
|
env_file_encoding = 'utf-8'
|
||||||
@ -15,10 +21,17 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
# Basic validation to ensure DATABASE_URL is set
|
# Validation for critical settings
|
||||||
if settings.DATABASE_URL is None:
|
if settings.DATABASE_URL is None:
|
||||||
print("Error: DATABASE_URL environment variable not set.")
|
print("Warning: DATABASE_URL environment variable not set.")
|
||||||
# Consider raising an exception for clearer failure
|
|
||||||
# raise ValueError("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.
|
# but restrict it as soon as possible.
|
||||||
# SvelteKit default dev port is 5173
|
# SvelteKit default dev port is 5173
|
||||||
origins = [
|
origins = [
|
||||||
"http://localhost:5173",
|
"http://localhost:5174",
|
||||||
"http://localhost:8000", # Allow requests from the API itself (e.g., Swagger UI)
|
"http://localhost:8000", # Allow requests from the API itself (e.g., Swagger UI)
|
||||||
# Add your deployed frontend URL here later
|
# Add your deployed frontend URL here later
|
||||||
# "https://your-frontend-domain.com",
|
# "https://your-frontend-domain.com",
|
||||||
|
130
be/app/models.py
130
be/app/models.py
@ -1,6 +1,8 @@
|
|||||||
# app/models.py
|
# app/models.py
|
||||||
import enum
|
import enum
|
||||||
from datetime import datetime
|
import secrets # For generating invite codes
|
||||||
|
from datetime import datetime, timedelta, timezone # For invite expiry
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column,
|
Column,
|
||||||
Integer,
|
Integer,
|
||||||
@ -10,15 +12,20 @@ from sqlalchemy import (
|
|||||||
Boolean,
|
Boolean,
|
||||||
Enum as SAEnum, # Renamed to avoid clash with Python's enum
|
Enum as SAEnum, # Renamed to avoid clash with Python's enum
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
|
Index, # Added for invite code index
|
||||||
|
DDL,
|
||||||
event,
|
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.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):
|
class UserRoleEnum(enum.Enum):
|
||||||
owner = "owner"
|
owner = "owner"
|
||||||
member = "member"
|
member = "member"
|
||||||
@ -29,23 +36,24 @@ class User(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
email = Column(String, unique=True, index=True, nullable=False)
|
email = Column(String, unique=True, index=True, nullable=False)
|
||||||
password_hash = Column(String, nullable=False)
|
password_hash = Column(String, nullable=False) # Column name used in CRUD
|
||||||
name = Column(String, index=True, nullable=True) # Allow nullable name initially
|
name = Column(String, index=True, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# --- Relationships ---
|
||||||
# Groups created by this user
|
# Groups created by this user
|
||||||
created_groups = relationship("Group", back_populates="creator")
|
created_groups = relationship("Group", back_populates="creator") # Links to Group.creator
|
||||||
# Association object for group membership
|
|
||||||
group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
# 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")
|
# 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")
|
# 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")
|
# 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")
|
# 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)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
name = Column(String, index=True, nullable=False)
|
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)
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
# Relationships
|
# --- Relationships ---
|
||||||
# The user who created this group
|
# The user who created this group (many-to-one)
|
||||||
creator = relationship("User", back_populates="created_groups")
|
creator = relationship("User", back_populates="created_groups") # Links to User.created_groups
|
||||||
# Association object for group membership
|
|
||||||
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan")
|
# 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 belonging to this group (Add later)
|
||||||
# lists = relationship("List", back_populates="group")
|
# lists = relationship("List", back_populates="group")
|
||||||
|
|
||||||
# --- UserGroup Association Model ---
|
|
||||||
|
# --- UserGroup Association Model (Many-to-Many link) ---
|
||||||
class UserGroup(Base):
|
class UserGroup(Base):
|
||||||
__tablename__ = "user_groups"
|
__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
|
id = Column(Integer, primary_key=True, index=True) # Surrogate primary key
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
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)
|
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group
|
||||||
role = Column(SAEnum(UserRoleEnum), nullable=False, default=UserRoleEnum.member)
|
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)
|
joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
# Relationships back to User and Group
|
# --- Relationships ---
|
||||||
user = relationship("User", back_populates="group_associations")
|
# Link back to User (many-to-one from the perspective of this table row)
|
||||||
group = relationship("Group", back_populates="member_associations")
|
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 List(Base): ...
|
||||||
# class Item(Base): ...
|
# class Item(Base): ...
|
||||||
# class Expense(Base): ...
|
# class Expense(Base): ...
|
||||||
# class ExpenseShare(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)
|
|
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
|
@ -6,3 +6,6 @@ psycopg2-binary>=2.9.0 # Often needed by Alembic even if app uses async
|
|||||||
alembic>=1.9.0 # Database migrations
|
alembic>=1.9.0 # Database migrations
|
||||||
pydantic-settings>=2.0.0 # For loading settings from .env
|
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
|
// 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 ---
|
// --- Configuration ---
|
||||||
// Get the base URL from environment variables provided by Vite/SvelteKit
|
// Read base URL from Vite environment variables
|
||||||
// Ensure VITE_API_BASE_URL is set in your .env file (e.g., VITE_API_BASE_URL=http://localhost:8000/api)
|
// 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;
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
|
||||||
if (!BASE_URL) {
|
// Initial check for configuration during module load (optional but good practice)
|
||||||
console.error('VITE_API_BASE_URL is not defined. Please set it in your .env file.');
|
if (!BASE_URL && browser) { // Only log error in browser, avoid during SSR build if possible
|
||||||
// In a real app, you might throw an error here or have a default,
|
console.error(
|
||||||
// but logging is often sufficient during development.
|
'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 {
|
export class ApiClientError extends Error {
|
||||||
status: number;
|
status: number; // HTTP status code
|
||||||
errorData: unknown;
|
errorData: unknown; // Parsed error data from response body (if any)
|
||||||
|
|
||||||
constructor(message: string, status: number, errorData: unknown = null) {
|
constructor(message: string, status: number, errorData: unknown = null) {
|
||||||
super(message);
|
super(message); // Pass message to the base Error class
|
||||||
this.name = 'ApiClientError';
|
this.name = 'ApiClientError'; // Custom error name
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.errorData = errorData;
|
this.errorData = errorData;
|
||||||
|
|
||||||
// --- Corrected Conditional Check ---
|
// Attempt to capture a cleaner stack trace in V8 environments (Node, Chrome)
|
||||||
// Check if the static method exists on the Error constructor object
|
// Conditionally check if the non-standard captureStackTrace exists
|
||||||
if (typeof (Error as any).captureStackTrace === 'function') {
|
if (typeof (Error as any).captureStackTrace === 'function') {
|
||||||
// Call it if it exists, casting Error to 'any' to bypass static type check
|
// 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'> {
|
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,
|
method: string,
|
||||||
path: string, // Relative path (e.g., /v1/health)
|
path: string, // Relative path to the API endpoint (e.g., /v1/users/me)
|
||||||
data?: unknown, // Optional request body data
|
bodyData?: unknown, // Optional data for the request body (can be object, FormData, URLSearchParams, etc.)
|
||||||
options: RequestOptions = {} // Optional fetch options (headers, etc.)
|
options: RequestOptions = {} // Optional fetch options (headers, credentials, mode, etc.)
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
|
||||||
|
// Runtime check for BASE_URL, in case it wasn't set or available during initial load
|
||||||
if (!BASE_URL) {
|
if (!BASE_URL) {
|
||||||
// Or use SvelteKit's error helper for server-side/universal loads
|
// Depending on context (load function vs. component event), choose how to handle
|
||||||
// error(500, 'API Base URL is not configured.');
|
// 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 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 cleanBase = BASE_URL.replace(/\/$/, ''); // Remove trailing slash from base
|
||||||
const cleanPath = path.replace(/^\//, ''); // Remove leading slash from path
|
const cleanPath = path.replace(/^\//, ''); // Remove leading slash from path
|
||||||
const url = `${cleanBase}/${cleanPath}`;
|
const url = `${cleanBase}/${cleanPath}`;
|
||||||
|
|
||||||
// Default headers
|
// Initialize headers, setting Accept to JSON by default
|
||||||
const headers = new Headers({
|
const headers = new Headers({
|
||||||
Accept: 'application/json',
|
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 = {
|
const fetchOptions: RequestInit = {
|
||||||
method: method.toUpperCase(),
|
method: method.toUpperCase(),
|
||||||
headers,
|
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
|
// --- Execute Fetch and Handle Response ---
|
||||||
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';
|
|
||||||
|
|
||||||
try {
|
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);
|
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) {
|
if (!response.ok) {
|
||||||
let errorJson: unknown = null;
|
let errorJson: unknown = null;
|
||||||
|
// Attempt to parse error details from the response body
|
||||||
try {
|
try {
|
||||||
// Try to parse error details from the response body
|
|
||||||
errorJson = await response.json();
|
errorJson = await response.json();
|
||||||
|
// console.debug(`API Error Response Body:`, errorJson);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore if response body isn't valid JSON
|
// Ignore if response body isn't valid JSON or empty
|
||||||
console.warn('API Error response was not valid JSON.', response.status, response.statusText)
|
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}`,
|
`API request failed: ${response.status} ${response.statusText}`,
|
||||||
response.status,
|
response.status,
|
||||||
errorJson
|
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) {
|
if (response.status === 204) {
|
||||||
// Type assertion needed because Promise<T> expects a value,
|
// Assert type as T, assuming T can accommodate null or void if needed
|
||||||
// but 204 has no body. We return null. Adjust T if needed.
|
|
||||||
return null as T;
|
return null as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse successful JSON response
|
// Parse successful JSON response body
|
||||||
const responseData = await response.json();
|
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) {
|
} catch (err) {
|
||||||
// Handle network errors or errors thrown above
|
// Handle network errors (fetch throws TypeError) or errors thrown above
|
||||||
console.error(`API Client request error: ${method} ${path}`, err);
|
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 it's already our custom error, re-throw it directly
|
||||||
if (err instanceof ApiClientError) {
|
if (err instanceof ApiClientError) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
// Otherwise, wrap network or other errors
|
|
||||||
|
// Otherwise, wrap network or other unexpected errors in our custom error type
|
||||||
throw new ApiClientError(
|
throw new ApiClientError(
|
||||||
`Network or unexpected error during API request: ${err instanceof Error ? err.message : String(err)}`,
|
`Network or unexpected error during API request: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
0, // Use 0 or a specific code for network errors
|
0, // Use 0 or a specific code (e.g., -1) for non-HTTP errors
|
||||||
err
|
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 = {
|
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> => {
|
get: <T = unknown>(path: string, options: RequestOptions = {}): Promise<T> => {
|
||||||
return request<T>('GET', path, undefined, options);
|
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> => {
|
post: <T = unknown>(path: string, data: unknown, options: RequestOptions = {}): Promise<T> => {
|
||||||
return request<T>('POST', path, data, options);
|
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> => {
|
put: <T = unknown>(path: string, data: unknown, options: RequestOptions = {}): Promise<T> => {
|
||||||
return request<T>('PUT', path, data, options);
|
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> => {
|
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);
|
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> => {
|
patch: <T = unknown>(path: string, data: unknown, options: RequestOptions = {}): Promise<T> => {
|
||||||
return request<T>('PATCH', path, data, options);
|
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
|
// Optional: Export the error class as well if needed externally
|
||||||
// export default apiClient;
|
// 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">
|
<script lang="ts">
|
||||||
// Import global styles if you have them, e.g., app.css
|
import '../app.css';
|
||||||
// We'll rely on Tailwind configured via app.postcss for now.
|
import { authStore, logout as performLogout } from '$lib/stores/authStore'; // Import store and logout action
|
||||||
import '../app.css'; // Import the main PostCSS file where Tailwind directives are
|
import { goto } from '$app/navigation'; // Import goto for logout redirect
|
||||||
console.log('Root layout loaded'); // For debugging in browser console
|
import { page } from '$app/stores'; // To check current route
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
console.log('Logging out from root layout...');
|
||||||
|
performLogout();
|
||||||
|
await goto('/login');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-h-screen flex-col bg-gray-50">
|
<div class="flex min-h-screen flex-col bg-gray-50">
|
||||||
<!-- Header Placeholder -->
|
<!-- 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">
|
<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">
|
<div class="container mx-auto flex items-center justify-between">
|
||||||
<h1 class="text-xl font-bold">Shared Lists App</h1>
|
<a href="/" class="text-xl font-bold hover:text-blue-200">Shared Lists App</a>
|
||||||
<!-- Navigation Placeholder -->
|
|
||||||
<nav class="space-x-4">
|
<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="/" class="hover:text-blue-200 hover:underline">Home</a>
|
||||||
<a href="/login" class="hover:text-blue-200 hover:underline">Login</a>
|
<a href="/login" class="hover:text-blue-200 hover:underline">Login</a>
|
||||||
<!-- Add other basic links later -->
|
<a href="/signup" class="hover:text-blue-200 hover:underline">Sign Up</a>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- 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">
|
<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 />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer Placeholder -->
|
<!-- 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">
|
<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>
|
<p>© {new Date().getFullYear()} Shared Lists App. All rights reserved.</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
{/if}
|
||||||
</div>
|
</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