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