Compare commits

...

6 Commits

49 changed files with 2152 additions and 1072 deletions

View File

@ -0,0 +1,53 @@
"""update_user_model_for_fastapi_users
Revision ID: 5e8b6dde50fc
Revises: 7c26d62e8005
Create Date: 2025-05-13 23:30:02.005611
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5e8b6dde50fc'
down_revision: Union[str, None] = '7c26d62e8005'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# 1. Add columns as nullable or with a default
op.add_column('users', sa.Column('hashed_password', sa.String(), nullable=True))
op.add_column('users', sa.Column('is_active', sa.Boolean(), nullable=True, server_default=sa.sql.expression.true()))
op.add_column('users', sa.Column('is_superuser', sa.Boolean(), nullable=True, server_default=sa.sql.expression.false()))
op.add_column('users', sa.Column('is_verified', sa.Boolean(), nullable=True, server_default=sa.sql.expression.false()))
# 2. Set default values for existing rows
op.execute("UPDATE users SET hashed_password = '' WHERE hashed_password IS NULL")
op.execute("UPDATE users SET is_active = true WHERE is_active IS NULL")
op.execute("UPDATE users SET is_superuser = false WHERE is_superuser IS NULL")
op.execute("UPDATE users SET is_verified = false WHERE is_verified IS NULL")
# 3. Alter columns to be non-nullable
op.alter_column('users', 'hashed_password', nullable=False)
op.alter_column('users', 'is_active', nullable=False)
op.alter_column('users', 'is_superuser', nullable=False)
op.alter_column('users', 'is_verified', nullable=False)
# 4. Drop the old column
op.drop_column('users', 'password_hash')
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('password_hash', sa.VARCHAR(), autoincrement=False, nullable=False))
op.drop_column('users', 'is_verified')
op.drop_column('users', 'is_superuser')
op.drop_column('users', 'is_active')
op.drop_column('users', 'hashed_password')
# ### end Alembic commands ###

View File

@ -0,0 +1,60 @@
"""add_missing_indexes_and_constraints
Revision ID: 7c26d62e8005
Revises: bc37e9c7ae19
Create Date: 2025-05-13 21:44:46.408395
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7c26d62e8005'
down_revision: Union[str, None] = 'bc37e9c7ae19'
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_index('ix_expense_splits_user_id', 'expense_splits', ['user_id'], unique=False)
op.create_index(op.f('ix_expenses_group_id'), 'expenses', ['group_id'], unique=False)
op.create_index(op.f('ix_expenses_list_id'), 'expenses', ['list_id'], unique=False)
op.create_index(op.f('ix_expenses_paid_by_user_id'), 'expenses', ['paid_by_user_id'], unique=False)
op.create_index(op.f('ix_settlements_group_id'), 'settlements', ['group_id'], unique=False)
op.create_index(op.f('ix_settlements_paid_by_user_id'), 'settlements', ['paid_by_user_id'], unique=False)
op.create_index(op.f('ix_settlements_paid_to_user_id'), 'settlements', ['paid_to_user_id'], unique=False)
# Add check constraints
op.create_check_constraint(
'chk_expense_context',
'expenses',
'(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)'
)
op.create_check_constraint(
'chk_settlement_different_users',
'settlements',
'paid_by_user_id != paid_to_user_id'
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# Drop check constraints
op.drop_constraint('chk_settlement_different_users', 'settlements', type_='check')
op.drop_constraint('chk_expense_context', 'expenses', type_='check')
op.drop_index(op.f('ix_settlements_paid_to_user_id'), table_name='settlements')
op.drop_index(op.f('ix_settlements_paid_by_user_id'), table_name='settlements')
op.drop_index(op.f('ix_settlements_group_id'), table_name='settlements')
op.drop_index(op.f('ix_expenses_paid_by_user_id'), table_name='expenses')
op.drop_index(op.f('ix_expenses_list_id'), table_name='expenses')
op.drop_index(op.f('ix_expenses_group_id'), table_name='expenses')
op.drop_index('ix_expense_splits_user_id', table_name='expense_splits')
# ### end Alembic commands ###

91
be/app/api/auth/oauth.py Normal file
View File

@ -0,0 +1,91 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_async_session
from app.models import User
from app.auth import oauth, fastapi_users
from app.config import settings
router = APIRouter()
@router.get('/google/login')
async def google_login(request: Request):
return await oauth.google.authorize_redirect(request, settings.GOOGLE_REDIRECT_URI)
@router.get('/google/callback')
async def google_callback(request: Request, db: AsyncSession = Depends(get_async_session)):
token_data = await oauth.google.authorize_access_token(request)
user_info = await oauth.google.parse_id_token(request, token_data)
# Check if user exists
existing_user = (await db.execute(select(User).where(User.email == user_info['email']))).scalar_one_or_none()
user_to_login = existing_user
if not existing_user:
# Create new user
new_user = User(
email=user_info['email'],
name=user_info.get('name', user_info.get('email')),
is_verified=True, # Email is verified by Google
is_active=True
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
user_to_login = new_user
# Generate JWT token
strategy = fastapi_users._auth_backends[0].get_strategy()
token = await strategy.write_token(user_to_login)
# Redirect to frontend with token
return RedirectResponse(
url=f"{settings.FRONTEND_URL}/auth/callback?token={token}"
)
@router.get('/apple/login')
async def apple_login(request: Request):
return await oauth.apple.authorize_redirect(request, settings.APPLE_REDIRECT_URI)
@router.get('/apple/callback')
async def apple_callback(request: Request, db: AsyncSession = Depends(get_async_session)):
token_data = await oauth.apple.authorize_access_token(request)
user_info = token_data.get('user', await oauth.apple.userinfo(token=token_data) if hasattr(oauth.apple, 'userinfo') else {})
if 'email' not in user_info and 'sub' in token_data:
parsed_id_token = await oauth.apple.parse_id_token(request, token_data) if hasattr(oauth.apple, 'parse_id_token') else {}
user_info = {**parsed_id_token, **user_info}
if 'email' not in user_info:
return RedirectResponse(url=f"{settings.FRONTEND_URL}/auth/callback?error=apple_email_missing")
# Check if user exists
existing_user = (await db.execute(select(User).where(User.email == user_info['email']))).scalar_one_or_none()
user_to_login = existing_user
if not existing_user:
# Create new user
name_info = user_info.get('name', {})
first_name = name_info.get('firstName', '')
last_name = name_info.get('lastName', '')
full_name = f"{first_name} {last_name}".strip() if first_name or last_name else user_info.get('email')
new_user = User(
email=user_info['email'],
name=full_name,
is_verified=True, # Email is verified by Apple
is_active=True
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
user_to_login = new_user
# Generate JWT token
strategy = fastapi_users._auth_backends[0].get_strategy()
token = await strategy.write_token(user_to_login)
# Redirect to frontend with token
return RedirectResponse(
url=f"{settings.FRONTEND_URL}/auth/callback?token={token}"
)

View File

@ -1,72 +0,0 @@
# 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
from app.config import settings
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=settings.OAUTH2_TOKEN_URL)
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=settings.AUTH_CREDENTIALS_ERROR,
headers={settings.AUTH_HEADER_NAME: settings.AUTH_HEADER_PREFIX},
)
payload = verify_access_token(token)
if payload is None:
logger.warning("Token verification failed (invalid, expired, or malformed).")
raise credentials_exception
email: Optional[str] = payload.get("sub")
if email is None:
logger.error("Token payload missing 'sub' (subject/email).")
raise credentials_exception # Token is malformed
# Fetch user from database
user = await crud_user.get_user_by_email(db, email=email)
if user is None:
logger.warning(f"User corresponding to token subject not found: {email}")
# Could happen if user deleted after token issuance
raise credentials_exception # Treat as invalid credentials
logger.debug(f"Authenticated user retrieved: {user.email} (ID: {user.id})")
return user
# Optional: Dependency for getting the *active* current user
# You might add an `is_active` flag to your User model later
# async def get_current_active_user(
# current_user: UserModel = Depends(get_current_user)
# ) -> UserModel:
# if not current_user.is_active: # Assuming an is_active attribute
# logger.warning(f"Authentication attempt by inactive user: {current_user.email}")
# raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user")
# return current_user

View File

@ -1,8 +1,6 @@
from fastapi import APIRouter
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
from app.api.v1.endpoints import lists
@ -14,8 +12,6 @@ from app.api.v1.endpoints import financials
api_router_v1 = APIRouter()
api_router_v1.include_router(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"])
api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"])

View File

@ -1,136 +0,0 @@
# app/api/v1/endpoints/auth.py
import logging
from typing import Annotated
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,
create_refresh_token,
verify_refresh_token
)
from app.core.exceptions import (
EmailAlreadyRegisteredError,
InvalidCredentialsError,
UserCreationError
)
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"/signup",
response_model=UserPublic,
status_code=201,
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 EmailAlreadyRegisteredError()
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})")
return created_user
except Exception as e:
logger.error(f"Error during user creation for {user_in.email}: {e}", exc_info=True)
raise UserCreationError()
@router.post(
"/login",
response_model=Token,
summary="User Login",
description="Authenticates a user and returns an access and refresh token.",
tags=["Authentication"]
)
async def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
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 JWT access and refresh tokens 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)
if not user or not verify_password(form_data.password, user.password_hash):
logger.warning(f"Login failed: Invalid credentials for user {form_data.username}")
raise InvalidCredentialsError()
access_token = create_access_token(subject=user.email)
refresh_token = create_refresh_token(subject=user.email)
logger.info(f"Login successful, tokens generated for user: {user.email}")
return Token(
access_token=access_token,
refresh_token=refresh_token,
token_type=settings.TOKEN_TYPE
)
@router.post(
"/refresh",
response_model=Token,
summary="Refresh Access Token",
description="Refreshes an access token using a refresh token.",
tags=["Authentication"]
)
async def refresh_token(
refresh_token_str: str,
db: AsyncSession = Depends(get_db)
):
"""
Handles access token refresh.
- Verifies the provided refresh token.
- If valid, generates and returns a new JWT access token and a new refresh token.
"""
logger.info("Access token refresh attempt")
payload = verify_refresh_token(refresh_token_str)
if not payload:
logger.warning("Refresh token invalid or expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
headers={"WWW-Authenticate": "Bearer"},
)
user_email = payload.get("sub")
if not user_email:
logger.error("User email not found in refresh token payload")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token payload",
headers={"WWW-Authenticate": "Bearer"},
)
new_access_token = create_access_token(subject=user_email)
new_refresh_token = create_refresh_token(subject=user_email)
logger.info(f"Access token refreshed and new refresh token issued for user: {user_email}")
return Token(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type=settings.TOKEN_TYPE
)

View File

