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 fastapi import APIRouter
from app.api.v1.endpoints import health 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 groups
from app.api.v1.endpoints import invites from app.api.v1.endpoints import invites
from app.api.v1.endpoints import lists 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 = APIRouter()
api_router_v1.include_router(health.router) 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(groups.router, prefix="/groups", tags=["Groups"])
api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"]) api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"])
api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"]) 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 decimal import Decimal, ROUND_HALF_UP
from app.database import get_db 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 ( from app.models import (
User as UserModel, User as UserModel,
Group as GroupModel, Group as GroupModel,
@ -41,7 +41,7 @@ router = APIRouter()
async def get_list_cost_summary( async def get_list_cost_summary(
list_id: int, list_id: int,
db: AsyncSession = Depends(get_db), 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, 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( async def get_group_balance_summary(
group_id: int, group_id: int,
db: AsyncSession = Depends(get_db), 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. 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 typing import List as PyList, Optional, Sequence
from app.database import get_db 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.models import User as UserModel, Group as GroupModel, List as ListModel, UserGroup as UserGroupModel, UserRoleEnum
from app.schemas.expense import ( from app.schemas.expense import (
ExpenseCreate, ExpensePublic, ExpenseCreate, ExpensePublic,
@ -47,7 +47,7 @@ async def check_list_access_for_financials(db: AsyncSession, list_id: int, user_
async def create_new_expense( async def create_new_expense(
expense_in: ExpenseCreate, expense_in: ExpenseCreate,
db: AsyncSession = Depends(get_db), 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}") logger.info(f"User {current_user.email} creating expense: {expense_in.description}")
effective_group_id = expense_in.group_id effective_group_id = expense_in.group_id
@ -110,7 +110,7 @@ async def create_new_expense(
async def get_expense( async def get_expense(
expense_id: int, expense_id: int,
db: AsyncSession = Depends(get_db), 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}") 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) 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), skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=200), limit: int = Query(100, ge=1, le=200),
db: AsyncSession = Depends(get_db), 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}") 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) 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), skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=200), limit: int = Query(100, ge=1, le=200),
db: AsyncSession = Depends(get_db), 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}") 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") 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_id: int,
expense_in: ExpenseUpdate, expense_in: ExpenseUpdate,
db: AsyncSession = Depends(get_db), 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). Updates an existing expense (description, currency, expense_date only).
@ -210,7 +210,7 @@ async def delete_expense_record(
expense_id: int, expense_id: int,
expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"), expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"),
db: AsyncSession = Depends(get_db), 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. Deletes an expense and its associated splits.
@ -274,7 +274,7 @@ async def delete_expense_record(
async def create_new_settlement( async def create_new_settlement(
settlement_in: SettlementCreate, settlement_in: SettlementCreate,
db: AsyncSession = Depends(get_db), 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}") 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") 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( async def get_settlement(
settlement_id: int, settlement_id: int,
db: AsyncSession = Depends(get_db), 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}") 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) 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), skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=200), limit: int = Query(100, ge=1, le=200),
db: AsyncSession = Depends(get_db), 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}") 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") 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_id: int,
settlement_in: SettlementUpdate, settlement_in: SettlementUpdate,
db: AsyncSession = Depends(get_db), 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). Updates an existing settlement (description, settlement_date only).
@ -388,7 +388,7 @@ async def delete_settlement_record(
settlement_id: int, settlement_id: int,
expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"), expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user), current_user: UserModel = Depends(current_active_user),
): ):
""" """
Deletes a settlement. Deletes a settlement.
@ -436,7 +436,4 @@ async def delete_settlement_record(
logger.error(f"Unexpected error deleting settlement {settlement_id}: {str(e)}", exc_info=True) logger.error(f"Unexpected error deleting settlement {settlement_id}: {str(e)}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.")
return Response(status_code=status.HTTP_204_NO_CONTENT) 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 sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db 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.models import User as UserModel, UserRoleEnum # Import model and enum
from app.schemas.group import GroupCreate, GroupPublic from app.schemas.group import GroupCreate, GroupPublic
from app.schemas.invite import InviteCodePublic from app.schemas.invite import InviteCodePublic
@ -37,7 +37,7 @@ router = APIRouter()
async def create_group( async def create_group(
group_in: GroupCreate, group_in: GroupCreate,
db: AsyncSession = Depends(get_db), 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.""" """Creates a new group, adding the creator as the owner."""
logger.info(f"User {current_user.email} creating group: {group_in.name}") 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( async def read_user_groups(
db: AsyncSession = Depends(get_db), 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.""" """Retrieves all groups the current user is a member of."""
logger.info(f"Fetching groups for user: {current_user.email}") logger.info(f"Fetching groups for user: {current_user.email}")
@ -72,7 +72,7 @@ async def read_user_groups(
async def read_group( async def read_group(
group_id: int, group_id: int,
db: AsyncSession = Depends(get_db), 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.""" """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}") 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( async def create_group_invite(
group_id: int, group_id: int,
db: AsyncSession = Depends(get_db), 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).""" """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}") 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( async def leave_group(
group_id: int, group_id: int,
db: AsyncSession = Depends(get_db), 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.""" """Removes the current user from the specified group."""
logger.info(f"User {current_user.email} attempting to leave group {group_id}") 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, group_id: int,
user_id_to_remove: int, user_id_to_remove: int,
db: AsyncSession = Depends(get_db), 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.""" """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}") 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( async def read_group_lists(
group_id: int, group_id: int,
db: AsyncSession = Depends(get_db), 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.""" """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}") 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.ext.asyncio import AsyncSession
from sqlalchemy.sql import text 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.schemas.health import HealthStatus
from app.core.exceptions import DatabaseConnectionError 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.", description="Checks the operational status of the API and its connection to the database.",
tags=["Health"] 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. 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 sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db 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.models import User as UserModel, UserRoleEnum
from app.schemas.invite import InviteAccept from app.schemas.invite import InviteAccept
from app.schemas.message import Message from app.schemas.message import Message
@ -31,7 +31,7 @@ router = APIRouter()
async def accept_invite( async def accept_invite(
invite_in: InviteAccept, invite_in: InviteAccept,
db: AsyncSession = Depends(get_db), 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.""" """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}") 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 sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.api.dependencies import get_current_user from app.auth import current_active_user
# --- Import Models Correctly --- # --- Import Models Correctly ---
from app.models import User as UserModel from app.models import User as UserModel
from app.models import Item as ItemModel # <-- IMPORT Item and alias it 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( async def get_item_and_verify_access(
item_id: int, item_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user) current_user: UserModel = Depends(current_active_user)
) -> ItemModel: ) -> ItemModel:
"""Dependency to get an item and verify the user has access to its list.""" """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) 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, list_id: int,
item_in: ItemCreate, item_in: ItemCreate,
db: AsyncSession = Depends(get_db), 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.""" """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 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( async def read_list_items(
list_id: int, list_id: int,
db: AsyncSession = Depends(get_db), 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' # 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.""" """Retrieves all items for a specific list if the user has access."""
@ -112,7 +112,7 @@ async def update_item(
item_in: ItemUpdate, item_in: ItemUpdate,
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
db: AsyncSession = Depends(get_db), 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). 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."), 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 item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
db: AsyncSession = Depends(get_db), 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. 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 sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db 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.models import User as UserModel
from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail
from app.schemas.message import Message # For simple responses from app.schemas.message import Message # For simple responses
@ -18,7 +18,8 @@ from app.core.exceptions import (
ListNotFoundError, ListNotFoundError,
ListPermissionError, ListPermissionError,
ListStatusNotFoundError, ListStatusNotFoundError,
ConflictError # Added ConflictError ConflictError, # Added ConflictError
DatabaseIntegrityError # Added DatabaseIntegrityError
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -29,17 +30,24 @@ router = APIRouter()
response_model=ListPublic, # Return basic list info on creation response_model=ListPublic, # Return basic list info on creation
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
summary="Create New List", 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( async def create_list(
list_in: ListCreate, list_in: ListCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user), current_user: UserModel = Depends(current_active_user),
): ):
""" """
Creates a new shopping list. Creates a new shopping list.
- If `group_id` is provided, the user must be a member of that group. - 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 `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}") logger.info(f"User {current_user.email} creating list: {list_in.name}")
group_id = list_in.group_id 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.") 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") raise GroupMembershipError(group_id, "create lists")
created_list = await crud_list.create_list(db=db, list_in=list_in, creator_id=current_user.id) try:
logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.") created_list = await crud_list.create_list(db=db, list_in=list_in, creator_id=current_user.id)
return created_list 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( @router.get(
@ -64,7 +92,7 @@ async def create_list(
) )
async def read_lists( async def read_lists(
db: AsyncSession = Depends(get_db), 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 # Add pagination parameters later if needed: skip: int = 0, limit: int = 100
): ):
""" """
@ -86,7 +114,7 @@ async def read_lists(
async def read_list( async def read_list(
list_id: int, list_id: int,
db: AsyncSession = Depends(get_db), 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, Retrieves details for a specific list, including its items,
@ -111,7 +139,7 @@ async def update_list(
list_id: int, list_id: int,
list_in: ListUpdate, list_in: ListUpdate,
db: AsyncSession = Depends(get_db), 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). Updates a list's details (name, description, is_complete).
@ -149,7 +177,7 @@ async def delete_list(
list_id: int, list_id: int,
expected_version: Optional[int] = Query(None, description="The expected version of the list to delete for optimistic locking."), expected_version: Optional[int] = Query(None, description="The expected version of the list to delete for optimistic locking."),
db: AsyncSession = Depends(get_db), 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. 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( async def read_list_status(
list_id: int, list_id: int,
db: AsyncSession = Depends(get_db), 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. 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 fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status
from google.api_core import exceptions as google_exceptions 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.models import User as UserModel
from app.schemas.ocr import OcrExtractResponse from app.schemas.ocr import OcrExtractResponse
from app.core.gemini import extract_items_from_image_gemini, gemini_initialization_error, GeminiOCRService from app.core.gemini import extract_items_from_image_gemini, gemini_initialization_error, GeminiOCRService
@ -30,7 +30,7 @@ ocr_service = GeminiOCRService()
tags=["OCR"] tags=["OCR"]
) )
async def ocr_extract_items( 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."), 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 from dotenv import load_dotenv
import logging import logging
import secrets import secrets
from typing import List
load_dotenv() load_dotenv()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -11,12 +12,12 @@ logger = logging.getLogger(__name__)
class Settings(BaseSettings): class Settings(BaseSettings):
DATABASE_URL: str | None = None DATABASE_URL: str | None = None
GEMINI_API_KEY: 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 SECRET_KEY: str # Must be set via environment variable
ALGORITHM: str = "HS256" # ALGORITHM: str = "HS256" # Handled by FastAPI-Users strategy
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # Default token lifetime: 30 minutes # ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # This specific line is commented, the one under Session Settings is used.
REFRESH_TOKEN_EXPIRE_MINUTES: int = 10080 # Default refresh token lifetime: 7 days
# --- OCR Settings --- # --- OCR Settings ---
MAX_FILE_SIZE_MB: int = 10 # Maximum allowed file size for OCR processing 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_DOCS_URL: str = "/api/docs"
API_REDOC_URL: str = "/api/redoc" API_REDOC_URL: str = "/api/redoc"
CORS_ORIGINS: list[str] = [ CORS_ORIGINS: list[str] = [
"http://localhost:5173", "http://localhost:5173", # Frontend dev server
"http://localhost:8000", "http://localhost:5174", # Alternative Vite port
# Add your deployed frontend URL here later "http://localhost:8000", # Backend server
# "https://your-frontend-domain.com", "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 Metadata ---
API_TITLE: str = "Shared Lists API" API_TITLE: str = "Shared Lists API"
@ -78,15 +82,6 @@ Organic Bananas
HEALTH_STATUS_OK: str = "ok" HEALTH_STATUS_OK: str = "ok"
HEALTH_STATUS_ERROR: str = "error" 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 Status Messages ---
HTTP_400_DETAIL: str = "Bad Request" HTTP_400_DETAIL: str = "Bad Request"
HTTP_401_DETAIL: str = "Unauthorized" HTTP_401_DETAIL: str = "Unauthorized"
@ -103,6 +98,20 @@ Organic Bananas
DB_TRANSACTION_ERROR: str = "Database transaction error" DB_TRANSACTION_ERROR: str = "Database transaction error"
DB_QUERY_ERROR: str = "Database query 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: class Config:
env_file = ".env" env_file = ".env"
env_file_encoding = 'utf-8' env_file_encoding = 'utf-8'

View File

@ -45,120 +45,14 @@ def hash_password(password: str) -> str:
# --- JSON Web Tokens (JWT) --- # --- JSON Web Tokens (JWT) ---
# FastAPI-Users now handles all tokenization.
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
# You might add a function here later to extract the 'sub' (subject/user id) # You might add a function here later to extract the 'sub' (subject/user id)
# specifically, often used in dependency injection for authentication. # specifically, often used in dependency injection for authentication.
# def get_subject_from_token(token: str) -> Optional[str]: # 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: # if payload:
# return payload.get("sub") # return payload.get("sub")
# return None # return None

View File

@ -206,4 +206,46 @@ async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus:
except OperationalError as e: except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}") raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e: except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to get list status: {str(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

@ -162,7 +162,4 @@ async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, ex
except Exception as e: except Exception as e:
await db.rollback() await db.rollback()
raise InvalidOperationError(f"Failed to delete settlement: {str(e)}") raise InvalidOperationError(f"Failed to delete settlement: {str(e)}")
return None 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() Base = declarative_base()
# Dependency to get DB session in path operations # 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. Dependency function that yields an AsyncSession.
Ensures the session is closed after the request. Ensures the session is closed after the request.
@ -44,4 +44,7 @@ async def get_db() -> AsyncSession: # type: ignore
await session.rollback() await session.rollback()
raise raise
finally: finally:
await session.close() # Not strictly necessary with async context manager, but explicit 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 import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware 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.api.api_router import api_router
from app.config import settings from app.config import settings
from app.core.api_config import API_METADATA, API_TAGS from app.core.api_config import API_METADATA, API_TAGS
# Import database and models if needed for startup/shutdown events later from app.auth import fastapi_users, auth_backend
# from . import database, models 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 Setup ---
logging.basicConfig( logging.basicConfig(
@ -23,30 +42,56 @@ app = FastAPI(
openapi_tags=API_TAGS openapi_tags=API_TAGS
) )
# --- CORS Middleware --- # Add session middleware for OAuth
# Define allowed origins. Be specific in production! app.add_middleware(
# Use ["*"] for wide open access during early development if needed, SessionMiddleware,
# but restrict it as soon as possible. secret_key=settings.SESSION_SECRET_KEY
# 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",
]
# --- CORS Middleware ---
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.CORS_ORIGINS, allow_origins=settings.CORS_ORIGINS,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
expose_headers=["*"]
) )
# --- End CORS Middleware --- # --- End CORS Middleware ---
# --- Include API Routers --- # --- 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) app.include_router(api_router, prefix=settings.API_PREFIX)
# --- End Include API Routers --- # --- End Include API Routers ---
@ -59,23 +104,23 @@ async def read_root():
Useful for basic reachability checks. Useful for basic reachability checks.
""" """
logger.info("Root endpoint '/' accessed.") logger.info("Root endpoint '/' accessed.")
return {"message": settings.ROOT_MESSAGE} return {"message": "Welcome to the API"}
# --- End Root Endpoint --- # --- End Root Endpoint ---
# --- Application Startup/Shutdown Events (Optional) --- # --- Application Startup/Shutdown Events (Optional) ---
# @app.on_event("startup") @app.on_event("startup")
# async def startup_event(): async def startup_event():
# logger.info("Application startup: Connecting to database...") logger.info("Application startup: Connecting to database...")
# # You might perform initial checks or warm-up here # You might perform initial checks or warm-up here
# # await database.engine.connect() # Example check (get_db handles sessions per request) # await database.engine.connect() # Example check (get_db handles sessions per request)
# logger.info("Application startup complete.") logger.info("Application startup complete.")
# @app.on_event("shutdown") @app.on_event("shutdown")
# async def shutdown_event(): async def shutdown_event():
# logger.info("Application shutdown: Disconnecting from database...") logger.info("Application shutdown: Disconnecting from database...")
# # await database.engine.dispose() # Close connection pool # await database.engine.dispose() # Close connection pool
# logger.info("Application shutdown complete.") logger.info("Application shutdown complete.")
# --- End Events --- # --- End Events ---

View File

@ -19,7 +19,8 @@ from sqlalchemy import (
func, func,
text as sa_text, text as sa_text,
Text, # <-- Add Text for description Text, # <-- Add Text for description
Numeric # <-- Add Numeric for price Numeric, # <-- Add Numeric for price
CheckConstraint
) )
from sqlalchemy.orm import relationship, backref from sqlalchemy.orm import relationship, backref
@ -44,8 +45,11 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False) email = Column(String, unique=True, index=True, nullable=False)
password_hash = Column(String, nullable=False) hashed_password = Column(String, nullable=False)
name = Column(String, index=True, nullable=True) 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) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships --- # --- Relationships ---
@ -184,15 +188,15 @@ class Expense(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
description = Column(String, nullable=False) description = Column(String, nullable=False)
total_amount = Column(Numeric(10, 2), 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) expense_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
split_type = Column(SAEnum(SplitTypeEnum, name="splittypeenum", create_type=True), nullable=False) split_type = Column(SAEnum(SplitTypeEnum, name="splittypeenum", create_type=True), nullable=False)
# Foreign Keys # Foreign Keys
list_id = Column(Integer, ForeignKey("lists.id"), nullable=True) list_id = Column(Integer, ForeignKey("lists.id"), nullable=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) # If not list-specific but group-specific group_id = Column(Integer, ForeignKey("groups.id"), nullable=True, index=True)
item_id = Column(Integer, ForeignKey("items.id"), nullable=True) # If the expense is for a specific item item_id = Column(Integer, ForeignKey("items.id"), nullable=True)
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) 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) 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) 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") splits = relationship("ExpenseSplit", back_populates="expense", cascade="all, delete-orphan")
__table_args__ = ( __table_args__ = (
# Example: Ensure either list_id or group_id is present if item_id is null # 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'), 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): class ExpenseSplit(Base):
__tablename__ = "expense_splits" __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) id = Column(Integer, primary_key=True, index=True)
expense_id = Column(Integer, ForeignKey("expenses.id", ondelete="CASCADE"), nullable=False) expense_id = Column(Integer, ForeignKey("expenses.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
owed_amount = Column(Numeric(10, 2), nullable=False) # For EQUAL or EXACT_AMOUNTS owed_amount = Column(Numeric(10, 2), nullable=False)
# For PERCENTAGE split (value from 0.00 to 100.00)
share_percentage = Column(Numeric(5, 2), nullable=True) 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) 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) 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) 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") expense = relationship("Expense", back_populates="splits")
user = relationship("User", foreign_keys=[user_id], back_populates="expense_splits") user = relationship("User", foreign_keys=[user_id], back_populates="expense_splits")
class Settlement(Base): class Settlement(Base):
__tablename__ = "settlements" __tablename__ = "settlements"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False) # Settlements usually within a group group_id = Column(Integer, ForeignKey("groups.id"), nullable=False, index=True)
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
paid_to_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) paid_to_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
amount = Column(Numeric(10, 2), nullable=False) amount = Column(Numeric(10, 2), nullable=False)
settlement_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) settlement_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
@ -257,7 +258,7 @@ class Settlement(Base):
__table_args__ = ( __table_args__ = (
# Ensure payer and payee are different users # 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. # Potential future: PaymentMethod model, etc.

View File

@ -12,9 +12,22 @@ class UserBase(BaseModel):
class UserCreate(UserBase): class UserCreate(UserBase):
password: str password: str
# Properties to receive via API on update (optional, add later if needed) def create_update_dict(self):
# class UserUpdate(UserBase): return {
# password: Optional[str] = None "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 # Properties stored in DB
class UserInDBBase(UserBase): class UserInDBBase(UserBase):

View File

@ -9,4 +9,11 @@ python-dotenv>=1.0.0 # To load .env file for scripts/alembic
passlib[bcrypt]>=1.7.4 passlib[bcrypt]>=1.7.4
python-jose[cryptography]>=3.3.0 python-jose[cryptography]>=3.3.0
pydantic[email] pydantic[email]
google-generativeai>=0.5.0 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,24 +1,21 @@
# docker-compose.yml (in project root)
version: '3.8'
services: services:
db: db:
image: postgres:15 # Use a specific PostgreSQL version image: postgres:17 # Use a specific PostgreSQL version
container_name: postgres_db container_name: postgres_db
environment: environment:
POSTGRES_USER: dev_user # Define DB user POSTGRES_USER: dev_user # Define DB user
POSTGRES_PASSWORD: dev_password # Define DB password POSTGRES_PASSWORD: dev_password # Define DB password
POSTGRES_DB: dev_db # Define Database name POSTGRES_DB: dev_db # Define Database name
volumes: volumes:
- postgres_data:/var/lib/postgresql/data # Persist data using a named volume - postgres_data:/var/lib/postgresql/data # Persist data using a named volume
ports: ports:
- "5432:5432" # Expose PostgreSQL port to host (optional, for direct access) - "5432:5432" # Expose PostgreSQL port to host (optional, for direct access)
healthcheck: 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 interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 10s start_period: 10s
restart: unless-stopped restart: unless-stopped
backend: backend:
@ -37,29 +34,29 @@ services:
# Uses the service name 'db' as the host, and credentials defined above # Uses the service name 'db' as the host, and credentials defined above
# IMPORTANT: Use the correct async driver prefix if your app needs it! # IMPORTANT: Use the correct async driver prefix if your app needs it!
- DATABASE_URL=postgresql+asyncpg://dev_user:dev_password@db:5432/dev_db - 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 # Add other environment variables needed by the backend here
# - SOME_OTHER_VAR=some_value # - SOME_OTHER_VAR=some_value
depends_on: 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 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 restart: unless-stopped
pgadmin: # Optional service for database administration frontend:
image: dpage/pgadmin4:latest container_name: vite_frontend
container_name: pgadmin4_server build:
environment: context: ./fe
PGADMIN_DEFAULT_EMAIL: admin@example.com # Change as needed dockerfile: Dockerfile
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
ports: ports:
- "5050:80" # Map container port 80 to host port 5050 - "80:80"
depends_on: depends_on:
- db # Depends on the database service - backend
restart: unless-stopped restart: unless-stopped
volumes: # Define named volumes for data persistence volumes:
# Define named volumes for data persistence
postgres_data: postgres_data:
pgadmin_data: pgadmin_data:

3
fe/.gitignore vendored
View File

@ -7,7 +7,7 @@ yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules **/node_modules/
.DS_Store .DS_Store
dist dist
dist-ssr dist-ssr
@ -28,6 +28,7 @@ coverage
*.sw? *.sw?
*.tsbuildinfo *.tsbuildinfo
*.sw.js
test-results/ test-results/
playwright-report/ 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", "name": "fe",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@sentry/tracing": "^7.120.3",
"@sentry/vue": "^7.120.3",
"@supabase/auth-js": "^2.69.1", "@supabase/auth-js": "^2.69.1",
"@supabase/supabase-js": "^2.49.4", "@supabase/supabase-js": "^2.49.4",
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",
@ -15,7 +17,8 @@
"pinia": "^3.0.2", "pinia": "^3.0.2",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "^12.0.0-alpha.2", "vue-i18n": "^12.0.0-alpha.2",
"vue-router": "^4.5.1" "vue-router": "^4.5.1",
"workbox-background-sync": "^7.3.0"
}, },
"devDependencies": { "devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8", "@intlify/unplugin-vue-i18n": "^6.0.8",
@ -3825,6 +3828,162 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@ -7427,7 +7586,6 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/ignore": { "node_modules/ignore": {
@ -7440,6 +7598,12 @@
"node": ">= 4" "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": { "node_modules/immutable": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz",
@ -8413,6 +8577,24 @@
"node": ">= 0.8.0" "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": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -11885,7 +12067,6 @@
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz", "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz",
"integrity": "sha512-PCSk3eK7Mxeuyatb22pcSx9dlgWNv3+M8PqPaYDokks8Y5/FX4soaOqj3yhAZr5k6Q5JWTOMYgaJBpbw11G9Eg==", "integrity": "sha512-PCSk3eK7Mxeuyatb22pcSx9dlgWNv3+M8PqPaYDokks8Y5/FX4soaOqj3yhAZr5k6Q5JWTOMYgaJBpbw11G9Eg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"idb": "^7.0.1", "idb": "^7.0.1",
@ -12220,7 +12401,6 @@
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.3.0.tgz", "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.3.0.tgz",
"integrity": "sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==", "integrity": "sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/workbox-expiration": { "node_modules/workbox-expiration": {

View File

@ -17,6 +17,8 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"@sentry/tracing": "^7.120.3",
"@sentry/vue": "^7.120.3",
"@supabase/auth-js": "^2.69.1", "@supabase/auth-js": "^2.69.1",
"@supabase/supabase-js": "^2.49.4", "@supabase/supabase-js": "^2.49.4",
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",
@ -24,7 +26,8 @@
"pinia": "^3.0.2", "pinia": "^3.0.2",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "^12.0.0-alpha.2", "vue-i18n": "^12.0.0-alpha.2",
"vue-router": "^4.5.1" "vue-router": "^4.5.1",
"workbox-background-sync": "^7.3.0"
}, },
"devDependencies": { "devDependencies": {
"@intlify/unplugin-vue-i18n": "^6.0.8", "@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 // src/assets/main.scss
// @import './variables.scss'; // Your custom variables // @import './variables.scss'; // Your custom variables
@import './valerie-ui.scss'; @use './valerie-ui.scss';
// Example global styles // Example global styles
body { body {
@ -13,6 +13,7 @@ body {
a { a {
color: var(--primary-color); color: var(--primary-color);
text-decoration: none; text-decoration: none;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
@ -23,4 +24,73 @@ a {
margin: 0 auto; margin: 0 auto;
padding: 1rem; 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 // Add more global utility classes or base styles

View File

@ -1,34 +1,26 @@
<template> <template>
<div v-if="show" class="modal-backdrop open" @click.self="closeDialog"> <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"> <div class="modal-header">
<h3 id="conflictDialogTitle">Conflict Resolution</h3> <h3 id="conflictDialogTitle">Conflict Resolution</h3>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p class="mb-2"> <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> </p>
<div class="tabs"> <div class="tabs">
<ul class="tab-list" role="tablist" aria-label="Conflict Resolution Options"> <ul class="tab-list" role="tablist" aria-label="Conflict Resolution Options">
<li <li class="tab-item" role="tab" :aria-selected="activeTab === 'compare'"
class="tab-item" :tabindex="activeTab === 'compare' ? 0 : -1" @click="activeTab = 'compare'"
role="tab" @keydown.enter.space="activeTab = 'compare'">
:aria-selected="activeTab === 'compare'"
:tabindex="activeTab === 'compare' ? 0 : -1"
@click="activeTab = 'compare'"
@keydown.enter.space="activeTab = 'compare'"
>
Compare Versions Compare Versions
</li> </li>
<li <li class="tab-item" role="tab" :aria-selected="activeTab === 'merge'"
class="tab-item" :tabindex="activeTab === 'merge' ? 0 : -1" @click="activeTab = 'merge'"
role="tab" @keydown.enter.space="activeTab = 'merge'">
:aria-selected="activeTab === 'merge'"
:tabindex="activeTab === 'merge' ? 0 : -1"
@click="activeTab = 'merge'"
@keydown.enter.space="activeTab = 'merge'"
>
Merge Changes Merge Changes
</li> </li>
</ul> </ul>
@ -47,7 +39,8 @@
<ul class="item-list simple-list"> <ul class="item-list simple-list">
<li v-for="(value, key) in conflictData?.localVersion.data" :key="key" class="list-item-simple"> <li v-for="(value, key) in conflictData?.localVersion.data" :key="key" class="list-item-simple">
<strong class="text-caption-strong">{{ formatKey(key) }}</strong> <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> </li>
</ul> </ul>
</div> </div>
@ -55,17 +48,18 @@
<!-- Server Version --> <!-- Server Version -->
<div class="card flex-grow" style="width: 50%;"> <div class="card flex-grow" style="width: 50%;">
<div class="card-header"> <div class="card-header">
<h4>Server Version</h4> <h4>Server Version</h4>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="text-caption mb-1"> <p class="text-caption mb-1">
Last modified: {{ formatDate(conflictData?.serverVersion.timestamp ?? 0) }} Last modified: {{ formatDate(conflictData?.serverVersion.timestamp ?? 0) }}
</p> </p>
<ul class="item-list simple-list"> <ul class="item-list simple-list">
<li v-for="(value, key) in conflictData?.serverVersion.data" :key="key" class="list-item-simple"> <li v-for="(value, key) in conflictData?.serverVersion.data" :key="key" class="list-item-simple">
<strong class="text-caption-strong">{{ formatKey(key) }}</strong> <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> </li>
</ul> </ul>
</div> </div>
@ -81,7 +75,8 @@
<div class="card-body"> <div class="card-body">
<p class="text-caption mb-2">Select which version to keep for each field.</p> <p class="text-caption mb-2">Select which version to keep for each field.</p>
<ul class="item-list simple-list"> <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> <strong class="text-caption-strong">{{ formatKey(key) }}</strong>
<div class="flex" style="gap: 1rem; margin-top: 0.5rem;"> <div class="flex" style="gap: 1rem; margin-top: 0.5rem;">
<div class="radio-group-inline"> <div class="radio-group-inline">
@ -95,7 +90,8 @@
<label class="radio-label"> <label class="radio-label">
<input type="radio" :name="String(key)" v-model="mergeChoices[key]" value="server" /> <input type="radio" :name="String(key)" v-model="mergeChoices[key]" value="server" />
<span class="checkmark radio-mark"></span> <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> </label>
</div> </div>
</div> </div>
@ -108,43 +104,20 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button <button v-if="activeTab === 'compare'" type="button" class="btn btn-neutral" @click="resolveConflict('local')">
v-if="activeTab === 'compare'"
type="button"
class="btn btn-neutral"
@click="resolveConflict('local')"
>
Keep Local Version Keep Local Version
</button> </button>
<button <button v-if="activeTab === 'compare'" type="button" class="btn btn-neutral ml-2"
v-if="activeTab === 'compare'" @click="resolveConflict('server')">
type="button"
class="btn btn-neutral ml-2"
@click="resolveConflict('server')"
>
Keep Server Version Keep Server Version
</button> </button>
<button <button v-if="activeTab === 'compare'" type="button" class="btn btn-primary ml-2" @click="activeTab = 'merge'">
v-if="activeTab === 'compare'"
type="button"
class="btn btn-primary ml-2"
@click="activeTab = 'merge'"
>
Merge Changes Merge Changes
</button> </button>
<button <button v-if="activeTab === 'merge'" type="button" class="btn btn-primary ml-2" @click="applyMergedChanges">
v-if="activeTab === 'merge'"
type="button"
class="btn btn-primary ml-2"
@click="applyMergedChanges"
>
Apply Merged Changes Apply Merged Changes
</button> </button>
<button <button type="button" class="btn btn-danger ml-2" @click="closeDialog">
type="button"
class="btn btn-danger ml-2"
@click="closeDialog"
>
Cancel Cancel
</button> </button>
</div> </div>
@ -155,17 +128,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useVModel, onClickOutside } from '@vueuse/core'; import { useVModel, onClickOutside } from '@vueuse/core';
// Assuming OfflineAction is defined elsewhere, e.g. in a Pinia store or a types file import type { OfflineAction } from '@/stores/offline';
// 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
}
interface ConflictData { interface ConflictData {
localVersion: { localVersion: {
@ -176,7 +139,7 @@ interface ConflictData {
data: Record<string, unknown>; data: Record<string, unknown>;
timestamp: number; timestamp: number;
}; };
action: OfflineAction; // Assuming OfflineAction is defined action: OfflineAction;
} }
const props = defineProps<{ const props = defineProps<{
@ -211,7 +174,7 @@ watch(() => props.conflictData, (newData) => {
Object.keys(newData.localVersion.data).forEach(key => { Object.keys(newData.localVersion.data).forEach(key => {
// Default to local, or server if local is undefined/null but server is not // Default to local, or server if local is undefined/null but server is not
if (isDifferent(key)) { if (isDifferent(key)) {
choices[key] = newData.localVersion.data[key] !== undefined ? 'local' : 'server'; choices[key] = newData.localVersion.data[key] !== undefined ? 'local' : 'server';
} else { } else {
choices[key] = 'local'; choices[key] = 'local';
} }
@ -282,6 +245,7 @@ const applyMergedChanges = (): void => {
color: var(--dark); color: var(--dark);
opacity: 0.8; opacity: 0.8;
} }
.text-caption-strong { .text-caption-strong {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--dark); color: var(--dark);
@ -291,9 +255,11 @@ const applyMergedChanges = (): void => {
} }
.text-positive-inline { .text-positive-inline {
color: var(--success); /* Assuming --success is greenish */ color: var(--success);
/* Assuming --success is greenish */
font-weight: bold; font-weight: bold;
background-color: #e6ffed; /* Light green background for highlight */ background-color: #e6ffed;
/* Light green background for highlight */
padding: 2px 4px; padding: 2px 4px;
border-radius: 3px; border-radius: 3px;
} }
@ -303,10 +269,12 @@ const applyMergedChanges = (): void => {
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
.list-item-simple { .list-item-simple {
padding: 0.5rem 0; padding: 0.5rem 0;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
} }
.list-item-simple:last-child { .list-item-simple:last-child {
border-bottom: none; border-bottom: none;
} }
@ -314,24 +282,35 @@ const applyMergedChanges = (): void => {
.merge-choice-item .radio-group-inline { .merge-choice-item .radio-group-inline {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.merge-choice-item .radio-label { .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 { .value-preview {
font-style: italic; font-style: italic;
color: #555; color: #555;
margin-left: 0.5em; margin-left: 0.5em;
display: inline-block; display: inline-block;
max-width: 200px; /* Adjust as needed */ max-width: 200px;
white-space: pre-wrap; /* Show formatted JSON */ /* Adjust as needed */
white-space: pre-wrap;
/* Show formatted JSON */
word-break: break-all; word-break: break-all;
} }
.ml-2 { .ml-2 {
margin-left: 0.5rem; 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 {
flex-grow: 1; flex-grow: 1;

View File

@ -1,53 +1,54 @@
<template> <template>
<div> <div>
<div <div v-if="!isOnline || hasPendingActions" class="alert offline-indicator" :class="{
v-if="!isOnline || hasPendingActions" 'alert-error': !isOnline,
class="alert offline-indicator" 'alert-warning': isOnline && hasPendingActions
:class="{ }" role="status">
'alert-error': !isOnline,
'alert-warning': isOnline && hasPendingActions
}"
role="status"
>
<div class="alert-content"> <div class="alert-content">
<svg class="icon" aria-hidden="true"> <svg class="icon" aria-hidden="true">
<use :xlink:href="!isOnline ? '#icon-alert-triangle' : '#icon-info'" /> <use :xlink:href="!isOnline ? '#icon-wifi-off' : '#icon-sync'" />
<!-- Placeholder icons, wifi_off and sync are not in Valerie UI default -->
</svg> </svg>
<span v-if="!isOnline"> <span v-if="!isOnline" class="status-text">
You are currently offline. Changes will be saved locally. You are currently offline. Changes will be saved locally.
</span> </span>
<span v-else> <span v-else class="status-text">
Syncing {{ pendingActionCount }} pending {{ pendingActionCount === 1 ? 'change' : 'changes' }}... Syncing {{ pendingActionCount }} pending {{ pendingActionCount === 1 ? 'change' : 'changes' }}...
</span> </span>
</div> </div>
<button <button v-if="hasPendingActions" class="btn btn-sm btn-neutral" @click="showPendingActionsModal = true">
v-if="hasPendingActions"
class="btn btn-sm btn-neutral"
@click="showPendingActionsModal = true"
>
View Changes View Changes
</button> </button>
</div> </div>
<div v-if="showPendingActionsModal" class="modal-backdrop open" @click.self="showPendingActionsModal = false"> <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"> <div class="modal-header">
<h3 id="pendingActionsTitle">Pending Changes</h3> <h3 id="pendingActionsTitle">Pending Changes</h3>
<button class="close-button" @click="showPendingActionsModal = false" aria-label="Close"> <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> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<ul v-if="pendingActions.length" class="item-list"> <ul v-if="pendingActions.length" class="item-list">
<li v-for="action in pendingActions" :key="action.id" class="list-item"> <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">
<span class="item-text">{{ getActionLabel(action) }}</span> <div class="action-info">
<small class="text-caption">{{ new Date(action.timestamp).toLocaleString() }}</small> <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> </div>
</li> </li>
</ul> </ul>
<p v-else>No pending changes.</p> <p v-else class="empty-state">No pending changes.</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-primary" @click="showPendingActionsModal = false">Close</button> <button type="button" class="btn btn-primary" @click="showPendingActionsModal = false">Close</button>
@ -56,58 +57,55 @@
</div> </div>
<!-- Conflict Resolution Dialog --> <!-- Conflict Resolution Dialog -->
<ConflictResolutionDialog <ConflictResolutionDialog v-model="offlineStore.showConflictDialog" :conflict-data="offlineStore.currentConflict"
v-model="offlineStore.showConflictDialog" @resolve="offlineStore.handleConflictResolution" />
:conflict-data="offlineStore.currentConflict"
@resolve="offlineStore.handleConflictResolution"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useNetwork, onClickOutside } from '@vueuse/core'; import { useNetwork, onClickOutside } from '@vueuse/core';
import { useOfflineStore } from '@/stores/offline'; // Assuming path import { useOfflineStore } from '@/stores/offline';
import type { OfflineAction } from '@/stores/offline'; // Assuming path import type { OfflineAction } from '@/stores/offline';
import ConflictResolutionDialog from '@/components/ConflictResolutionDialog.vue'; import ConflictResolutionDialog from '@/components/ConflictResolutionDialog.vue';
const offlineStore = useOfflineStore(); const offlineStore = useOfflineStore();
const showPendingActionsModal = ref(false); const showPendingActionsModal = ref(false);
const pendingActionsModalRef = ref<HTMLElement | null>(null); 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 { const {
pendingActions, pendingActions,
hasPendingActions, hasPendingActions,
pendingActionCount, pendingActionCount,
// showConflictDialog, // Handled by offlineStore.showConflictDialog
// currentConflict, // Handled by offlineStore.currentConflict
// handleConflictResolution // Handled by offlineStore.handleConflictResolution
} = offlineStore; } = offlineStore;
onClickOutside(pendingActionsModalRef, () => { onClickOutside(pendingActionsModalRef, () => {
showPendingActionsModal.value = false; showPendingActionsModal.value = false;
}); });
const removePendingAction = (actionId: string) => {
offlineStore.pendingActions = offlineStore.pendingActions.filter(a => a.id !== actionId);
};
const getActionLabel = (action: OfflineAction) => { const getActionLabel = (action: OfflineAction) => {
// This is a simplified version of your original getActionLabel const data = action.payload as { title?: string; name?: string;[key: string]: unknown };
// 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 itemTitle = data.title || data.name || (typeof data === 'string' ? data : 'Untitled Item'); const itemTitle = data.title || data.name || (typeof data === 'string' ? data : 'Untitled Item');
switch (action.type) { switch (action.type) {
case 'add': case 'create_list':
case 'create': // Common alias return `Create List: ${itemTitle}`;
return `Add: ${itemTitle}`; case 'update_list':
case 'complete': return `Update List: ${itemTitle}`;
return `Complete: ${itemTitle}`; case 'delete_list':
case 'update': return `Delete List: ${itemTitle}`;
return `Update: ${itemTitle}`; case 'create_list_item':
case 'delete': return `Add Item: ${itemTitle}`;
return `Delete: ${itemTitle}`; case 'update_list_item':
return `Update Item: ${itemTitle}`;
case 'delete_list_item':
return `Delete Item: ${itemTitle}`;
default: default:
return `Unknown action: ${action.type} for ${itemTitle}`; return `Unknown action: ${action.type} for ${itemTitle}`;
} }
@ -121,22 +119,83 @@ const getActionLabel = (action: OfflineAction) => {
right: 1rem; right: 1rem;
z-index: 1000; z-index: 1000;
max-width: 400px; 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 { .text-caption {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--dark); color: var(--dark);
opacity: 0.7; opacity: 0.7;
} }
/* Simplified list item for pending actions modal */ .item-list {
.item-list .list-item .list-item-content { list-style: none;
padding: 0.75rem 1rem; 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; 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> </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 = { export const API_ENDPOINTS = {
// Auth // Auth
AUTH: { AUTH: {
LOGIN: '/auth/login', LOGIN: '/auth/jwt/login',
SIGNUP: '/auth/signup', SIGNUP: '/auth/register',
REFRESH_TOKEN: '/auth/refresh-token', LOGOUT: '/auth/jwt/logout',
LOGOUT: '/auth/logout', VERIFY_EMAIL: '/auth/verify',
VERIFY_EMAIL: '/auth/verify-email', RESET_PASSWORD: '/auth/forgot-password',
RESET_PASSWORD: '/auth/reset-password',
FORGOT_PASSWORD: '/auth/forgot-password', FORGOT_PASSWORD: '/auth/forgot-password',
}, },
// Users // Users
USERS: { USERS: {
PROFILE: '/users/me', PROFILE: '/users/me',
UPDATE_PROFILE: '/users/me', UPDATE_PROFILE: '/api/v1/users/me',
PASSWORD: '/users/password', PASSWORD: '/api/v1/users/password',
AVATAR: '/users/avatar', AVATAR: '/api/v1/users/avatar',
SETTINGS: '/users/settings', SETTINGS: '/api/v1/users/settings',
NOTIFICATIONS: '/users/notifications', NOTIFICATIONS: '/api/v1/users/notifications',
PREFERENCES: '/users/preferences', PREFERENCES: '/api/v1/users/preferences',
}, },
// Lists // Lists

View File

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

View File

@ -1,5 +1,7 @@
import { createApp } from 'vue'; import { createApp } from 'vue';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import * as Sentry from '@sentry/vue';
import { BrowserTracing } from '@sentry/tracing';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
// import { createI18n } from 'vue-i18n'; // import { createI18n } from 'vue-i18n';
@ -33,6 +35,23 @@ const app = createApp(App);
const pinia = createPinia(); const pinia = createPinia();
app.use(pinia); 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 // Initialize auth state before mounting the app
const authStore = useAuthStore(); const authStore = useAuthStore();
if (authStore.accessToken) { 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> <template>
<main class="container page-padding"> <main class="container page-padding">
<div v-if="loading" class="text-center"> <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> <p>Loading list details...</p>
</div> </div>
<div v-else-if="error" class="alert alert-error mb-3" role="alert"> <div v-else-if="error" class="alert alert-error mb-3" role="alert">
<div class="alert-content"> <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 }} {{ error }}
</div> </div>
<button type="button" class="btn btn-sm btn-danger" @click="fetchListDetails">Retry</button> <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"> <div class="flex justify-between items-center flex-wrap mb-2">
<h1>{{ list.name }}</h1> <h1>{{ list.name }}</h1>
<div class="flex items-center flex-wrap" style="gap: 0.5rem;"> <div class="flex items-center flex-wrap" style="gap: 0.5rem;">
<button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true"> <button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true"
<svg class="icon icon-sm"><use xlink:href="#icon-clipboard"/></svg> <!-- Placeholder icon --> :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 Cost Summary
</button> </button>
<button class="btn btn-secondary btn-sm" @click="openOcrDialog"> <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 --> :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 Add via OCR
</button> </button>
<span class="item-badge ml-1" :class="list.is_complete ? 'badge-settled' : 'badge-pending'"> <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="flex items-end flex-wrap" style="gap: 1rem;">
<div class="form-group flex-grow" style="margin-bottom: 0;"> <div class="form-group flex-grow" style="margin-bottom: 0;">
<label for="newItemName" class="form-label">Item Name</label> <label for="newItemName" class="form-label">Item Name</label>
<input <input type="text" id="newItemName" v-model="newItem.name" class="form-input" required
type="text" ref="itemNameInputRef" />
id="newItemName"
v-model="newItem.name"
class="form-input"
required
ref="itemNameInputRef"
/>
</div> </div>
<div class="form-group" style="margin-bottom: 0; min-width: 120px;"> <div class="form-group" style="margin-bottom: 0; min-width: 120px;">
<label for="newItemQuantity" class="form-label">Quantity</label> <label for="newItemQuantity" class="form-label">Quantity</label>
<input <input type="number" id="newItemQuantity" v-model="newItem.quantity" class="form-input" min="1" />
type="number"
id="newItemQuantity"
v-model="newItem.quantity"
class="form-input"
min="1"
/>
</div> </div>
<button type="submit" class="btn btn-primary" :disabled="addingItem"> <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 Add Item
</button> </button>
</div> </div>
@ -66,64 +64,48 @@
<!-- Items List --> <!-- Items List -->
<div v-if="list.items.length === 0" class="card empty-state-card"> <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> <h3>No Items Yet!</h3>
<p>This list is empty. Add some items using the form above.</p> <p>This list is empty. Add some items using the form above.</p>
</div> </div>
<ul v-else class="item-list"> <ul v-else class="item-list">
<li <li v-for="item in list.items" :key="item.id" class="list-item" :class="{
v-for="item in list.items" 'completed': item.is_complete,
:key="item.id" 'is-swiped': item.swiped,
class="list-item" 'offline-item': isItemPendingSync(item),
:class="{ 'completed': item.is_complete, 'is-swiped': item.swiped }" 'synced': !isItemPendingSync(item)
@touchstart="handleTouchStart($event, item)" }" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="handleTouchEnd">
@touchmove="handleTouchMove($event, item)"
@touchend="handleTouchEnd(item)"
>
<div class="list-item-content"> <div class="list-item-content">
<div class="list-item-main"> <div class="list-item-main">
<label class="checkbox-label mb-0 flex-shrink-0"> <label class="checkbox-label mb-0 flex-shrink-0">
<input <input type="checkbox" :checked="item.is_complete"
type="checkbox"
:checked="item.is_complete"
@change="confirmUpdateItem(item, ($event.target as HTMLInputElement).checked)" @change="confirmUpdateItem(item, ($event.target as HTMLInputElement).checked)"
:disabled="item.updating" :disabled="item.updating" :aria-label="item.name" />
:aria-label="item.name"
/>
<span class="checkmark"></span> <span class="checkmark"></span>
</label> </label>
<div class="item-text flex-grow"> <div class="item-text flex-grow">
<span :class="{ 'text-decoration-line-through': item.is_complete }">{{ item.name }}</span> <span :class="{ 'text-decoration-line-through': item.is_complete }">{{ item.name }}</span>
<small v-if="item.quantity" class="item-caption">Quantity: {{ item.quantity }}</small> <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;"> <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> <label :for="`price-${item.id}`" class="sr-only">Price for {{ item.name }}</label>
<input <input :id="`price-${item.id}`" type="number" v-model.number="item.priceInput"
:id="`price-${item.id}`" class="form-input form-input-sm" placeholder="Price" step="0.01" @blur="updateItemPrice(item)"
type="number" @keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
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> </div>
</div> </div>
<!-- Non-swipe actions can be added here or handled by swipe --> <div class="list-item-actions">
<div class="list-item-actions"> <button class="btn btn-danger btn-sm btn-icon-only" @click.stop="confirmDeleteItem(item)"
<button :disabled="item.deleting" aria-label="Delete item">
class="btn btn-danger btn-sm btn-icon-only" <svg class="icon icon-sm">
@click.stop="confirmDeleteItem(item)" <use xlink:href="#icon-trash"></use>
:disabled="item.deleting" </svg>
aria-label="Delete item" </button>
>
<svg class="icon icon-sm"><use xlink:href="#icon-trash"></use></svg>
</button>
</div> </div>
</div> </div>
<!-- Swipe actions could be added here if fully implementing swipe from Valerie UI example -->
</li> </li>
</ul> </ul>
</template> </template>
@ -133,11 +115,13 @@
<div class="modal-container" ref="ocrModalRef" style="min-width: 400px;"> <div class="modal-container" ref="ocrModalRef" style="min-width: 400px;">
<div class="modal-header"> <div class="modal-header">
<h3>Add Items via OCR</h3> <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>
<div class="modal-body"> <div class="modal-body">
<div v-if="ocrLoading" class="text-center"> <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> <p>Processing image...</p>
</div> </div>
<div v-else-if="ocrItems.length > 0"> <div v-else-if="ocrItems.length > 0">
@ -147,7 +131,9 @@
<div class="list-item-content flex items-center" style="gap: 0.5rem;"> <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 /> <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)"> <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> </button>
</div> </div>
</li> </li>
@ -155,36 +141,57 @@
</div> </div>
<div v-else class="form-group"> <div v-else class="form-group">
<label for="ocrFile" class="form-label">Upload Image</label> <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> <p v-if="ocrError" class="form-error-text mt-1">{{ ocrError }}</p>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="closeOcrDialog">Cancel</button> <button type="button" class="btn btn-neutral" @click="closeOcrDialog">Cancel</button>
<button <button v-if="ocrItems.length > 0" type="button" class="btn btn-primary ml-2" @click="addOcrItems"
v-if="ocrItems.length > 0" :disabled="addingOcrItems">
type="button" <span v-if="addingOcrItems" class="spinner-dots-sm"><span /><span /><span /></span>
class="btn btn-primary ml-2"
@click="addOcrItems"
:disabled="addingOcrItems"
>
<span v-if="addingOcrItems" class="spinner-dots-sm"><span/><span/><span/></span>
Add Items Add Items
</button> </button>
</div> </div>
</div> </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 --> <!-- Cost Summary Dialog -->
<div v-if="showCostSummaryDialog" class="modal-backdrop open" @click.self="showCostSummaryDialog = false"> <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-container" ref="costSummaryModalRef" style="min-width: 550px;">
<div class="modal-header"> <div class="modal-header">
<h3>List Cost Summary</h3> <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>
<div class="modal-body"> <div class="modal-body">
<div v-if="costSummaryLoading" class="text-center"> <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>
<div v-else-if="costSummaryError" class="alert alert-error">{{ costSummaryError }}</div> <div v-else-if="costSummaryError" class="alert alert-error">{{ costSummaryError }}</div>
<div v-else-if="listCostSummary"> <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.items_added_value) }}</td>
<td class="text-right">{{ formatCurrency(userShare.amount_due) }}</td> <td class="text-right">{{ formatCurrency(userShare.amount_due) }}</td>
<td class="text-right"> <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) }} {{ formatCurrency(userShare.balance) }}
</span> </span>
</td> </td>
@ -227,33 +235,16 @@
</div> </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>
</main> </main>
</template> </template>
<script setup lang="ts"> <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 { useRoute } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api'; 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 { useNotificationStore } from '@/stores/notifications';
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
interface Item { interface Item {
id: number; id: number;
@ -298,13 +289,14 @@ interface ListCostSummaryData {
} }
const route = useRoute(); const route = useRoute();
const { isOnline } = useNetwork();
const notificationStore = useNotificationStore(); const notificationStore = useNotificationStore();
const offlineStore = useOfflineStore();
const list = ref<List | null>(null); const list = ref<List | null>(null);
const loading = ref(true); const loading = ref(true);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const addingItem = ref(false); 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 lastListUpdate = ref<string | null>(null);
const lastItemUpdate = 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 ocrItems = ref<{ name: string }[]>([]); // Items extracted from OCR
const addingOcrItems = ref(false); const addingOcrItems = ref(false);
const ocrError = ref<string | null>(null); const ocrError = ref<string | null>(null);
const ocrFileInputRef = ref<HTMLInputElement|null>(null); const ocrFileInputRef = ref<HTMLInputElement | null>(null);
const { files: ocrFiles, reset: resetOcrFileDialog } = useFileDialog({ const { files: ocrFiles, reset: resetOcrFileDialog } = useFileDialog({
accept: 'image/*', accept: 'image/*',
multiple: false, multiple: false,
@ -365,14 +357,14 @@ const fetchListDetails = async () => {
const rawList = response.data as List; const rawList = response.data as List;
rawList.items = processListItems(rawList.items); rawList.items = processListItems(rawList.items);
list.value = rawList; list.value = rawList;
lastListUpdate.value = rawList.updated_at; lastListUpdate.value = rawList.updated_at;
lastItemUpdate.value = rawList.items.reduce((latest: string, item: Item) => { lastItemUpdate.value = rawList.items.reduce((latest: string, item: Item) => {
return item.updated_at > latest ? item.updated_at : latest; return item.updated_at > latest ? item.updated_at : latest;
}, ''); }, '');
if (showCostSummaryDialog.value) { // If dialog is open, refresh its data if (showCostSummaryDialog.value) { // If dialog is open, refresh its data
await fetchListCostSummary(); await fetchListCostSummary();
} }
} catch (err: unknown) { } catch (err: unknown) {
@ -390,7 +382,7 @@ const checkForUpdates = async () => {
const newLastItemUpdate = newItems.reduce((latest: string, item: Item) => item.updated_at > latest ? item.updated_at : latest, ''); const newLastItemUpdate = newItems.reduce((latest: string, item: Item) => item.updated_at > latest ? item.updated_at : latest, '');
if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) || if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) ||
(lastItemUpdate.value && newLastItemUpdate > lastItemUpdate.value)) { (lastItemUpdate.value && newLastItemUpdate > lastItemUpdate.value)) {
await fetchListDetails(); await fetchListDetails();
} }
} catch (err) { } catch (err) {
@ -406,6 +398,16 @@ const stopPolling = () => {
if (pollingInterval.value) clearInterval(pollingInterval.value); 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 () => { const onAddItem = async () => {
if (!list.value || !newItem.value.name.trim()) { if (!list.value || !newItem.value.name.trim()) {
notificationStore.addNotification({ message: 'Please enter an item name.', type: 'warning' }); notificationStore.addNotification({ message: 'Please enter an item name.', type: 'warning' });
@ -413,6 +415,35 @@ const onAddItem = async () => {
return; return;
} }
addingItem.value = true; 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 { try {
const response = await apiClient.post( const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)), API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
@ -435,77 +466,104 @@ const updateItem = async (item: Item, newCompleteStatus: boolean) => {
const originalCompleteStatus = item.is_complete; const originalCompleteStatus = item.is_complete;
item.is_complete = newCompleteStatus; // Optimistic update 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 { try {
const payload: { is_complete: boolean; version: number; price?: number | null } = { await apiClient.put(
is_complete: item.is_complete, API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
version: item.version, { completed: newCompleteStatus, version: item.version }
}; );
if (item.is_complete && item.priceInput !== undefined && item.priceInput !== null && String(item.priceInput).trim() !== '') { item.version++;
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();
} catch (err) { } 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' }); notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item.', type: 'error' });
} finally { } finally {
item.updating = false; item.updating = false;
} }
}; };
const updateItemPrice = async (item: Item) => { 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; 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; item.updating = true;
const originalPrice = item.price; const originalPrice = item.price;
item.price = newPrice; // Optimistic const originalPriceInput = item.priceInput;
try { item.price = newPrice;
const response = await apiClient.put(
API_ENDPOINTS.ITEMS.BY_ID(String(item.id)), if (!isOnline.value) {
{ price: item.price, is_complete: item.is_complete, version: item.version } // Add to offline queue
); offlineStore.addAction({
const updatedItemFromServer = processListItems([response.data as Item])[0]; type: 'update_list_item',
const index = list.value.items.findIndex(i => i.id === item.id); payload: {
if (index !== -1) { listId: String(list.value.id),
list.value.items[index] = { ...list.value.items[index], ...updatedItemFromServer, updating: false }; itemId: String(item.id),
} data: {
if (showCostSummaryDialog.value) await fetchListCostSummary(); price: newPrice,
} catch (err) { completed: item.is_complete
item.price = originalPrice; // Revert },
item.priceInput = originalPrice !== null && originalPrice !== undefined ? originalPrice : ''; version: item.version
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item price.', type: 'error' }); }
} finally { });
item.updating = false; item.updating = false;
} return;
}
try {
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ price: newPrice, completed: item.is_complete, version: item.version }
);
item.version++;
} catch (err) {
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) => { const deleteItem = async (item: Item) => {
if (!list.value) return; if (!list.value) return;
item.deleting = true; 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 { try {
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id))); 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); list.value.items = list.value.items.filter(i => i.id !== item.id);
if (showCostSummaryDialog.value) await fetchListCostSummary();
} catch (err) { } catch (err) {
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' }); notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' });
} finally { } finally {
@ -540,20 +598,20 @@ const cancelConfirmation = () => {
// OCR Functionality // OCR Functionality
const openOcrDialog = () => { const openOcrDialog = () => {
ocrItems.value = []; ocrItems.value = [];
ocrError.value = null; ocrError.value = null;
resetOcrFileDialog(); // Clear previous file selection from useFileDialog resetOcrFileDialog(); // Clear previous file selection from useFileDialog
showOcrDialogState.value = true; showOcrDialogState.value = true;
nextTick(() => { nextTick(() => {
if (ocrFileInputRef.value) { if (ocrFileInputRef.value) {
ocrFileInputRef.value.value = ''; // Manually clear input type=file ocrFileInputRef.value.value = ''; // Manually clear input type=file
} }
}); });
}; };
const closeOcrDialog = () => { const closeOcrDialog = () => {
showOcrDialogState.value = false; showOcrDialogState.value = false;
ocrItems.value = []; ocrItems.value = [];
ocrError.value = null; ocrError.value = null;
}; };
watch(ocrFiles, async (newFiles) => { watch(ocrFiles, async (newFiles) => {
@ -564,10 +622,10 @@ watch(ocrFiles, async (newFiles) => {
}); });
const handleOcrFileUpload = (event: Event) => { const handleOcrFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) { if (target.files && target.files.length > 0) {
handleOcrUpload(target.files[0]); handleOcrUpload(target.files[0]);
} }
}; };
const handleOcrUpload = async (file: File) => { const handleOcrUpload = async (file: File) => {
@ -581,15 +639,15 @@ const handleOcrUpload = async (file: File) => {
const response = await apiClient.post(API_ENDPOINTS.OCR.PROCESS, formData, { const response = await apiClient.post(API_ENDPOINTS.OCR.PROCESS, formData, {
headers: { 'Content-Type': 'multipart/form-data' } headers: { 'Content-Type': 'multipart/form-data' }
}); });
ocrItems.value = response.data.extracted_items.map((nameStr: string) => ({ name: nameStr.trim() })).filter(item => item.name); ocrItems.value = response.data.extracted_items.map((nameStr: string) => ({ name: nameStr.trim() })).filter((item: { name: string }) => item.name);
if(ocrItems.value.length === 0) { if (ocrItems.value.length === 0) {
ocrError.value = "No items extracted from the image."; ocrError.value = "No items extracted from the image.";
} }
} catch (err) { } catch (err) {
ocrError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to process image.'; ocrError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to process image.';
} finally { } finally {
ocrLoading.value = false; ocrLoading.value = false;
if (ocrFileInputRef.value) ocrFileInputRef.value.value = ''; // Reset file input if (ocrFileInputRef.value) ocrFileInputRef.value.value = ''; // Reset file input
} }
}; };
@ -645,10 +703,10 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
// Check if a modal is open or if focus is already in an input/textarea // Check if a modal is open or if focus is already in an input/textarea
const activeElement = document.activeElement; const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) { if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
return; return;
} }
if (showOcrDialogState.value || showCostSummaryDialog.value || showConfirmDialogState.value) { if (showOcrDialogState.value || showCostSummaryDialog.value || showConfirmDialogState.value) {
return; return;
} }
event.preventDefault(); event.preventDefault();
itemNameInputRef.value?.focus(); itemNameInputRef.value?.focus();
@ -656,24 +714,23 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
}); });
// Swipe detection (basic) // Swipe detection (basic)
// let touchStartX = 0; // Commented out as unused let touchStartX = 0;
// const SWIPE_THRESHOLD = 50; // pixels // Commented out as unused const SWIPE_THRESHOLD = 50; // pixels
const handleTouchStart = (event: TouchEvent, /* item: Item */) => { // Commented out unused item const handleTouchStart = (event: TouchEvent) => {
touchStartX = event.changedTouches[0].clientX; touchStartX = event.changedTouches[0].clientX;
// Add class for visual feedback during swipe if desired // 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 // 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' // 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 // For a simple demo, one might toggle it here based on a more complex gesture
// This would require more state per item and logic // This would require more state per item and logic
// For now, swipe actions are not visually implemented // 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> </script>
<style scoped> <style scoped>
.page-padding { padding: 1rem; } .page-padding {
.mb-1 { margin-bottom: 0.5rem; } padding: 1rem;
.mb-2 { margin-bottom: 1rem; } }
.mb-3 { margin-bottom: 1.5rem; }
.mt-1 { margin-top: 0.5rem; } .mb-1 {
.mt-2 { margin-top: 1rem; } margin-bottom: 0.5rem;
.ml-1 { margin-left: 0.25rem; } }
.ml-2 { margin-left: 0.5rem; }
.text-right { text-align: right; } .mb-2 {
.flex-grow { flex-grow: 1; } 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 { .item-caption {
display: block; display: block;
@ -712,27 +798,102 @@ onUnmounted(() => {
opacity: 0.6; opacity: 0.6;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.text-decoration-line-through { .text-decoration-line-through {
text-decoration: line-through; text-decoration: line-through;
} }
.form-input-sm { /* For price input */
padding: 0.4rem 0.6rem; .form-input-sm {
font-size: 0.9rem; /* For price input */
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
} }
.cost-overview p { .cost-overview p {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-size: 1.05rem; font-size: 1.05rem;
} }
.form-error-text { .form-error-text {
color: var(--danger); color: var(--danger);
font-size: 0.85rem; font-size: 0.85rem;
} }
.list-item.completed .item-text { .list-item.completed .item-text {
/* text-decoration: line-through; is handled by span class */ /* text-decoration: line-through; is handled by span class */
opacity: 0.7; opacity: 0.7;
} }
.list-item-actions { .list-item-actions {
margin-left: auto; /* Pushes actions to the right */ margin-left: auto;
padding-left: 1rem; /* Space before actions */ /* 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> </style>

View File

@ -2,51 +2,43 @@
<main class="flex items-center justify-center page-container"> <main class="flex items-center justify-center page-container">
<div class="card login-card"> <div class="card login-card">
<div class="card-header"> <div class="card-header">
<h3>Login</h3> <h3>mitlist</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<form @submit.prevent="onSubmit" class="form-layout"> <form @submit.prevent="onSubmit" class="form-layout">
<div class="form-group mb-2"> <div class="form-group mb-2">
<label for="email" class="form-label">Email</label> <label for="email" class="form-label">Email</label>
<input <input type="email" id="email" v-model="email" class="form-input" required autocomplete="email" />
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> <p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
</div> </div>
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="password" class="form-label">Password</label> <label for="password" class="form-label">Password</label>
<div class="input-with-icon-append"> <div class="input-with-icon-append">
<input <input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input"
:type="isPwdVisible ? 'text' : 'password'" required autocomplete="current-password" />
id="password" <button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn"
v-model="password" aria-label="Toggle password visibility">
class="form-input" <svg class="icon icon-sm">
required <use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
autocomplete="current-password" </svg> <!-- Placeholder for visibility icons -->
/>
<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> </button>
</div> </div>
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p> <p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
</div> </div>
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p> <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"> <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 Login
</button> </button>
<div class="text-center mt-2"> <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> </div>
<SocialLoginButtons />
</form> </form>
</div> </div>
</div> </div>
@ -58,6 +50,7 @@ import { ref } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth'; // Assuming path import { useAuthStore } from '@/stores/auth'; // Assuming path
import { useNotificationStore } from '@/stores/notifications'; import { useNotificationStore } from '@/stores/notifications';
import SocialLoginButtons from '@/components/SocialLoginButtons.vue';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@ -112,14 +105,17 @@ const onSubmit = async () => {
<style scoped> <style scoped>
.page-container { .page-container {
min-height: 100vh; /* dvh for dynamic viewport height */ min-height: 100vh;
/* dvh for dynamic viewport height */
min-height: 100dvh; min-height: 100dvh;
padding: 1rem; padding: 1rem;
} }
.login-card { .login-card {
width: 100%; width: 100%;
max-width: 400px; 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. */ /* 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 { .link-styled {
@ -128,35 +124,45 @@ const onSubmit = async () => {
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
transition: border-color var(--transition-speed) var(--transition-ease-out); 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); border-bottom-color: var(--primary);
} }
.form-error-text { .form-error-text {
color: var(--danger); color: var(--danger);
font-size: 0.85rem; font-size: 0.85rem;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.alert.form-error-text { /* For general error message */
padding: 0.75rem 1rem; .alert.form-error-text {
margin-bottom: 1rem; /* For general error message */
padding: 0.75rem 1rem;
margin-bottom: 1rem;
} }
.input-with-icon-append { .input-with-icon-append {
position: relative; position: relative;
display: flex; display: flex;
} }
.input-with-icon-append .form-input { .input-with-icon-append .form-input {
padding-right: 3rem; /* Space for the button */ padding-right: 3rem;
/* Space for the button */
} }
.icon-append-btn { .icon-append-btn {
position: absolute; position: absolute;
right: 0; right: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 3rem; /* Width of the button */ width: 3rem;
/* Width of the button */
background: transparent; background: transparent;
border: none; border: none;
border-left: var(--border); /* Separator line */ border-left: var(--border);
/* Separator line */
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@ -164,9 +170,16 @@ const onSubmit = async () => {
color: var(--dark); color: var(--dark);
opacity: 0.7; opacity: 0.7;
} }
.icon-append-btn:hover, .icon-append-btn:focus {
.icon-append-btn:hover,
.icon-append-btn:focus {
opacity: 1; 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> </style>

View File

@ -8,51 +8,42 @@
<form @submit.prevent="onSubmit" class="form-layout"> <form @submit.prevent="onSubmit" class="form-layout">
<div class="form-group mb-2"> <div class="form-group mb-2">
<label for="name" class="form-label">Full Name</label> <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> <p v-if="formErrors.name" class="form-error-text">{{ formErrors.name }}</p>
</div> </div>
<div class="form-group mb-2"> <div class="form-group mb-2">
<label for="email" class="form-label">Email</label> <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> <p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
</div> </div>
<div class="form-group mb-2"> <div class="form-group mb-2">
<label for="password" class="form-label">Password</label> <label for="password" class="form-label">Password</label>
<div class="input-with-icon-append"> <div class="input-with-icon-append">
<input <input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input"
:type="isPwdVisible ? 'text' : 'password'" required autocomplete="new-password" />
id="password" <button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn"
v-model="password" aria-label="Toggle password visibility">
class="form-input" <svg class="icon icon-sm">
required <use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
autocomplete="new-password" </svg> <!-- Placeholder for visibility icons -->
/> </button>
<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> </div>
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p> <p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
</div> </div>
<div class="form-group mb-3"> <div class="form-group mb-3">
<label for="confirmPassword" class="form-label">Confirm Password</label> <label for="confirmPassword" class="form-label">Confirm Password</label>
<input <input :type="isPwdVisible ? 'text' : 'password'" id="confirmPassword" v-model="confirmPassword"
:type="isPwdVisible ? 'text' : 'password'" class="form-input" required autocomplete="new-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> <p v-if="formErrors.confirmPassword" class="form-error-text">{{ formErrors.confirmPassword }}</p>
</div> </div>
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p> <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"> <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 Sign Up
</button> </button>
@ -68,7 +59,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useRouter } from 'vue-router'; 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'; import { useNotificationStore } from '@/stores/notifications';
const router = useRouter(); const router = useRouter();
@ -143,10 +134,12 @@ const onSubmit = async () => {
min-height: 100dvh; min-height: 100dvh;
padding: 1rem; padding: 1rem;
} }
.signup-card { .signup-card {
width: 100%; width: 100%;
max-width: 400px; 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. */ /* 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 { .link-styled {
@ -155,26 +148,33 @@ const onSubmit = async () => {
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
transition: border-color var(--transition-speed) var(--transition-ease-out); 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); border-bottom-color: var(--primary);
} }
.form-error-text { .form-error-text {
color: var(--danger); color: var(--danger);
font-size: 0.85rem; font-size: 0.85rem;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.alert.form-error-text { /* For general error message */
padding: 0.75rem 1rem; .alert.form-error-text {
margin-bottom: 1rem; /* For general error message */
padding: 0.75rem 1rem;
margin-bottom: 1rem;
} }
.input-with-icon-append { .input-with-icon-append {
position: relative; position: relative;
display: flex; display: flex;
} }
.input-with-icon-append .form-input { .input-with-icon-append .form-input {
padding-right: 3rem; padding-right: 3rem;
} }
.icon-append-btn { .icon-append-btn {
position: absolute; position: absolute;
right: 0; right: 0;
@ -191,9 +191,14 @@ const onSubmit = async () => {
color: var(--dark); color: var(--dark);
opacity: 0.7; opacity: 0.7;
} }
.icon-append-btn:hover, .icon-append-btn:focus {
.icon-append-btn:hover,
.icon-append-btn:focus {
opacity: 1; 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> </style>

View File

@ -17,7 +17,7 @@ router.beforeEach(async (to, from, next) => {
// Auth guard logic // Auth guard logic
const authStore = useAuthStore(); const authStore = useAuthStore();
const isAuthenticated = authStore.isAuthenticated; 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); const requiresAuth = !publicRoutes.includes(to.path);
if (requiresAuth && !isAuthenticated) { if (requiresAuth && !isAuthenticated) {

View File

@ -37,6 +37,7 @@ const routes: RouteRecordRaw[] = [
children: [ children: [
{ path: 'login', name: 'Login', component: () => import('../pages/LoginPage.vue') }, { path: 'login', name: 'Login', component: () => import('../pages/LoginPage.vue') },
{ path: 'signup', name: 'Signup', component: () => import('../pages/SignupPage.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: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
withCredentials: true, // Enable sending cookies and authentication headers
}); });
// Request interceptor // 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 // 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 // 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. // 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, refresh_token: refreshTokenValue,
}); });
@ -77,10 +78,8 @@ export { api, globalAxios };
import { API_VERSION, API_ENDPOINTS } from '@/config/api-config'; import { API_VERSION, API_ENDPOINTS } from '@/config/api-config';
export const getApiUrl = (endpoint: string): string => { export const getApiUrl = (endpoint: string): string => {
// Assuming API_BASE_URL already includes http://localhost:8000 // The API_ENDPOINTS already include the full path, so we just need to combine with base URL
// and endpoint starts with / return `${API_BASE_URL}${endpoint}`;
// The original `getApiUrl` added /api/v1, ensure this is correct for your setup
return `${API_BASE_URL}/api/${API_VERSION}${endpoint}`;
}; };
export const apiClient = { export const apiClient = {

View File

@ -7,7 +7,6 @@ import router from '@/router';
interface AuthState { interface AuthState {
accessToken: string | null; accessToken: string | null;
refreshToken: string | null;
user: { user: {
email: string; email: string;
name: string; name: string;
@ -18,7 +17,6 @@ interface AuthState {
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
// State // State
const accessToken = ref<string | null>(localStorage.getItem('token')); const accessToken = ref<string | null>(localStorage.getItem('token'));
const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'));
const user = ref<AuthState['user']>(null); const user = ref<AuthState['user']>(null);
// Getters // Getters
@ -26,19 +24,15 @@ export const useAuthStore = defineStore('auth', () => {
const getUser = computed(() => user.value); const getUser = computed(() => user.value);
// Actions // Actions
const setTokens = (tokens: { access_token: string; refresh_token: string }) => { const setTokens = (tokens: { access_token: string }) => {
accessToken.value = tokens.access_token; accessToken.value = tokens.access_token;
refreshToken.value = tokens.refresh_token;
localStorage.setItem('token', tokens.access_token); localStorage.setItem('token', tokens.access_token);
localStorage.setItem('refresh_token', tokens.refresh_token);
}; };
const clearTokens = () => { const clearTokens = () => {
accessToken.value = null; accessToken.value = null;
refreshToken.value = null;
user.value = null; user.value = null;
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
}; };
const setUser = (userData: AuthState['user']) => { const setUser = (userData: AuthState['user']) => {
@ -56,20 +50,8 @@ export const useAuthStore = defineStore('auth', () => {
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
console.error('AuthStore: Failed to fetch current user:', error); console.error('AuthStore: Failed to fetch current user:', error);
if (error.response?.status === 401) { clearTokens();
try { return null;
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;
}
} }
}; };
@ -84,8 +66,8 @@ export const useAuthStore = defineStore('auth', () => {
}, },
}); });
const { access_token, refresh_token } = response.data; const { access_token } = response.data;
setTokens({ access_token, refresh_token }); setTokens({ access_token });
await fetchCurrentUser(); await fetchCurrentUser();
return response.data; return response.data;
}; };
@ -95,29 +77,6 @@ export const useAuthStore = defineStore('auth', () => {
return response.data; 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 () => { const logout = async () => {
clearTokens(); clearTokens();
await router.push('/auth/login'); await router.push('/auth/login');
@ -125,7 +84,6 @@ export const useAuthStore = defineStore('auth', () => {
return { return {
accessToken, accessToken,
refreshToken,
user, user,
isAuthenticated, isAuthenticated,
getUser, getUser,
@ -135,7 +93,6 @@ export const useAuthStore = defineStore('auth', () => {
fetchCurrentUser, fetchCurrentUser,
login, login,
signup, signup,
refreshAccessToken,
logout, logout,
}; };
}); });

View File

@ -4,29 +4,62 @@ import { ref, computed } from 'vue';
// import { LocalStorage } from 'quasar'; // REMOVE // import { LocalStorage } from 'quasar'; // REMOVE
import { useStorage } from '@vueuse/core'; // VueUse alternative import { useStorage } from '@vueuse/core'; // VueUse alternative
import { useNotificationStore } from '@/stores/notifications'; // Your custom notification store 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', () => { export const useOfflineStore = defineStore('offline', () => {
// const $q = useQuasar(); // REMOVE // const $q = useQuasar(); // REMOVE
const notificationStore = useNotificationStore(); const notificationStore = useNotificationStore();
const isOnline = ref(navigator.onLine); 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 // Use useStorage for reactive localStorage
const pendingActions = useStorage<OfflineAction[]>('offline-actions', []); const pendingActions = useStorage<OfflineAction[]>('offline-actions', []);
const isProcessingQueue = ref(false); const isProcessingQueue = ref(false);
const showConflictDialog = ref(false); // You'll need to implement this dialog const showConflictDialog = ref(false); // You'll need to implement this dialog
const currentConflict = ref<ConflictData | null>(null); const currentConflict = ref<ConflictData | null>(null);
@ -36,13 +69,12 @@ export const useOfflineStore = defineStore('offline', () => {
// saveToStorage is also handled by useStorage automatically saving on change // saveToStorage is also handled by useStorage automatically saving on change
const addAction = (action: Omit<OfflineAction, 'id' | 'timestamp'>) => { const addAction = (action: Omit<OfflineAction, 'id' | 'timestamp'>) => {
const newAction: OfflineAction = { const newAction = {
...action, ...action,
id: crypto.randomUUID(), id: crypto.randomUUID(),
timestamp: Date.now(), timestamp: Date.now(),
}; } as OfflineAction;
pendingActions.value.push(newAction); pendingActions.value.push(newAction);
// useStorage handles saving
}; };
const processQueue = async () => { const processQueue = async () => {
@ -52,90 +84,290 @@ export const useOfflineStore = defineStore('offline', () => {
for (const action of actionsToProcess) { for (const action of actionsToProcess) {
try { 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); pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
} catch (error) { } catch (error: any) { // Catch error as any to check for our custom flag
if (error instanceof Error && 'response' in error && typeof error.response === 'object' && error.response && 'status' in error.response && error.response.status === 409) { if (error && error.isConflict && error.serverVersionData) {
notificationStore.addNotification({ notificationStore.addNotification({
type: 'warning', type: 'warning',
message: 'Item was modified by someone else. Please review.', message: `Conflict detected for action ${action.type}. Please review.`,
// actions: [ ... ] // Custom actions for notifications would be more complex
}); });
// Here you would trigger the conflict resolution dialog
// For example, find the item and its server version, then: let localData: Record<string, unknown>;
// currentConflict.value = { localVersion: ..., serverVersion: ..., action }; // Extract local data based on action type
// showConflictDialog.value = true; if (action.type === 'update_list' || action.type === 'update_list_item') {
// The loop should probably pause or handle this conflict before continuing localData = (action.payload as UpdateListPayload | UpdateListItemPayload).data;
console.warn('Conflict detected for action:', action.id, error); } else if (action.type === 'create_list' || action.type === 'create_list_item') {
// Break or decide how to handle queue processing on conflict localData = action.payload as CreateListPayload | CreateListItemPayload;
break; } else {
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 { } else {
console.error('Failed to process offline action:', action.id, error); console.error('processQueue: Action failed, remains in queue:', action.id, error);
notificationStore.addNotification({
type: 'error',
message: `Failed to sync action: ${action.type}`,
});
} }
} }
} }
isProcessingQueue.value = false; 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));
};
const processAction = async (action: OfflineAction) => {
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 = () => { const setupNetworkListeners = () => {
window.addEventListener('online', () => { window.addEventListener('online', () => {
isOnline.value = true; isOnline.value = true;
processQueue().catch(err => console.error("Error processing queue on online event:", err)); processQueue().catch(err => console.error("Error processing queue on online event:", err));
}); });
window.addEventListener('offline', () => { window.addEventListener('offline', () => {
isOnline.value = false; isOnline.value = false;
}); });
}; };
setupNetworkListeners(); // Call this once setupNetworkListeners(); // Call this once
const hasPendingActions = computed(() => pendingActions.value.length > 0); const hasPendingActions = computed(() => pendingActions.value.length > 0);
const pendingActionCount = computed(() => pendingActions.value.length); const pendingActionCount = computed(() => pendingActions.value.length);
const handleConflictResolution = (resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }) => { const handleConflictResolution = async (resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }) => {
console.log('Conflict resolution chosen:', resolution); if (!resolution.action || !currentConflict.value) {
// TODO: Implement logic to apply the chosen resolution console.error("handleConflictResolution called without an action or active conflict.");
// This might involve making another API call with the resolved data showConflictDialog.value = false;
// or updating local state and then trying to sync again. currentConflict.value = null;
// After resolving, remove the action from pending or mark as resolved. return;
// For now, just remove it as an example: }
pendingActions.value = pendingActions.value.filter(a => a.id !== resolution.action.id); const { action, version, mergedData } = resolution;
showConflictDialog.value = false; const serverVersionNumber = (currentConflict.value.serverVersion.data as any)?.version;
currentConflict.value = null;
processQueue().catch(err => console.error("Error processing queue after conflict resolution:", err)); // Try processing queue again 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 { return {
isOnline, isOnline,
pendingActions, pendingActions,
hasPendingActions, isProcessingQueue,
pendingActionCount,
showConflictDialog, showConflictDialog,
currentConflict, currentConflict,
addAction, addAction,
processAction,
processQueue, processQueue,
handleConflictResolution, handleConflictResolution,
hasPendingActions,
pendingActionCount,
}; };
}); });

View File

@ -5,7 +5,10 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope & 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 { clientsClaim } from 'workbox-core';
import { import {
@ -17,16 +20,32 @@ import { registerRoute, NavigationRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst } from 'workbox-strategies'; import { CacheFirst, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration'; import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response'; import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import type { WorkboxPlugin } from 'workbox-core/types'; import type { WorkboxPlugin } from 'workbox-core/types';
self.skipWaiting().catch((error) => { // Create a background sync plugin instance
console.error('Error during service worker activation:', error); 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 // Use with precache injection
// vite-plugin-pwa will populate self.__WB_MANIFEST // 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(); 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( registerRoute(
({ url }) => url.pathname.startsWith('/api/'), // Make sure this matches your actual API path structure ({ url }) => url.pathname.startsWith('/api/'), // Make sure this matches your actual API path structure
new NetworkFirst({ new NetworkFirst({
@ -64,6 +83,7 @@ registerRoute(
maxEntries: 50, maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60, // 24 hours maxAgeSeconds: 24 * 60 * 60, // 24 hours
}) as WorkboxPlugin, }) as WorkboxPlugin,
bgSyncPlugin, // Add background sync plugin for failed requests
], ],
}) })
); );
@ -80,4 +100,7 @@ if (import.meta.env.MODE !== 'ssr' || import.meta.env.PROD) {
denylist: [PWA_SERVICE_WORKER_REGEX, /workbox-(.)*\.js$/], denylist: [PWA_SERVICE_WORKER_REGEX, /workbox-(.)*\.js$/],
}), }),
); );
} }
// Initialize the service worker
initializeSW();

View File

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