Compare commits
6 Commits
9583aa4bab
...
515534dcce
Author | SHA1 | Date | |
---|---|---|---|
![]() |
515534dcce | ||
![]() |
3f0cfff9f1 | ||
![]() |
72b988b79b | ||
![]() |
1c08e57afd | ||
![]() |
29682b7e9c | ||
![]() |
18f759aa7c |
@ -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 ###
|
@ -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
91
be/app/api/auth/oauth.py
Normal 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}"
|
||||
)
|
@ -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
|
@ -1,8 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import health
|
||||
from app.api.v1.endpoints import auth
|
||||
from app.api.v1.endpoints import users
|
||||
from app.api.v1.endpoints import groups
|
||||
from app.api.v1.endpoints import invites
|
||||
from app.api.v1.endpoints import lists
|
||||
@ -14,8 +12,6 @@ from app.api.v1.endpoints import financials
|
||||
api_router_v1 = APIRouter()
|
||||
|
||||
api_router_v1.include_router(health.router)
|
||||
api_router_v1.include_router(auth.router, prefix="/auth", tags=["Authentication"])
|
||||
api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])
|
||||
api_router_v1.include_router(groups.router, prefix="/groups", tags=["Groups"])
|
||||
api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"])
|
||||
api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"])
|
||||
|
@ -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
|
||||
)
|
@ -7,7 +7,7 @@ from sqlalchemy.orm import Session, selectinload
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from app.database import get_db
|
||||
from app.api.dependencies import get_current_user
|
||||
from app.auth import current_active_user
|
||||
from app.models import (
|
||||
User as UserModel,
|
||||
Group as GroupModel,
|
||||
@ -41,7 +41,7 @@ router = APIRouter()
|
||||
async def get_list_cost_summary(
|
||||
list_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Retrieves a calculated cost summary for a specific list, detailing total costs,
|
||||
@ -184,7 +184,7 @@ async def get_list_cost_summary(
|
||||
async def get_group_balance_summary(
|
||||
group_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Retrieves a detailed financial balance summary for all users within a specific group.
|
||||
|
@ -6,7 +6,7 @@ from sqlalchemy import select
|
||||
from typing import List as PyList, Optional, Sequence
|
||||
|
||||
from app.database import get_db
|
||||
from app.api.dependencies import get_current_user
|
||||
from app.auth import current_active_user
|
||||
from app.models import User as UserModel, Group as GroupModel, List as ListModel, UserGroup as UserGroupModel, UserRoleEnum
|
||||
from app.schemas.expense import (
|
||||
ExpenseCreate, ExpensePublic,
|
||||
@ -47,7 +47,7 @@ async def check_list_access_for_financials(db: AsyncSession, list_id: int, user_
|
||||
async def create_new_expense(
|
||||
expense_in: ExpenseCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
logger.info(f"User {current_user.email} creating expense: {expense_in.description}")
|
||||
effective_group_id = expense_in.group_id
|
||||
@ -110,7 +110,7 @@ async def create_new_expense(
|
||||
async def get_expense(
|
||||
expense_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
logger.info(f"User {current_user.email} requesting expense ID {expense_id}")
|
||||
expense = await crud_expense.get_expense_by_id(db, expense_id=expense_id)
|
||||
@ -131,7 +131,7 @@ async def list_list_expenses(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
logger.info(f"User {current_user.email} listing expenses for list ID {list_id}")
|
||||
await check_list_access_for_financials(db, list_id, current_user.id)
|
||||
@ -144,7 +144,7 @@ async def list_group_expenses(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
logger.info(f"User {current_user.email} listing expenses for group ID {group_id}")
|
||||
await crud_group.check_group_membership(db, group_id=group_id, user_id=current_user.id, action="list expenses for")
|
||||
@ -156,7 +156,7 @@ async def update_expense_details(
|
||||
expense_id: int,
|
||||
expense_in: ExpenseUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Updates an existing expense (description, currency, expense_date only).
|
||||
@ -210,7 +210,7 @@ async def delete_expense_record(
|
||||
expense_id: int,
|
||||
expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Deletes an expense and its associated splits.
|
||||
@ -274,7 +274,7 @@ async def delete_expense_record(
|
||||
async def create_new_settlement(
|
||||
settlement_in: SettlementCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
logger.info(f"User {current_user.email} recording settlement in group {settlement_in.group_id}")
|
||||
await crud_group.check_group_membership(db, group_id=settlement_in.group_id, user_id=current_user.id, action="record settlements in")
|
||||
@ -300,7 +300,7 @@ async def create_new_settlement(
|
||||
async def get_settlement(
|
||||
settlement_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
logger.info(f"User {current_user.email} requesting settlement ID {settlement_id}")
|
||||
settlement = await crud_settlement.get_settlement_by_id(db, settlement_id=settlement_id)
|
||||
@ -322,7 +322,7 @@ async def list_group_settlements(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
logger.info(f"User {current_user.email} listing settlements for group ID {group_id}")
|
||||
await crud_group.check_group_membership(db, group_id=group_id, user_id=current_user.id, action="list settlements for this group")
|
||||
@ -334,7 +334,7 @@ async def update_settlement_details(
|
||||
settlement_id: int,
|
||||
settlement_in: SettlementUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Updates an existing settlement (description, settlement_date only).
|
||||
@ -388,7 +388,7 @@ async def delete_settlement_record(
|
||||
settlement_id: int,
|
||||
expected_version: Optional[int] = Query(None, description="Expected version for optimistic locking"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Deletes a settlement.
|
||||
@ -437,6 +437,3 @@ async def delete_settlement_record(
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.")
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# TODO (remaining from original list):
|
||||
# (None - GET/POST/PUT/DELETE implemented for Expense/Settlement)
|
@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.api.dependencies import get_current_user
|
||||
from app.auth import current_active_user
|
||||
from app.models import User as UserModel, UserRoleEnum # Import model and enum
|
||||
from app.schemas.group import GroupCreate, GroupPublic
|
||||
from app.schemas.invite import InviteCodePublic
|
||||
@ -37,7 +37,7 @@ router = APIRouter()
|
||||
async def create_group(
|
||||
group_in: GroupCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""Creates a new group, adding the creator as the owner."""
|
||||
logger.info(f"User {current_user.email} creating group: {group_in.name}")
|
||||
@ -55,7 +55,7 @@ async def create_group(
|
||||
)
|
||||
async def read_user_groups(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""Retrieves all groups the current user is a member of."""
|
||||
logger.info(f"Fetching groups for user: {current_user.email}")
|
||||
@ -72,7 +72,7 @@ async def read_user_groups(
|
||||
async def read_group(
|
||||
group_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""Retrieves details for a specific group, including members, if the user is part of it."""
|
||||
logger.info(f"User {current_user.email} requesting details for group ID: {group_id}")
|
||||
@ -99,7 +99,7 @@ async def read_group(
|
||||
async def create_group_invite(
|
||||
group_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""Generates a new invite code for the group. Requires owner/admin role (MVP: owner only)."""
|
||||
logger.info(f"User {current_user.email} attempting to create invite for group {group_id}")
|
||||
@ -132,7 +132,7 @@ async def create_group_invite(
|
||||
async def leave_group(
|
||||
group_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""Removes the current user from the specified group."""
|
||||
logger.info(f"User {current_user.email} attempting to leave group {group_id}")
|
||||
@ -171,7 +171,7 @@ async def remove_group_member(
|
||||
group_id: int,
|
||||
user_id_to_remove: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""Removes a specified user from the group. Requires current user to be owner."""
|
||||
logger.info(f"Owner {current_user.email} attempting to remove user {user_id_to_remove} from group {group_id}")
|
||||
@ -210,7 +210,7 @@ async def remove_group_member(
|
||||
async def read_group_lists(
|
||||
group_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""Retrieves all lists belonging to a specific group, if the user is a member."""
|
||||
logger.info(f"User {current_user.email} requesting lists for group ID: {group_id}")
|
||||
|
@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql import text
|
||||
|
||||
from app.database import get_db
|
||||
from app.database import get_async_session
|
||||
from app.schemas.health import HealthStatus
|
||||
from app.core.exceptions import DatabaseConnectionError
|
||||
|
||||
@ -18,7 +18,7 @@ router = APIRouter()
|
||||
description="Checks the operational status of the API and its connection to the database.",
|
||||
tags=["Health"]
|
||||
)
|
||||
async def check_health(db: AsyncSession = Depends(get_db)):
|
||||
async def check_health(db: AsyncSession = Depends(get_async_session)):
|
||||
"""
|
||||
Health check endpoint. Verifies API reachability and database connection.
|
||||
"""
|
||||
|
@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.api.dependencies import get_current_user
|
||||
from app.auth import current_active_user
|
||||
from app.models import User as UserModel, UserRoleEnum
|
||||
from app.schemas.invite import InviteAccept
|
||||
from app.schemas.message import Message
|
||||
@ -31,7 +31,7 @@ router = APIRouter()
|
||||
async def accept_invite(
|
||||
invite_in: InviteAccept,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""Accepts a group invite using the provided invite code."""
|
||||
logger.info(f"User {current_user.email} attempting to accept invite code: {invite_in.invite_code}")
|
||||
|
@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.api.dependencies import get_current_user
|
||||
from app.auth import current_active_user
|
||||
# --- Import Models Correctly ---
|
||||
from app.models import User as UserModel
|
||||
from app.models import Item as ItemModel # <-- IMPORT Item and alias it
|
||||
@ -24,7 +24,7 @@ router = APIRouter()
|
||||
async def get_item_and_verify_access(
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user)
|
||||
current_user: UserModel = Depends(current_active_user)
|
||||
) -> ItemModel:
|
||||
"""Dependency to get an item and verify the user has access to its list."""
|
||||
item_db = await crud_item.get_item_by_id(db, item_id=item_id)
|
||||
@ -53,7 +53,7 @@ async def create_list_item(
|
||||
list_id: int,
|
||||
item_in: ItemCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""Adds a new item to a specific list. User must have access to the list."""
|
||||
user_email = current_user.email # Access email attribute before async operations
|
||||
@ -81,7 +81,7 @@ async def create_list_item(
|
||||
async def read_list_items(
|
||||
list_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
# Add sorting/filtering params later if needed: sort_by: str = 'created_at', order: str = 'asc'
|
||||
):
|
||||
"""Retrieves all items for a specific list if the user has access."""
|
||||
@ -112,7 +112,7 @@ async def update_item(
|
||||
item_in: ItemUpdate,
|
||||
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user), # Need user ID for completed_by
|
||||
current_user: UserModel = Depends(current_active_user), # Need user ID for completed_by
|
||||
):
|
||||
"""
|
||||
Updates an item's details (name, quantity, is_complete, price).
|
||||
@ -153,7 +153,7 @@ async def delete_item(
|
||||
expected_version: Optional[int] = Query(None, description="The expected version of the item to delete for optimistic locking."),
|
||||
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user), # Log who deleted it
|
||||
current_user: UserModel = Depends(current_active_user), # Log who deleted it
|
||||
):
|
||||
"""
|
||||
Deletes an item. User must have access to the list the item belongs to.
|
||||
|
@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Response, Query #
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.api.dependencies import get_current_user
|
||||
from app.auth import current_active_user
|
||||
from app.models import User as UserModel
|
||||
from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail
|
||||
from app.schemas.message import Message # For simple responses
|
||||
@ -18,7 +18,8 @@ from app.core.exceptions import (
|
||||
ListNotFoundError,
|
||||
ListPermissionError,
|
||||
ListStatusNotFoundError,
|
||||
ConflictError # Added ConflictError
|
||||
ConflictError, # Added ConflictError
|
||||
DatabaseIntegrityError # Added DatabaseIntegrityError
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -29,17 +30,24 @@ router = APIRouter()
|
||||
response_model=ListPublic, # Return basic list info on creation
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create New List",
|
||||
tags=["Lists"]
|
||||
tags=["Lists"],
|
||||
responses={
|
||||
status.HTTP_409_CONFLICT: {
|
||||
"description": "Conflict: A list with this name already exists in the specified group",
|
||||
"model": ListPublic
|
||||
}
|
||||
}
|
||||
)
|
||||
async def create_list(
|
||||
list_in: ListCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Creates a new shopping list.
|
||||
- If `group_id` is provided, the user must be a member of that group.
|
||||
- If `group_id` is null, it's a personal list.
|
||||
- If a list with the same name already exists in the group, returns 409 with the existing list.
|
||||
"""
|
||||
logger.info(f"User {current_user.email} creating list: {list_in.name}")
|
||||
group_id = list_in.group_id
|
||||
@ -51,9 +59,29 @@ async def create_list(
|
||||
logger.warning(f"User {current_user.email} attempted to create list in group {group_id} but is not a member.")
|
||||
raise GroupMembershipError(group_id, "create lists")
|
||||
|
||||
created_list = await crud_list.create_list(db=db, list_in=list_in, creator_id=current_user.id)
|
||||
logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.")
|
||||
return created_list
|
||||
try:
|
||||
created_list = await crud_list.create_list(db=db, list_in=list_in, creator_id=current_user.id)
|
||||
logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.")
|
||||
return created_list
|
||||
except DatabaseIntegrityError as e:
|
||||
# Check if this is a unique constraint violation
|
||||
if "unique constraint" in str(e).lower():
|
||||
# Find the existing list with the same name in the group
|
||||
existing_list = await crud_list.get_list_by_name_and_group(
|
||||
db=db,
|
||||
name=list_in.name,
|
||||
group_id=group_id,
|
||||
user_id=current_user.id
|
||||
)
|
||||
if existing_list:
|
||||
logger.info(f"List '{list_in.name}' already exists in group {group_id}. Returning existing list.")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"A list named '{list_in.name}' already exists in this group.",
|
||||
headers={"X-Existing-List": str(existing_list.id)}
|
||||
)
|
||||
# If it's not a unique constraint or we couldn't find the existing list, re-raise
|
||||
raise
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -64,7 +92,7 @@ async def create_list(
|
||||
)
|
||||
async def read_lists(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
# Add pagination parameters later if needed: skip: int = 0, limit: int = 100
|
||||
):
|
||||
"""
|
||||
@ -86,7 +114,7 @@ async def read_lists(
|
||||
async def read_list(
|
||||
list_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Retrieves details for a specific list, including its items,
|
||||
@ -111,7 +139,7 @@ async def update_list(
|
||||
list_id: int,
|
||||
list_in: ListUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Updates a list's details (name, description, is_complete).
|
||||
@ -149,7 +177,7 @@ async def delete_list(
|
||||
list_id: int,
|
||||
expected_version: Optional[int] = Query(None, description="The expected version of the list to delete for optimistic locking."),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Deletes a list. Requires user to be the creator of the list.
|
||||
@ -184,7 +212,7 @@ async def delete_list(
|
||||
async def read_list_status(
|
||||
list_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Retrieves the last update time for the list and its items, plus item count.
|
||||
|
@ -4,7 +4,7 @@ from typing import List
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status
|
||||
from google.api_core import exceptions as google_exceptions
|
||||
|
||||
from app.api.dependencies import get_current_user
|
||||
from app.auth import current_active_user
|
||||
from app.models import User as UserModel
|
||||
from app.schemas.ocr import OcrExtractResponse
|
||||
from app.core.gemini import extract_items_from_image_gemini, gemini_initialization_error, GeminiOCRService
|
||||
@ -30,7 +30,7 @@ ocr_service = GeminiOCRService()
|
||||
tags=["OCR"]
|
||||
)
|
||||
async def ocr_extract_items(
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
image_file: UploadFile = File(..., description="Image file (JPEG, PNG, WEBP) of the shopping list or receipt."),
|
||||
):
|
||||
"""
|
||||
|
@ -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
91
be/app/auth.py
Normal 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)
|
@ -4,6 +4,7 @@ from pydantic_settings import BaseSettings
|
||||
from dotenv import load_dotenv
|
||||
import logging
|
||||
import secrets
|
||||
from typing import List
|
||||
|
||||
load_dotenv()
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -11,12 +12,12 @@ logger = logging.getLogger(__name__)
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str | None = None
|
||||
GEMINI_API_KEY: str | None = None
|
||||
SENTRY_DSN: str | None = None # Sentry DSN for error tracking
|
||||
|
||||
# --- JWT Settings ---
|
||||
# --- JWT Settings --- (SECRET_KEY is used by FastAPI-Users)
|
||||
SECRET_KEY: str # Must be set via environment variable
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # Default token lifetime: 30 minutes
|
||||
REFRESH_TOKEN_EXPIRE_MINUTES: int = 10080 # Default refresh token lifetime: 7 days
|
||||
# ALGORITHM: str = "HS256" # Handled by FastAPI-Users strategy
|
||||
# ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # This specific line is commented, the one under Session Settings is used.
|
||||
|
||||
# --- OCR Settings ---
|
||||
MAX_FILE_SIZE_MB: int = 10 # Maximum allowed file size for OCR processing
|
||||
@ -58,11 +59,14 @@ Organic Bananas
|
||||
API_DOCS_URL: str = "/api/docs"
|
||||
API_REDOC_URL: str = "/api/redoc"
|
||||
CORS_ORIGINS: list[str] = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8000",
|
||||
# Add your deployed frontend URL here later
|
||||
# "https://your-frontend-domain.com",
|
||||
"http://localhost:5173", # Frontend dev server
|
||||
"http://localhost:5174", # Alternative Vite port
|
||||
"http://localhost:8000", # Backend server
|
||||
"http://127.0.0.1:5173", # Frontend with IP
|
||||
"http://127.0.0.1:5174", # Alternative Vite with IP
|
||||
"http://127.0.0.1:8000", # Backend with IP
|
||||
]
|
||||
FRONTEND_URL: str = "http://localhost:5173" # URL for the frontend application
|
||||
|
||||
# --- API Metadata ---
|
||||
API_TITLE: str = "Shared Lists API"
|
||||
@ -78,15 +82,6 @@ Organic Bananas
|
||||
HEALTH_STATUS_OK: str = "ok"
|
||||
HEALTH_STATUS_ERROR: str = "error"
|
||||
|
||||
# --- Auth Settings ---
|
||||
OAUTH2_TOKEN_URL: str = "/api/v1/auth/login" # Path to login endpoint
|
||||
TOKEN_TYPE: str = "bearer" # Default token type for OAuth2
|
||||
AUTH_HEADER_PREFIX: str = "Bearer" # Prefix for Authorization header
|
||||
AUTH_HEADER_NAME: str = "WWW-Authenticate" # Name of auth header
|
||||
AUTH_CREDENTIALS_ERROR: str = "Could not validate credentials"
|
||||
AUTH_INVALID_CREDENTIALS: str = "Incorrect email or password"
|
||||
AUTH_NOT_AUTHENTICATED: str = "Not authenticated"
|
||||
|
||||
# --- HTTP Status Messages ---
|
||||
HTTP_400_DETAIL: str = "Bad Request"
|
||||
HTTP_401_DETAIL: str = "Unauthorized"
|
||||
@ -103,6 +98,20 @@ Organic Bananas
|
||||
DB_TRANSACTION_ERROR: str = "Database transaction error"
|
||||
DB_QUERY_ERROR: str = "Database query error"
|
||||
|
||||
# OAuth Settings
|
||||
GOOGLE_CLIENT_ID: str = ""
|
||||
GOOGLE_CLIENT_SECRET: str = ""
|
||||
GOOGLE_REDIRECT_URI: str = "http://localhost:8000/auth/google/callback"
|
||||
|
||||
APPLE_CLIENT_ID: str = ""
|
||||
APPLE_TEAM_ID: str = ""
|
||||
APPLE_KEY_ID: str = ""
|
||||
APPLE_PRIVATE_KEY: str = ""
|
||||
APPLE_REDIRECT_URI: str = "http://localhost:8000/auth/apple/callback"
|
||||
|
||||
# Session Settings
|
||||
SESSION_SECRET_KEY: str = "your-session-secret-key" # Change this in production
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = 'utf-8'
|
||||
|
@ -45,120 +45,14 @@ def hash_password(password: str) -> str:
|
||||
|
||||
|
||||
# --- JSON Web Tokens (JWT) ---
|
||||
|
||||
def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""
|
||||
Creates a JWT access token.
|
||||
|
||||
Args:
|
||||
subject: The subject of the token (e.g., user ID or email).
|
||||
expires_delta: Optional timedelta object for token expiry. If None,
|
||||
uses ACCESS_TOKEN_EXPIRE_MINUTES from settings.
|
||||
|
||||
Returns:
|
||||
The encoded JWT access token string.
|
||||
"""
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
|
||||
# Data to encode in the token payload
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "access"}
|
||||
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
def create_refresh_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""
|
||||
Creates a JWT refresh token.
|
||||
|
||||
Args:
|
||||
subject: The subject of the token (e.g., user ID or email).
|
||||
expires_delta: Optional timedelta object for token expiry. If None,
|
||||
uses REFRESH_TOKEN_EXPIRE_MINUTES from settings.
|
||||
|
||||
Returns:
|
||||
The encoded JWT refresh token string.
|
||||
"""
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
|
||||
# Data to encode in the token payload
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"}
|
||||
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_access_token(token: str) -> Optional[dict]:
|
||||
"""
|
||||
Verifies a JWT access token and returns its payload if valid.
|
||||
|
||||
Args:
|
||||
token: The JWT token string to verify.
|
||||
|
||||
Returns:
|
||||
The decoded token payload (dict) if the token is valid and not expired,
|
||||
otherwise None.
|
||||
"""
|
||||
try:
|
||||
# Decode the token. This also automatically verifies:
|
||||
# - Signature (using SECRET_KEY and ALGORITHM)
|
||||
# - Expiration ('exp' claim)
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
if payload.get("type") != "access":
|
||||
raise JWTError("Invalid token type")
|
||||
return payload
|
||||
except JWTError as e:
|
||||
# Handles InvalidSignatureError, ExpiredSignatureError, etc.
|
||||
print(f"JWT Error: {e}") # Log the error for debugging
|
||||
return None
|
||||
except Exception as e:
|
||||
# Handle other potential unexpected errors during decoding
|
||||
print(f"Unexpected error decoding JWT: {e}")
|
||||
return None
|
||||
|
||||
def verify_refresh_token(token: str) -> Optional[dict]:
|
||||
"""
|
||||
Verifies a JWT refresh token and returns its payload if valid.
|
||||
|
||||
Args:
|
||||
token: The JWT token string to verify.
|
||||
|
||||
Returns:
|
||||
The decoded token payload (dict) if the token is valid, not expired,
|
||||
and is a refresh token, otherwise None.
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
if payload.get("type") != "refresh":
|
||||
raise JWTError("Invalid token type")
|
||||
return payload
|
||||
except JWTError as e:
|
||||
print(f"JWT Error: {e}") # Log the error for debugging
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Unexpected error decoding JWT: {e}")
|
||||
return None
|
||||
# FastAPI-Users now handles all tokenization.
|
||||
|
||||
# You might add a function here later to extract the 'sub' (subject/user id)
|
||||
# specifically, often used in dependency injection for authentication.
|
||||
# def get_subject_from_token(token: str) -> Optional[str]:
|
||||
# payload = verify_access_token(token)
|
||||
# # This would need to use FastAPI-Users' token verification if ever implemented
|
||||
# # For example, by decoding the token using the strategy from the auth backend
|
||||
# payload = {} # Placeholder for actual token decoding logic
|
||||
# if payload:
|
||||
# return payload.get("sub")
|
||||
# return None
|
@ -207,3 +207,45 @@ async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus:
|
||||
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
|
||||
except SQLAlchemyError as e:
|
||||
raise DatabaseQueryError(f"Failed to get list status: {str(e)}")
|
||||
|
||||
async def get_list_by_name_and_group(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
group_id: Optional[int],
|
||||
user_id: int
|
||||
) -> Optional[ListModel]:
|
||||
"""
|
||||
Gets a list by name and group, ensuring the user has permission to access it.
|
||||
Used for conflict resolution when creating lists.
|
||||
"""
|
||||
try:
|
||||
# Build the base query
|
||||
query = select(ListModel).where(ListModel.name == name)
|
||||
|
||||
# Add group condition
|
||||
if group_id is not None:
|
||||
query = query.where(ListModel.group_id == group_id)
|
||||
else:
|
||||
query = query.where(ListModel.group_id.is_(None))
|
||||
|
||||
# Add permission conditions
|
||||
conditions = [
|
||||
ListModel.created_by_id == user_id # User is creator
|
||||
]
|
||||
if group_id is not None:
|
||||
# User is member of the group
|
||||
conditions.append(
|
||||
and_(
|
||||
ListModel.group_id == group_id,
|
||||
ListModel.created_by_id != user_id # Not the creator
|
||||
)
|
||||
)
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().first()
|
||||
except OperationalError as e:
|
||||
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
|
||||
except SQLAlchemyError as e:
|
||||
raise DatabaseQueryError(f"Failed to query list by name and group: {str(e)}")
|
@ -163,6 +163,3 @@ async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, ex
|
||||
await db.rollback()
|
||||
raise InvalidOperationError(f"Failed to delete settlement: {str(e)}")
|
||||
return None
|
||||
|
||||
# TODO: Implement update_settlement (consider restrictions, versioning)
|
||||
# TODO: Implement delete_settlement (consider implications on balances)
|
@ -30,7 +30,7 @@ AsyncSessionLocal = sessionmaker(
|
||||
Base = declarative_base()
|
||||
|
||||
# Dependency to get DB session in path operations
|
||||
async def get_db() -> AsyncSession: # type: ignore
|
||||
async def get_async_session() -> AsyncSession: # type: ignore
|
||||
"""
|
||||
Dependency function that yields an AsyncSession.
|
||||
Ensures the session is closed after the request.
|
||||
@ -45,3 +45,6 @@ async def get_db() -> AsyncSession: # type: ignore
|
||||
raise
|
||||
finally:
|
||||
await session.close() # Not strictly necessary with async context manager, but explicit
|
||||
|
||||
# Alias for backward compatibility
|
||||
get_db = get_async_session
|
@ -3,12 +3,31 @@ import logging
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||
|
||||
from app.api.api_router import api_router
|
||||
from app.config import settings
|
||||
from app.core.api_config import API_METADATA, API_TAGS
|
||||
# Import database and models if needed for startup/shutdown events later
|
||||
# from . import database, models
|
||||
from app.auth import fastapi_users, auth_backend
|
||||
from app.models import User
|
||||
from app.api.auth.oauth import router as oauth_router
|
||||
from app.schemas.user import UserPublic, UserCreate, UserUpdate
|
||||
|
||||
# Initialize Sentry
|
||||
sentry_sdk.init(
|
||||
dsn=settings.SENTRY_DSN,
|
||||
integrations=[
|
||||
FastApiIntegration(),
|
||||
],
|
||||
# Set traces_sample_rate to 1.0 to capture 100% of transactions for performance monitoring.
|
||||
# We recommend adjusting this value in production.
|
||||
traces_sample_rate=1.0,
|
||||
# If you wish to associate users to errors (assuming you are using
|
||||
# FastAPI's users system) you may enable sending PII data.
|
||||
send_default_pii=True
|
||||
)
|
||||
|
||||
# --- Logging Setup ---
|
||||
logging.basicConfig(
|
||||
@ -23,30 +42,56 @@ app = FastAPI(
|
||||
openapi_tags=API_TAGS
|
||||
)
|
||||
|
||||
# --- CORS Middleware ---
|
||||
# Define allowed origins. Be specific in production!
|
||||
# Use ["*"] for wide open access during early development if needed,
|
||||
# but restrict it as soon as possible.
|
||||
# SvelteKit default dev port is 5173
|
||||
origins = [
|
||||
"http://localhost:5174",
|
||||
"http://localhost:8000", # Allow requests from the API itself (e.g., Swagger UI)
|
||||
# Add your deployed frontend URL here later
|
||||
# "https://your-frontend-domain.com",
|
||||
]
|
||||
# Add session middleware for OAuth
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.SESSION_SECRET_KEY
|
||||
)
|
||||
|
||||
# --- CORS Middleware ---
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["*"]
|
||||
)
|
||||
# --- End CORS Middleware ---
|
||||
|
||||
|
||||
# --- Include API Routers ---
|
||||
# All API endpoints will be prefixed with /api
|
||||
# Include FastAPI-Users routes
|
||||
app.include_router(
|
||||
fastapi_users.get_auth_router(auth_backend),
|
||||
prefix="/auth/jwt",
|
||||
tags=["auth"],
|
||||
)
|
||||
app.include_router(
|
||||
fastapi_users.get_register_router(UserPublic, UserCreate),
|
||||
prefix="/auth",
|
||||
tags=["auth"],
|
||||
)
|
||||
app.include_router(
|
||||
fastapi_users.get_reset_password_router(),
|
||||
prefix="/auth",
|
||||
tags=["auth"],
|
||||
)
|
||||
app.include_router(
|
||||
fastapi_users.get_verify_router(UserPublic),
|
||||
prefix="/auth",
|
||||
tags=["auth"],
|
||||
)
|
||||
app.include_router(
|
||||
fastapi_users.get_users_router(UserPublic, UserUpdate),
|
||||
prefix="/users",
|
||||
tags=["users"],
|
||||
)
|
||||
|
||||
# Include OAuth routes
|
||||
app.include_router(oauth_router, prefix="/auth", tags=["auth"])
|
||||
|
||||
# Include your API router
|
||||
app.include_router(api_router, prefix=settings.API_PREFIX)
|
||||
# --- End Include API Routers ---
|
||||
|
||||
@ -59,23 +104,23 @@ async def read_root():
|
||||
Useful for basic reachability checks.
|
||||
"""
|
||||
logger.info("Root endpoint '/' accessed.")
|
||||
return {"message": settings.ROOT_MESSAGE}
|
||||
return {"message": "Welcome to the API"}
|
||||
# --- End Root Endpoint ---
|
||||
|
||||
|
||||
# --- Application Startup/Shutdown Events (Optional) ---
|
||||
# @app.on_event("startup")
|
||||
# async def startup_event():
|
||||
# logger.info("Application startup: Connecting to database...")
|
||||
# # You might perform initial checks or warm-up here
|
||||
# # await database.engine.connect() # Example check (get_db handles sessions per request)
|
||||
# logger.info("Application startup complete.")
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
logger.info("Application startup: Connecting to database...")
|
||||
# You might perform initial checks or warm-up here
|
||||
# await database.engine.connect() # Example check (get_db handles sessions per request)
|
||||
logger.info("Application startup complete.")
|
||||
|
||||
# @app.on_event("shutdown")
|
||||
# async def shutdown_event():
|
||||
# logger.info("Application shutdown: Disconnecting from database...")
|
||||
# # await database.engine.dispose() # Close connection pool
|
||||
# logger.info("Application shutdown complete.")
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
logger.info("Application shutdown: Disconnecting from database...")
|
||||
# await database.engine.dispose() # Close connection pool
|
||||
logger.info("Application shutdown complete.")
|
||||
# --- End Events ---
|
||||
|
||||
|
||||
|
@ -19,7 +19,8 @@ from sqlalchemy import (
|
||||
func,
|
||||
text as sa_text,
|
||||
Text, # <-- Add Text for description
|
||||
Numeric # <-- Add Numeric for price
|
||||
Numeric, # <-- Add Numeric for price
|
||||
CheckConstraint
|
||||
)
|
||||
from sqlalchemy.orm import relationship, backref
|
||||
|
||||
@ -44,8 +45,11 @@ class User(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String, nullable=False)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
name = Column(String, index=True, nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
is_superuser = Column(Boolean, default=False, nullable=False)
|
||||
is_verified = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# --- Relationships ---
|
||||
@ -184,15 +188,15 @@ class Expense(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
description = Column(String, nullable=False)
|
||||
total_amount = Column(Numeric(10, 2), nullable=False)
|
||||
currency = Column(String, nullable=False, default="USD") # Consider making this an Enum too if few currencies
|
||||
currency = Column(String, nullable=False, default="USD")
|
||||
expense_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
split_type = Column(SAEnum(SplitTypeEnum, name="splittypeenum", create_type=True), nullable=False)
|
||||
|
||||
# Foreign Keys
|
||||
list_id = Column(Integer, ForeignKey("lists.id"), nullable=True)
|
||||
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) # If not list-specific but group-specific
|
||||
item_id = Column(Integer, ForeignKey("items.id"), nullable=True) # If the expense is for a specific item
|
||||
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
list_id = Column(Integer, ForeignKey("lists.id"), nullable=True, index=True)
|
||||
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True, index=True)
|
||||
item_id = Column(Integer, ForeignKey("items.id"), nullable=True)
|
||||
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
@ -206,27 +210,25 @@ class Expense(Base):
|
||||
splits = relationship("ExpenseSplit", back_populates="expense", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
# Example: Ensure either list_id or group_id is present if item_id is null
|
||||
# CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'),
|
||||
# Ensure at least one context is provided
|
||||
CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'),
|
||||
)
|
||||
|
||||
class ExpenseSplit(Base):
|
||||
__tablename__ = "expense_splits"
|
||||
__table_args__ = (UniqueConstraint('expense_id', 'user_id', name='uq_expense_user_split'),)
|
||||
__table_args__ = (
|
||||
UniqueConstraint('expense_id', 'user_id', name='uq_expense_user_split'),
|
||||
Index('ix_expense_splits_user_id', 'user_id'), # For looking up user's splits
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
expense_id = Column(Integer, ForeignKey("expenses.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
owed_amount = Column(Numeric(10, 2), nullable=False) # For EQUAL or EXACT_AMOUNTS
|
||||
# For PERCENTAGE split (value from 0.00 to 100.00)
|
||||
owed_amount = Column(Numeric(10, 2), nullable=False)
|
||||
share_percentage = Column(Numeric(5, 2), nullable=True)
|
||||
# For SHARES split (e.g., user A has 2 shares, user B has 3 shares)
|
||||
share_units = Column(Integer, nullable=True)
|
||||
|
||||
# is_settled might be better tracked via actual Settlement records or a reconciliation process
|
||||
# is_settled = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
@ -234,14 +236,13 @@ class ExpenseSplit(Base):
|
||||
expense = relationship("Expense", back_populates="splits")
|
||||
user = relationship("User", foreign_keys=[user_id], back_populates="expense_splits")
|
||||
|
||||
|
||||
class Settlement(Base):
|
||||
__tablename__ = "settlements"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False) # Settlements usually within a group
|
||||
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
paid_to_user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False, index=True)
|
||||
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
paid_to_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
amount = Column(Numeric(10, 2), nullable=False)
|
||||
settlement_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
@ -257,7 +258,7 @@ class Settlement(Base):
|
||||
|
||||
__table_args__ = (
|
||||
# Ensure payer and payee are different users
|
||||
# CheckConstraint('paid_by_user_id <> paid_to_user_id', name='chk_settlement_payer_ne_payee'),
|
||||
CheckConstraint('paid_by_user_id != paid_to_user_id', name='chk_settlement_different_users'),
|
||||
)
|
||||
|
||||
# Potential future: PaymentMethod model, etc.
|
@ -12,9 +12,22 @@ class UserBase(BaseModel):
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
|
||||
# Properties to receive via API on update (optional, add later if needed)
|
||||
# class UserUpdate(UserBase):
|
||||
# password: Optional[str] = None
|
||||
def create_update_dict(self):
|
||||
return {
|
||||
"email": self.email,
|
||||
"name": self.name,
|
||||
"password": self.password,
|
||||
"is_active": True,
|
||||
"is_superuser": False,
|
||||
"is_verified": False
|
||||
}
|
||||
|
||||
# Properties to receive via API on update
|
||||
class UserUpdate(UserBase):
|
||||
password: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_superuser: Optional[bool] = None
|
||||
is_verified: Optional[bool] = None
|
||||
|
||||
# Properties stored in DB
|
||||
class UserInDBBase(UserBase):
|
||||
|
@ -10,3 +10,10 @@ passlib[bcrypt]>=1.7.4
|
||||
python-jose[cryptography]>=3.3.0
|
||||
pydantic[email]
|
||||
google-generativeai>=0.5.0
|
||||
sentry-sdk[fastapi]>=1.39.0
|
||||
python-multipart>=0.0.6 # Required for form data handling
|
||||
fastapi-users[sqlalchemy]>=12.1.2
|
||||
email-validator>=2.0.0
|
||||
fastapi-users[oauth]>=12.1.2
|
||||
authlib>=1.3.0
|
||||
itsdangerous>=2.1.2
|
@ -1,24 +1,21 @@
|
||||
# docker-compose.yml (in project root)
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15 # Use a specific PostgreSQL version
|
||||
image: postgres:17 # Use a specific PostgreSQL version
|
||||
container_name: postgres_db
|
||||
environment:
|
||||
POSTGRES_USER: dev_user # Define DB user
|
||||
POSTGRES_PASSWORD: dev_password # Define DB password
|
||||
POSTGRES_DB: dev_db # Define Database name
|
||||
POSTGRES_USER: dev_user # Define DB user
|
||||
POSTGRES_PASSWORD: dev_password # Define DB password
|
||||
POSTGRES_DB: dev_db # Define Database name
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data # Persist data using a named volume
|
||||
ports:
|
||||
- "5432:5432" # Expose PostgreSQL port to host (optional, for direct access)
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
@ -37,29 +34,29 @@ services:
|
||||
# Uses the service name 'db' as the host, and credentials defined above
|
||||
# IMPORTANT: Use the correct async driver prefix if your app needs it!
|
||||
- DATABASE_URL=postgresql+asyncpg://dev_user:dev_password@db:5432/dev_db
|
||||
- GEMINI_API_KEY=AIzaSyDKoZBIzUKoeGRtc3m7FtSoqId_nZjfl7M
|
||||
- SECRET_KEY=zaSyDKoZBIzUKoeGRtc3m7zaSyGRtc3m7zaSyDKoZBIzUKoeGRtc3m7
|
||||
# Add other environment variables needed by the backend here
|
||||
# - SOME_OTHER_VAR=some_value
|
||||
depends_on:
|
||||
db: # Wait for the db service to be healthy before starting backend
|
||||
db:
|
||||
# Wait for the db service to be healthy before starting backend
|
||||
condition: service_healthy
|
||||
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] # Override CMD for development reload
|
||||
command: [ "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ] # Override CMD for development reload
|
||||
restart: unless-stopped
|
||||
|
||||
pgadmin: # Optional service for database administration
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: pgadmin4_server
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@example.com # Change as needed
|
||||
PGADMIN_DEFAULT_PASSWORD: admin_password # Change to a secure password
|
||||
PGADMIN_CONFIG_SERVER_MODE: 'False' # Run in Desktop mode for easier local dev server setup
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin # Persist pgAdmin configuration
|
||||
frontend:
|
||||
container_name: vite_frontend
|
||||
build:
|
||||
context: ./fe
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "5050:80" # Map container port 80 to host port 5050
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- db # Depends on the database service
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes: # Define named volumes for data persistence
|
||||
volumes:
|
||||
# Define named volumes for data persistence
|
||||
postgres_data:
|
||||
pgadmin_data:
|
3
fe/.gitignore
vendored
3
fe/.gitignore
vendored
@ -7,7 +7,7 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
**/node_modules/
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
@ -28,6 +28,7 @@ coverage
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
*.sw.js
|
||||
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
31
fe/Dockerfile
Normal file
31
fe/Dockerfile
Normal 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
188
fe/package-lock.json
generated
@ -8,6 +8,8 @@
|
||||
"name": "fe",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@sentry/tracing": "^7.120.3",
|
||||
"@sentry/vue": "^7.120.3",
|
||||
"@supabase/auth-js": "^2.69.1",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
@ -15,7 +17,8 @@
|
||||
"pinia": "^3.0.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^12.0.0-alpha.2",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.5.1",
|
||||
"workbox-background-sync": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.8",
|
||||
@ -3825,6 +3828,162 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sentry-internal/feedback": {
|
||||
"version": "7.120.3",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.120.3.tgz",
|
||||
"integrity": "sha512-ewJJIQ0mbsOX6jfiVFvqMjokxNtgP3dNwUv+4nenN+iJJPQsM6a0ocro3iscxwVdbkjw5hY3BUV2ICI5Q0UWoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "7.120.3",
|
||||
"@sentry/types": "7.120.3",
|
||||
"@sentry/utils": "7.120.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/replay-canvas": {
|
||||
"version": "7.120.3",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-7.120.3.tgz",
|
||||
"integrity": "sha512-s5xy+bVL1eDZchM6gmaOiXvTqpAsUfO7122DxVdEDMtwVq3e22bS2aiGa8CUgOiJkulZ+09q73nufM77kOmT/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "7.120.3",
|
||||
"@sentry/replay": "7.120.3",
|
||||
"@sentry/types": "7.120.3",
|
||||
"@sentry/utils": "7.120.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/tracing": {
|
||||
"version": "7.120.3",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.120.3.tgz",
|
||||
"integrity": "sha512-Ausx+Jw1pAMbIBHStoQ6ZqDZR60PsCByvHdw/jdH9AqPrNE9xlBSf9EwcycvmrzwyKspSLaB52grlje2cRIUMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "7.120.3",
|
||||
"@sentry/types": "7.120.3",
|
||||
"@sentry/utils": "7.120.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/browser": {
|
||||
"version": "7.120.3",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.120.3.tgz",
|
||||
"integrity": "sha512-i9vGcK9N8zZ/JQo1TCEfHHYZ2miidOvgOABRUc9zQKhYdcYQB2/LU1kqlj77Pxdxf4wOa9137d6rPrSn9iiBxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/feedback": "7.120.3",
|
||||
"@sentry-internal/replay-canvas": "7.120.3",
|
||||
"@sentry-internal/tracing": "7.120.3",
|
||||
"@sentry/core": "7.120.3",
|
||||
"@sentry/integrations": "7.120.3",
|
||||
"@sentry/replay": "7.120.3",
|
||||
"@sentry/types": "7.120.3",
|
||||
"@sentry/utils": "7.120.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/core": {
|
||||
"version": "7.120.3",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.120.3.tgz",
|
||||
"integrity": "sha512-vyy11fCGpkGK3qI5DSXOjgIboBZTriw0YDx/0KyX5CjIjDDNgp5AGgpgFkfZyiYiaU2Ww3iFuKo4wHmBusz1uA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/types": "7.120.3",
|
||||
"@sentry/utils": "7.120.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/integrations": {
|
||||
"version": "7.120.3",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.120.3.tgz",
|
||||
"integrity": "sha512-6i/lYp0BubHPDTg91/uxHvNui427df9r17SsIEXa2eKDwQ9gW2qRx5IWgvnxs2GV/GfSbwcx4swUB3RfEWrXrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "7.120.3",
|
||||
"@sentry/types": "7.120.3",
|
||||
"@sentry/utils": "7.120.3",
|
||||
"localforage": "^1.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/replay": {
|
||||
"version": "7.120.3",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.120.3.tgz",
|
||||
"integrity": "sha512-CjVq1fP6bpDiX8VQxudD5MPWwatfXk8EJ2jQhJTcWu/4bCSOQmHxnnmBM+GVn5acKUBCodWHBN+IUZgnJheZSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/tracing": "7.120.3",
|
||||
"@sentry/core": "7.120.3",
|
||||
"@sentry/types": "7.120.3",
|
||||
"@sentry/utils": "7.120.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/tracing": {
|
||||
"version": "7.120.3",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-7.120.3.tgz",
|
||||
"integrity": "sha512-B7bqyYFgHuab1Pn7w5KXsZP/nfFo4VDBDdSXDSWYk5+TYJ3IDruO3eJFhOrircfsz4YwazWm9kbeZhkpsHDyHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/tracing": "7.120.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/types": {
|
||||
"version": "7.120.3",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.120.3.tgz",
|
||||
"integrity": "sha512-C4z+3kGWNFJ303FC+FxAd4KkHvxpNFYAFN8iMIgBwJdpIl25KZ8Q/VdGn0MLLUEHNLvjob0+wvwlcRBBNLXOow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/utils": {
|
||||
"version": "7.120.3",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.120.3.tgz",
|
||||
"integrity": "sha512-UDAOQJtJDxZHQ5Nm1olycBIsz2wdGX8SdzyGVHmD8EOQYAeDZQyIlQYohDe9nazdIOQLZCIc3fU0G9gqVLkaGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/types": "7.120.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/vue": {
|
||||
"version": "7.120.3",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-7.120.3.tgz",
|
||||
"integrity": "sha512-YKKLGx6VNk5OTz5JqIsjIqOgaU8u88Q1OBfLZgOpm55vhrvpZGGc+rHyh8XtXxh4DfC+6vTRTrAngvdPOG9Oxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/browser": "7.120.3",
|
||||
"@sentry/core": "7.120.3",
|
||||
"@sentry/types": "7.120.3",
|
||||
"@sentry/utils": "7.120.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "2.x || 3.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/merge-streams": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
|
||||
@ -7427,7 +7586,6 @@
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
@ -7440,6 +7598,12 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz",
|
||||
@ -8413,6 +8577,24 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
|
||||
"integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/localforage": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
|
||||
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"lie": "3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@ -11885,7 +12067,6 @@
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz",
|
||||
"integrity": "sha512-PCSk3eK7Mxeuyatb22pcSx9dlgWNv3+M8PqPaYDokks8Y5/FX4soaOqj3yhAZr5k6Q5JWTOMYgaJBpbw11G9Eg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"idb": "^7.0.1",
|
||||
@ -12220,7 +12401,6 @@
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.3.0.tgz",
|
||||
"integrity": "sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/workbox-expiration": {
|
||||
|
@ -17,6 +17,8 @@
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/tracing": "^7.120.3",
|
||||
"@sentry/vue": "^7.120.3",
|
||||
"@supabase/auth-js": "^2.69.1",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
@ -24,7 +26,8 @@
|
||||
"pinia": "^3.0.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^12.0.0-alpha.2",
|
||||
"vue-router": "^4.5.1"
|
||||
"vue-router": "^4.5.1",
|
||||
"workbox-background-sync": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.8",
|
||||
|
45
fe/public/offline.html
Normal file
45
fe/public/offline.html
Normal 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>
|
@ -1,6 +1,6 @@
|
||||
// src/assets/main.scss
|
||||
// @import './variables.scss'; // Your custom variables
|
||||
@import './valerie-ui.scss';
|
||||
@use './valerie-ui.scss';
|
||||
|
||||
// Example global styles
|
||||
body {
|
||||
@ -13,6 +13,7 @@ body {
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@ -23,4 +24,73 @@ a {
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
// Offline UI styles
|
||||
.offline-item {
|
||||
position: relative;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3Cpath d='M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16'/%3E%3Cpath d='M16 21h5v-5'/%3E%3C/svg%3E");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
&.synced {
|
||||
opacity: 1;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Disabled offline features
|
||||
.feature-offline-disabled {
|
||||
position: relative;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
|
||||
&::before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg-color-tooltip, #333);
|
||||
color: white;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
// Add more global utility classes or base styles
|
@ -1,34 +1,26 @@
|
||||
<template>
|
||||
<div v-if="show" class="modal-backdrop open" @click.self="closeDialog">
|
||||
<div class="modal-container" style="min-width: 600px" ref="modalContentRef" role="dialog" aria-modal="true" aria-labelledby="conflictDialogTitle">
|
||||
<div class="modal-container" style="min-width: 600px" ref="modalContentRef" role="dialog" aria-modal="true"
|
||||
aria-labelledby="conflictDialogTitle">
|
||||
<div class="modal-header">
|
||||
<h3 id="conflictDialogTitle">Conflict Resolution</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-2">
|
||||
This item was modified while you were offline. Please review the changes and choose how to resolve the conflict.
|
||||
This item was modified while you were offline. Please review the changes and choose how to resolve the
|
||||
conflict.
|
||||
</p>
|
||||
|
||||
<div class="tabs">
|
||||
<ul class="tab-list" role="tablist" aria-label="Conflict Resolution Options">
|
||||
<li
|
||||
class="tab-item"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'compare'"
|
||||
:tabindex="activeTab === 'compare' ? 0 : -1"
|
||||
@click="activeTab = 'compare'"
|
||||
@keydown.enter.space="activeTab = 'compare'"
|
||||
>
|
||||
<li class="tab-item" role="tab" :aria-selected="activeTab === 'compare'"
|
||||
:tabindex="activeTab === 'compare' ? 0 : -1" @click="activeTab = 'compare'"
|
||||
@keydown.enter.space="activeTab = 'compare'">
|
||||
Compare Versions
|
||||
</li>
|
||||
<li
|
||||
class="tab-item"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'merge'"
|
||||
:tabindex="activeTab === 'merge' ? 0 : -1"
|
||||
@click="activeTab = 'merge'"
|
||||
@keydown.enter.space="activeTab = 'merge'"
|
||||
>
|
||||
<li class="tab-item" role="tab" :aria-selected="activeTab === 'merge'"
|
||||
:tabindex="activeTab === 'merge' ? 0 : -1" @click="activeTab = 'merge'"
|
||||
@keydown.enter.space="activeTab = 'merge'">
|
||||
Merge Changes
|
||||
</li>
|
||||
</ul>
|
||||
@ -47,7 +39,8 @@
|
||||
<ul class="item-list simple-list">
|
||||
<li v-for="(value, key) in conflictData?.localVersion.data" :key="key" class="list-item-simple">
|
||||
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
|
||||
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value) }}</span>
|
||||
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value)
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -55,17 +48,18 @@
|
||||
|
||||
<!-- Server Version -->
|
||||
<div class="card flex-grow" style="width: 50%;">
|
||||
<div class="card-header">
|
||||
<h4>Server Version</h4>
|
||||
</div>
|
||||
<div class="card-header">
|
||||
<h4>Server Version</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-caption mb-1">
|
||||
Last modified: {{ formatDate(conflictData?.serverVersion.timestamp ?? 0) }}
|
||||
</p>
|
||||
<ul class="item-list simple-list">
|
||||
<li v-for="(value, key) in conflictData?.serverVersion.data" :key="key" class="list-item-simple">
|
||||
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
|
||||
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value) }}</span>
|
||||
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
|
||||
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value)
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -81,7 +75,8 @@
|
||||
<div class="card-body">
|
||||
<p class="text-caption mb-2">Select which version to keep for each field.</p>
|
||||
<ul class="item-list simple-list">
|
||||
<li v-for="(localValue, key) in conflictData?.localVersion.data" :key="key" class="list-item-simple merge-choice-item">
|
||||
<li v-for="(localValue, key) in conflictData?.localVersion.data" :key="key"
|
||||
class="list-item-simple merge-choice-item">
|
||||
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
|
||||
<div class="flex" style="gap: 1rem; margin-top: 0.5rem;">
|
||||
<div class="radio-group-inline">
|
||||
@ -95,7 +90,8 @@
|
||||
<label class="radio-label">
|
||||
<input type="radio" :name="String(key)" v-model="mergeChoices[key]" value="server" />
|
||||
<span class="checkmark radio-mark"></span>
|
||||
Server Version: <span class="value-preview">{{ formatValue(conflictData?.serverVersion.data[key]) }}</span>
|
||||
Server Version: <span class="value-preview">{{
|
||||
formatValue(conflictData?.serverVersion.data[key]) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -108,43 +104,20 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
v-if="activeTab === 'compare'"
|
||||
type="button"
|
||||
class="btn btn-neutral"
|
||||
@click="resolveConflict('local')"
|
||||
>
|
||||
<button v-if="activeTab === 'compare'" type="button" class="btn btn-neutral" @click="resolveConflict('local')">
|
||||
Keep Local Version
|
||||
</button>
|
||||
<button
|
||||
v-if="activeTab === 'compare'"
|
||||
type="button"
|
||||
class="btn btn-neutral ml-2"
|
||||
@click="resolveConflict('server')"
|
||||
>
|
||||
<button v-if="activeTab === 'compare'" type="button" class="btn btn-neutral ml-2"
|
||||
@click="resolveConflict('server')">
|
||||
Keep Server Version
|
||||
</button>
|
||||
<button
|
||||
v-if="activeTab === 'compare'"
|
||||
type="button"
|
||||
class="btn btn-primary ml-2"
|
||||
@click="activeTab = 'merge'"
|
||||
>
|
||||
<button v-if="activeTab === 'compare'" type="button" class="btn btn-primary ml-2" @click="activeTab = 'merge'">
|
||||
Merge Changes
|
||||
</button>
|
||||
<button
|
||||
v-if="activeTab === 'merge'"
|
||||
type="button"
|
||||
class="btn btn-primary ml-2"
|
||||
@click="applyMergedChanges"
|
||||
>
|
||||
<button v-if="activeTab === 'merge'" type="button" class="btn btn-primary ml-2" @click="applyMergedChanges">
|
||||
Apply Merged Changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger ml-2"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<button type="button" class="btn btn-danger ml-2" @click="closeDialog">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@ -155,17 +128,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useVModel, onClickOutside } from '@vueuse/core';
|
||||
// Assuming OfflineAction is defined elsewhere, e.g. in a Pinia store or a types file
|
||||
// For this example, let's define a placeholder if not available from `src/stores/offline`
|
||||
// import type { OfflineAction } from 'src/stores/offline';
|
||||
interface OfflineAction {
|
||||
id: string | number;
|
||||
type: string;
|
||||
payload: unknown;
|
||||
timestamp: number;
|
||||
// other potential fields
|
||||
}
|
||||
|
||||
import type { OfflineAction } from '@/stores/offline';
|
||||
|
||||
interface ConflictData {
|
||||
localVersion: {
|
||||
@ -176,7 +139,7 @@ interface ConflictData {
|
||||
data: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
};
|
||||
action: OfflineAction; // Assuming OfflineAction is defined
|
||||
action: OfflineAction;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@ -211,7 +174,7 @@ watch(() => props.conflictData, (newData) => {
|
||||
Object.keys(newData.localVersion.data).forEach(key => {
|
||||
// Default to local, or server if local is undefined/null but server is not
|
||||
if (isDifferent(key)) {
|
||||
choices[key] = newData.localVersion.data[key] !== undefined ? 'local' : 'server';
|
||||
choices[key] = newData.localVersion.data[key] !== undefined ? 'local' : 'server';
|
||||
} else {
|
||||
choices[key] = 'local';
|
||||
}
|
||||
@ -282,6 +245,7 @@ const applyMergedChanges = (): void => {
|
||||
color: var(--dark);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.text-caption-strong {
|
||||
font-size: 0.9rem;
|
||||
color: var(--dark);
|
||||
@ -291,9 +255,11 @@ const applyMergedChanges = (): void => {
|
||||
}
|
||||
|
||||
.text-positive-inline {
|
||||
color: var(--success); /* Assuming --success is greenish */
|
||||
color: var(--success);
|
||||
/* Assuming --success is greenish */
|
||||
font-weight: bold;
|
||||
background-color: #e6ffed; /* Light green background for highlight */
|
||||
background-color: #e6ffed;
|
||||
/* Light green background for highlight */
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@ -303,10 +269,12 @@ const applyMergedChanges = (): void => {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.list-item-simple {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.list-item-simple:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
@ -314,24 +282,35 @@ const applyMergedChanges = (): void => {
|
||||
.merge-choice-item .radio-group-inline {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.merge-choice-item .radio-label {
|
||||
align-items: flex-start; /* Better alignment for multi-line content */
|
||||
align-items: flex-start;
|
||||
/* Better alignment for multi-line content */
|
||||
}
|
||||
|
||||
.value-preview {
|
||||
font-style: italic;
|
||||
color: #555;
|
||||
margin-left: 0.5em;
|
||||
display: inline-block;
|
||||
max-width: 200px; /* Adjust as needed */
|
||||
white-space: pre-wrap; /* Show formatted JSON */
|
||||
max-width: 200px;
|
||||
/* Adjust as needed */
|
||||
white-space: pre-wrap;
|
||||
/* Show formatted JSON */
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.mb-1 { margin-bottom: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 1rem; }
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
|
@ -1,53 +1,54 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="!isOnline || hasPendingActions"
|
||||
class="alert offline-indicator"
|
||||
:class="{
|
||||
'alert-error': !isOnline,
|
||||
'alert-warning': isOnline && hasPendingActions
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
<div v-if="!isOnline || hasPendingActions" class="alert offline-indicator" :class="{
|
||||
'alert-error': !isOnline,
|
||||
'alert-warning': isOnline && hasPendingActions
|
||||
}" role="status">
|
||||
<div class="alert-content">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use :xlink:href="!isOnline ? '#icon-alert-triangle' : '#icon-info'" />
|
||||
<!-- Placeholder icons, wifi_off and sync are not in Valerie UI default -->
|
||||
<use :xlink:href="!isOnline ? '#icon-wifi-off' : '#icon-sync'" />
|
||||
</svg>
|
||||
<span v-if="!isOnline">
|
||||
<span v-if="!isOnline" class="status-text">
|
||||
You are currently offline. Changes will be saved locally.
|
||||
</span>
|
||||
<span v-else>
|
||||
<span v-else class="status-text">
|
||||
Syncing {{ pendingActionCount }} pending {{ pendingActionCount === 1 ? 'change' : 'changes' }}...
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="hasPendingActions"
|
||||
class="btn btn-sm btn-neutral"
|
||||
@click="showPendingActionsModal = true"
|
||||
>
|
||||
<button v-if="hasPendingActions" class="btn btn-sm btn-neutral" @click="showPendingActionsModal = true">
|
||||
View Changes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showPendingActionsModal" class="modal-backdrop open" @click.self="showPendingActionsModal = false">
|
||||
<div class="modal-container" ref="pendingActionsModalRef" role="dialog" aria-modal="true" aria-labelledby="pendingActionsTitle">
|
||||
<div class="modal-container" ref="pendingActionsModalRef" role="dialog" aria-modal="true"
|
||||
aria-labelledby="pendingActionsTitle">
|
||||
<div class="modal-header">
|
||||
<h3 id="pendingActionsTitle">Pending Changes</h3>
|
||||
<button class="close-button" @click="showPendingActionsModal = false" aria-label="Close">
|
||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-close" /></svg>
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-close" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul v-if="pendingActions.length" class="item-list">
|
||||
<li v-for="action in pendingActions" :key="action.id" class="list-item">
|
||||
<div class="list-item-content" style="flex-direction: column; align-items: flex-start;">
|
||||
<span class="item-text">{{ getActionLabel(action) }}</span>
|
||||
<small class="text-caption">{{ new Date(action.timestamp).toLocaleString() }}</small>
|
||||
<div class="list-item-content">
|
||||
<div class="action-info">
|
||||
<span class="item-text">{{ getActionLabel(action) }}</span>
|
||||
<small class="text-caption">{{ new Date(action.timestamp).toLocaleString() }}</small>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-error" @click="removePendingAction(action.id)"
|
||||
title="Remove this pending action">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-trash" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else>No pending changes.</p>
|
||||
<p v-else class="empty-state">No pending changes.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" @click="showPendingActionsModal = false">Close</button>
|
||||
@ -56,58 +57,55 @@
|
||||
</div>
|
||||
|
||||
<!-- Conflict Resolution Dialog -->
|
||||
<ConflictResolutionDialog
|
||||
v-model="offlineStore.showConflictDialog"
|
||||
:conflict-data="offlineStore.currentConflict"
|
||||
@resolve="offlineStore.handleConflictResolution"
|
||||
/>
|
||||
<ConflictResolutionDialog v-model="offlineStore.showConflictDialog" :conflict-data="offlineStore.currentConflict"
|
||||
@resolve="offlineStore.handleConflictResolution" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useNetwork, onClickOutside } from '@vueuse/core';
|
||||
import { useOfflineStore } from '@/stores/offline'; // Assuming path
|
||||
import type { OfflineAction } from '@/stores/offline'; // Assuming path
|
||||
import { useOfflineStore } from '@/stores/offline';
|
||||
import type { OfflineAction } from '@/stores/offline';
|
||||
import ConflictResolutionDialog from '@/components/ConflictResolutionDialog.vue';
|
||||
|
||||
const offlineStore = useOfflineStore();
|
||||
const showPendingActionsModal = ref(false);
|
||||
const pendingActionsModalRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const { isOnline } = useNetwork(); // VueUse composable for network status
|
||||
const { isOnline } = useNetwork();
|
||||
|
||||
// Expose parts of the store directly, this pattern is fine with Pinia
|
||||
const {
|
||||
pendingActions,
|
||||
hasPendingActions,
|
||||
pendingActionCount,
|
||||
// showConflictDialog, // Handled by offlineStore.showConflictDialog
|
||||
// currentConflict, // Handled by offlineStore.currentConflict
|
||||
// handleConflictResolution // Handled by offlineStore.handleConflictResolution
|
||||
} = offlineStore;
|
||||
|
||||
|
||||
onClickOutside(pendingActionsModalRef, () => {
|
||||
showPendingActionsModal.value = false;
|
||||
});
|
||||
|
||||
const removePendingAction = (actionId: string) => {
|
||||
offlineStore.pendingActions = offlineStore.pendingActions.filter(a => a.id !== actionId);
|
||||
};
|
||||
|
||||
const getActionLabel = (action: OfflineAction) => {
|
||||
// This is a simplified version of your original getActionLabel
|
||||
// You might need to adjust based on the actual structure of action.data
|
||||
const data = action.payload as { title?: string; name?: string; [key: string]: unknown };
|
||||
const data = action.payload as { title?: string; name?: string;[key: string]: unknown };
|
||||
const itemTitle = data.title || data.name || (typeof data === 'string' ? data : 'Untitled Item');
|
||||
|
||||
switch (action.type) {
|
||||
case 'add':
|
||||
case 'create': // Common alias
|
||||
return `Add: ${itemTitle}`;
|
||||
case 'complete':
|
||||
return `Complete: ${itemTitle}`;
|
||||
case 'update':
|
||||
return `Update: ${itemTitle}`;
|
||||
case 'delete':
|
||||
return `Delete: ${itemTitle}`;
|
||||
case 'create_list':
|
||||
return `Create List: ${itemTitle}`;
|
||||
case 'update_list':
|
||||
return `Update List: ${itemTitle}`;
|
||||
case 'delete_list':
|
||||
return `Delete List: ${itemTitle}`;
|
||||
case 'create_list_item':
|
||||
return `Add Item: ${itemTitle}`;
|
||||
case 'update_list_item':
|
||||
return `Update Item: ${itemTitle}`;
|
||||
case 'delete_list_item':
|
||||
return `Delete Item: ${itemTitle}`;
|
||||
default:
|
||||
return `Unknown action: ${action.type} for ${itemTitle}`;
|
||||
}
|
||||
@ -121,22 +119,83 @@ const getActionLabel = (action: OfflineAction) => {
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
max-width: 400px;
|
||||
/* Valerie UI .alert already has box-shadow */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Styles for text-caption if not globally available enough */
|
||||
.text-caption {
|
||||
font-size: 0.85rem;
|
||||
color: var(--dark);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Simplified list item for pending actions modal */
|
||||
.item-list .list-item .list-item-content {
|
||||
padding: 0.75rem 1rem;
|
||||
.item-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.item-list .list-item .item-text {
|
||||
|
||||
.list-item {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.list-item-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.action-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.item-text {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.btn-error {
|
||||
padding: 0.25rem;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.btn-error .icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
</style>
|
112
fe/src/components/SocialLoginButtons.vue
Normal file
112
fe/src/components/SocialLoginButtons.vue
Normal 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>
|
@ -8,24 +8,23 @@ export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:80
|
||||
export const API_ENDPOINTS = {
|
||||
// Auth
|
||||
AUTH: {
|
||||
LOGIN: '/auth/login',
|
||||
SIGNUP: '/auth/signup',
|
||||
REFRESH_TOKEN: '/auth/refresh-token',
|
||||
LOGOUT: '/auth/logout',
|
||||
VERIFY_EMAIL: '/auth/verify-email',
|
||||
RESET_PASSWORD: '/auth/reset-password',
|
||||
LOGIN: '/auth/jwt/login',
|
||||
SIGNUP: '/auth/register',
|
||||
LOGOUT: '/auth/jwt/logout',
|
||||
VERIFY_EMAIL: '/auth/verify',
|
||||
RESET_PASSWORD: '/auth/forgot-password',
|
||||
FORGOT_PASSWORD: '/auth/forgot-password',
|
||||
},
|
||||
|
||||
// Users
|
||||
USERS: {
|
||||
PROFILE: '/users/me',
|
||||
UPDATE_PROFILE: '/users/me',
|
||||
PASSWORD: '/users/password',
|
||||
AVATAR: '/users/avatar',
|
||||
SETTINGS: '/users/settings',
|
||||
NOTIFICATIONS: '/users/notifications',
|
||||
PREFERENCES: '/users/preferences',
|
||||
UPDATE_PROFILE: '/api/v1/users/me',
|
||||
PASSWORD: '/api/v1/users/password',
|
||||
AVATAR: '/api/v1/users/avatar',
|
||||
SETTINGS: '/api/v1/users/settings',
|
||||
NOTIFICATIONS: '/api/v1/users/notifications',
|
||||
PREFERENCES: '/api/v1/users/preferences',
|
||||
},
|
||||
|
||||
// Lists
|
||||
|
@ -1,4 +1,3 @@
|
||||
// src/layouts/AuthLayout.vue
|
||||
<template>
|
||||
<div class="auth-layout">
|
||||
<main class="auth-page-container">
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
import { BrowserTracing } from '@sentry/tracing';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
// import { createI18n } from 'vue-i18n';
|
||||
@ -33,6 +35,23 @@ const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
|
||||
// Initialize Sentry
|
||||
Sentry.init({
|
||||
app,
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
integrations: [
|
||||
new BrowserTracing({
|
||||
routingInstrumentation: Sentry.vueRouterInstrumentation(router),
|
||||
tracingOrigins: ['localhost', /^\//],
|
||||
}),
|
||||
],
|
||||
// Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: 1.0,
|
||||
// Set environment
|
||||
environment: import.meta.env.MODE,
|
||||
});
|
||||
|
||||
// Initialize auth state before mounting the app
|
||||
const authStore = useAuthStore();
|
||||
if (authStore.accessToken) {
|
||||
|
92
fe/src/pages/AuthCallbackPage.vue
Normal file
92
fe/src/pages/AuthCallbackPage.vue
Normal 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>
|
@ -1,13 +1,15 @@
|
||||
<template>
|
||||
<main class="container page-padding">
|
||||
<div v-if="loading" class="text-center">
|
||||
<div class="spinner-dots" role="status"><span/><span/><span/></div>
|
||||
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||||
<p>Loading list details...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
|
||||
<div class="alert-content">
|
||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
|
||||
<div class="alert-content">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-alert-triangle" />
|
||||
</svg>
|
||||
{{ error }}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-danger" @click="fetchListDetails">Retry</button>
|
||||
@ -17,12 +19,20 @@
|
||||
<div class="flex justify-between items-center flex-wrap mb-2">
|
||||
<h1>{{ list.name }}</h1>
|
||||
<div class="flex items-center flex-wrap" style="gap: 0.5rem;">
|
||||
<button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true">
|
||||
<svg class="icon icon-sm"><use xlink:href="#icon-clipboard"/></svg> <!-- Placeholder icon -->
|
||||
<button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true"
|
||||
:class="{ 'feature-offline-disabled': !isOnline }"
|
||||
:data-tooltip="!isOnline ? 'Cost summary requires online connection' : ''">
|
||||
<svg class="icon icon-sm">
|
||||
<use xlink:href="#icon-clipboard" />
|
||||
</svg>
|
||||
Cost Summary
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="openOcrDialog">
|
||||
<svg class="icon icon-sm"><use xlink:href="#icon-plus"/></svg> <!-- Placeholder, camera_alt not in Valerie -->
|
||||
<button class="btn btn-secondary btn-sm" @click="openOcrDialog"
|
||||
:class="{ 'feature-offline-disabled': !isOnline }"
|
||||
:data-tooltip="!isOnline ? 'OCR requires online connection' : ''">
|
||||
<svg class="icon icon-sm">
|
||||
<use xlink:href="#icon-plus" />
|
||||
</svg>
|
||||
Add via OCR
|
||||
</button>
|
||||
<span class="item-badge ml-1" :class="list.is_complete ? 'badge-settled' : 'badge-pending'">
|
||||
@ -37,27 +47,15 @@
|
||||
<div class="flex items-end flex-wrap" style="gap: 1rem;">
|
||||
<div class="form-group flex-grow" style="margin-bottom: 0;">
|
||||
<label for="newItemName" class="form-label">Item Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="newItemName"
|
||||
v-model="newItem.name"
|
||||
class="form-input"
|
||||
required
|
||||
ref="itemNameInputRef"
|
||||
/>
|
||||
<input type="text" id="newItemName" v-model="newItem.name" class="form-input" required
|
||||
ref="itemNameInputRef" />
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 0; min-width: 120px;">
|
||||
<label for="newItemQuantity" class="form-label">Quantity</label>
|
||||
<input
|
||||
type="number"
|
||||
id="newItemQuantity"
|
||||
v-model="newItem.quantity"
|
||||
class="form-input"
|
||||
min="1"
|
||||
/>
|
||||
<input type="number" id="newItemQuantity" v-model="newItem.quantity" class="form-input" min="1" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" :disabled="addingItem">
|
||||
<span v-if="addingItem" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
||||
<span v-if="addingItem" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
@ -66,64 +64,48 @@
|
||||
|
||||
<!-- Items List -->
|
||||
<div v-if="list.items.length === 0" class="card empty-state-card">
|
||||
<svg class="icon icon-lg" aria-hidden="true"><use xlink:href="#icon-clipboard" /></svg>
|
||||
<svg class="icon icon-lg" aria-hidden="true">
|
||||
<use xlink:href="#icon-clipboard" />
|
||||
</svg>
|
||||
<h3>No Items Yet!</h3>
|
||||
<p>This list is empty. Add some items using the form above.</p>
|
||||
</div>
|
||||
|
||||
<ul v-else class="item-list">
|
||||
<li
|
||||
v-for="item in list.items"
|
||||
:key="item.id"
|
||||
class="list-item"
|
||||
:class="{ 'completed': item.is_complete, 'is-swiped': item.swiped }"
|
||||
@touchstart="handleTouchStart($event, item)"
|
||||
@touchmove="handleTouchMove($event, item)"
|
||||
@touchend="handleTouchEnd(item)"
|
||||
>
|
||||
<li v-for="item in list.items" :key="item.id" class="list-item" :class="{
|
||||
'completed': item.is_complete,
|
||||
'is-swiped': item.swiped,
|
||||
'offline-item': isItemPendingSync(item),
|
||||
'synced': !isItemPendingSync(item)
|
||||
}" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="handleTouchEnd">
|
||||
<div class="list-item-content">
|
||||
<div class="list-item-main">
|
||||
<label class="checkbox-label mb-0 flex-shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="item.is_complete"
|
||||
<input type="checkbox" :checked="item.is_complete"
|
||||
@change="confirmUpdateItem(item, ($event.target as HTMLInputElement).checked)"
|
||||
:disabled="item.updating"
|
||||
:aria-label="item.name"
|
||||
/>
|
||||
:disabled="item.updating" :aria-label="item.name" />
|
||||
<span class="checkmark"></span>
|
||||
</label>
|
||||
<div class="item-text flex-grow">
|
||||
<span :class="{ 'text-decoration-line-through': item.is_complete }">{{ item.name }}</span>
|
||||
<small v-if="item.quantity" class="item-caption">Quantity: {{ item.quantity }}</small>
|
||||
<div v-if="item.is_complete" class="form-group mt-1" style="max-width: 150px; margin-bottom: 0;">
|
||||
<label :for="`price-${item.id}`" class="sr-only">Price for {{ item.name }}</label>
|
||||
<input
|
||||
:id="`price-${item.id}`"
|
||||
type="number"
|
||||
v-model.number="item.priceInput"
|
||||
class="form-input form-input-sm"
|
||||
placeholder="Price"
|
||||
step="0.01"
|
||||
@blur="updateItemPrice(item)"
|
||||
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
|
||||
/>
|
||||
<label :for="`price-${item.id}`" class="sr-only">Price for {{ item.name }}</label>
|
||||
<input :id="`price-${item.id}`" type="number" v-model.number="item.priceInput"
|
||||
class="form-input form-input-sm" placeholder="Price" step="0.01" @blur="updateItemPrice(item)"
|
||||
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Non-swipe actions can be added here or handled by swipe -->
|
||||
<div class="list-item-actions">
|
||||
<button
|
||||
class="btn btn-danger btn-sm btn-icon-only"
|
||||
@click.stop="confirmDeleteItem(item)"
|
||||
:disabled="item.deleting"
|
||||
aria-label="Delete item"
|
||||
>
|
||||
<svg class="icon icon-sm"><use xlink:href="#icon-trash"></use></svg>
|
||||
</button>
|
||||
<div class="list-item-actions">
|
||||
<button class="btn btn-danger btn-sm btn-icon-only" @click.stop="confirmDeleteItem(item)"
|
||||
:disabled="item.deleting" aria-label="Delete item">
|
||||
<svg class="icon icon-sm">
|
||||
<use xlink:href="#icon-trash"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Swipe actions could be added here if fully implementing swipe from Valerie UI example -->
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@ -133,11 +115,13 @@
|
||||
<div class="modal-container" ref="ocrModalRef" style="min-width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h3>Add Items via OCR</h3>
|
||||
<button class="close-button" @click="closeOcrDialog" aria-label="Close"><svg class="icon"><use xlink:href="#icon-close"/></svg></button>
|
||||
<button class="close-button" @click="closeOcrDialog" aria-label="Close"><svg class="icon">
|
||||
<use xlink:href="#icon-close" />
|
||||
</svg></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div v-if="ocrLoading" class="text-center">
|
||||
<div class="spinner-dots"><span/><span/><span/></div>
|
||||
<div class="spinner-dots"><span /><span /><span /></div>
|
||||
<p>Processing image...</p>
|
||||
</div>
|
||||
<div v-else-if="ocrItems.length > 0">
|
||||
@ -147,7 +131,9 @@
|
||||
<div class="list-item-content flex items-center" style="gap: 0.5rem;">
|
||||
<input type="text" v-model="ocrItem.name" class="form-input flex-grow" required />
|
||||
<button class="btn btn-danger btn-sm btn-icon-only" @click="ocrItems.splice(index, 1)">
|
||||
<svg class="icon icon-sm"><use xlink:href="#icon-trash"/></svg>
|
||||
<svg class="icon icon-sm">
|
||||
<use xlink:href="#icon-trash" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
@ -155,36 +141,57 @@
|
||||
</div>
|
||||
<div v-else class="form-group">
|
||||
<label for="ocrFile" class="form-label">Upload Image</label>
|
||||
<input type="file" id="ocrFile" class="form-input" accept="image/*" @change="handleOcrFileUpload" ref="ocrFileInputRef"/>
|
||||
<input type="file" id="ocrFile" class="form-input" accept="image/*" @change="handleOcrFileUpload"
|
||||
ref="ocrFileInputRef" />
|
||||
<p v-if="ocrError" class="form-error-text mt-1">{{ ocrError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-neutral" @click="closeOcrDialog">Cancel</button>
|
||||
<button
|
||||
v-if="ocrItems.length > 0"
|
||||
type="button"
|
||||
class="btn btn-primary ml-2"
|
||||
@click="addOcrItems"
|
||||
:disabled="addingOcrItems"
|
||||
>
|
||||
<span v-if="addingOcrItems" class="spinner-dots-sm"><span/><span/><span/></span>
|
||||
<button v-if="ocrItems.length > 0" type="button" class="btn btn-primary ml-2" @click="addOcrItems"
|
||||
:disabled="addingOcrItems">
|
||||
<span v-if="addingOcrItems" class="spinner-dots-sm"><span /><span /><span /></span>
|
||||
Add Items
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<div v-if="showConfirmDialogState" class="modal-backdrop open" @click.self="cancelConfirmation">
|
||||
<div class="modal-container confirm-modal" ref="confirmModalRef">
|
||||
<div class="modal-header">
|
||||
<h3>Confirmation</h3>
|
||||
<button class="close-button" @click="cancelConfirmation" aria-label="Close"><svg class="icon">
|
||||
<use xlink:href="#icon-close" />
|
||||
</svg></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<svg class="icon icon-lg mb-2" style="color: var(--warning);">
|
||||
<use xlink:href="#icon-alert-triangle" />
|
||||
</svg>
|
||||
<p>{{ confirmDialogMessage }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-neutral" @click="cancelConfirmation">Cancel</button>
|
||||
<button class="btn btn-primary ml-2" @click="handleConfirmedAction">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cost Summary Dialog -->
|
||||
<div v-if="showCostSummaryDialog" class="modal-backdrop open" @click.self="showCostSummaryDialog = false">
|
||||
<div class="modal-container" ref="costSummaryModalRef" style="min-width: 550px;">
|
||||
<div class="modal-header">
|
||||
<h3>List Cost Summary</h3>
|
||||
<button class="close-button" @click="showCostSummaryDialog = false" aria-label="Close"><svg class="icon"><use xlink:href="#icon-close"/></svg></button>
|
||||
<button class="close-button" @click="showCostSummaryDialog = false" aria-label="Close"><svg class="icon">
|
||||
<use xlink:href="#icon-close" />
|
||||
</svg></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div v-if="costSummaryLoading" class="text-center">
|
||||
<div class="spinner-dots"><span/><span/><span/></div><p>Loading summary...</p>
|
||||
<div class="spinner-dots"><span /><span /><span /></div>
|
||||
<p>Loading summary...</p>
|
||||
</div>
|
||||
<div v-else-if="costSummaryError" class="alert alert-error">{{ costSummaryError }}</div>
|
||||
<div v-else-if="listCostSummary">
|
||||
@ -210,7 +217,8 @@
|
||||
<td class="text-right">{{ formatCurrency(userShare.items_added_value) }}</td>
|
||||
<td class="text-right">{{ formatCurrency(userShare.amount_due) }}</td>
|
||||
<td class="text-right">
|
||||
<span class="item-badge" :class="parseFloat(String(userShare.balance)) >= 0 ? 'badge-settled' : 'badge-pending'">
|
||||
<span class="item-badge"
|
||||
:class="parseFloat(String(userShare.balance)) >= 0 ? 'badge-settled' : 'badge-pending'">
|
||||
{{ formatCurrency(userShare.balance) }}
|
||||
</span>
|
||||
</td>
|
||||
@ -227,33 +235,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<div v-if="showConfirmDialogState" class="modal-backdrop open" @click.self="cancelConfirmation">
|
||||
<div class="modal-container confirm-modal" ref="confirmModalRef">
|
||||
<div class="modal-header">
|
||||
<h3>Confirmation</h3>
|
||||
<button class="close-button" @click="cancelConfirmation" aria-label="Close"><svg class="icon"><use xlink:href="#icon-close"/></svg></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<svg class="icon icon-lg mb-2" style="color: var(--warning);"><use xlink:href="#icon-alert-triangle"/></svg>
|
||||
<p>{{ confirmDialogMessage }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-neutral" @click="cancelConfirmation">Cancel</button>
|
||||
<button class="btn btn-primary ml-2" @click="handleConfirmedAction">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
||||
import { onClickOutside, useEventListener, useFileDialog } from '@vueuse/core';
|
||||
import { onClickOutside, useEventListener, useFileDialog, useNetwork } from '@vueuse/core';
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
import { useOfflineStore, type CreateListItemPayload } from '@/stores/offline';
|
||||
|
||||
interface Item {
|
||||
id: number;
|
||||
@ -298,13 +289,14 @@ interface ListCostSummaryData {
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const { isOnline } = useNetwork();
|
||||
const notificationStore = useNotificationStore();
|
||||
const offlineStore = useOfflineStore();
|
||||
const list = ref<List | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const addingItem = ref(false);
|
||||
const pollingInterval = ref<NodeJS.Timeout | null>(null);
|
||||
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
const lastListUpdate = ref<string | null>(null);
|
||||
const lastItemUpdate = ref<string | null>(null);
|
||||
|
||||
@ -318,7 +310,7 @@ const ocrLoading = ref(false);
|
||||
const ocrItems = ref<{ name: string }[]>([]); // Items extracted from OCR
|
||||
const addingOcrItems = ref(false);
|
||||
const ocrError = ref<string | null>(null);
|
||||
const ocrFileInputRef = ref<HTMLInputElement|null>(null);
|
||||
const ocrFileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const { files: ocrFiles, reset: resetOcrFileDialog } = useFileDialog({
|
||||
accept: 'image/*',
|
||||
multiple: false,
|
||||
@ -372,7 +364,7 @@ const fetchListDetails = async () => {
|
||||
}, '');
|
||||
|
||||
if (showCostSummaryDialog.value) { // If dialog is open, refresh its data
|
||||
await fetchListCostSummary();
|
||||
await fetchListCostSummary();
|
||||
}
|
||||
|
||||
} 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, '');
|
||||
|
||||
if ((lastListUpdate.value && newListUpdatedAt > lastListUpdate.value) ||
|
||||
(lastItemUpdate.value && newLastItemUpdate > lastItemUpdate.value)) {
|
||||
(lastItemUpdate.value && newLastItemUpdate > lastItemUpdate.value)) {
|
||||
await fetchListDetails();
|
||||
}
|
||||
} catch (err) {
|
||||
@ -406,6 +398,16 @@ const stopPolling = () => {
|
||||
if (pollingInterval.value) clearInterval(pollingInterval.value);
|
||||
};
|
||||
|
||||
const isItemPendingSync = (item: Item) => {
|
||||
return offlineStore.pendingActions.some(action => {
|
||||
if (action.type === 'update_list_item' || action.type === 'delete_list_item') {
|
||||
const payload = action.payload as { listId: string; itemId: string };
|
||||
return payload.itemId === String(item.id);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const onAddItem = async () => {
|
||||
if (!list.value || !newItem.value.name.trim()) {
|
||||
notificationStore.addNotification({ message: 'Please enter an item name.', type: 'warning' });
|
||||
@ -413,6 +415,35 @@ const onAddItem = async () => {
|
||||
return;
|
||||
}
|
||||
addingItem.value = true;
|
||||
|
||||
if (!isOnline.value) {
|
||||
// Add to offline queue
|
||||
offlineStore.addAction({
|
||||
type: 'create_list_item',
|
||||
payload: {
|
||||
listId: String(list.value.id),
|
||||
itemData: {
|
||||
name: newItem.value.name,
|
||||
quantity: newItem.value.quantity?.toString()
|
||||
}
|
||||
}
|
||||
});
|
||||
// Optimistically add to UI
|
||||
const optimisticItem: Item = {
|
||||
id: Date.now(), // Temporary ID
|
||||
name: newItem.value.name,
|
||||
quantity: newItem.value.quantity,
|
||||
is_complete: false,
|
||||
version: 1,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
list.value.items.push(processListItems([optimisticItem])[0]);
|
||||
newItem.value = { name: '' };
|
||||
itemNameInputRef.value?.focus();
|
||||
addingItem.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
|
||||
@ -435,77 +466,104 @@ const updateItem = async (item: Item, newCompleteStatus: boolean) => {
|
||||
const originalCompleteStatus = item.is_complete;
|
||||
item.is_complete = newCompleteStatus; // Optimistic update
|
||||
|
||||
if (!isOnline.value) {
|
||||
// Add to offline queue
|
||||
offlineStore.addAction({
|
||||
type: 'update_list_item',
|
||||
payload: {
|
||||
listId: String(list.value.id),
|
||||
itemId: String(item.id),
|
||||
data: {
|
||||
completed: newCompleteStatus
|
||||
},
|
||||
version: item.version
|
||||
}
|
||||
});
|
||||
item.updating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: { is_complete: boolean; version: number; price?: number | null } = {
|
||||
is_complete: item.is_complete,
|
||||
version: item.version,
|
||||
};
|
||||
if (item.is_complete && item.priceInput !== undefined && item.priceInput !== null && String(item.priceInput).trim() !== '') {
|
||||
payload.price = parseFloat(String(item.priceInput));
|
||||
} else if (item.is_complete && (item.priceInput === undefined || String(item.priceInput).trim() === '')) {
|
||||
// If complete and price is empty, don't send price, or send null if API expects it
|
||||
payload.price = null; // Or omit, depending on API
|
||||
}
|
||||
|
||||
|
||||
const response = await apiClient.put(API_ENDPOINTS.ITEMS.BY_ID(String(item.id)), payload);
|
||||
const updatedItemFromServer = processListItems([response.data as Item])[0];
|
||||
|
||||
const index = list.value.items.findIndex(i => i.id === item.id);
|
||||
if (index !== -1) {
|
||||
list.value.items[index] = { ...list.value.items[index], ...updatedItemFromServer, updating: false };
|
||||
}
|
||||
|
||||
// If cost summary was open, refresh it
|
||||
if (showCostSummaryDialog.value) await fetchListCostSummary();
|
||||
|
||||
await apiClient.put(
|
||||
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
|
||||
{ completed: newCompleteStatus, version: item.version }
|
||||
);
|
||||
item.version++;
|
||||
} catch (err) {
|
||||
item.is_complete = originalCompleteStatus; // Revert optimistic update
|
||||
item.is_complete = originalCompleteStatus; // Revert on error
|
||||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item.', type: 'error' });
|
||||
} finally {
|
||||
item.updating = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const updateItemPrice = async (item: Item) => {
|
||||
if (!list.value || !item.is_complete) return; // Only update price if item is complete
|
||||
if (!list.value || !item.is_complete) return;
|
||||
|
||||
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null;
|
||||
if (item.price === newPrice) return; // No change
|
||||
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null;
|
||||
if (item.price === newPrice) return;
|
||||
|
||||
item.updating = true;
|
||||
const originalPrice = item.price;
|
||||
item.price = newPrice; // Optimistic
|
||||
item.updating = true;
|
||||
const originalPrice = item.price;
|
||||
const originalPriceInput = item.priceInput;
|
||||
|
||||
try {
|
||||
const response = await apiClient.put(
|
||||
API_ENDPOINTS.ITEMS.BY_ID(String(item.id)),
|
||||
{ price: item.price, is_complete: item.is_complete, version: item.version }
|
||||
);
|
||||
const updatedItemFromServer = processListItems([response.data as Item])[0];
|
||||
const index = list.value.items.findIndex(i => i.id === item.id);
|
||||
if (index !== -1) {
|
||||
list.value.items[index] = { ...list.value.items[index], ...updatedItemFromServer, updating: false };
|
||||
}
|
||||
if (showCostSummaryDialog.value) await fetchListCostSummary();
|
||||
} catch (err) {
|
||||
item.price = originalPrice; // Revert
|
||||
item.priceInput = originalPrice !== null && originalPrice !== undefined ? originalPrice : '';
|
||||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to update item price.', type: 'error' });
|
||||
} finally {
|
||||
item.updating = false;
|
||||
}
|
||||
item.price = newPrice;
|
||||
|
||||
if (!isOnline.value) {
|
||||
// Add to offline queue
|
||||
offlineStore.addAction({
|
||||
type: 'update_list_item',
|
||||
payload: {
|
||||
listId: String(list.value.id),
|
||||
itemId: String(item.id),
|
||||
data: {
|
||||
price: newPrice,
|
||||
completed: item.is_complete
|
||||
},
|
||||
version: item.version
|
||||
}
|
||||
});
|
||||
item.updating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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) => {
|
||||
if (!list.value) return;
|
||||
item.deleting = true;
|
||||
|
||||
if (!isOnline.value) {
|
||||
// Add to offline queue
|
||||
offlineStore.addAction({
|
||||
type: 'delete_list_item',
|
||||
payload: {
|
||||
listId: String(list.value.id),
|
||||
itemId: String(item.id)
|
||||
}
|
||||
});
|
||||
// Optimistically remove from UI
|
||||
list.value.items = list.value.items.filter(i => i.id !== item.id);
|
||||
item.deleting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.delete(API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)));
|
||||
list.value.items = list.value.items.filter(i => i.id !== item.id);
|
||||
if (showCostSummaryDialog.value) await fetchListCostSummary();
|
||||
} catch (err) {
|
||||
notificationStore.addNotification({ message: (err instanceof Error ? err.message : String(err)) || 'Failed to delete item.', type: 'error' });
|
||||
} finally {
|
||||
@ -540,20 +598,20 @@ const cancelConfirmation = () => {
|
||||
|
||||
// OCR Functionality
|
||||
const openOcrDialog = () => {
|
||||
ocrItems.value = [];
|
||||
ocrError.value = null;
|
||||
resetOcrFileDialog(); // Clear previous file selection from useFileDialog
|
||||
showOcrDialogState.value = true;
|
||||
nextTick(() => {
|
||||
if (ocrFileInputRef.value) {
|
||||
ocrFileInputRef.value.value = ''; // Manually clear input type=file
|
||||
}
|
||||
});
|
||||
ocrItems.value = [];
|
||||
ocrError.value = null;
|
||||
resetOcrFileDialog(); // Clear previous file selection from useFileDialog
|
||||
showOcrDialogState.value = true;
|
||||
nextTick(() => {
|
||||
if (ocrFileInputRef.value) {
|
||||
ocrFileInputRef.value.value = ''; // Manually clear input type=file
|
||||
}
|
||||
});
|
||||
};
|
||||
const closeOcrDialog = () => {
|
||||
showOcrDialogState.value = false;
|
||||
ocrItems.value = [];
|
||||
ocrError.value = null;
|
||||
showOcrDialogState.value = false;
|
||||
ocrItems.value = [];
|
||||
ocrError.value = null;
|
||||
};
|
||||
|
||||
watch(ocrFiles, async (newFiles) => {
|
||||
@ -564,10 +622,10 @@ watch(ocrFiles, async (newFiles) => {
|
||||
});
|
||||
|
||||
const handleOcrFileUpload = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files && target.files.length > 0) {
|
||||
handleOcrUpload(target.files[0]);
|
||||
}
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files && target.files.length > 0) {
|
||||
handleOcrUpload(target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOcrUpload = async (file: File) => {
|
||||
@ -581,15 +639,15 @@ const handleOcrUpload = async (file: File) => {
|
||||
const response = await apiClient.post(API_ENDPOINTS.OCR.PROCESS, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
ocrItems.value = response.data.extracted_items.map((nameStr: string) => ({ name: nameStr.trim() })).filter(item => item.name);
|
||||
if(ocrItems.value.length === 0) {
|
||||
ocrError.value = "No items extracted from the image.";
|
||||
ocrItems.value = response.data.extracted_items.map((nameStr: string) => ({ name: nameStr.trim() })).filter((item: { name: string }) => item.name);
|
||||
if (ocrItems.value.length === 0) {
|
||||
ocrError.value = "No items extracted from the image.";
|
||||
}
|
||||
} catch (err) {
|
||||
ocrError.value = (err instanceof Error ? err.message : String(err)) || 'Failed to process image.';
|
||||
} finally {
|
||||
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
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
|
||||
return;
|
||||
return;
|
||||
}
|
||||
if (showOcrDialogState.value || showCostSummaryDialog.value || showConfirmDialogState.value) {
|
||||
return;
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
itemNameInputRef.value?.focus();
|
||||
@ -656,24 +714,23 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
||||
});
|
||||
|
||||
// Swipe detection (basic)
|
||||
// let touchStartX = 0; // Commented out as unused
|
||||
// const SWIPE_THRESHOLD = 50; // pixels // Commented out as unused
|
||||
let touchStartX = 0;
|
||||
const SWIPE_THRESHOLD = 50; // pixels
|
||||
|
||||
const handleTouchStart = (event: TouchEvent, /* item: Item */) => { // Commented out unused item
|
||||
touchStartX = event.changedTouches[0].clientX;
|
||||
// Add class for visual feedback during swipe if desired
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
touchStartX = event.changedTouches[0].clientX;
|
||||
// Add class for visual feedback during swipe if desired
|
||||
};
|
||||
|
||||
const handleTouchMove = (/* event: TouchEvent, item: Item */) => { // Commented out unused event and item
|
||||
// Can be used for interactive swipe effect
|
||||
const handleTouchMove = () => {
|
||||
// Can be used for interactive swipe effect
|
||||
};
|
||||
|
||||
const handleTouchEnd = (/* item: Item */) => { // Commented out unused item
|
||||
// Not implemented: Valerie UI swipe example implies JS adds/removes 'is-swiped'
|
||||
// For a simple demo, one might toggle it here based on a more complex gesture
|
||||
// This would require more state per item and logic
|
||||
// For now, swipe actions are not visually implemented
|
||||
// item.swiped = !item.swiped; // Example of toggling. A real implementation would be more complex.
|
||||
const handleTouchEnd = () => {
|
||||
// Not implemented: Valerie UI swipe example implies JS adds/removes 'is-swiped'
|
||||
// For a simple demo, one might toggle it here based on a more complex gesture
|
||||
// This would require more state per item and logic
|
||||
// For now, swipe actions are not visually implemented
|
||||
};
|
||||
|
||||
|
||||
@ -695,16 +752,45 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-padding { padding: 1rem; }
|
||||
.mb-1 { margin-bottom: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 1rem; }
|
||||
.mb-3 { margin-bottom: 1.5rem; }
|
||||
.mt-1 { margin-top: 0.5rem; }
|
||||
.mt-2 { margin-top: 1rem; }
|
||||
.ml-1 { margin-left: 0.25rem; }
|
||||
.ml-2 { margin-left: 0.5rem; }
|
||||
.text-right { text-align: right; }
|
||||
.flex-grow { flex-grow: 1; }
|
||||
.page-padding {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.item-caption {
|
||||
display: block;
|
||||
@ -712,27 +798,102 @@ onUnmounted(() => {
|
||||
opacity: 0.6;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.text-decoration-line-through {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.form-input-sm { /* For price input */
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
|
||||
.form-input-sm {
|
||||
/* For price input */
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cost-overview p {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.form-error-text {
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.list-item.completed .item-text {
|
||||
/* text-decoration: line-through; is handled by span class */
|
||||
opacity: 0.7;
|
||||
/* text-decoration: line-through; is handled by span class */
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.list-item-actions {
|
||||
margin-left: auto; /* Pushes actions to the right */
|
||||
padding-left: 1rem; /* Space before actions */
|
||||
margin-left: auto;
|
||||
/* Pushes actions to the right */
|
||||
padding-left: 1rem;
|
||||
/* Space before actions */
|
||||
}
|
||||
|
||||
.offline-item {
|
||||
position: relative;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.offline-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3Cpath d='M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16'/%3E%3Cpath d='M16 21h5v-5'/%3E%3C/svg%3E");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.offline-item.synced {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.offline-item.synced::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-offline-disabled {
|
||||
position: relative;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.feature-offline-disabled::before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg-color-tooltip, #333);
|
||||
color: white;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.feature-offline-disabled:hover::before {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
</style>
|
@ -2,51 +2,43 @@
|
||||
<main class="flex items-center justify-center page-container">
|
||||
<div class="card login-card">
|
||||
<div class="card-header">
|
||||
<h3>Login</h3>
|
||||
<h3>mitlist</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="onSubmit" class="form-layout">
|
||||
<div class="form-group mb-2">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
v-model="email"
|
||||
class="form-input"
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email" />
|
||||
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<div class="input-with-icon-append">
|
||||
<input
|
||||
:type="isPwdVisible ? 'text' : 'password'"
|
||||
id="password"
|
||||
v-model="password"
|
||||
class="form-input"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn" aria-label="Toggle password visibility">
|
||||
<svg class="icon icon-sm"><use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use></svg> <!-- Placeholder for visibility icons -->
|
||||
<input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input"
|
||||
required autocomplete="current-password" />
|
||||
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn"
|
||||
aria-label="Toggle password visibility">
|
||||
<svg class="icon icon-sm">
|
||||
<use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
|
||||
</svg> <!-- Placeholder for visibility icons -->
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
|
||||
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
|
||||
<span v-if="loading" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
||||
<span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
||||
Login
|
||||
</button>
|
||||
|
||||
<div class="text-center mt-2">
|
||||
<router-link to="/signup" class="link-styled">Don't have an account? Sign up</router-link>
|
||||
<router-link to="/auth/signup" class="link-styled">Don't have an account? Sign up</router-link>
|
||||
</div>
|
||||
|
||||
<SocialLoginButtons />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@ -58,6 +50,7 @@ import { ref } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth'; // Assuming path
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
import SocialLoginButtons from '@/components/SocialLoginButtons.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@ -112,14 +105,17 @@ const onSubmit = async () => {
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
min-height: 100vh; /* dvh for dynamic viewport height */
|
||||
min-height: 100vh;
|
||||
/* dvh for dynamic viewport height */
|
||||
min-height: 100dvh;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* form-layout, w-full, mt-2, text-center styles were removed as utility classes from Valerie UI are used directly or are available. */
|
||||
|
||||
.link-styled {
|
||||
@ -128,35 +124,45 @@ const onSubmit = async () => {
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: border-color var(--transition-speed) var(--transition-ease-out);
|
||||
}
|
||||
.link-styled:hover, .link-styled:focus {
|
||||
|
||||
.link-styled:hover,
|
||||
.link-styled:focus {
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.form-error-text {
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.alert.form-error-text { /* For general error message */
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.alert.form-error-text {
|
||||
/* For general error message */
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.input-with-icon-append {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.input-with-icon-append .form-input {
|
||||
padding-right: 3rem; /* Space for the button */
|
||||
padding-right: 3rem;
|
||||
/* Space for the button */
|
||||
}
|
||||
|
||||
.icon-append-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3rem; /* Width of the button */
|
||||
width: 3rem;
|
||||
/* Width of the button */
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: var(--border); /* Separator line */
|
||||
border-left: var(--border);
|
||||
/* Separator line */
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -164,9 +170,16 @@ const onSubmit = async () => {
|
||||
color: var(--dark);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.icon-append-btn:hover, .icon-append-btn:focus {
|
||||
|
||||
.icon-append-btn:hover,
|
||||
.icon-append-btn:focus {
|
||||
opacity: 1;
|
||||
background-color: rgba(0,0,0,0.03);
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
.icon-append-btn .icon { margin: 0; } /* Remove default icon margin */
|
||||
|
||||
.icon-append-btn .icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Remove default icon margin */
|
||||
</style>
|
@ -8,51 +8,42 @@
|
||||
<form @submit.prevent="onSubmit" class="form-layout">
|
||||
<div class="form-group mb-2">
|
||||
<label for="name" class="form-label">Full Name</label>
|
||||
<input type="text" id="name" v-model="name" class="form-input" required autocomplete="name"/>
|
||||
<input type="text" id="name" v-model="name" class="form-input" required autocomplete="name" />
|
||||
<p v-if="formErrors.name" class="form-error-text">{{ formErrors.name }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-2">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email"/>
|
||||
<input type="email" id="email" v-model="email" class="form-input" required autocomplete="email" />
|
||||
<p v-if="formErrors.email" class="form-error-text">{{ formErrors.email }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-2">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<div class="input-with-icon-append">
|
||||
<input
|
||||
:type="isPwdVisible ? 'text' : 'password'"
|
||||
id="password"
|
||||
v-model="password"
|
||||
class="form-input"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn" aria-label="Toggle password visibility">
|
||||
<svg class="icon icon-sm"><use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use></svg> <!-- Placeholder for visibility icons -->
|
||||
</button>
|
||||
<div class="input-with-icon-append">
|
||||
<input :type="isPwdVisible ? 'text' : 'password'" id="password" v-model="password" class="form-input"
|
||||
required autocomplete="new-password" />
|
||||
<button type="button" @click="isPwdVisible = !isPwdVisible" class="icon-append-btn"
|
||||
aria-label="Toggle password visibility">
|
||||
<svg class="icon icon-sm">
|
||||
<use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
|
||||
</svg> <!-- Placeholder for visibility icons -->
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||
<input
|
||||
:type="isPwdVisible ? 'text' : 'password'"
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
class="form-input"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<input :type="isPwdVisible ? 'text' : 'password'" id="confirmPassword" v-model="confirmPassword"
|
||||
class="form-input" required autocomplete="new-password" />
|
||||
<p v-if="formErrors.confirmPassword" class="form-error-text">{{ formErrors.confirmPassword }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full mt-2" :disabled="loading">
|
||||
<span v-if="loading" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
||||
<span v-if="loading" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
||||
Sign Up
|
||||
</button>
|
||||
|
||||
@ -68,7 +59,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from 'stores/auth'; // Assuming path
|
||||
import { useAuthStore } from '@/stores/auth'; // Assuming path is correct
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
|
||||
const router = useRouter();
|
||||
@ -143,10 +134,12 @@ const onSubmit = async () => {
|
||||
min-height: 100dvh;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.signup-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* form-layout, w-full, mt-2, text-center styles were removed as utility classes from Valerie UI are used directly or are available. */
|
||||
|
||||
.link-styled {
|
||||
@ -155,26 +148,33 @@ const onSubmit = async () => {
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: border-color var(--transition-speed) var(--transition-ease-out);
|
||||
}
|
||||
.link-styled:hover, .link-styled:focus {
|
||||
|
||||
.link-styled:hover,
|
||||
.link-styled:focus {
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.form-error-text {
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.alert.form-error-text { /* For general error message */
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.alert.form-error-text {
|
||||
/* For general error message */
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.input-with-icon-append {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.input-with-icon-append .form-input {
|
||||
padding-right: 3rem;
|
||||
}
|
||||
|
||||
.icon-append-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
@ -191,9 +191,14 @@ const onSubmit = async () => {
|
||||
color: var(--dark);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.icon-append-btn:hover, .icon-append-btn:focus {
|
||||
|
||||
.icon-append-btn:hover,
|
||||
.icon-append-btn:focus {
|
||||
opacity: 1;
|
||||
background-color: rgba(0,0,0,0.03);
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.icon-append-btn .icon {
|
||||
margin: 0;
|
||||
}
|
||||
.icon-append-btn .icon { margin: 0; }
|
||||
</style>
|
@ -17,7 +17,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
// Auth guard logic
|
||||
const authStore = useAuthStore();
|
||||
const isAuthenticated = authStore.isAuthenticated;
|
||||
const publicRoutes = ['/auth/login', '/auth/signup']; // Fixed public routes paths
|
||||
const publicRoutes = ['/auth/login', '/auth/signup', '/auth/callback']; // Added callback route
|
||||
const requiresAuth = !publicRoutes.includes(to.path);
|
||||
|
||||
if (requiresAuth && !isAuthenticated) {
|
||||
|
@ -37,6 +37,7 @@ const routes: RouteRecordRaw[] = [
|
||||
children: [
|
||||
{ path: 'login', name: 'Login', component: () => import('../pages/LoginPage.vue') },
|
||||
{ path: 'signup', name: 'Signup', component: () => import('../pages/SignupPage.vue') },
|
||||
{ path: 'callback', name: 'AuthCallback', component: () => import('../pages/AuthCallbackPage.vue') },
|
||||
],
|
||||
},
|
||||
// {
|
||||
|
@ -9,6 +9,7 @@ const api = axios.create({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true, // Enable sending cookies and authentication headers
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
@ -46,7 +47,7 @@ api.interceptors.response.use(
|
||||
// Use the store's refresh mechanism if it already handles API call and token setting
|
||||
// However, the interceptor is specifically for retrying requests, so direct call is fine here
|
||||
// as long as it correctly updates tokens for the subsequent retry.
|
||||
const response = await api.post('/api/v1/auth/refresh', {
|
||||
const response = await api.post('/auth/jwt/refresh', {
|
||||
refresh_token: refreshTokenValue,
|
||||
});
|
||||
|
||||
@ -77,10 +78,8 @@ export { api, globalAxios };
|
||||
import { API_VERSION, API_ENDPOINTS } from '@/config/api-config';
|
||||
|
||||
export const getApiUrl = (endpoint: string): string => {
|
||||
// Assuming API_BASE_URL already includes http://localhost:8000
|
||||
// and endpoint starts with /
|
||||
// The original `getApiUrl` added /api/v1, ensure this is correct for your setup
|
||||
return `${API_BASE_URL}/api/${API_VERSION}${endpoint}`;
|
||||
// The API_ENDPOINTS already include the full path, so we just need to combine with base URL
|
||||
return `${API_BASE_URL}${endpoint}`;
|
||||
};
|
||||
|
||||
export const apiClient = {
|
||||
|
@ -7,7 +7,6 @@ import router from '@/router';
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
user: {
|
||||
email: string;
|
||||
name: string;
|
||||
@ -18,7 +17,6 @@ interface AuthState {
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// State
|
||||
const accessToken = ref<string | null>(localStorage.getItem('token'));
|
||||
const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'));
|
||||
const user = ref<AuthState['user']>(null);
|
||||
|
||||
// Getters
|
||||
@ -26,19 +24,15 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const getUser = computed(() => user.value);
|
||||
|
||||
// Actions
|
||||
const setTokens = (tokens: { access_token: string; refresh_token: string }) => {
|
||||
const setTokens = (tokens: { access_token: string }) => {
|
||||
accessToken.value = tokens.access_token;
|
||||
refreshToken.value = tokens.refresh_token;
|
||||
localStorage.setItem('token', tokens.access_token);
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token);
|
||||
};
|
||||
|
||||
const clearTokens = () => {
|
||||
accessToken.value = null;
|
||||
refreshToken.value = null;
|
||||
user.value = null;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
};
|
||||
|
||||
const setUser = (userData: AuthState['user']) => {
|
||||
@ -56,20 +50,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('AuthStore: Failed to fetch current user:', error);
|
||||
if (error.response?.status === 401) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
const response = await apiClient.get(API_ENDPOINTS.USERS.PROFILE);
|
||||
setUser(response.data);
|
||||
return response.data;
|
||||
} catch (refreshOrRetryError) {
|
||||
console.error('AuthStore: Failed to refresh token or fetch user after refresh:', refreshOrRetryError);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
clearTokens();
|
||||
return null;
|
||||
}
|
||||
clearTokens();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@ -84,8 +66,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { access_token, refresh_token } = response.data;
|
||||
setTokens({ access_token, refresh_token });
|
||||
const { access_token } = response.data;
|
||||
setTokens({ access_token });
|
||||
await fetchCurrentUser();
|
||||
return response.data;
|
||||
};
|
||||
@ -95,29 +77,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const refreshAccessToken = async () => {
|
||||
if (!refreshToken.value) {
|
||||
clearTokens();
|
||||
await router.push('/auth/login');
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(API_ENDPOINTS.AUTH.REFRESH_TOKEN, {
|
||||
refresh_token: refreshToken.value,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token: newRefreshToken } = response.data;
|
||||
setTokens({ access_token, refresh_token: newRefreshToken });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('AuthStore: Refresh token failed:', error);
|
||||
clearTokens();
|
||||
await router.push('/auth/login');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
clearTokens();
|
||||
await router.push('/auth/login');
|
||||
@ -125,7 +84,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user,
|
||||
isAuthenticated,
|
||||
getUser,
|
||||
@ -135,7 +93,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
fetchCurrentUser,
|
||||
login,
|
||||
signup,
|
||||
refreshAccessToken,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
|
@ -4,25 +4,58 @@ import { ref, computed } from 'vue';
|
||||
// import { LocalStorage } from 'quasar'; // REMOVE
|
||||
import { useStorage } from '@vueuse/core'; // VueUse alternative
|
||||
import { useNotificationStore } from '@/stores/notifications'; // Your custom notification store
|
||||
import { apiClient, API_ENDPOINTS } from '@/services/api'; // Import apiClient and API_ENDPOINTS
|
||||
|
||||
// ... (interfaces remain the same)
|
||||
export type CreateListPayload = { name: string; description?: string; /* other list properties */ };
|
||||
export type UpdateListPayload = { listId: string; data: Partial<CreateListPayload>; version?: number; };
|
||||
export type DeleteListPayload = { listId: string; };
|
||||
export type CreateListItemPayload = { listId: string; itemData: { name: string; quantity?: number | string; completed?: boolean; price?: number | null; /* other item properties */ }; };
|
||||
export type UpdateListItemPayload = { listId: string; itemId: string; data: Partial<CreateListItemPayload['itemData']>; version?: number; };
|
||||
export type DeleteListItemPayload = { listId: string; itemId: string; };
|
||||
|
||||
export type OfflineAction = {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
type:
|
||||
| 'create_list'
|
||||
| 'update_list'
|
||||
| 'delete_list'
|
||||
| 'create_list_item'
|
||||
| 'update_list_item'
|
||||
| 'delete_list_item';
|
||||
payload:
|
||||
| CreateListPayload
|
||||
| UpdateListPayload
|
||||
| DeleteListPayload
|
||||
| CreateListItemPayload
|
||||
| UpdateListItemPayload
|
||||
| DeleteListItemPayload;
|
||||
};
|
||||
|
||||
export type ConflictData = {
|
||||
localVersion: { data: Record<string, unknown>; timestamp: number; };
|
||||
serverVersion: { data: Record<string, unknown>; timestamp: number; };
|
||||
action: OfflineAction;
|
||||
};
|
||||
|
||||
interface ServerListData {
|
||||
id: string;
|
||||
version: number;
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ServerItemData {
|
||||
id: string;
|
||||
version: number;
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const useOfflineStore = defineStore('offline', () => {
|
||||
// const $q = useQuasar(); // REMOVE
|
||||
const notificationStore = useNotificationStore();
|
||||
const isOnline = ref(navigator.onLine);
|
||||
interface OfflineAction {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
type: string;
|
||||
// Add other necessary fields
|
||||
}
|
||||
|
||||
interface ConflictData {
|
||||
localVersion: unknown; // Replace with proper type
|
||||
serverVersion: unknown; // Replace with proper type
|
||||
action: OfflineAction;
|
||||
}
|
||||
|
||||
// Use useStorage for reactive localStorage
|
||||
const pendingActions = useStorage<OfflineAction[]>('offline-actions', []);
|
||||
@ -36,13 +69,12 @@ export const useOfflineStore = defineStore('offline', () => {
|
||||
// saveToStorage is also handled by useStorage automatically saving on change
|
||||
|
||||
const addAction = (action: Omit<OfflineAction, 'id' | 'timestamp'>) => {
|
||||
const newAction: OfflineAction = {
|
||||
const newAction = {
|
||||
...action,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} as OfflineAction;
|
||||
pendingActions.value.push(newAction);
|
||||
// useStorage handles saving
|
||||
};
|
||||
|
||||
const processQueue = async () => {
|
||||
@ -52,61 +84,141 @@ export const useOfflineStore = defineStore('offline', () => {
|
||||
|
||||
for (const action of actionsToProcess) {
|
||||
try {
|
||||
await processAction(action); // processAction needs to use your actual API client
|
||||
await processAction(action);
|
||||
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'response' in error && typeof error.response === 'object' && error.response && 'status' in error.response && error.response.status === 409) {
|
||||
} catch (error: any) { // Catch error as any to check for our custom flag
|
||||
if (error && error.isConflict && error.serverVersionData) {
|
||||
notificationStore.addNotification({
|
||||
type: 'warning',
|
||||
message: 'Item was modified by someone else. Please review.',
|
||||
// actions: [ ... ] // Custom actions for notifications would be more complex
|
||||
message: `Conflict detected for action ${action.type}. Please review.`,
|
||||
});
|
||||
// Here you would trigger the conflict resolution dialog
|
||||
// For example, find the item and its server version, then:
|
||||
// currentConflict.value = { localVersion: ..., serverVersion: ..., action };
|
||||
// showConflictDialog.value = true;
|
||||
// The loop should probably pause or handle this conflict before continuing
|
||||
console.warn('Conflict detected for action:', action.id, error);
|
||||
// Break or decide how to handle queue processing on conflict
|
||||
break;
|
||||
|
||||
let localData: Record<string, unknown>;
|
||||
// Extract local data based on action type
|
||||
if (action.type === 'update_list' || action.type === 'update_list_item') {
|
||||
localData = (action.payload as UpdateListPayload | UpdateListItemPayload).data;
|
||||
} else if (action.type === 'create_list' || action.type === 'create_list_item') {
|
||||
localData = action.payload as CreateListPayload | CreateListItemPayload;
|
||||
} else {
|
||||
console.error("Conflict detected for unhandled action type for data extraction:", action.type);
|
||||
localData = {}; // Fallback
|
||||
}
|
||||
|
||||
currentConflict.value = {
|
||||
localVersion: {
|
||||
data: localData,
|
||||
timestamp: action.timestamp,
|
||||
},
|
||||
serverVersion: {
|
||||
data: error.serverVersionData, // Assumes API 409 response body is the server item
|
||||
timestamp: error.serverVersionData.updated_at ? new Date(error.serverVersionData.updated_at).getTime() : action.timestamp + 1, // Prefer server updated_at
|
||||
},
|
||||
action: action,
|
||||
};
|
||||
showConflictDialog.value = true;
|
||||
console.warn('Conflict detected by processQueue for action:', action.id, error);
|
||||
// Stop processing queue on first conflict to await resolution
|
||||
isProcessingQueue.value = false; // Allow queue to be re-triggered after resolution
|
||||
return; // Stop processing further actions
|
||||
} else {
|
||||
console.error('Failed to process offline action:', action.id, error);
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: `Failed to sync action: ${action.type}`,
|
||||
});
|
||||
console.error('processQueue: Action failed, remains in queue:', action.id, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
isProcessingQueue.value = false;
|
||||
};
|
||||
|
||||
// processAction needs to be implemented with your actual API calls
|
||||
const processAction = async (action: OfflineAction) => {
|
||||
console.log('Processing action (TODO: Implement API call):', action);
|
||||
// Example:
|
||||
// import { apiClient } from '@/services/api';
|
||||
// import { API_ENDPOINTS } from '@/config/api-config';
|
||||
// switch (action.type) {
|
||||
// case 'add':
|
||||
// // Assuming action.data is { listId: string, itemData: { name: string, quantity?: string } }
|
||||
// // await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(action.data.listId), action.data.itemData);
|
||||
// break;
|
||||
// // ... other cases
|
||||
// }
|
||||
// Simulate async work
|
||||
return new Promise(resolve => setTimeout(resolve, 500));
|
||||
try {
|
||||
let request: Request;
|
||||
let endpoint: string;
|
||||
let method: 'POST' | 'PUT' | 'DELETE' = 'POST';
|
||||
let body: any;
|
||||
|
||||
switch (action.type) {
|
||||
case 'create_list':
|
||||
endpoint = API_ENDPOINTS.LISTS.BASE;
|
||||
body = action.payload;
|
||||
break;
|
||||
case 'update_list': {
|
||||
const { listId, data } = action.payload as UpdateListPayload;
|
||||
endpoint = API_ENDPOINTS.LISTS.BY_ID(listId);
|
||||
method = 'PUT';
|
||||
body = data;
|
||||
break;
|
||||
}
|
||||
case 'delete_list': {
|
||||
const { listId } = action.payload as DeleteListPayload;
|
||||
endpoint = API_ENDPOINTS.LISTS.BY_ID(listId);
|
||||
method = 'DELETE';
|
||||
break;
|
||||
}
|
||||
case 'create_list_item': {
|
||||
const { listId, itemData } = action.payload as CreateListItemPayload;
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEMS(listId);
|
||||
body = itemData;
|
||||
break;
|
||||
}
|
||||
case 'update_list_item': {
|
||||
const { listId, itemId, data } = action.payload as UpdateListItemPayload;
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEM(listId, itemId);
|
||||
method = 'PUT';
|
||||
body = data;
|
||||
break;
|
||||
}
|
||||
case 'delete_list_item': {
|
||||
const { listId, itemId } = action.payload as DeleteListItemPayload;
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEM(listId, itemId);
|
||||
method = 'DELETE';
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown action type: ${action.type}`);
|
||||
}
|
||||
|
||||
// Create the request with the action metadata
|
||||
request = new Request(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Offline-Action': action.id,
|
||||
},
|
||||
body: method !== 'DELETE' ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
// Use fetch with the request
|
||||
const response = await fetch(request);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 409) {
|
||||
const error = new Error('Conflict detected') as any;
|
||||
error.isConflict = true;
|
||||
error.serverVersionData = await response.json();
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// If successful, remove from pending actions
|
||||
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
|
||||
return await response.json();
|
||||
} catch (error: any) {
|
||||
if (error.isConflict) {
|
||||
throw error;
|
||||
}
|
||||
// For other errors, let Workbox handle the retry
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const setupNetworkListeners = () => {
|
||||
window.addEventListener('online', () => {
|
||||
isOnline.value = true;
|
||||
processQueue().catch(err => console.error("Error processing queue on online event:", err));
|
||||
});
|
||||
window.addEventListener('offline', () => {
|
||||
isOnline.value = false;
|
||||
});
|
||||
window.addEventListener('online', () => {
|
||||
isOnline.value = true;
|
||||
processQueue().catch(err => console.error("Error processing queue on online event:", err));
|
||||
});
|
||||
window.addEventListener('offline', () => {
|
||||
isOnline.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
setupNetworkListeners(); // Call this once
|
||||
@ -114,28 +226,148 @@ export const useOfflineStore = defineStore('offline', () => {
|
||||
const hasPendingActions = computed(() => pendingActions.value.length > 0);
|
||||
const pendingActionCount = computed(() => pendingActions.value.length);
|
||||
|
||||
const handleConflictResolution = (resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }) => {
|
||||
console.log('Conflict resolution chosen:', resolution);
|
||||
// TODO: Implement logic to apply the chosen resolution
|
||||
// This might involve making another API call with the resolved data
|
||||
// or updating local state and then trying to sync again.
|
||||
// After resolving, remove the action from pending or mark as resolved.
|
||||
// For now, just remove it as an example:
|
||||
pendingActions.value = pendingActions.value.filter(a => a.id !== resolution.action.id);
|
||||
showConflictDialog.value = false;
|
||||
currentConflict.value = null;
|
||||
processQueue().catch(err => console.error("Error processing queue after conflict resolution:", err)); // Try processing queue again
|
||||
const handleConflictResolution = async (resolution: { version: 'local' | 'server' | 'merge'; action: OfflineAction; mergedData?: Record<string, unknown> }) => {
|
||||
if (!resolution.action || !currentConflict.value) {
|
||||
console.error("handleConflictResolution called without an action or active conflict.");
|
||||
showConflictDialog.value = false;
|
||||
currentConflict.value = null;
|
||||
return;
|
||||
}
|
||||
const { action, version, mergedData } = resolution;
|
||||
const serverVersionNumber = (currentConflict.value.serverVersion.data as any)?.version;
|
||||
|
||||
try {
|
||||
let success = false;
|
||||
if (version === 'local') {
|
||||
let dataToPush: any;
|
||||
let endpoint: string;
|
||||
let method: 'post' | 'put' = 'put';
|
||||
|
||||
if (action.type === 'update_list') {
|
||||
const payload = action.payload as UpdateListPayload;
|
||||
dataToPush = { ...payload.data, version: serverVersionNumber };
|
||||
endpoint = API_ENDPOINTS.LISTS.BY_ID(payload.listId);
|
||||
} else if (action.type === 'update_list_item') {
|
||||
const payload = action.payload as UpdateListItemPayload;
|
||||
dataToPush = { ...payload.data, version: serverVersionNumber };
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEM(payload.listId, payload.itemId);
|
||||
} else if (action.type === 'create_list') {
|
||||
const serverData = currentConflict.value.serverVersion.data as ServerListData | null;
|
||||
if (serverData?.id) {
|
||||
// Server returned existing list, update it instead
|
||||
dataToPush = { ...action.payload, version: serverData.version };
|
||||
endpoint = API_ENDPOINTS.LISTS.BY_ID(serverData.id);
|
||||
} else {
|
||||
// True conflict, need to modify the data
|
||||
dataToPush = {
|
||||
...action.payload,
|
||||
name: `${(action.payload as CreateListPayload).name} (${new Date().toLocaleString()})`
|
||||
};
|
||||
endpoint = API_ENDPOINTS.LISTS.BASE;
|
||||
method = 'post';
|
||||
}
|
||||
} else if (action.type === 'create_list_item') {
|
||||
const serverData = currentConflict.value.serverVersion.data as ServerItemData | null;
|
||||
if (serverData?.id) {
|
||||
// Server returned existing item, update it instead
|
||||
dataToPush = { ...action.payload, version: serverData.version };
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEM(
|
||||
(action.payload as CreateListItemPayload).listId,
|
||||
serverData.id
|
||||
);
|
||||
} else {
|
||||
// True conflict, need to modify the data
|
||||
dataToPush = {
|
||||
...action.payload,
|
||||
name: `${(action.payload as CreateListItemPayload).itemData.name} (${new Date().toLocaleString()})`
|
||||
};
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEMS((action.payload as CreateListItemPayload).listId);
|
||||
method = 'post';
|
||||
}
|
||||
} else {
|
||||
console.error("Unsupported action type for 'keep local' resolution:", action.type);
|
||||
throw new Error("Unsupported action for 'keep local'");
|
||||
}
|
||||
|
||||
if (method === 'put') {
|
||||
await apiClient.put(endpoint, dataToPush);
|
||||
} else {
|
||||
await apiClient.post(endpoint, dataToPush);
|
||||
}
|
||||
success = true;
|
||||
notificationStore.addNotification({ type: 'success', message: 'Your version was saved to the server.' });
|
||||
|
||||
} else if (version === 'server') {
|
||||
success = true;
|
||||
notificationStore.addNotification({ type: 'info', message: 'Local changes discarded; server version kept.' });
|
||||
|
||||
} else if (version === 'merge' && mergedData) {
|
||||
let dataWithVersion: any;
|
||||
let endpoint: string;
|
||||
|
||||
if (action.type === 'update_list') {
|
||||
const payload = action.payload as UpdateListPayload;
|
||||
dataWithVersion = { ...mergedData, version: serverVersionNumber };
|
||||
endpoint = API_ENDPOINTS.LISTS.BY_ID(payload.listId);
|
||||
} else if (action.type === 'update_list_item') {
|
||||
const payload = action.payload as UpdateListItemPayload;
|
||||
dataWithVersion = { ...mergedData, version: serverVersionNumber };
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEM(payload.listId, payload.itemId);
|
||||
} else if (action.type === 'create_list' || action.type === 'create_list_item') {
|
||||
// For create actions, merging means updating the existing item
|
||||
const serverData = currentConflict.value.serverVersion.data as (ServerListData | ServerItemData) | null;
|
||||
if (!serverData?.id) {
|
||||
throw new Error("Cannot merge create action: server data is missing or invalid");
|
||||
}
|
||||
|
||||
if (action.type === 'create_list') {
|
||||
dataWithVersion = { ...mergedData, version: serverData.version };
|
||||
endpoint = API_ENDPOINTS.LISTS.BY_ID(serverData.id);
|
||||
} else {
|
||||
dataWithVersion = { ...mergedData, version: serverData.version };
|
||||
endpoint = API_ENDPOINTS.LISTS.ITEM(
|
||||
(action.payload as CreateListItemPayload).listId,
|
||||
serverData.id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error("Merge resolution for unsupported action type:", action.type);
|
||||
throw new Error("Merge for this action type is not supported");
|
||||
}
|
||||
|
||||
await apiClient.put(endpoint, dataWithVersion);
|
||||
success = true;
|
||||
notificationStore.addNotification({ type: 'success', message: 'Merged version saved to the server.' });
|
||||
}
|
||||
|
||||
if (success) {
|
||||
pendingActions.value = pendingActions.value.filter(a => a.id !== action.id);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during conflict resolution API call:', error);
|
||||
notificationStore.addNotification({
|
||||
type: 'error',
|
||||
message: `Failed to resolve conflict for ${action.type}. Please try again.`,
|
||||
});
|
||||
} finally {
|
||||
showConflictDialog.value = false;
|
||||
currentConflict.value = null;
|
||||
processQueue().catch(err => console.error("Error processing queue after conflict resolution:", err));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isOnline,
|
||||
pendingActions,
|
||||
hasPendingActions,
|
||||
pendingActionCount,
|
||||
isProcessingQueue,
|
||||
showConflictDialog,
|
||||
currentConflict,
|
||||
addAction,
|
||||
processAction,
|
||||
processQueue,
|
||||
handleConflictResolution,
|
||||
hasPendingActions,
|
||||
pendingActionCount,
|
||||
};
|
||||
});
|
35
fe/src/sw.ts
35
fe/src/sw.ts
@ -5,7 +5,10 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope &
|
||||
typeof globalThis & { skipWaiting: () => Promise<void> };
|
||||
typeof globalThis & {
|
||||
skipWaiting: () => Promise<void>;
|
||||
__WB_MANIFEST: Array<{ url: string; revision: string | null }>;
|
||||
};
|
||||
|
||||
import { clientsClaim } from 'workbox-core';
|
||||
import {
|
||||
@ -17,16 +20,32 @@ import { registerRoute, NavigationRoute } from 'workbox-routing';
|
||||
import { CacheFirst, NetworkFirst } from 'workbox-strategies';
|
||||
import { ExpirationPlugin } from 'workbox-expiration';
|
||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
||||
import { BackgroundSyncPlugin } from 'workbox-background-sync';
|
||||
import type { WorkboxPlugin } from 'workbox-core/types';
|
||||
|
||||
self.skipWaiting().catch((error) => {
|
||||
console.error('Error during service worker activation:', error);
|
||||
// Create a background sync plugin instance
|
||||
const bgSyncPlugin = new BackgroundSyncPlugin('offline-actions-queue', {
|
||||
maxRetentionTime: 24 * 60, // Retry for max of 24 Hours (specified in minutes)
|
||||
});
|
||||
clientsClaim();
|
||||
|
||||
// Initialize service worker
|
||||
const initializeSW = async () => {
|
||||
try {
|
||||
await self.skipWaiting();
|
||||
clientsClaim();
|
||||
console.log('Service Worker initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Error during service worker initialization:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Use with precache injection
|
||||
// vite-plugin-pwa will populate self.__WB_MANIFEST
|
||||
precacheAndRoute(self.__WB_MANIFEST || []); // Provide a fallback empty array
|
||||
if (self.__WB_MANIFEST) {
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
} else {
|
||||
console.warn('No manifest found for precaching');
|
||||
}
|
||||
|
||||
cleanupOutdatedCaches();
|
||||
|
||||
@ -51,7 +70,7 @@ registerRoute(
|
||||
})
|
||||
);
|
||||
|
||||
// Cache API calls with Network First strategy
|
||||
// Cache API calls with Network First strategy and Background Sync for failed requests
|
||||
registerRoute(
|
||||
({ url }) => url.pathname.startsWith('/api/'), // Make sure this matches your actual API path structure
|
||||
new NetworkFirst({
|
||||
@ -64,6 +83,7 @@ registerRoute(
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24 hours
|
||||
}) as WorkboxPlugin,
|
||||
bgSyncPlugin, // Add background sync plugin for failed requests
|
||||
],
|
||||
})
|
||||
);
|
||||
@ -81,3 +101,6 @@ if (import.meta.env.MODE !== 'ssr' || import.meta.env.PROD) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize the service worker
|
||||
initializeSW();
|
@ -7,12 +7,15 @@ import path from 'node:path';
|
||||
|
||||
const pwaOptions: Partial<VitePWAOptions> = {
|
||||
registerType: 'autoUpdate',
|
||||
strategies: 'injectManifest', // Crucial for custom service worker
|
||||
srcDir: 'src', // Directory where sw.ts is located
|
||||
filename: 'sw.ts', // Your custom service worker filename
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
devOptions: {
|
||||
enabled: true, // Enable PWA in development
|
||||
enabled: true,
|
||||
type: 'module',
|
||||
navigateFallback: 'index.html',
|
||||
suppressWarnings: true,
|
||||
swSrc: 'src/sw.ts',
|
||||
},
|
||||
manifest: {
|
||||
name: 'mitlist',
|
||||
@ -31,8 +34,20 @@ const pwaOptions: Partial<VitePWAOptions> = {
|
||||
],
|
||||
},
|
||||
injectManifest: {
|
||||
// Options for workbox.injectManifest
|
||||
// Ensure your custom service worker (sw.ts) correctly handles __WB_MANIFEST
|
||||
globPatterns: [
|
||||
'**/*.{js,css,html,ico,png,svg,woff2}',
|
||||
'offline.html',
|
||||
],
|
||||
globIgnores: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'sw.js',
|
||||
],
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB
|
||||
},
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
};
|
||||
|
||||
@ -42,8 +57,8 @@ export default defineConfig({
|
||||
VitePWA(pwaOptions),
|
||||
VueI18nPlugin({
|
||||
include: [path.resolve(path.dirname(fileURLToPath(import.meta.url)), './src/i18n/**')],
|
||||
strictMessage: false, // To avoid warnings for missing Quasar translations initially
|
||||
runtimeOnly: false, // If you use <i18n> component or complex messages
|
||||
strictMessage: false,
|
||||
runtimeOnly: false,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
@ -51,9 +66,8 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
// Define env variables similar to Quasar's process.env
|
||||
define: {
|
||||
'process.env.PWA_FALLBACK_HTML': JSON.stringify('/index.html'), // Adjust if you have a specific offline.html
|
||||
'process.env.PWA_FALLBACK_HTML': JSON.stringify('/index.html'),
|
||||
'process.env.PWA_SERVICE_WORKER_REGEX': JSON.stringify(/^(sw|workbox)-.*\.js$/),
|
||||
'process.env.MODE': JSON.stringify(process.env.NODE_ENV),
|
||||
'process.env.PROD': JSON.stringify(process.env.NODE_ENV === 'production'),
|
||||
|
Loading…
Reference in New Issue
Block a user