@ -7,7 +7,7 @@ from sqlalchemy.orm import Session, selectinload
from decimal import Decimal, ROUND_HALF_UP
from app.database import get_db
from app.api.dependencies import get_current_user
from app.auth import current_active_user
from app.models import (
User as UserModel,
Group as GroupModel,
@ -41,7 +41,7 @@ router = APIRouter()
async def get_list_cost_summary(
list_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""
Retrieves a calculated cost summary for a specific list, detailing total costs,
@ -184,7 +184,7 @@ async def get_list_cost_summary(
async def get_group_balance_summary(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""
Retrieves a detailed financial balance summary for all users within a specific group.

View File

@ -6,7 +6,7 @@ from sqlalchemy import select
from typing import List as PyList, Optional, Sequence
from app.database import get_db
from app.api.dependencies import get_current_user
from app.auth import current_active_user
from app.models import User as UserModel, Group as GroupModel, List as ListModel, UserGroup as UserGroupModel, UserRoleEnum
from app.schemas.expense import (
ExpenseCreate, ExpensePublic,
@ -47,7 +47,7 @@ async def check_list_access_for_financials(db: AsyncSession, list_id: int, user_
async def create_new_expense(
expense_in: ExpenseCreate,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
logger.info(f"User {current_user.email} creating expense: {expense_in.description}")
effective_group_id = expense_in.group_id
@ -110,7 +110,7 @@ async def create_new_expense(
async def get_expense(
expense_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
logger.info(f"User {current_user.email} requesting expense ID {expense_id}")
expense = await crud_expense.get_expense_by_id(db, expense_id=expense_id)
@ -131,7 +131,7 @@ async def list_list_expenses(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=200),
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
logger.info(f"User {current_user.email} listing expenses for list ID {list_id}")
await check_list_access_for_financials(db, list_id, current_user.id)
@ -144,7 +144,7 @@ async def list_group_expenses(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=200),
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
logger.info(f"User {current_user.email} listing expenses for group ID {group_id}")
await crud_group.check_group_membership(db, group_id=group_id, user_id=current_user.id, action="list expenses for")
@ -156,7 +156,7 @@ async def update_expense_details(
expense_id: int,
expense_in: ExpenseUpdate,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""
Updates an existing expense (description, currency, expense_date only).
@ -210,7 +210,7 @@ async def delete_expense_record(
expense_id: int,
expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"),
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""
Deletes an expense and its associated splits.
@ -274,7 +274,7 @@ async def delete_expense_record(
async def create_new_settlement(
settlement_in: SettlementCreate,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
logger.info(f"User {current_user.email} recording settlement in group {settlement_in.group_id}")
await crud_group.check_group_membership(db, group_id=settlement_in.group_id, user_id=current_user.id, action="record settlements in")
@ -300,7 +300,7 @@ async def create_new_settlement(
async def get_settlement(
settlement_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
logger.info(f"User {current_user.email} requesting settlement ID {settlement_id}")
settlement = await crud_settlement.get_settlement_by_id(db, settlement_id=settlement_id)
@ -322,7 +322,7 @@ async def list_group_settlements(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=200),
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
logger.info(f"User {current_user.email} listing settlements for group ID {group_id}")
await crud_group.check_group_membership(db, group_id=group_id, user_id=current_user.id, action="list settlements for this group")
@ -334,7 +334,7 @@ async def update_settlement_details(
settlement_id: int,
settlement_in: SettlementUpdate,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""
Updates an existing settlement (description, settlement_date only).
@ -388,7 +388,7 @@ async def delete_settlement_record(
settlement_id: int,
expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"),
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""
Deletes a settlement.
@ -437,6 +437,3 @@ async def delete_settlement_record(
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.")
return Response(status_code=status.HTTP_204_NO_CONTENT)
# TODO (remaining from original list):
# (None - GET/POST/PUT/DELETE implemented for Expense/Settlement)

View File

@ -6,7 +6,7 @@ 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.auth import current_active_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
@ -37,7 +37,7 @@ router = APIRouter()
async def create_group(
group_in: GroupCreate,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""Creates a new group, adding the creator as the owner."""
logger.info(f"User {current_user.email} creating group: {group_in.name}")
@ -55,7 +55,7 @@ async def create_group(
)
async def read_user_groups(
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all groups the current user is a member of."""
logger.info(f"Fetching groups for user: {current_user.email}")
@ -72,7 +72,7 @@ async def read_user_groups(
async def read_group(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_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}")
@ -99,7 +99,7 @@ async def read_group(
async def create_group_invite(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_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}")
@ -132,7 +132,7 @@ async def create_group_invite(
async def leave_group(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""Removes the current user from the specified group."""
logger.info(f"User {current_user.email} attempting to leave group {group_id}")
@ -171,7 +171,7 @@ 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),
current_user: UserModel = Depends(current_active_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}")
@ -210,7 +210,7 @@ async def remove_group_member(
async def read_group_lists(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all lists belonging to a specific group, if the user is a member."""
logger.info(f"User {current_user.email} requesting lists for group ID: {group_id}")

View File

@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import text
from app.database import get_db
from app.database import get_async_session
from app.schemas.health import HealthStatus
from app.core.exceptions import DatabaseConnectionError
@ -18,7 +18,7 @@ router = APIRouter()
description="Checks the operational status of the API and its connection to the database.",
tags=["Health"]
)
async def check_health(db: AsyncSession = Depends(get_db)):
async def check_health(db: AsyncSession = Depends(get_async_session)):
"""
Health check endpoint. Verifies API reachability and database connection.
"""

View File

@ -4,7 +4,7 @@ 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.auth import current_active_user
from app.models import User as UserModel, UserRoleEnum
from app.schemas.invite import InviteAccept
from app.schemas.message import Message
@ -31,7 +31,7 @@ router = APIRouter()
async def accept_invite(
invite_in: InviteAccept,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""Accepts a group invite using the provided invite code."""
logger.info(f"User {current_user.email} attempting to accept invite code: {invite_in.invite_code}")

View File

@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.api.dependencies import get_current_user
from app.auth import current_active_user
# --- Import Models Correctly ---
from app.models import User as UserModel
from app.models import Item as ItemModel # <-- IMPORT Item and alias it
@ -24,7 +24,7 @@ router = APIRouter()
async def get_item_and_verify_access(
item_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user)
current_user: UserModel = Depends(current_active_user)
) -> ItemModel:
"""Dependency to get an item and verify the user has access to its list."""
item_db = await crud_item.get_item_by_id(db, item_id=item_id)
@ -53,7 +53,7 @@ async def create_list_item(
list_id: int,
item_in: ItemCreate,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""Adds a new item to a specific list. User must have access to the list."""
user_email = current_user.email # Access email attribute before async operations
@ -81,7 +81,7 @@ async def create_list_item(
async def read_list_items(
list_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
# Add sorting/filtering params later if needed: sort_by: str = 'created_at', order: str = 'asc'
):
"""Retrieves all items for a specific list if the user has access."""
@ -112,7 +112,7 @@ async def update_item(
item_in: ItemUpdate,
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user), # Need user ID for completed_by
current_user: UserModel = Depends(current_active_user), # Need user ID for completed_by
):
"""
Updates an item's details (name, quantity, is_complete, price).
@ -153,7 +153,7 @@ async def delete_item(
expected_version: Optional[int] = Query(None, description="The expected version of the item to delete for optimistic locking."),
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user), # Log who deleted it
current_user: UserModel = Depends(current_active_user), # Log who deleted it
):
"""
Deletes an item. User must have access to the list the item belongs to.

View File

@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Response, Query #
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.api.dependencies import get_current_user
from app.auth import current_active_user
from app.models import User as UserModel
from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail
from app.schemas.message import Message # For simple responses
@ -18,7 +18,8 @@ from app.core.exceptions import (
ListNotFoundError,
ListPermissionError,
ListStatusNotFoundError,
ConflictError # Added ConflictError
ConflictError, # Added ConflictError
DatabaseIntegrityError # Added DatabaseIntegrityError
)
logger = logging.getLogger(__name__)
@ -29,17 +30,24 @@ router = APIRouter()
response_model=ListPublic, # Return basic list info on creation
status_code=status.HTTP_201_CREATED,
summary="Create New List",
tags=["Lists"]
tags=["Lists"],
responses={
status.HTTP_409_CONFLICT: {
"description": "Conflict: A list with this name already exists in the specified group",
"model": ListPublic
}
}
)
async def create_list(
list_in: ListCreate,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""
Creates a new shopping list.
- If `group_id` is provided, the user must be a member of that group.
- If `group_id` is null, it's a personal list.
- If a list with the same name already exists in the group, returns 409 with the existing list.
"""
logger.info(f"User {current_user.email} creating list: {list_in.name}")
group_id = list_in.group_id
@ -51,9 +59,29 @@ async def create_list(
logger.warning(f"User {current_user.email} attempted to create list in group {group_id} but is not a member.")
raise GroupMembershipError(group_id, "create lists")
try:
created_list = await crud_list.create_list(db=db, list_in=list_in, creator_id=current_user.id)
logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.")
return created_list
except DatabaseIntegrityError as e:
# Check if this is a unique constraint violation
if "unique constraint" in str(e).lower():
# Find the existing list with the same name in the group
existing_list = await crud_list.get_list_by_name_and_group(
db=db,
name=list_in.name,
group_id=group_id,
user_id=current_user.id
)
if existing_list:
logger.info(f"List '{list_in.name}' already exists in group {group_id}. Returning existing list.")
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A list named '{list_in.name}' already exists in this group.",
headers={"X-Existing-List": str(existing_list.id)}
)
# If it's not a unique constraint or we couldn't find the existing list, re-raise
raise
@router.get(
@ -64,7 +92,7 @@ async def create_list(
)
async def read_lists(
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
# Add pagination parameters later if needed: skip: int = 0, limit: int = 100
):
"""
@ -86,7 +114,7 @@ async def read_lists(
async def read_list(
list_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""
Retrieves details for a specific list, including its items,
@ -111,7 +139,7 @@ async def update_list(
list_id: int,
list_in: ListUpdate,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""
Updates a list's details (name, description, is_complete).
@ -149,7 +177,7 @@ async def delete_list(
list_id: int,
expected_version: Optional[int] = Query(None, description="The expected version of the list to delete for optimistic locking."),
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""
Deletes a list. Requires user to be the creator of the list.
@ -184,7 +212,7 @@ async def delete_list(
async def read_list_status(
list_id: int,
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
):
"""
Retrieves the last update time for the list and its items, plus item count.

View File

@ -4,7 +4,7 @@ from typing import List
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status
from google.api_core import exceptions as google_exceptions
from app.api.dependencies import get_current_user
from app.auth import current_active_user
from app.models import User as UserModel
from app.schemas.ocr import OcrExtractResponse
from app.core.gemini import extract_items_from_image_gemini, gemini_initialization_error, GeminiOCRService
@ -30,7 +30,7 @@ ocr_service = GeminiOCRService()
tags=["OCR"]
)
async def ocr_extract_items(
current_user: UserModel = Depends(get_current_user),
current_user: UserModel = Depends(current_active_user),
image_file: UploadFile = File(..., description="Image file (JPEG, PNG, WEBP) of the shopping list or receipt."),
):
"""

View File

@ -1,30 +0,0 @@
# 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))

91
be/app/auth.py Normal file
View File

@ -0,0 +1,91 @@
from typing import Optional
from fastapi import Depends, Request
from fastapi.security import OAuth2PasswordRequestForm
from fastapi_users import BaseUserManager, FastAPIUsers, IntegerIDMixin
from fastapi_users.authentication import (
AuthenticationBackend,
BearerTransport,
JWTStrategy,
)
from fastapi_users.db import SQLAlchemyUserDatabase
from sqlalchemy.ext.asyncio import AsyncSession
from authlib.integrations.starlette_client import OAuth
from starlette.config import Config
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import Response
from .database import get_async_session
from .models import User
from .config import settings
# OAuth2 configuration
config = Config('.env')
oauth = OAuth(config)
# Google OAuth2 setup
oauth.register(
name='google',
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={
'scope': 'openid email profile',
'redirect_uri': settings.GOOGLE_REDIRECT_URI
}
)
# Apple OAuth2 setup
oauth.register(
name='apple',
server_metadata_url='https://appleid.apple.com/.well-known/openid-configuration',
client_kwargs={
'scope': 'openid email name',
'redirect_uri': settings.APPLE_REDIRECT_URI
}
)
class UserManager(IntegerIDMixin, BaseUserManager[User, int]):
reset_password_token_secret = settings.SECRET_KEY
verification_token_secret = settings.SECRET_KEY
async def on_after_register(self, user: User, request: Optional[Request] = None):
print(f"User {user.id} has registered.")
async def on_after_forgot_password(
self, user: User, token: str, request: Optional[Request] = None
):
print(f"User {user.id} has forgot their password. Reset token: {token}")
async def on_after_request_verify(
self, user: User, token: str, request: Optional[Request] = None
):
print(f"Verification requested for user {user.id}. Verification token: {token}")
async def on_after_login(
self, user: User, request: Optional[Request] = None, response: Optional[Response] = None
):
print(f"User {user.id} has logged in.")
async def get_user_db(session: AsyncSession = Depends(get_async_session)):
yield SQLAlchemyUserDatabase(session, User)
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
yield UserManager(user_db)
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60)
auth_backend = AuthenticationBackend(
name="jwt",
transport=bearer_transport,
get_strategy=get_jwt_strategy,
)
fastapi_users = FastAPIUsers[User, int](
get_user_manager,
[auth_backend],
)
current_active_user = fastapi_users.current_user(active=True)
current_superuser = fastapi_users.current_user(active=True, superuser=True)

View File

@ -4,6 +4,7 @@ from pydantic_settings import BaseSettings
from dotenv import load_dotenv
import logging
import secrets
from typing import List
load_dotenv()
logger = logging.getLogger(__name__)
@ -11,12 +12,12 @@ logger = logging.getLogger(__name__)
class Settings(BaseSettings):
DATABASE_URL: str | None = None
GEMINI_API_KEY: str | None = None
SENTRY_DSN: str | None = None # Sentry DSN for error tracking
# --- JWT Settings ---
# --- JWT Settings --- (SECRET_KEY is used by FastAPI-Users)
SECRET_KEY: str # Must be set via environment variable
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # Default token lifetime: 30 minutes
REFRESH_TOKEN_EXPIRE_MINUTES: int = 10080 # Default refresh token lifetime: 7 days
# ALGORITHM: str = "HS256" # Handled by FastAPI-Users strategy
# ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # This specific line is commented, the one under Session Settings is used.
# --- OCR Settings ---
MAX_FILE_SIZE_MB: int = 10 # Maximum allowed file size for OCR processing
@ -58,11 +59,14 @@ Organic Bananas
API_DOCS_URL: str = "/api/docs"
API_REDOC_URL: str = "/api/redoc"
CORS_ORIGINS: list[str] = [
"http://localhost:5173",
"http://localhost:8000",
# Add your deployed frontend URL here later
# "https://your-frontend-domain.com",
"http://localhost:5173", # Frontend dev server
"http://localhost:5174", # Alternative Vite port
"http://localhost:8000", # Backend server
"http://127.0.0.1:5173", # Frontend with IP
"http://127.0.0.1:5174", # Alternative Vite with IP
"http://127.0.0.1:8000", # Backend with IP
]
FRONTEND_URL: str = "http://localhost:5173" # URL for the frontend application
# --- API Metadata ---
API_TITLE: str = "Shared Lists API"
@ -78,15 +82,6 @@ Organic Bananas
HEALTH_STATUS_OK: str = "ok"
HEALTH_STATUS_ERROR: str = "error"
# --- Auth Settings ---
OAUTH2_TOKEN_URL: str = "/api/v1/auth/login" # Path to login endpoint
TOKEN_TYPE: str = "bearer" # Default token type for OAuth2
AUTH_HEADER_PREFIX: str = "Bearer" # Prefix for Authorization header
AUTH_HEADER_NAME: str = "WWW-Authenticate" # Name of auth header
AUTH_CREDENTIALS_ERROR: str = "Could not validate credentials"
AUTH_INVALID_CREDENTIALS: str = "Incorrect email or password"
AUTH_NOT_AUTHENTICATED: str = "Not authenticated"
# --- HTTP Status Messages ---
HTTP_400_DETAIL: str = "Bad Request"
HTTP_401_DETAIL: str = "Unauthorized"
@ -103,6 +98,20 @@ Organic Bananas
DB_TRANSACTION_ERROR: str = "Database transaction error"
DB_QUERY_ERROR: str = "Database query error"
# OAuth Settings
GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = ""
GOOGLE_REDIRECT_URI: str = "http://localhost:8000/auth/google/callback"
APPLE_CLIENT_ID: str = ""
APPLE_TEAM_ID: str = ""
APPLE_KEY_ID: str = ""
APPLE_PRIVATE_KEY: str = ""
APPLE_REDIRECT_URI: str = "http://localhost:8000/auth/apple/callback"
# Session Settings
SESSION_SECRET_KEY: str = "your-session-secret-key" # Change this in production
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
class Config:
env_file = ".env"
env_file_encoding = 'utf-8'

View File

@ -45,120 +45,14 @@ def hash_password(password: str) -> str:
# --- 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), "type": "access"}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
def create_refresh_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""
Creates a JWT refresh 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 REFRESH_TOKEN_EXPIRE_MINUTES from settings.
Returns:
The encoded JWT refresh token string.
"""
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES
)
# Data to encode in the token payload
to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"}
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]
)
if payload.get("type") != "access":
raise JWTError("Invalid token type")
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
def verify_refresh_token(token: str) -> Optional[dict]:
"""
Verifies a JWT refresh 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, not expired,
and is a refresh token, otherwise None.
"""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
if payload.get("type") != "refresh":
raise JWTError("Invalid token type")
return payload
except JWTError as e:
print(f"JWT Error: {e}") # Log the error for debugging
return None
except Exception as e:
print(f"Unexpected error decoding JWT: {e}")
return None
# FastAPI-Users now handles all tokenization.
# 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)
# # This would need to use FastAPI-Users' token verification if ever implemented
# # For example, by decoding the token using the strategy from the auth backend
# payload = {} # Placeholder for actual token decoding logic
# if payload:
# return payload.get("sub")
# return None

View File

@ -207,3 +207,45 @@ async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to get list status: {str(e)}")
async def get_list_by_name_and_group(
db: AsyncSession,
name: str,
group_id: Optional[int],
user_id: int
) -> Optional[ListModel]:
"""
Gets a list by name and group, ensuring the user has permission to access it.
Used for conflict resolution when creating lists.
"""
try:
# Build the base query
query = select(ListModel).where(ListModel.name == name)
# Add group condition
if group_id is not None:
query = query.where(ListModel.group_id == group_id)
else:
query = query.where(ListModel.group_id.is_(None))
# Add permission conditions
conditions = [
ListModel.created_by_id == user_id # User is creator
]
if group_id is not None:
# User is member of the group
conditions.append(
and_(
ListModel.group_id == group_id,
ListModel.created_by_id != user_id # Not the creator
)
)
query = query.where(or_(*conditions))
result = await db.execute(query)
return result.scalars().first()
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query list by name and group: {str(e)}")

View File

@ -163,6 +163,3 @@ async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, ex
await db.rollback()
raise InvalidOperationError(f"Failed to delete settlement: {str(e)}")
return None
# TODO: Implement update_settlement (consider restrictions, versioning)
# TODO: Implement delete_settlement (consider implications on balances)

View File

@ -30,7 +30,7 @@ AsyncSessionLocal = sessionmaker(
Base = declarative_base()
# Dependency to get DB session in path operations
async def get_db() -> AsyncSession: # type: ignore
async def get_async_session() -> AsyncSession: # type: ignore
"""
Dependency function that yields an AsyncSession.
Ensures the session is closed after the request.
@ -45,3 +45,6 @@ async def get_db() -> AsyncSession: # type: ignore
raise
finally:
await session.close() # Not strictly necessary with async context manager, but explicit
# Alias for backward compatibility
get_db = get_async_session

View File

@ -3,12 +3,31 @@ import logging
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
from app.api.api_router import api_router
from app.config import settings
from app.core.api_config import API_METADATA, API_TAGS
# Import database and models if needed for startup/shutdown events later
# from . import database, models
from app.auth import fastapi_users, auth_backend
from app.models import User
from app.api.auth.oauth import router as oauth_router
from app.schemas.user import UserPublic, UserCreate, UserUpdate
# Initialize Sentry
sentry_sdk.init(
dsn=settings.SENTRY_DSN,
integrations=[
FastApiIntegration(),
],
# Set traces_sample_rate to 1.0 to capture 100% of transactions for performance monitoring.
# We recommend adjusting this value in production.
traces_sample_rate=1.0,
# If you wish to associate users to errors (assuming you are using
# FastAPI's users system) you may enable sending PII data.
send_default_pii=True
)
# --- Logging Setup ---
logging.basicConfig(
@ -23,30 +42,56 @@ app = FastAPI(
openapi_tags=API_TAGS
)
# --- CORS Middleware ---
# Define allowed origins. Be specific in production!
# Use ["*"] for wide open access during early development if needed,
# but restrict it as soon as possible.
# SvelteKit default dev port is 5173
origins = [
"http://localhost:5174",
"http://localhost:8000", # Allow requests from the API itself (e.g., Swagger UI)
# Add your deployed frontend URL here later
# "https://your-frontend-domain.com",
]
# Add session middleware for OAuth
app.add_middleware(
SessionMiddleware,
secret_key=settings.SESSION_SECRET_KEY
)
# --- CORS Middleware ---
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"]
)
# --- End CORS Middleware ---
# --- Include API Routers ---
# All API endpoints will be prefixed with /api
# Include FastAPI-Users routes
app.include_router(
fastapi_users.get_auth_router(auth_backend),
prefix="/auth/jwt",
tags=["auth"],
)
app.include_router(
fastapi_users.get_register_router(UserPublic, UserCreate),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_verify_router(UserPublic),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_users_router(UserPublic, UserUpdate),
prefix="/users",
tags=["users"],
)
# Include OAuth routes
app.include_router(oauth_router, prefix="/auth", tags=["auth"])
# Include your API router
app.include_router(api_router, prefix=settings.API_PREFIX)
# --- End Include API Routers ---
@ -59,23 +104,23 @@ async def read_root():
Useful for basic reachability checks.
"""
logger.info("Root endpoint '/' accessed.")
return {"message": settings.ROOT_MESSAGE}
return {"message": "Welcome to the API"}
# --- End Root Endpoint ---
# --- Application Startup/Shutdown Events (Optional) ---
# @app.on_event("startup")
# async def startup_event():
# logger.info("Application startup: Connecting to database...")
# # You might perform initial checks or warm-up here
# # await database.engine.connect() # Example check (get_db handles sessions per request)
# logger.info("Application startup complete.")
@app.on_event("startup")
async def startup_event():
logger.info("Application startup: Connecting to database...")
# You might perform initial checks or warm-up here
# await database.engine.connect() # Example check (get_db handles sessions per request)
logger.info("Application startup complete.")
# @app.on_event("shutdown")
# async def shutdown_event():
# logger.info("Application shutdown: Disconnecting from database...")
# # await database.engine.dispose() # Close connection pool
# logger.info("Application shutdown complete.")
@app.on_event("shutdown")
async def shutdown_event():
logger.info("Application shutdown: Disconnecting from database...")
# await database.engine.dispose() # Close connection pool
logger.info("Application shutdown complete.")
# --- End Events ---

View File

@ -19,7 +19,8 @@ from sqlalchemy import (
func,
text as sa_text,
Text, # <-- Add Text for description
Numeric # <-- Add Numeric for price
Numeric, # <-- Add Numeric for price
CheckConstraint
)
from sqlalchemy.orm import relationship, backref
@ -44,8 +45,11 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
password_hash = Column(String, nullable=False)
hashed_password = Column(String, nullable=False)
name = Column(String, index=True, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
is_superuser = Column(Boolean, default=False, nullable=False)
is_verified = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships ---
@ -184,15 +188,15 @@ class Expense(Base):
id = Column(Integer, primary_key=True, index=True)
description = Column(String, nullable=False)
total_amount = Column(Numeric(10, 2), nullable=False)
currency = Column(String, nullable=False, default="USD") # Consider making this an Enum too if few currencies
currency = Column(String, nullable=False, default="USD")
expense_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
split_type = Column(SAEnum(SplitTypeEnum, name="splittypeenum", create_type=True), nullable=False)
# Foreign Keys
list_id = Column(Integer, ForeignKey("lists.id"), nullable=True)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) # If not list-specific but group-specific
item_id = Column(Integer, ForeignKey("items.id"), nullable=True) # If the expense is for a specific item
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
list_id = Column(Integer, ForeignKey("lists.id"), nullable=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True, index=True)
item_id = Column(Integer, ForeignKey("items.id"), nullable=True)
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
@ -206,27 +210,25 @@ class Expense(Base):
splits = relationship("ExpenseSplit", back_populates="expense", cascade="all, delete-orphan")
__table_args__ = (
# Example: Ensure either list_id or group_id is present if item_id is null
# CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'),
# Ensure at least one context is provided
CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'),
)
class ExpenseSplit(Base):
__tablename__ = "expense_splits"
__table_args__ = (UniqueConstraint('expense_id', 'user_id', name='uq_expense_user_split'),)
__table_args__ = (
UniqueConstraint('expense_id', 'user_id', name='uq_expense_user_split'),
Index('ix_expense_splits_user_id', 'user_id'), # For looking up user's splits
)
id = Column(Integer, primary_key=True, index=True)
expense_id = Column(Integer, ForeignKey("expenses.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
owed_amount = Column(Numeric(10, 2), nullable=False) # For EQUAL or EXACT_AMOUNTS
# For PERCENTAGE split (value from 0.00 to 100.00)
owed_amount = Column(Numeric(10, 2), nullable=False)
share_percentage = Column(Numeric(5, 2), nullable=True)
# For SHARES split (e.g., user A has 2 shares, user B has 3 shares)
share_units = Column(Integer, nullable=True)
# is_settled might be better tracked via actual Settlement records or a reconciliation process
# is_settled = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
@ -234,14 +236,13 @@ class ExpenseSplit(Base):
expense = relationship("Expense", back_populates="splits")
user = relationship("User", foreign_keys=[user_id], back_populates="expense_splits")
class Settlement(Base):
__tablename__ = "settlements"
id = Column(Integer, primary_key=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False) # Settlements usually within a group
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
paid_to_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False, index=True)
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
paid_to_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
amount = Column(Numeric(10, 2), nullable=False)
settlement_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
description = Column(Text, nullable=True)
@ -257,7 +258,7 @@ class Settlement(Base):
__table_args__ = (
# Ensure payer and payee are different users
# CheckConstraint('paid_by_user_id <> paid_to_user_id', name='chk_settlement_payer_ne_payee'),
CheckConstraint('paid_by_user_id != paid_to_user_id', name='chk_settlement_different_users'),
)
# Potential future: PaymentMethod model, etc.

View File

@ -12,9 +12,22 @@ class UserBase(BaseModel):
class UserCreate(UserBase):
password: str
# Properties to receive via API on update (optional, add later if needed)
# class UserUpdate(UserBase):
# password: Optional[str] = None
def create_update_dict(self):
return {
"email": self.email,
"name": self.name,
"password": self.password,
"is_active": True,
"is_superuser": False,
"is_verified": False
}
# Properties to receive via API on update
class UserUpdate(UserBase):
password: Optional[str] = None
is_active: Optional[bool] = None
is_superuser: Optional[bool] = None
is_verified: Optional[bool] = None
# Properties stored in DB
class UserInDBBase(UserBase):

View File

@ -10,3 +10,10 @@ passlib[bcrypt]>=1.7.4
python-jose[cryptography]>=3.3.0
pydantic[email]
google-generativeai>=0.5.0
sentry-sdk[fastapi]>=1.39.0
python-multipart>=0.0.6 # Required for form data handling
fastapi-users[sqlalchemy]>=12.1.2
email-validator>=2.0.0
fastapi-users[oauth]>=12.1.2
authlib>=1.3.0
itsdangerous>=2.1.2

View File

@ -1,9 +1,6 @@
# docker-compose.yml (in project root)
version: '3.8'
services:
db:
image: postgres:15 # Use a specific PostgreSQL version
image: postgres:17 # Use a specific PostgreSQL version
container_name: postgres_db
environment:
POSTGRES_USER: dev_user # Define DB user
@ -14,7 +11,7 @@ services:
ports:
- "5432:5432" # Expose PostgreSQL port to host (optional, for direct access)
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ]
interval: 10s
timeout: 5s
retries: 5
@ -37,29 +34,29 @@ services:
# Uses the service name 'db' as the host, and credentials defined above
# IMPORTANT: Use the correct async driver prefix if your app needs it!
- DATABASE_URL=postgresql+asyncpg://dev_user:dev_password@db:5432/dev_db
- GEMINI_API_KEY=AIzaSyDKoZBIzUKoeGRtc3m7FtSoqId_nZjfl7M
- SECRET_KEY=zaSyDKoZBIzUKoeGRtc3m7zaSyGRtc3m7zaSyDKoZBIzUKoeGRtc3m7
# Add other environment variables needed by the backend here
# - SOME_OTHER_VAR=some_value
depends_on:
db: # Wait for the db service to be healthy before starting backend
db:
# Wait for the db service to be healthy before starting backend
condition: service_healthy
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] # Override CMD for development reload
command: [ "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ] # Override CMD for development reload
restart: unless-stopped
pgadmin: # Optional service for database administration
image: dpage/pgadmin4:latest
container_name: pgadmin4_server
environment:
PGADMIN_DEFAULT_EMAIL: admin@example.com # Change as needed
PGADMIN_DEFAULT_PASSWORD: admin_password # Change to a secure password
PGADMIN_CONFIG_SERVER_MODE: 'False' # Run in Desktop mode for easier local dev server setup
volumes:
- pgadmin_data:/var/lib/pgadmin # Persist pgAdmin configuration
frontend:
container_name: vite_frontend
build:
context: ./fe
dockerfile: Dockerfile
ports:
- "5050:80" # Map container port 80 to host port 5050
- "80:80"
depends_on:
- db # Depends on the database service
- backend
restart: unless-stopped
volumes: # Define named volumes for data persistence
volumes:
# Define named volumes for data persistence
postgres_data:
pgadmin_data:

3
fe/.gitignore vendored
View File

@ -7,7 +7,7 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
**/node_modules/
.DS_Store
dist
dist-ssr
@ -28,6 +28,7 @@ coverage
*.sw?
*.tsbuildinfo
*.sw.js
test-results/
playwright-report/

31
fe/Dockerfile Normal file
View File

@ -0,0 +1,31 @@
# Build stage
FROM node:24-alpine AS build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built assets from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration if needed
# COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

188
fe/package-lock.json generated
View File

@ -8,6 +8,8 @@
"name": "fe",
"version": "0.0.0",
"dependencies": {
"@sentry/tracing": "^7.120.3",
"@sentry/vue": "^7.120.3",
"@supabase/auth-js": "^2.69.1",
"@supabase/supabase-js": "^2.49.4",
"@vueuse/core": "^13.1.0",
@ -15,7 +17,8 @@
"pinia": "^3.0.2",
"vue": "^3.5.13",
"vue-i18n": "^12.0.0-alpha.2",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"workbox-background-sync": "^7.3.0"
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8",
@ -3825,6 +3828,162 @@
"dev": true,
"license": "MIT"
},
"node_modules/@sentry-internal/feedback": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.120.3.tgz",
"integrity": "sha512-ewJJIQ0mbsOX6jfiVFvqMjokxNtgP3dNwUv+4nenN+iJJPQsM6a0ocro3iscxwVdbkjw5hY3BUV2ICI5Q0UWoA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-7.120.3.tgz",
"integrity": "sha512-s5xy+bVL1eDZchM6gmaOiXvTqpAsUfO7122DxVdEDMtwVq3e22bS2aiGa8CUgOiJkulZ+09q73nufM77kOmT/A==",
"license": "MIT",
"dependencies": {
"@sentry/core": "7.120.3",
"@sentry/replay": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@sentry-internal/tracing": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.120.3.tgz",
"integrity": "sha512-Ausx+Jw1pAMbIBHStoQ6ZqDZR60PsCByvHdw/jdH9AqPrNE9xlBSf9EwcycvmrzwyKspSLaB52grlje2cRIUMg==",
"license": "MIT",
"dependencies": {
"@sentry/core": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/browser": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.120.3.tgz",
"integrity": "sha512-i9vGcK9N8zZ/JQo1TCEfHHYZ2miidOvgOABRUc9zQKhYdcYQB2/LU1kqlj77Pxdxf4wOa9137d6rPrSn9iiBxg==",
"license": "MIT",
"dependencies": {
"@sentry-internal/feedback": "7.120.3",
"@sentry-internal/replay-canvas": "7.120.3",
"@sentry-internal/tracing": "7.120.3",
"@sentry/core": "7.120.3",
"@sentry/integrations": "7.120.3",
"@sentry/replay": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/core": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.120.3.tgz",
"integrity": "sha512-vyy11fCGpkGK3qI5DSXOjgIboBZTriw0YDx/0KyX5CjIjDDNgp5AGgpgFkfZyiYiaU2Ww3iFuKo4wHmBusz1uA==",
"license": "MIT",
"dependencies": {
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/integrations": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.120.3.tgz",
"integrity": "sha512-6i/lYp0BubHPDTg91/uxHvNui427df9r17SsIEXa2eKDwQ9gW2qRx5IWgvnxs2GV/GfSbwcx4swUB3RfEWrXrQ==",
"license": "MIT",
"dependencies": {
"@sentry/core": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3",
"localforage": "^1.8.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/replay": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.120.3.tgz",
"integrity": "sha512-CjVq1fP6bpDiX8VQxudD5MPWwatfXk8EJ2jQhJTcWu/4bCSOQmHxnnmBM+GVn5acKUBCodWHBN+IUZgnJheZSg==",
"license": "MIT",
"dependencies": {
"@sentry-internal/tracing": "7.120.3",
"@sentry/core": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@sentry/tracing": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-7.120.3.tgz",
"integrity": "sha512-B7bqyYFgHuab1Pn7w5KXsZP/nfFo4VDBDdSXDSWYk5+TYJ3IDruO3eJFhOrircfsz4YwazWm9kbeZhkpsHDyHg==",
"license": "MIT",
"dependencies": {
"@sentry-internal/tracing": "7.120.3"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/types": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.120.3.tgz",
"integrity": "sha512-C4z+3kGWNFJ303FC+FxAd4KkHvxpNFYAFN8iMIgBwJdpIl25KZ8Q/VdGn0MLLUEHNLvjob0+wvwlcRBBNLXOow==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/utils": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.120.3.tgz",
"integrity": "sha512-UDAOQJtJDxZHQ5Nm1olycBIsz2wdGX8SdzyGVHmD8EOQYAeDZQyIlQYohDe9nazdIOQLZCIc3fU0G9gqVLkaGQ==",
"license": "MIT",
"dependencies": {
"@sentry/types": "7.120.3"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/vue": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-7.120.3.tgz",
"integrity": "sha512-YKKLGx6VNk5OTz5JqIsjIqOgaU8u88Q1OBfLZgOpm55vhrvpZGGc+rHyh8XtXxh4DfC+6vTRTrAngvdPOG9Oxw==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "7.120.3",
"@sentry/core": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"vue": "2.x || 3.x"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@ -7427,7 +7586,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ignore": {
@ -7440,6 +7598,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immutable": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz",
@ -8413,6 +8577,24 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
"integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/localforage": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
"license": "Apache-2.0",
"dependencies": {
"lie": "3.1.1"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -11885,7 +12067,6 @@
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz",
"integrity": "sha512-PCSk3eK7Mxeuyatb22pcSx9dlgWNv3+M8PqPaYDokks8Y5/FX4soaOqj3yhAZr5k6Q5JWTOMYgaJBpbw11G9Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
"idb": "^7.0.1",
@ -12220,7 +12401,6 @@
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.3.0.tgz",
"integrity": "sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==",
"dev": true,
"license": "MIT"
},
"node_modules/workbox-expiration": {

View File

@ -17,6 +17,8 @@
"format": "prettier --write src/"
},
"dependencies": {
"@sentry/tracing": "^7.120.3",
"@sentry/vue": "^7.120.3",
"@supabase/auth-js": "^2.69.1",
"@supabase/supabase-js": "^2.49.4",
"@vueuse/core": "^13.1.0",
@ -24,7 +26,8 @@
"pinia": "^3.0.2",
"vue": "^3.5.13",
"vue-i18n": "^12.0.0-alpha.2",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"workbox-background-sync": "^7.3.0"
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8",

45
fe/public/offline.html Normal file
View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline</title>
<style>
body {
font-family: sans-serif;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f0f0f0;
color: #333;
text-align: center;
}
.container {
padding: 20px;
border-radius: 8px;
background-color: #fff;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
h1 {
color: #d32f2f;
/* A reddish color to indicate an issue */
}
</style>
</head>
<body>
<div class="container">
<h1>You are Offline</h1>
<p>It seems you've lost your internet connection.</p>
<p>Please check your network settings and try again once you're back online.</p>
<p><small>Some previously cached content might still be available.</small></p>
</div>
</body>
</html>

View File

@ -1,6 +1,6 @@
// src/assets/main.scss
// @import './variables.scss'; // Your custom variables
@import './valerie-ui.scss';
@use './valerie-ui.scss';
// Example global styles
body {
@ -13,6 +13,7 @@ body {
a {
color: var(--primary-color);
text-decoration: none;
&:hover {
text-decoration: underline;
}
@ -23,4 +24,73 @@ a {
margin: 0 auto;
padding: 1rem;
}
// Offline UI styles
.offline-item {
position: relative;
opacity: 0.8;
transition: opacity 0.3s ease;
&::after {
content: '';
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 1rem;
height: 1rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3Cpath d='M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16'/%3E%3Cpath d='M16 21h5v-5'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
animation: spin 1s linear infinite;
}
&.synced {
opacity: 1;
&::after {
display: none;
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// Disabled offline features
.feature-offline-disabled {
position: relative;
cursor: not-allowed;
opacity: 0.6;
&::before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 0.5rem;
background-color: var(--bg-color-tooltip, #333);
color: white;
border-radius: 0.25rem;
font-size: 0.875rem;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
z-index: 1000;
}
&:hover::before {
opacity: 1;
visibility: visible;
}
}
// Add more global utility classes or base styles

View File

@ -1,34 +1,26 @@
<template>
<div v-if="show" class="modal-backdrop open" @click.self="closeDialog">
<div class="modal-container" style="min-width: 600px" ref="modalContentRef" role="dialog" aria-modal="true" aria-labelledby="conflictDialogTitle">
<div class="modal-container" style="min-width: 600px" ref="modalContentRef" role="dialog" aria-modal="true"
aria-labelledby="conflictDialogTitle">
<div class="modal-header">
<h3 id="conflictDialogTitle">Conflict Resolution</h3>
</div>
<div class="modal-body">
<p class="mb-2">
This item was modified while you were offline. Please review the changes and choose how to resolve the conflict.
This item was modified while you were offline. Please review the changes and choose how to resolve the
conflict.
</p>
<div class="tabs">
<ul class="tab-list" role="tablist" aria-label="Conflict Resolution Options">
<li
class="tab-item"
role="tab"
:aria-selected="activeTab === 'compare'"
:tabindex="activeTab === 'compare' ? 0 : -1"
@click="activeTab = 'compare'"
@keydown.enter.space="activeTab = 'compare'"
>
<li class="tab-item" role="tab" :aria-selected="activeTab === 'compare'"
:tabindex="activeTab === 'compare' ? 0 : -1" @click="activeTab = 'compare'"
@keydown.enter.space="activeTab = 'compare'">
Compare Versions
</li>
<li
class="tab-item"
role="tab"
:aria-selected="activeTab === 'merge'"
:tabindex="activeTab === 'merge' ? 0 : -1"
@click="activeTab = 'merge'"
@keydown.enter.space="activeTab = 'merge'"
>
<li class="tab-item" role="tab" :aria-selected="activeTab === 'merge'"
:tabindex="activeTab === 'merge' ? 0 : -1" @click="activeTab = 'merge'"
@keydown.enter.space="activeTab = 'merge'">
Merge Changes
</li>
</ul>
@ -47,7 +39,8 @@
<ul class="item-list simple-list">
<li v-for="(value, key) in conflictData?.localVersion.data" :key="key" class="list-item-simple">
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value) }}</span>
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value)
}}</span>
</li>
</ul>
</div>
@ -65,7 +58,8 @@
<ul class="item-list simple-list">
<li v-for="(value, key) in conflictData?.serverVersion.data" :key="key" class="list-item-simple">
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value) }}</span>
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value)
}}</span>
</li>
</ul>
</div>
@ -81,7 +75,8 @@
<div class="card-body">
<p class="text-caption mb-2">Select which version to keep for each field.</p>
<ul class="item-list simple-list">
<li v-for="(localValue, key) in conflictData?.localVersion.data" :key="key" class="list-item-simple merge-choice-item">
<li v-for="(localValue, key) in conflictData?.localVersion.data" :key="key"
class="list-item-simple merge-choice-item">
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
<div class="flex" style="gap: 1rem; margin-top: 0.5rem;">
<div class="radio-group-inline">
@ -95,7 +90,8 @@
<label class="radio-label">
<input type="radio" :name="String(key)" v-model="mergeChoices[key]" value="server" />
<span class="checkmark radio-mark"></span>
Server Version: <span class="value-preview">{{ formatValue(conflictData?.serverVersion.data[key]) }}</span>
Server Version: <span class="value-preview">{{
formatValue(conflictData?.serverVersion.data[key]) }}</span>
</label>
</div>
</div>
@ -108,43 +104,20 @@
</div>
<div class="modal-footer">
<button
v-if="activeTab === 'compare'"
type="button"
class="btn btn-neutral"
@click="resolveConflict('local')"
>
<button v-if="activeTab === 'compare'" type="button" class="btn btn-neutral" @click="resolveConflict('local')">
Keep Local Version
</button>
<button
v-if="activeTab === 'compare'"
type="button"
class="btn btn-neutral ml-2"
@click="resolveConflict('server')"
>
<button v-if="activeTab === 'compare'" type="button" class="btn btn-neutral ml-2"
@click="resolveConflict('server')">
Keep Server Version
</button>
<button
v-if="activeTab === 'compare'"
type="button"
class="btn btn-primary ml-2"
@click="activeTab = 'merge'"
>
<button v-if="activeTab === 'compare'" type="button" class="btn btn-primary ml-2" @click="activeTab = 'merge'">
Merge Changes
</button>
<button
v-if="activeTab === 'merge'"
type="button"
class="btn btn-primary ml-2"
@click="applyMergedChanges"
>
<button v-if="activeTab === 'merge'" type="button" class="btn btn-primary ml-2" @click="applyMergedChanges">
Apply Merged Changes
</button>
<button
type="button"
class="btn btn-danger ml-2"
@click="closeDialog"
>
<button type="button" class="btn btn-danger ml-2" @click="closeDialog">
Cancel
</button>
</div>
@ -155,17 +128,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useVModel, onClickOutside } from '@vueuse/core';
// Assuming OfflineAction is defined elsewhere, e.g. in a Pinia store or a types file
// For this example, let's define a placeholder if not available from `src/stores/offline`
// import type { OfflineAction } from 'src/stores/offline';
interface OfflineAction {
id: string | number;
type: string;
payload: unknown;
timestamp: number;
// other potential fields
}
import type { OfflineAction } from '@/stores/offline';
interface ConflictData {
localVersion: {
@ -176,7 +139,7 @@ interface ConflictData {
data: Record<string, unknown>;
timestamp: number;
};
action: OfflineAction; // Assuming OfflineAction is defined
action: OfflineAction;
}
const props = defineProps<{
@ -282,6 +245,7 @@ const applyMergedChanges = (): void => {
color: var(--dark);
opacity: 0.8;
}
.text-caption-strong {
font-size: 0.9rem;
color: var(--dark);
@ -291,9 +255,11 @@ const applyMergedChanges = (): void => {
}
.text-positive-inline {
color: var(--success); /* Assuming --success is greenish */
color: var(--success);
/* Assuming --success is greenish */
font-weight: bold;
background-color: #e6ffed; /* Light green background for highlight */
background-color: #e6ffed;
/* Light green background for highlight */
padding: 2px 4px;
border-radius: 3px;
}
@ -303,10 +269,12 @@ const applyMergedChanges = (): void => {
padding: 0;
margin: 0;
}
.list-item-simple {
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
}
.list-item-simple:last-child {
border-bottom: none;
}
@ -314,24 +282,35 @@ const applyMergedChanges = (): void => {
.merge-choice-item .radio-group-inline {
margin-bottom: 0.5rem;
}
.merge-choice-item .radio-label {
align-items: flex-start; /* Better alignment for multi-line content */
align-items: flex-start;
/* Better alignment for multi-line content */
}
.value-preview {
font-style: italic;
color: #555;
margin-left: 0.5em;
display: inline-block;
max-width: 200px; /* Adjust as needed */
white-space: pre-wrap; /* Show formatted JSON */
max-width: 200px;
/* Adjust as needed */
white-space: pre-wrap;
/* Show formatted JSON */
word-break: break-all;
}
.ml-2 {
margin-left: 0.5rem;
}
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.mb-1 {
margin-bottom: 0.5rem;
}
.mb-2 {
margin-bottom: 1rem;
}
.flex-grow {
flex-grow: 1;

View File

@ -1,53 +1,54 @@
<template>
<div>
<div
v-if="!isOnline || hasPendingActions"
class="alert offline-indicator"
:class="{
<div v-if="!isOnline || hasPendingActions" class="alert offline-indicator" :class="{
'alert-error': !isOnline,
'alert-warning': isOnline && hasPendingActions
}"
role="status"
>
}" role="status">
<div class="alert-content">
<svg class="icon" aria-hidden="true">
<use :xlink:href="!isOnline ? '#icon-alert-triangle' : '#icon-info'" />
<!-- Placeholder icons, wifi_off and sync are not in Valerie UI default -->
<use :xlink:href="!isOnline ? '#icon-wifi-off' : '#icon-sync'" />
</svg>
<span v-if="!isOnline">
<span v-if="!isOnline" class="status-text">
You are currently offline. Changes will be saved locally.
</span>
<span v-else>
<span v-else class="status-text">
Syncing {{ pendingActionCount }} pending {{ pendingActionCount === 1 ? 'change' : 'changes' }}...
</span>
</div>
<button
v-if="hasPendingActions"
class="btn btn-sm btn-neutral"
@click="showPendingActionsModal = true"
>
<button v-if="hasPendingActions" class="btn btn-sm btn-neutral" @click="showPendingActionsModal = true">
View Changes
</button>
</div>
<div v-if="showPendingActionsModal" class="modal-backdrop open" @click.self="showPendingActionsModal = false">
<div class="modal-container" ref="pendingActionsModalRef" role="dialog" aria-modal="true" aria-labelledby="pendingActionsTitle">
<div class="modal-container" ref="pendingActionsModalRef" role="dialog" aria-modal="true"
aria-labelledby="pendingActionsTitle">
<div class="modal-header">
<h3 id="pendingActionsTitle">Pending Changes</h3>
<button class="close-button" @click="showPendingActionsModal = false" aria-label="Close">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-close" />
</svg>
</button>
</div>
<div class="modal-body">
<ul v-if="pendingActions.length" class="item-list">
<li v-for="action in pendingActions" :key="action.id" class="list-item">
<div class="list-item-content" style="flex-direction: column; align-items: flex-start;">
<div class="list-item-content">
<div class="action-info">
<span class="item-text">{{ getActionLabel(action) }}</span>
<small class="text-caption">{{ new Date(action.timestamp).toLocaleString() }}</small>
</div>
<button class="btn btn-sm btn-error" @click="removePendingAction(action.id)"
title="Remove this pending action">
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-trash" />
</svg>
</button>
</div>
</li>
</ul>
<p v-else>No pending changes.</p>
<p v-else class="empty-state">No pending changes.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" @click="showPendingActionsModal = false">Close</button>
@ -56,58 +57,55 @@
</div>
<!-- Conflict Resolution Dialog -->
<ConflictResolutionDialog
v-model="offlineStore.showConflictDialog"
:conflict-data="offlineStore.currentConflict"
@resolve="offlineStore.handleConflictResolution"
/>
<ConflictResolutionDialog v-model="offlineStore.showConflictDialog" :conflict-data="offlineStore.currentConflict"
@resolve="offlineStore.handleConflictResolution" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useNetwork, onClickOutside } from '@vueuse/core';
import { useOfflineStore } from '@/stores/offline'; // Assuming path
import type { OfflineAction } from '@/stores/offline'; // Assuming path
import { useOfflineStore } from '@/stores/offline';
import type { OfflineAction } from '@/stores/offline';
import ConflictResolutionDialog from '@/components/ConflictResolutionDialog.vue';
const offlineStore = useOfflineStore();
const showPendingActionsModal = ref(false);
const pendingActionsModalRef = ref<HTMLElement | null>(null);
const { isOnline } = useNetwork(); // VueUse composable for network status
const { isOnline } = useNetwork();
// Expose parts of the store directly, this pattern is fine with Pinia
const {
pendingActions,
hasPendingActions,
pendingActionCount,
// showConflictDialog, // Handled by offlineStore.showConflictDialog
// currentConflict, // Handled by offlineStore.currentConflict
// handleConflictResolution // Handled by offlineStore.handleConflictResolution
} = offlineStore;
onClickOutside(pendingActionsModalRef, () => {
showPendingActionsModal.value = false;
});
const removePendingAction = (actionId: string) => {
offlineStore.pendingActions = offlineStore.pendingActions.filter(a => a.id !== actionId);
};
const getActionLabel = (action: OfflineAction) => {
// This is a simplified version of your original getActionLabel
// You might need to adjust based on the actual structure of action.data
const data = action.payload as { title?: string; name?: string; [key: string]: unknown };
const data = action.payload as { title?: string; name?: string;[key: string]: unknown };
const itemTitle = data.title || data.name || (typeof data === 'string' ? data : 'Untitled Item');
switch (action.type) {
case 'add':
case 'create': // Common alias
return `Add: ${itemTitle}`;
case 'complete':
return `Complete: ${itemTitle}`;
case 'update':
return `Update: ${itemTitle}`;
case 'delete':
return `Delete: ${itemTitle}`;
case 'create_list':
return `Create List: ${itemTitle}`;
case 'update_list':
return `Update List: ${itemTitle}`;
case 'delete_list':
return `Delete List: ${itemTitle}`;
case 'create_list_item':
return `Add Item: ${itemTitle}`;
case 'update_list_item':
return `Update Item: ${itemTitle}`;
case 'delete_list_item':
return `Delete Item: ${itemTitle}`;
default:
return `Unknown action: ${action.type} for ${itemTitle}`;
}
@ -121,22 +119,83 @@ const getActionLabel = (action: OfflineAction) => {
right: 1rem;
z-index: 1000;
max-width: 400px;
/* Valerie UI .alert already has box-shadow */
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
}
.alert-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
.status-text {
font-weight: 500;
}
/* Styles for text-caption if not globally available enough */
.text-caption {
font-size: 0.85rem;
color: var(--dark);
opacity: 0.7;
}
/* Simplified list item for pending actions modal */
.item-list .list-item .list-item-content {
padding: 0.75rem 1rem;
.item-list {
list-style: none;
padding: 0;
margin: 0;
}
.item-list .list-item .item-text {
.list-item {
border-bottom: 1px solid var(--border-color);
}
.list-item:last-child {
border-bottom: none;
}
.list-item-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
gap: 1rem;
}
.action-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.item-text {
font-weight: 500;
margin-bottom: 0.25rem;
}
.empty-state {
text-align: center;
color: var(--text-muted);
padding: 2rem;
}
.btn-error {
padding: 0.25rem;
min-width: auto;
}
.btn-error .icon {
width: 1rem;
height: 1rem;
}
</style>

View File

@ -0,0 +1,112 @@
<template>
<div class="social-login-container">
<div class="divider">
<span>or continue with</span>
</div>
<div class="social-buttons">
<button @click="handleGoogleLogin" class="btn btn-social btn-google">
<svg class="icon" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4" />
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853" />
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05" />
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335" />
</svg>
Continue with Google
</button>
<!-- <button @click="handleAppleLogin" class="btn btn-social btn-apple">
<svg class="icon" viewBox="0 0 24 24">
<path
d="M17.05 20.28c-.98.95-2.05.88-3.08.41-1.09-.47-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.41C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.19 2.31-.89 3.51-.84 1.54.07 2.7.61 3.44 1.57-3.14 1.88-2.29 5.13.22 6.41-.65 1.29-1.51 2.58-2.25 4.03zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
fill="#000" />
</svg>
Continue with Apple
</button> -->
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
const handleGoogleLogin = () => {
window.location.href = '/auth/google/login';
};
const handleAppleLogin = () => {
window.location.href = '/auth/apple/login';
};
</script>
<style scoped>
.social-login-container {
margin-top: 1.5rem;
}
.divider {
display: flex;
align-items: center;
text-align: center;
margin: 1rem 0;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--border-color);
}
.divider span {
padding: 0 1rem;
color: var(--text-muted);
font-size: 0.875rem;
}
.social-buttons {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.btn-social {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--bg-color);
color: var(--text-color);
font-weight: 500;
transition: all var(--transition-speed) var(--transition-ease-out);
}
.btn-social:hover {
background: var(--bg-hover);
}
.btn-google {
border-color: #4285F4;
}
.btn-apple {
border-color: #000;
}
.icon {
width: 1.5rem;
height: 1.5rem;
}
</style>

View File

@ -8,24 +8,23 @@ export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:80
export const API_ENDPOINTS = {
// Auth
AUTH: {
LOGIN: '/auth/login',
SIGNUP: '/auth/signup',
REFRESH_TOKEN: '/auth/refresh-token',
LOGOUT: '/auth/logout',
VERIFY_EMAIL: '/auth/verify-email',
RESET_PASSWORD: '/auth/reset-password',
LOGIN: '/auth/jwt/login',
SIGNUP: '/auth/register',
LOGOUT: '/auth/jwt/logout',
VERIFY_EMAIL: '/auth/verify',
RESET_PASSWORD: '/auth/forgot-password',
FORGOT_PASSWORD: '/auth/forgot-password',
},
// Users
USERS: {
PROFILE: '/users/me',
UPDATE_PROFILE: '/users/me',
PASSWORD: '/users/password',
AVATAR: '/users/avatar',
SETTINGS: '/users/settings',
NOTIFICATIONS: '/users/notifications',
PREFERENCES: '/users/preferences',
UPDATE_PROFILE: '/api/v1/users/me',
PASSWORD: '/api/v1/users/password',
AVATAR: '/api/v1/users/avatar',
SETTINGS: '/api/v1/users/settings',
NOTIFICATIONS: '/api/v1/users/notifications',
PREFERENCES: '/api/v1/users/preferences',
},
// Lists

View File

@ -1,4 +1,3 @@
// src/layouts/AuthLayout.vue
<template>
<div class="auth-layout">
<main class="auth-page-container">

View File

@ -1,5 +1,7 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import * as Sentry from '@sentry/vue';
import { BrowserTracing } from '@sentry/tracing';
import App from './App.vue';
import router from './router';
// import { createI18n } from 'vue-i18n';
@ -33,6 +35,23 @@ const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
// Initialize Sentry
Sentry.init({
app,
dsn: import.meta.env.VITE_SENTRY_DSN,
integrations: [
new BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(router),
tracingOrigins: ['localhost', /^\//],
}),
],
// Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring.
// We recommend adjusting this value in production
tracesSampleRate: 1.0,
// Set environment
environment: import.meta.env.MODE,
});
// Initialize auth state before mounting the app
const authStore = useAuthStore();
if (authStore.accessToken) {

View File

@ -0,0 +1,92 @@
<template>
<main class="flex items-center justify-center page-container">
<div class="card">
<div class="card-body text-center">
<div v-if="loading" class="spinner-dots" role="status">
<span /><span /><span />
</div>
<p v-else-if="error" class="text-error">{{ error }}</p>
<p v-else>Redirecting...</p>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { useNotificationStore } from '@/stores/notifications';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const notificationStore = useNotificationStore();
const loading = ref(true);
const error = ref<string | null>(null);
onMounted(async () => {
try {
const token = route.query.token as string;
if (!token) {
throw new Error('No token provided');
}
await authStore.setTokens({ access_token: token, refresh_token: '' });
notificationStore.addNotification({ message: 'Login successful', type: 'success' });
router.push('/');
} catch (err) {
error.value = err instanceof Error ? err.message : 'Authentication failed';
notificationStore.addNotification({ message: error.value, type: 'error' });
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.page-container {
min-height: 100vh;
min-height: 100dvh;
padding: 1rem;
}
.card {
width: 100%;
max-width: 400px;
text-align: center;
}
.spinner-dots {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.spinner-dots span {
width: 0.75rem;
height: 0.75rem;
background-color: var(--primary);
border-radius: 50%;
animation: bounce 0.5s infinite alternate;
}
.spinner-dots span:nth-child(2) {
animation-delay: 0.2s;
}
.spinner-dots span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes bounce {
from {
transform: translateY(0);
}
to {
transform: translateY(-0.5rem);
}
}
</style>

View File

@ -1,13 +1,15 @@
<template>
<main class="container page-padding">
<div v-if="loading" class="text-center">
<div class="spinner-dots" role="status"><span/><span/><span/></div>
<div class="spinner-dots" role="status"><span /><span /><span /></div>
<p>Loading list details...</p>
</div>
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
<div class="alert-content">
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-alert-triangle" />
</svg>
{{ error }}
</div>
<button type="button" class="btn btn-sm btn-danger" @click="fetchListDetails">Retry</button>
@ -17,12 +19,20 @@
<div class="flex justify-between items-center flex-wrap mb-2">
<h1>{{ list.name }}</h1>
<div class="flex items-center flex-wrap" style="gap: 0.5rem;">
<button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true">
<svg class="icon icon-sm"><use xlink:href="#icon-clipboard"/></svg> <!-- Placeholder icon -->
<button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true"
:class="{ 'feature-offline-disabled': !isOnline }"
:data-tooltip="!isOnline ? 'Cost summary requires online connection' : ''">
<svg class="icon icon-sm">
<use xlink:href="#icon-clipboard" />
</svg>
Cost Summary
</button>
<button class="btn btn-secondary btn-sm" @click="openOcrDialog">
<svg class="icon icon-sm"><use xlink:href="#icon-plus"/></svg> <!-- Placeholder, camera_alt not in Valerie -->
<button class="btn btn-secondary btn-sm" @click="openOcrDialog"
:class="{ 'feature-offline-disabled': !isOnline }"
:data-tooltip="!isOnline ? 'OCR requires online connection' : ''">
<svg class="icon icon-sm">
<use xlink:href="#icon-plus" />
</svg>
Add via OCR
</button>
<span class="item-badge ml-1" :class="list.is_complete ? 'badge-settled' : 'badge-pending'">
@ -37,27 +47,15 @@
<div class="flex items-end flex-wrap" style="gap: 1rem;">
<div class="form-group flex-grow" style="margin-bottom: 0;">
<label for="newItemName" class="form-label">Item Name</label>
<input
type="text"
id="newItemName"
v-model="newItem.name"
class="form-input"
required
ref="itemNameInputRef"
/>
<input type="text" id="newItemName" v-model="newItem.name" class="form-input" required
ref="itemNameInputRef" />
</div>
<div class="form-group" style="margin-bottom: 0; min-width: 120px;">
<label for="newItemQuantity" class="form-label">Quantity</label>
<input
type="number"
id="newItemQuantity"
v-model="newItem.quantity"
class="form-input"
min="1"
/>
<input type="number" id="newItemQuantity" v-model="newItem.quantity" class="form-input" min="1" />
</div>
<button type="submit" class="btn btn-primary" :disabled="addingItem">
<span v-if="addingItem" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
<span v-if="addingItem" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Add Item
</button>
</div>
@ -66,31 +64,26 @@
<!-- Items List -->
<div v-if="list.items.length === 0" class="card empty-state-card">
<svg class="icon icon-lg" aria-hidden="true"><use xlink:href="#icon-clipboard" /></svg>
<svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-clipboard" />
</svg>
<h3>No Items Yet!</h3>
<p>This list is empty. Add some items using the form above.</p>
</div>
<ul v-else class="item-list">
<li
v-for="item in list.items"
:key="item.id"
class="list-item"
:class="{ 'completed': item.is_complete, 'is-swiped': item.swiped }"
@touchstart="handleTouchStart($event, item)"
@touchmove="handleTouchMove($event, item)"
@touchend="handleTouchEnd(item)"
>
<li v-for="item in list.items" :key="item.id" class="list-item" :class="{
'completed': item.is_complete,
'is-swiped': item.swiped,
'offline-item': isItemPendingSync(item),
'synced': !isItemPendingSync(item)
}" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="handleTouchEnd">
<div class="list-item-content">
<div class="list-item-main">
<label class="checkbox-label mb-0 flex-shrink-0">
<input
type="checkbox"
:checked="item.is_complete"
<input type="checkbox" :checked="item.is_complete"
@change="confirmUpdateItem(item, ($event.target as HTMLInputElement).checked)"
:disabled="item.updating"
:aria-label="item.name"
/>
:disabled="item.updating" :aria-label="item.name" />
<span class="checkmark"></span>
</label>
<div class="item-text flex-grow">
@ -98,32 +91,21 @@
<small v-if="item.quantity" class="item-caption">Quantity: {{ item.quantity }}</small>
<div v-if="item.is_complete" class="form-group mt-1" style="max-width: 150px; margin-bottom: 0;">
<label :for="`price-${item.id}`" class="sr-only">Price for {{ item.name }}</label>
<input
:id="`price-${item.id}`"
type="number"
v-model.number="item.priceInput"
class="form-input form-input-sm"
placeholder="Price"
step="0.01"
@blur="updateItemPrice(item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
/>
<input :id="`price-${item.id}`" type="number" v-model.number="item.priceInput"
class="form-input form-input-sm" placeholder="Price" step="0.01" @blur="updateItemPrice(item)"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
</div>
</div>
</div>
<!-- Non-swipe actions can be added here or handled by swipe -->
<div class="list-item-actions">
<button
class="btn btn-danger btn-sm btn-icon-only"
@click.stop="confirmDeleteItem(item)"
:disabled="item.deleting"
aria-label="Delete item"
>
<svg class="icon icon-sm"><use xlink:href="#icon-trash"></use></svg>
<button class="btn btn-danger btn-sm btn-icon-only" @click.stop="confirmDeleteItem(item)"
:disabled="item.deleting" aria-label="Delete item">
<svg class="icon icon-sm">
<use xlink:href="#icon-trash"></use>
</svg>
</button>
</div>
</div>
<!-- Swipe actions could be added here if fully implementing swipe from Valerie UI example -->
</li>
</ul>
</template>
@ -133,11 +115,13 @@
<div class="modal-container" ref="ocrModalRef" style="min-width: 400px;">
<div class="modal-header">
<h3>Add Items via OCR</h3>
<button class="close-button" @click="closeOcrDialog" aria-label="Close"><svg class="icon"><use xlink:href="#icon-close"/></svg></button>
<button class="close-button" @click="closeOcrDialog" aria-label="Close"><svg class="icon">
<use xlink:href="#icon-close" />
</svg></button>
</div>
<div class="modal-body">
<div v-if="ocrLoading" class="text-center">
<div class="spinner-dots"><span/><span/><span/></div>
<div class="spinner-dots"><span /><span /><span /></div>
<p>Processing image...</p>
</div>
<div v-else-if="ocrItems.length > 0">
@ -147,7 +131,9 @@
<div class="list-item-content flex items-center" style="gap: 0.5rem;">
<input type="text" v-model="ocrItem.name" class="form-input flex-grow" required />
<button class="btn btn-danger btn-sm btn-icon-only" @click="ocrItems.splice(index, 1)">
<svg class="icon icon-sm"><use xlink:href="#icon-trash"/></svg>
<svg class="icon icon-sm">
<use xlink:href="#icon-trash" />
</svg>
</button>
</div>
</li>
@ -155,36 +141,57 @@
</div>
<div v-else class="form-group">
<label for="ocrFile" class="form-label">Upload Image</label>
<input type="file" id="ocrFile" class="form-input" accept="image/*" @change="handleOcrFileUpload" ref="ocrFileInputRef"/>
<input type="file" id="ocrFile" class="form-input" accept="image/*" @change="handleOcrFileUpload"
ref="ocrFileInputRef" />
<p v-if="ocrError" class="form-error-text mt-1">{{ ocrError }}</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="closeOcrDialog">Cancel</button>
<button
v-if="ocrItems.length > 0"
type="button"
class="btn btn-primary ml-2"
@click="addOcrItems"
:disabled="addingOcrItems"
>
<span v-if="addingOcrItems" class="spinner-dots-sm"><span/><span/><span/></span>
<button v-if="ocrItems.length > 0" type="button" class="btn btn-primary ml-2" @click="addOcrItems"
:disabled="addingOcrItems">
<span v-if="addingOcrItems" class="spinner-dots-sm"><span /><span /><span /></span>
Add Items
</button>
</div>
</div>
</div>
<!-- Confirmation Dialog -->
<div v-if="showConfirmDialogState" class="modal-backdrop open" @click.self="cancelConfirmation">
<div class="modal-container confirm-modal" ref="confirmModalRef">
<div class="modal-header">
<h3>Confirmation</h3>
<button class="close-button" @click="cancelConfirmation" aria-label="Close"><svg class="icon">
<use xlink:href="#icon-close" />
</svg></button>
</div>
<div class="modal-body">
<svg class="icon icon-lg mb-2" style="color: var(--warning);">
<use xlink:href="#icon-alert-triangle" />
</svg>
<p>{{ confirmDialogMessage }}</p>
</div>
<div class="modal-footer">
<button class="btn btn-neutral" @click="cancelConfirmation">Cancel</button>
<button class="btn btn-primary ml-2" @click="handleConfirmedAction">Confirm</button>
</div>
</div>
</div>
<!-- Cost Summary Dialog -->
<div v-if="showCostSummaryDialog" class="modal-backdrop open" @click.self="showCostSummaryDialog = false">
<div class="modal-container" ref="costSummaryModalRef" style="min-width: 550px;">
<div class="modal-header">
<h3>List Cost Summary</h3>
<button class="close-button" @click="showCostSummaryDialog = false" aria-label="Close"><svg class="icon"><use xlink:href="#icon-close"/></svg></button>
<button class="close-button" @click="showCostSummaryDialog = false" aria-label="Close"><svg class="icon">
<use xlink:href="#icon-close" />
</svg></button>
</div>
<div class="modal-body">
<div v-if="costSummaryLoading" class="text-center">
<div class="spinner-dots"><span/><span/><span/></div><p>Loading summary...</p>
<div class="spinner-dots"><span /><span /><span /></div>
<p>Loading summary...</p>
</div>
<div v-else-if="costSummaryError" class="alert alert-error">{{ costSummaryError }}</div>
<div v-else-if="listCostSummary">
@ -210,7 +217,8 @@
<td class="text-right">{{ formatCurrency(userShare.items_added_value) }}</td>
<td class="text-right">{{ formatCurrency(userShare.amount_due) }}</td>
<td class="text-right">
<span class="item-badge" :class="parseFloat(String(userShare.balance)) >= 0 ? 'badge-settled' : 'badge-pending'">
<span class="item-badge"
:class="parseFloat(String(userShare.balance)) >= 0 ? 'badge-settled' : 'badge-pending'">
{{ formatCurrency(userShare.balance) }}
</span>
</td>
@ -227,33 +235,16 @@
</div>
</div>
<!-- Confirmation Dialog -->
<div v-if="showConfirmDialogState" class="modal-backdrop open" @click.self="cancelConfirmation">
<div class="modal-container confirm-modal" ref="confirmModalRef">
<div class="modal-header">
<h3>Confirmation</h3>
<button class="close-button" @click="cancelConfirmation" aria-label="Close"><svg class="icon"><use xlink:href="#icon-close"/></svg></button>
</div>
<div class="modal-body">
<svg class="icon icon-lg mb-2" style="color: var(--warning);"><use xlink:href="#icon-alert-triangle"/></svg>
<p>{{ confirmDialogMessage }}</p>
</div>
<div class="modal-footer">
<button class="btn btn-neutral" @click="cancelConfirmation">Cancel</button>
<button class="btn btn-primary ml-2" @click="handleConfirmedAction">Confirm</button>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
import { useRoute } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { onClickOutside, useEventListener, useFileDialog } from '@vueuse/core';
import { onClickOutside, useEventListener, useFileDialog, useNetwork } from '@vueuse/core';
import { useNotificationStore } from '@/stores/notifications';
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
interface Item {
id: number;
@ -298,13 +289,14 @@ interface ListCostSummaryData {
}
const route = useRoute();
const { isOnline } = useNetwork();
const notificationStore = useNotificationStore();
const offlineStore = useOfflineStore();
const list = ref<List | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const addingItem = ref(false);
const pollingInterval = ref<NodeJS.Timeout | null>(null);
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
const lastListUpdate = ref<string | null>(null);
const lastItemUpdate = ref<string | null>(null);
@ -318,7 +310,7 @@ const ocrLoading = ref(false);
const ocrItems = ref<{ name: string }[]>([]); // Items extracted from OCR
const addingOcrItems = ref(false);
const ocrError = ref<string | null>(null);
const ocrFileInputRef = ref<HTMLInputElement|null>(null);
const ocrFileInputRef = ref<HTMLInputElement | null>(null);
const { files: ocrFiles, reset: resetOcrFileDialog } = useFileDialog({
accept: 'image/*',
multiple: false,
@ -406,6 +398,16 @@ const stopPolling = () => {
if (pollingInterval.value) clearInterval(pollingInterval.value);
};
const isItemPendingSync = (item: Item) => {
return offlineStore.pendingActions.some(action => {
if (action.type === 'update_list_item' || action.type === 'delete_list_item') {
const payload = action.payload as { listId: string; itemId: string };
return payload.itemId === String(item.id);
}
return false;
});
};
const onAddItem = async () => {
if (!list.value || !newItem.value.name.trim()) {
notificationStore.addNotification({ message: 'Please enter an item name.', type: 'warning' });
@ -413,6 +415,35 @@ const onAddItem = async () => {
return;
}
addingItem.value = true;
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'create_list_item',
payload: {
listId: String(list.value.id),
itemData: {
name: newItem.value.name,
quantity: newItem.value.quantity?.toString()
}
}
});
// Optimistically add to UI
const optimisticItem: Item = {
id: Date.now(), // Temporary ID
name: newItem.value.name,
quantity: newItem.value.quantity,
is_complete: false,
version: 1,
updated_at: new Date().toISOString()
};
list.value.items.push(processListItems([optimisticItem])[0]);
newItem.value = { name: '' };
itemNameInputRef.value?.focus();
addingItem.value = false;
return;
}
try {
const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
@ -435,77 +466,104 @@ const updateItem = async (item: Item, newCompleteStatus: boolean) => {
const originalCompleteStatus = item.is_complete;
item.is_complete = newCompleteStatus; // Optimistic update
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'update_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id),
data: {
completed: newCompleteStatus
},
version: item.version
}
});
item.updating = false;
return;
}
try {
const payload: { is_complete: boolean; version: number; price?: number | null } = {
is_complete: item.is_complete,
version: item.version,
};
if (item.is_complete && item.priceInput !== undefined && item.priceInput !== null && String(item.priceInput).trim() !== '') {
payload.price = parseFloat(String(item.priceInput));
} else if (item.is_complete && (item.priceInput === undefined || String(item.priceInput).trim() === '')) {
// If complete and price is empty, don't send price, or send null if API expects it
payload.price = null; // Or omit, depending on API
}
const response = await apiClient.put(API_ENDPOINTS.ITEMS.BY_ID(String(item.id)), payload);
const updatedItemFromServer = processListItems([response.data as Item])[0];
const index = list.value.items.findIndex(i => i.id === item.id);
if (index !== -1) {
list.value.items[index] = { ...list.value.items[index], ...updatedItemFromServer, updating: false };
}
// If cost summary was open, refresh it
if (showCostSummaryDialog.value) await fetchListCostSummary();
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ completed: newCompleteStatus, version: item.version }
);
item.version++;
} catch (err) {
item.is_complete = originalCompleteStatus; // Revert optimistic update
item.is_complete = originalCompleteStatus; // Revert on error
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item.', type: 'error' });
} finally {
item.updating = false;
}
};
const updateItemPrice = async (item: Item) => {
if (!list.value || !item.is_complete) return; // Only update price if item is complete
if (!list.value || !item.is_complete) return;
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null;
if (item.price === newPrice) return; // No change
if (item.price === newPrice) return;
item.updating = true;
const originalPrice = item.price;
item.price = newPrice; // Optimistic
const originalPriceInput = item.priceInput;
item.price = newPrice;
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'update_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id),
data: {
price: newPrice,
completed: item.is_complete
},
version: item.version
}
});
item.updating = false;
return;
}
try {
const response = await apiClient.put(
API_ENDPOINTS.ITEMS.BY_ID(String(item.id)),
{ price: item.price, is_complete: item.is_complete, version: item.version }
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ price: newPrice, completed: item.is_complete, version: item.version }
);
const updatedItemFromServer = processListItems([response.data as Item])[0];
const index = list.value.items.findIndex(i => i.id === item.id);
if (index !== -1) {
list.value.items[index] = { ...list.value.items[index], ...updatedItemFromServer, updating: false };
}
if (showCostSummaryDialog.value) await fetchListCostSummary();
item.version++;
} catch (err) {
item.price = originalPrice; // Revert
item.priceInput = originalPrice !== null && originalPrice !== undefined ? originalPrice : '';
item.price = originalPrice;
item.priceInput = originalPriceInput;
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item price.', type: 'error' });
} finally {
item.updating = false;
}
};
const deleteItem = async (item: Item) => {
if (!list.value) return;
item.deleting = true;
if (!isOnline.value) {
// Add to offline queue
offlineStore.addAction({
type: 'delete_list_item',
payload: {
listId: String(list.value.id),
itemId: String(item.id)
}
});
// Optimistically remove from UI
list.value.items = list.value.items.filter(i => i.id !== item.id);
item.deleting = false;
return;
}
try {
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)));
list.value.items = list.value.items.filter(i => i.id !== item.id);
if (showCostSummaryDialog.value) await fetchListCostSummary();
} catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' });
} finally {
@ -581,8 +639,8 @@ const handleOcrUpload = async (file: File) => {
const response = await apiClient.post(API_ENDPOINTS.OCR.PROCESS, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
ocrItems.value = response.data.extracted_items.map((nameStr: string) => ({ name: nameStr.trim() })).filter(item => item.name);
if(ocrItems.value.length === 0) {
ocrItems.value = response.data.extracted_items.map((nameStr: string) => ({ name: nameStr.trim() })).filter((item: { name: string }) => item.name);
if (ocrItems.value.length === 0) {
ocrError.value = "No items extracted from the image.";
}
} catch (err) {
@ -656,24 +714,23 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
});
// Swipe detection (basic)
// let touchStartX = 0; // Commented out as unused
// const SWIPE_THRESHOLD = 50; // pixels // Commented out as unused
let touchStartX = 0;
const SWIPE_THRESHOLD = 50; // pixels
const handleTouchStart = (event: TouchEvent, /* item: Item */) => { // Commented out unused item
const handleTouchStart = (event: TouchEvent) => {
touchStartX = event.changedTouches[0].clientX;
// Add class for visual feedback during swipe if desired
};
const handleTouchMove = (/* event: TouchEvent, item: Item */) => { // Commented out unused event and item
const handleTouchMove = () => {
// Can be used for interactive swipe effect
};
const handleTouchEnd = (/* item: Item */) => { // Commented out unused item
const handleTouchEnd = () => {
// Not implemented: Valerie UI swipe example implies JS adds/removes 'is-swiped'
// For a simple demo, one might toggle it here based on a more complex gesture
// This would require more state per item and logic
// For now, swipe actions are not visually implemented
// item.swiped = !item.swiped; // Example of toggling. A real implementation would be more complex.
};
@ -695,16 +752,45 @@ onUnmounted(() => {
</script>
<style scoped>
.page-padding { padding: 1rem; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.mb-3 { margin-bottom: 1.5rem; }
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.ml-1 { margin-left: 0.25rem; }
.ml-2 { margin-left: 0.5rem; }
.text-right { text-align: right; }
.flex-grow { flex-grow: 1; }
.page-padding {
padding: 1rem;
}
.mb-1 {
margin-bottom: 0.5rem;
}
.mb-2 {
margin-bottom: 1rem;
}
.mb-3 {
margin-bottom: 1.5rem;
}
.mt-1 {
margin-top: 0.5rem;
}
.mt-2 {
margin-top: 1rem;
}
.ml-1 {
margin-left: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.text-right {
text-align: right;
}
.flex-grow {
flex-grow: 1;
}
.item-caption {
display: block;
@ -712,27 +798,102 @@ onUnmounted(() => {
opacity: 0.6;
margin-top: 0.25rem;
}
.text-decoration-line-through {
text-decoration: line-through;
}
.form-input-sm { /* For price input */
.form-input-sm {
/* For price input */
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
}
.cost-overview p {
margin-bottom: 0.5rem;
font-size: 1.05rem;
}
.form-error-text {
color: var(--danger);
font-size: 0.85rem;
}
.list-item.completed .item-text {
/* text-decoration: line-through; is handled by span class */
opacity: 0.7;
}
.list-item-actions {
margin-left: auto; /* Pushes actions to the right */
padding-left: 1rem; /* Space before actions */
margin-left: auto;
/* Pushes actions to the right */
padding-left: 1rem;
/* Space before actions */
}
.offline-item {
position: relative;
opacity: 0.8;
transition: opacity 0.3s ease;
}
.offline-item::after {
content: '';
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 1rem;
height: 1rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3Cpath d='M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16'/%3E%3Cpath d='M16 21h5v-5'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
animation: spin 1s linear infinite;
}
.offline-item.synced {
opacity: 1;
}
.offline-item.synced::after {
display: none;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.feature-offline-disabled {
position: relative;
cursor: not-allowed;
opacity: 0.6;
}
.feature-offline-disabled::before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 0.5rem;
background-color: var(--bg-color-tooltip, #333);
color: white;
border-radius: 0.25rem;
font-size: 0.875rem;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
z-index: 1000;
}
.feature-offline-disabled:hover::before {
opacity: 1;
visibility: visible;
}
</style>

View File

@ -2,36 +2,26 @@
<main class="flex items-center justify-center page-container">
<div class="card login-card">
<div class="card-header">
<h3>Login</h3>
<h3>mitlist</h3>
</div>
<div class="card-body">
<form @submit.prevent="onSubmit" class="form-layout">
<div class="form-group mb-2">
<label for="email" class="form-label">Email</label>
<input
type="email"
id="email"
v-model="email"
class="form-input"
required
autocomplete="email"
/>
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email" />
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
</div>
<div class="form-group mb-3">
<label for="password" class="form-label">Password</label>
<div class="input-with-icon-append">
<input
:type="isPwdVisible ? 'text' : 'password'"
id="password"
v-model="password"
class="form-input"
required
autocomplete="current-password"
/>
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn" aria-label="Toggle password visibility">
<svg class="icon icon-sm"><use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use></svg> <!-- Placeholder for visibility icons -->
<input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input"
required autocomplete="current-password" />
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn"
aria-label="Toggle password visibility">
<svg class="icon icon-sm">
<use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
</svg> <!-- Placeholder for visibility icons -->
</button>
</div>
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
@ -40,13 +30,15 @@
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
<span v-if="loading" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
<span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Login
</button>
<div class="text-center mt-2">
<router-link to="/signup" class="link-styled">Don't have an account? Sign up</router-link>
<router-link to="/auth/signup" class="link-styled">Don't have an account? Sign up</router-link>
</div>
<SocialLoginButtons />
</form>
</div>
</div>
@ -58,6 +50,7 @@ import { ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth'; // Assuming path
import { useNotificationStore } from '@/stores/notifications';
import SocialLoginButtons from '@/components/SocialLoginButtons.vue';
const router = useRouter();
const route = useRoute();
@ -112,14 +105,17 @@ const onSubmit = async () => {
<style scoped>
.page-container {
min-height: 100vh; /* dvh for dynamic viewport height */
min-height: 100vh;
/* dvh for dynamic viewport height */
min-height: 100dvh;
padding: 1rem;
}
.login-card {
width: 100%;
max-width: 400px;
}
/* form-layout, w-full, mt-2, text-center styles were removed as utility classes from Valerie UI are used directly or are available. */
.link-styled {
@ -128,15 +124,20 @@ const onSubmit = async () => {
border-bottom: 2px solid transparent;
transition: border-color var(--transition-speed) var(--transition-ease-out);
}
.link-styled:hover, .link-styled:focus {
.link-styled:hover,
.link-styled:focus {
border-bottom-color: var(--primary);
}
.form-error-text {
color: var(--danger);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.alert.form-error-text { /* For general error message */
.alert.form-error-text {
/* For general error message */
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
@ -145,18 +146,23 @@ const onSubmit = async () => {
position: relative;
display: flex;
}
.input-with-icon-append .form-input {
padding-right: 3rem; /* Space for the button */
padding-right: 3rem;
/* Space for the button */
}
.icon-append-btn {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 3rem; /* Width of the button */
width: 3rem;
/* Width of the button */
background: transparent;
border: none;
border-left: var(--border); /* Separator line */
border-left: var(--border);
/* Separator line */
cursor: pointer;
display: flex;
align-items: center;
@ -164,9 +170,16 @@ const onSubmit = async () => {
color: var(--dark);
opacity: 0.7;
}
.icon-append-btn:hover, .icon-append-btn:focus {
.icon-append-btn:hover,
.icon-append-btn:focus {
opacity: 1;
background-color: rgba(0,0,0,0.03);
background-color: rgba(0, 0, 0, 0.03);
}
.icon-append-btn .icon { margin: 0; } /* Remove default icon margin */
.icon-append-btn .icon {
margin: 0;
}
/* Remove default icon margin */
</style>

View File

@ -8,29 +8,26 @@
<form @submit.prevent="onSubmit" class="form-layout">
<div class="form-group mb-2">
<label for="name" class="form-label">Full Name</label>
<input type="text" id="name" v-model="name" class="form-input" required autocomplete="name"/>
<input type="text" id="name" v-model="name" class="form-input" required autocomplete="name" />
<p v-if="formErrors.name" class="form-error-text">{{ formErrors.name }}</p>
</div>
<div class="form-group mb-2">
<label for="email" class="form-label">Email</label>
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email"/>
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email" />
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
</div>
<div class="form-group mb-2">
<label for="password" class="form-label">Password</label>
<div class="input-with-icon-append">
<input
:type="isPwdVisible ? 'text' : 'password'"
id="password"
v-model="password"
class="form-input"
required
autocomplete="new-password"
/>
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn" aria-label="Toggle password visibility">
<svg class="icon icon-sm"><use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use></svg> <!-- Placeholder for visibility icons -->
<input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input"
required autocomplete="new-password" />
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn"
aria-label="Toggle password visibility">
<svg class="icon icon-sm">
<use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
</svg> <!-- Placeholder for visibility icons -->
</button>
</div>
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
@ -38,21 +35,15 @@
<div class="form-group mb-3">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<input
:type="isPwdVisible ? 'text' : 'password'"
id="confirmPassword"
v-model="confirmPassword"
class="form-input"
required
autocomplete="new-password"
/>
<input :type="isPwdVisible ? 'text' : 'password'" id="confirmPassword" v-model="confirmPassword"
class="form-input" required autocomplete="new-password" />
<p v-if="formErrors.confirmPassword" class="form-error-text">{{ formErrors.confirmPassword }}</p>
</div>
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
<span v-if="loading" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
<span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
Sign Up
</button>
@ -68,7 +59,7 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from 'stores/auth'; // Assuming path
import { useAuthStore } from '@/stores/auth'; // Assuming path is correct
import { useNotificationStore } from '@/stores/notifications';
const router = useRouter();
@ -143,10 +134,12 @@ const onSubmit = async () => {
min-height: 100dvh;
padding: 1rem;
}
.signup-card {
width: 100%;
max-width: 400px;
}
/* form-layout, w-full, mt-2, text-center styles were removed as utility classes from Valerie UI are used directly or are available. */
.link-styled {
@ -155,15 +148,20 @@ const onSubmit = async () => {
border-bottom: 2px solid transparent;
transition: border-color var(--transition-speed) var(--transition-ease-out);
}
.link-styled:hover, .link-styled:focus {
.link-styled:hover,
.link-styled:focus {
border-bottom-color: var(--primary);
}
.form-error-text {
color: var(--danger);
font-size: 0.85rem;
margin-top: 0.25rem;
}
.alert.form-error-text { /* For general error message */
.alert.form-error-text {
/* For general error message */
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
@ -172,9 +170,11 @@ const onSubmit = async () => {
position: relative;
display: flex;
}
.input-with-icon-append .form-input {
padding-right: 3rem;
}
.icon-append-btn {
position: absolute;
right: 0;
@ -191,9 +191,14 @@ const onSubmit = async () => {
color: var(--dark);
opacity: 0.7;
}
.icon-append-btn:hover, .icon-append-btn:focus {
.icon-append-btn:hover,
.icon-append-btn:focus {
opacity: 1;
background-color: rgba(0,0,0,0.03);
background-color: rgba(0, 0, 0, 0.03);
}
.icon-append-btn .icon {
margin: 0;
}
.icon-append-btn .icon { margin: 0; }
</style>

View File

@ -17,7 +17,7 @@ router.beforeEach(async (to, from, next) => {
// Auth guard logic
const authStore = useAuthStore();
const isAuthenticated = authStore.isAuthenticated;
const publicRoutes = ['/auth/login', '/auth/signup']; // Fixed public routes paths
const publicRoutes = ['/auth/login', '/auth/signup', '/auth/callback']; // Added callback route
const requiresAuth = !publicRoutes.includes(to.path);
if (requiresAuth && !isAuthenticated) {

View File

@ -37,6 +37,7 @@ const routes: RouteRecordRaw[] = [
children: [
{ path: 'login', name: 'Login', component: () => import('../pages/LoginPage.vue') },
{ path: 'signup', name: 'Signup', component: () => import('../pages/SignupPage.vue') },
{ path: 'callback', name: 'AuthCallback', component: () => import('../pages/AuthCallbackPage.vue') },
],
},
// {

View File

@ -9,6 +9,7 @@ const api = axios.create({
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // Enable sending cookies and authentication headers
});
// Request interceptor
@ -46,7 +47,7 @@ api.interceptors.response.use(
// Use the store's refresh mechanism if it already handles API call and token setting
// However, the interceptor is specifically for retrying requests, so direct call is fine here
// as long as it correctly updates tokens for the subsequent retry.
const response = await api.post('/api/v1/auth/refresh', {
const response = await api.post('/auth/jwt/refresh', {
refresh_token: refreshTokenValue,
});
@ -77,10 +78,8 @@ export { api, globalAxios };
import { API_VERSION, API_ENDPOINTS } from '@/config/api-config';
export const getApiUrl = (endpoint: string): string => {
// Assuming API_BASE_URL already includes http://localhost:8000
// and endpoint starts with /
// The original `getApiUrl` added /api/v1, ensure this is correct for your setup
return `${API_BASE_URL}/api/${API_VERSION}${endpoint}`;
// The API_ENDPOINTS already include the full path, so we just need to combine with base URL
return `${API_BASE_URL}${endpoint}`;
};
export const apiClient = {

View File

@ -7,7 +7,6 @@ import router from '@/router';
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
user: {
email: string;
name: string;
@ -18,7 +17,6 @@ interface AuthState {
export const useAuthStore = defineStore('auth', () => {
// State
const accessToken = ref<string | null>(localStorage.getItem('token'));
const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'));
const user = ref<AuthState['user']>(null);
// Getters
@ -26,19 +24,15 @@ export const useAuthStore = defineStore('auth', () => {
const getUser = computed(() => user.value);
// Actions
const setTokens = (tokens: { access_token: string; refresh_token: string }) => {
const setTokens = (tokens: { access_token: string }) => {
accessToken.value = tokens.access_token;
refreshToken.value = tokens.refresh_token;
localStorage.setItem('token', tokens.access_token);
localStorage.setItem('refresh_token', tokens.refresh_token);
};
const clearTokens = () => {
accessToken.value = null;
refreshToken.value = null;
user.value = null;
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
};
const setUser = (userData: AuthState['user']) => {
@ -56,21 +50,9 @@ export const useAuthStore = defineStore('auth', () => {
return response.data;
} catch (error: any) {
console.error('AuthStore: Failed to fetch current user:', error);
if (error.response?.status === 401) {
try {
await refreshAccessToken();
const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE);
setUser(response.data);
return response.data;
} catch (refreshOrRetryError) {
console.error('AuthStore: Failed to refresh token or fetch user after refresh:', refreshOrRetryError);
return null;
}
} else {
clearTokens();
return null;
}
}
};
const login = async (email: string, password: string) => {
@ -84,8 +66,8 @@ export const useAuthStore = defineStore('auth', () => {
},
});
const { access_token, refresh_token } = response.data;
setTokens({ access_token, refresh_token });
const { access_token } = response.data;
setTokens({ access_token });
await fetchCurrentUser();
return response.data;
};
@ -95,29 +77,6 @@ export const useAuthStore = defineStore('auth', () => {
return response.data;
};
const refreshAccessToken = async () => {
if (!refreshToken.value) {
clearTokens();
await router.push('/auth/login');
throw new Error('No refresh token available');
}
try {
const response = await apiClient.post(API_ENDPOINTS.AUTH.REFRESH_TOKEN, {
refresh_token: refreshToken.value,
});
const { access_token, refresh_token: newRefreshToken } = response.data;
setTokens({ access_token, refresh_token: newRefreshToken });
return response.data;
} catch (error) {
console.error('AuthStore: Refresh token failed:', error);
clearTokens();
await router.push('/auth/login');
throw error;
}
};
const logout = async () => {
clearTokens();
await router.push('/auth/login');
@ -125,7 +84,6 @@ export const useAuthStore = defineStore('auth', () => {
return {
accessToken,
refreshToken,
user,
isAuthenticated,
getUser,
@ -135,7 +93,6 @@ export const useAuthStore = defineStore('auth', () => {
fetchCurrentUser,
login,
signup,
refreshAccessToken,
logout,
};
});

View File

@ -4,25 +4,58 @@ import { ref, computed } from 'vue';
// import { LocalStorage } from 'quasar'; // REMOVE
import { useStorage } from '@vueuse/core'; // VueUse alternative
import { useNotificationStore } from '@/stores/notifications'; // Your custom notification store
import { apiClient, API_ENDPOINTS } from '@/services/api'; // Import apiClient and API_ENDPOINTS
// ... (interfaces remain the same)
export type CreateListPayload = { name: string; description?: string; /* other list properties */ };
export type UpdateListPayload = { listId: string; data: Partial<CreateListPayload>; version?: number; };
export type DeleteListPayload = { listId: string; };
export type CreateListItemPayload = { listId: string; itemData: { name: string; quantity?: number | string; completed?: boolean; price?: number | null; /* other item properties */ }; };
export type UpdateListItemPayload = { listId: string; itemId: string; data: Partial<CreateListItemPayload['itemData']>; version?: number; };
export type DeleteListItemPayload = { listId: string; itemId: string; };
export type OfflineAction = {
id: string;
timestamp: number;
type:
| 'create_list'
| 'update_list'
| 'delete_list'
| 'create_list_item'
| 'update_list_item'
| 'delete_list_item';
payload:
| CreateListPayload
| UpdateListPayload
| DeleteListPayload
| CreateListItemPayload
| UpdateListItemPayload
| DeleteListItemPayload;
};
export type ConflictData = {
localVersion: { data: Record<string, unknown>; timestamp: number; };
serverVersion: { data: Record<string, unknown>; timestamp: number; };
action: OfflineAction;
};
interface ServerListData {
id: string;
version: number;
name: string;
[key: string]: unknown;
}
interface ServerItemData {
id: string;
version: number;
name: string;
[key: string]: unknown;
}
export const useOfflineStore = defineStore('offline', () => {
// const $q = useQuasar(); // REMOVE
const notificationStore = useNotificationStore();
const isOnline = ref(navigator.onLine);
interface OfflineAction {
id: string;
timestamp: number;
type: string;
// Add other necessary fields
}
interface ConflictData {
localVersion: unknown; // Replace with proper type
serverVersion: unknown; // Replace with proper type
action: OfflineAction;
}
// Use useStorage for reactive localStorage
const pendingActions = useStorage<OfflineAction[]>('offline-actions', []);
@ -36,13 +69,12 @@ export const useOfflineStore = defineStore('offline', () => {
// saveToStorage is also handled by useStorage automatically saving on change
const addAction = (action: Omit<OfflineAction, 'id' | 'timestamp'>) => {
const newAction: OfflineAction = {
const newAction = {
...action,
id: crypto.randomUUID(),
timestamp: Date.now(),
};
} as OfflineAction;
pendingActions.value.push(newAction);
// useStorage handles saving
};
const processQueue = async () => {
@ -52,52 +84,132 @@ export const useOfflineStore = defineStore('offline', () => {
for (const action of actionsToProcess) {
try {
await processAction(action); // processAction needs to use your actual API client
await processAction(action);
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
} catch (error) {
if (error instanceof Error && 'response' in error && typeof error.response === 'object' && error.response && 'status' in error.response && error.response.status === 409) {
} catch (error: any) { // Catch error as any to check for our custom flag
if (error && error.isConflict && error.serverVersionData) {
notificationStore.addNotification({
type: 'warning',
message: 'Item was modified by someone else. Please review.',
// actions: [ ... ] // Custom actions for notifications would be more complex
message: `Conflict detected for action ${action.type}. Please review.`,
});
// Here you would trigger the conflict resolution dialog
// For example, find the item and its server version, then:
// currentConflict.value = { localVersion: ..., serverVersion: ..., action };
// showConflictDialog.value = true;
// The loop should probably pause or handle this conflict before continuing
console.warn('Conflict detected for action:', action.id, error);
// Break or decide how to handle queue processing on conflict
break;
let localData: Record<string, unknown>;
// Extract local data based on action type
if (action.type === 'update_list' || action.type === 'update_list_item') {
localData = (action.payload as UpdateListPayload | UpdateListItemPayload).data;
} else if (action.type === 'create_list' || action.type === 'create_list_item') {
localData = action.payload as CreateListPayload | CreateListItemPayload;
} else {
console.error('Failed to process offline action:', action.id, error);
notificationStore.addNotification({
type: 'error',
message: `Failed to sync action: ${action.type}`,
});
console.error("Conflict detected for unhandled action type for data extraction:", action.type);
localData = {}; // Fallback
}
currentConflict.value = {
localVersion: {
data: localData,
timestamp: action.timestamp,
},
serverVersion: {
data: error.serverVersionData, // Assumes API 409 response body is the server item
timestamp: error.serverVersionData.updated_at ? new Date(error.serverVersionData.updated_at).getTime() : action.timestamp + 1, // Prefer server updated_at
},
action: action,
};
showConflictDialog.value = true;
console.warn('Conflict detected by processQueue for action:', action.id, error);
// Stop processing queue on first conflict to await resolution
isProcessingQueue.value = false; // Allow queue to be re-triggered after resolution
return; // Stop processing further actions
} else {
console.error('processQueue: Action failed, remains in queue:', action.id, error);
}
}
}
isProcessingQueue.value = false;
};
// processAction needs to be implemented with your actual API calls
const processAction = async (action: OfflineAction) => {
console.log('Processing action (TODO: Implement API call):', action);
// Example:
// import { apiClient } from '@/services/api';
// import { API_ENDPOINTS } from '@/config/api-config';
// switch (action.type) {
// case 'add':
// // Assuming action.data is { listId: string, itemData: { name: string, quantity?: string } }
// // await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(action.data.listId), action.data.itemData);
// break;
// // ... other cases
// }
// Simulate async work
return new Promise(resolve => setTimeout(resolve, 500));
};
try {
let request: Request;
let endpoint: string;
let method: 'POST' | 'PUT' | 'DELETE' = 'POST';
let body: any;
switch (action.type) {
case 'create_list':
endpoint = API_ENDPOINTS.LISTS.BASE;
body = action.payload;
break;
case 'update_list': {
const { listId, data } = action.payload as UpdateListPayload;
endpoint = API_ENDPOINTS.LISTS.BY_ID(listId);
method = 'PUT';
body = data;
break;
}
case 'delete_list': {
const { listId } = action.payload as DeleteListPayload;
endpoint = API_ENDPOINTS.LISTS.BY_ID(listId);
method = 'DELETE';
break;
}
case 'create_list_item': {
const { listId, itemData } = action.payload as CreateListItemPayload;
endpoint = API_ENDPOINTS.LISTS.ITEMS(listId);
body = itemData;
break;
}
case 'update_list_item': {
const { listId, itemId, data } = action.payload as UpdateListItemPayload;
endpoint = API_ENDPOINTS.LISTS.ITEM(listId, itemId);
method = 'PUT';
body = data;
break;
}
case 'delete_list_item': {
const { listId, itemId } = action.payload as DeleteListItemPayload;
endpoint = API_ENDPOINTS.LISTS.ITEM(listId, itemId);
method = 'DELETE';
break;
}
default:
throw new Error(`Unknown action type: ${action.type}`);
}
// Create the request with the action metadata
request = new Request(endpoint, {
method,
headers: {
'Content-Type': 'application/json',
'X-Offline-Action': action.id,
},
body: method !== 'DELETE' ? JSON.stringify(body) : undefined,
});
// Use fetch with the request
const response = await fetch(request);
if (!response.ok) {
if (response.status === 409) {
const error = new Error('Conflict detected') as any;
error.isConflict = true;
error.serverVersionData = await response.json();
throw error;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
// If successful, remove from pending actions
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
return await response.json();
} catch (error: any) {
if (error.isConflict) {
throw error;
}
// For other errors, let Workbox handle the retry
throw error;
}
};
const setupNetworkListeners = () => {
window.addEventListener('online', () => {
@ -114,28 +226,148 @@ export const useOfflineStore = defineStore('offline', () => {
const hasPendingActions = computed(() => pendingActions.value.length > 0);
const pendingActionCount = computed(() => pendingActions.value.length);
const handleConflictResolution = (resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }) => {
console.log('Conflict resolution chosen:', resolution);
// TODO: Implement logic to apply the chosen resolution
// This might involve making another API call with the resolved data
// or updating local state and then trying to sync again.
// After resolving, remove the action from pending or mark as resolved.
// For now, just remove it as an example:
pendingActions.value = pendingActions.value.filter(a => a.id !== resolution.action.id);
const handleConflictResolution = async (resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }) => {
if (!resolution.action || !currentConflict.value) {
console.error("handleConflictResolution called without an action or active conflict.");
showConflictDialog.value = false;
currentConflict.value = null;
processQueue().catch(err => console.error("Error processing queue after conflict resolution:", err)); // Try processing queue again
return;
}
const { action, version, mergedData } = resolution;
const serverVersionNumber = (currentConflict.value.serverVersion.data as any)?.version;
try {
let success = false;
if (version === 'local') {
let dataToPush: any;
let endpoint: string;
let method: 'post' | 'put' = 'put';
if (action.type === 'update_list') {
const payload = action.payload as UpdateListPayload;
dataToPush = { ...payload.data, version: serverVersionNumber };
endpoint = API_ENDPOINTS.LISTS.BY_ID(payload.listId);
} else if (action.type === 'update_list_item') {
const payload = action.payload as UpdateListItemPayload;
dataToPush = { ...payload.data, version: serverVersionNumber };
endpoint = API_ENDPOINTS.LISTS.ITEM(payload.listId, payload.itemId);
} else if (action.type === 'create_list') {
const serverData = currentConflict.value.serverVersion.data as ServerListData | null;
if (serverData?.id) {
// Server returned existing list, update it instead
dataToPush = { ...action.payload, version: serverData.version };
endpoint = API_ENDPOINTS.LISTS.BY_ID(serverData.id);
} else {
// True conflict, need to modify the data
dataToPush = {
...action.payload,
name: `${(action.payload as CreateListPayload).name} (${new Date().toLocaleString()})`
};
endpoint = API_ENDPOINTS.LISTS.BASE;
method = 'post';
}
} else if (action.type === 'create_list_item') {
const serverData = currentConflict.value.serverVersion.data as ServerItemData | null;
if (serverData?.id) {
// Server returned existing item, update it instead
dataToPush = { ...action.payload, version: serverData.version };
endpoint = API_ENDPOINTS.LISTS.ITEM(
(action.payload as CreateListItemPayload).listId,
serverData.id
);
} else {
// True conflict, need to modify the data
dataToPush = {
...action.payload,
name: `${(action.payload as CreateListItemPayload).itemData.name} (${new Date().toLocaleString()})`
};
endpoint = API_ENDPOINTS.LISTS.ITEMS((action.payload as CreateListItemPayload).listId);
method = 'post';
}
} else {
console.error("Unsupported action type for 'keep local' resolution:", action.type);
throw new Error("Unsupported action for 'keep local'");
}
if (method === 'put') {
await apiClient.put(endpoint, dataToPush);
} else {
await apiClient.post(endpoint, dataToPush);
}
success = true;
notificationStore.addNotification({ type: 'success', message: 'Your version was saved to the server.' });
} else if (version === 'server') {
success = true;
notificationStore.addNotification({ type: 'info', message: 'Local changes discarded; server version kept.' });
} else if (version === 'merge' && mergedData) {
let dataWithVersion: any;
let endpoint: string;
if (action.type === 'update_list') {
const payload = action.payload as UpdateListPayload;
dataWithVersion = { ...mergedData, version: serverVersionNumber };
endpoint = API_ENDPOINTS.LISTS.BY_ID(payload.listId);
} else if (action.type === 'update_list_item') {
const payload = action.payload as UpdateListItemPayload;
dataWithVersion = { ...mergedData, version: serverVersionNumber };
endpoint = API_ENDPOINTS.LISTS.ITEM(payload.listId, payload.itemId);
} else if (action.type === 'create_list' || action.type === 'create_list_item') {
// For create actions, merging means updating the existing item
const serverData = currentConflict.value.serverVersion.data as (ServerListData | ServerItemData) | null;
if (!serverData?.id) {
throw new Error("Cannot merge create action: server data is missing or invalid");
}
if (action.type === 'create_list') {
dataWithVersion = { ...mergedData, version: serverData.version };
endpoint = API_ENDPOINTS.LISTS.BY_ID(serverData.id);
} else {
dataWithVersion = { ...mergedData, version: serverData.version };
endpoint = API_ENDPOINTS.LISTS.ITEM(
(action.payload as CreateListItemPayload).listId,
serverData.id
);
}
} else {
console.error("Merge resolution for unsupported action type:", action.type);
throw new Error("Merge for this action type is not supported");
}
await apiClient.put(endpoint, dataWithVersion);
success = true;
notificationStore.addNotification({ type: 'success', message: 'Merged version saved to the server.' });
}
if (success) {
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
}
} catch (error) {
console.error('Error during conflict resolution API call:', error);
notificationStore.addNotification({
type: 'error',
message: `Failed to resolve conflict for ${action.type}. Please try again.`,
});
} finally {
showConflictDialog.value = false;
currentConflict.value = null;
processQueue().catch(err => console.error("Error processing queue after conflict resolution:", err));
}
};
return {
isOnline,
pendingActions,
hasPendingActions,
pendingActionCount,
isProcessingQueue,
showConflictDialog,
currentConflict,
addAction,
processAction,
processQueue,
handleConflictResolution,
hasPendingActions,
pendingActionCount,
};
});

View File

@ -5,7 +5,10 @@
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope &
typeof globalThis & { skipWaiting: () => Promise<void> };
typeof globalThis & {
skipWaiting: () => Promise<void>;
__WB_MANIFEST: Array<{ url: string; revision: string | null }>;
};
import { clientsClaim } from 'workbox-core';
import {
@ -17,16 +20,32 @@ import { registerRoute, NavigationRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import type { WorkboxPlugin } from 'workbox-core/types';
self.skipWaiting().catch((error) => {
console.error('Error during service worker activation:', error);
// Create a background sync plugin instance
const bgSyncPlugin = new BackgroundSyncPlugin('offline-actions-queue', {
maxRetentionTime: 24 * 60, // Retry for max of 24 Hours (specified in minutes)
});
clientsClaim();
// Initialize service worker
const initializeSW = async () => {
try {
await self.skipWaiting();
clientsClaim();
console.log('Service Worker initialized successfully');
} catch (error) {
console.error('Error during service worker initialization:', error);
}
};
// Use with precache injection
// vite-plugin-pwa will populate self.__WB_MANIFEST
precacheAndRoute(self.__WB_MANIFEST || []); // Provide a fallback empty array
if (self.__WB_MANIFEST) {
precacheAndRoute(self.__WB_MANIFEST);
} else {
console.warn('No manifest found for precaching');
}
cleanupOutdatedCaches();
@ -51,7 +70,7 @@ registerRoute(
})
);
// Cache API calls with Network First strategy
// Cache API calls with Network First strategy and Background Sync for failed requests
registerRoute(
({ url }) => url.pathname.startsWith('/api/'), // Make sure this matches your actual API path structure
new NetworkFirst({
@ -64,6 +83,7 @@ registerRoute(
maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60, // 24 hours
}) as WorkboxPlugin,
bgSyncPlugin, // Add background sync plugin for failed requests
],
})
);
@ -81,3 +101,6 @@ if (import.meta.env.MODE !== 'ssr' || import.meta.env.PROD) {
}),
);
}
// Initialize the service worker
initializeSW();

View File

@ -7,12 +7,15 @@ import path from 'node:path';
const pwaOptions: Partial<VitePWAOptions> = {
registerType: 'autoUpdate',
strategies: 'injectManifest', // Crucial for custom service worker
srcDir: 'src', // Directory where sw.ts is located
filename: 'sw.ts', // Your custom service worker filename
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
devOptions: {
enabled: true, // Enable PWA in development
enabled: true,
type: 'module',
navigateFallback: 'index.html',
suppressWarnings: true,
swSrc: 'src/sw.ts',
},
manifest: {
name: 'mitlist',
@ -31,8 +34,20 @@ const pwaOptions: Partial<VitePWAOptions> = {
],
},
injectManifest: {
// Options for workbox.injectManifest
// Ensure your custom service worker (sw.ts) correctly handles __WB_MANIFEST
globPatterns: [
'**/*.{js,css,html,ico,png,svg,woff2}',
'offline.html',
],
globIgnores: [
'**/node_modules/**',
'**/dist/**',
'sw.js',
],
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB
},
workbox: {
cleanupOutdatedCaches: true,
sourcemap: true,
},
};
@ -42,8 +57,8 @@ export default defineConfig({
VitePWA(pwaOptions),
VueI18nPlugin({
include: [path.resolve(path.dirname(fileURLToPath(import.meta.url)), './src/i18n/**')],
strictMessage: false, // To avoid warnings for missing Quasar translations initially
runtimeOnly: false, // If you use <i18n> component or complex messages
strictMessage: false,
runtimeOnly: false,
}),
],
resolve: {
@ -51,9 +66,8 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
// Define env variables similar to Quasar's process.env
define: {
'process.env.PWA_FALLBACK_HTML': JSON.stringify('/index.html'), // Adjust if you have a specific offline.html
'process.env.PWA_FALLBACK_HTML': JSON.stringify('/index.html'),
'process.env.PWA_SERVICE_WORKER_REGEX': JSON.stringify(/^(sw|workbox)-.*\.js$/),
'process.env.MODE': JSON.stringify(process.env.NODE_ENV),
'process.env.PROD': JSON.stringify(process.env.NODE_ENV === 'production'),