fastapi-users, oauth, docker support, cleanup

This commit is contained in:
mohamad 2025-05-14 00:10:31 +02:00
parent 29682b7e9c
commit 1c08e57afd
20 changed files with 727 additions and 211 deletions

View File

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

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

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

View File

@ -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)

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

@ -0,0 +1,90 @@
from typing import Optional
from fastapi import Depends, Request
from fastapi_users import BaseUserManager, FastAPIUsers, IntegerIDMixin
from fastapi_users.authentication import (
AuthenticationBackend,
BearerTransport,
JWTStrategy,
OAuth2PasswordRequestForm,
)
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 .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
):
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=3600)
auth_backend = AuthenticationBackend(
name="jwt",
transport=bearer_transport,
get_strategy=get_jwt_strategy,
)
fastapi_users = FastAPIUsers[User, int](
get_user_manager,
[auth_backend],
)
current_active_user = fastapi_users.current_user(active=True)
current_superuser = fastapi_users.current_user(active=True, superuser=True)

View File

@ -4,6 +4,7 @@ from pydantic_settings import BaseSettings
from dotenv import load_dotenv from dotenv import load_dotenv
import logging import logging
import secrets import secrets
from typing import List
load_dotenv() load_dotenv()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -13,11 +14,11 @@ class Settings(BaseSettings):
GEMINI_API_KEY: str | None = None GEMINI_API_KEY: str | None = None
SENTRY_DSN: str | None = None # Sentry DSN for error tracking 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 # Handled by FastAPI-Users strategy
REFRESH_TOKEN_EXPIRE_MINUTES: int = 10080 # Default refresh token lifetime: 7 days # REFRESH_TOKEN_EXPIRE_MINUTES: int = 10080 # Handled by FastAPI-Users strategy
# --- 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
@ -64,6 +65,7 @@ Organic Bananas
# Add your deployed frontend URL here later # Add your deployed frontend URL here later
# "https://your-frontend-domain.com", # "https://your-frontend-domain.com",
] ]
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"
@ -79,14 +81,14 @@ Organic Bananas
HEALTH_STATUS_OK: str = "ok" HEALTH_STATUS_OK: str = "ok"
HEALTH_STATUS_ERROR: str = "error" HEALTH_STATUS_ERROR: str = "error"
# --- Auth Settings --- # --- Auth Settings --- (These are largely handled by FastAPI-Users now)
OAUTH2_TOKEN_URL: str = "/api/v1/auth/login" # Path to login endpoint # OAUTH2_TOKEN_URL: str = "/api/v1/auth/login" # FastAPI-Users has its own token URL
TOKEN_TYPE: str = "bearer" # Default token type for OAuth2 # TOKEN_TYPE: str = "bearer"
AUTH_HEADER_PREFIX: str = "Bearer" # Prefix for Authorization header # AUTH_HEADER_PREFIX: str = "Bearer"
AUTH_HEADER_NAME: str = "WWW-Authenticate" # Name of auth header # AUTH_HEADER_NAME: str = "WWW-Authenticate"
AUTH_CREDENTIALS_ERROR: str = "Could not validate credentials" # AUTH_CREDENTIALS_ERROR: str = "Could not validate credentials"
AUTH_INVALID_CREDENTIALS: str = "Incorrect email or password" # AUTH_INVALID_CREDENTIALS: str = "Incorrect email or password"
AUTH_NOT_AUTHENTICATED: str = "Not authenticated" # AUTH_NOT_AUTHENTICATED: str = "Not authenticated"
# --- HTTP Status Messages --- # --- HTTP Status Messages ---
HTTP_400_DETAIL: str = "Bad Request" HTTP_400_DETAIL: str = "Bad Request"
@ -104,6 +106,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
class Config: class Config:
env_file = ".env" env_file = ".env"
env_file_encoding = 'utf-8' env_file_encoding = 'utf-8'

View File

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

View File

@ -3,14 +3,16 @@ 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 import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration 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
# Initialize Sentry # Initialize Sentry
sentry_sdk.init( sentry_sdk.init(
@ -39,6 +41,12 @@ app = FastAPI(
openapi_tags=API_TAGS openapi_tags=API_TAGS
) )
# Add session middleware for OAuth
app.add_middleware(
SessionMiddleware,
secret_key=settings.SESSION_SECRET_KEY
)
# --- CORS Middleware --- # --- CORS Middleware ---
# Define allowed origins. Be specific in production! # Define allowed origins. Be specific in production!
# Use ["*"] for wide open access during early development if needed, # Use ["*"] for wide open access during early development if needed,
@ -62,7 +70,37 @@ app.add_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(),
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(),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_users_router(),
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 ---
@ -80,18 +118,18 @@ async def read_root():
# --- Application Startup/Shutdown Events (Optional) --- # --- Application Startup/Shutdown Events (Optional) ---
# @app.on_event("startup") @app.on_event("startup")
# async def startup_event(): async def startup_event():
# logger.info("Application startup: Connecting to database...") logger.info("Application startup: Connecting to database...")
# # You might perform initial checks or warm-up here # You might perform initial checks or warm-up here
# # await database.engine.connect() # Example check (get_db handles sessions per request) # await database.engine.connect() # Example check (get_db handles sessions per request)
# logger.info("Application startup complete.") logger.info("Application startup complete.")
# @app.on_event("shutdown") @app.on_event("shutdown")
# async def shutdown_event(): async def shutdown_event():
# logger.info("Application shutdown: Disconnecting from database...") logger.info("Application shutdown: Disconnecting from database...")
# # await database.engine.dispose() # Close connection pool # await database.engine.dispose() # Close connection pool
# logger.info("Application shutdown complete.") logger.info("Application shutdown complete.")
# --- End Events --- # --- End Events ---

View File

@ -45,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 ---

View File

@ -10,4 +10,10 @@ 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 sentry-sdk[fastapi]>=1.39.0
python-multipart>=0.0.6 # Required for form data handling
fastapi-users[sqlalchemy]>=12.1.2
email-validator>=2.0.0
fastapi-users[oauth]>=12.1.2
authlib>=1.3.0
itsdangerous>=2.1.2

View File

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

31
fe/Dockerfile Normal file
View File

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

View File

@ -1,6 +1,6 @@
// src/assets/main.scss // src/assets/main.scss
// @import './variables.scss'; // Your custom variables // @import './variables.scss'; // Your custom variables
@import './valerie-ui.scss'; @use './valerie-ui.scss';
// Example global styles // Example global styles
body { body {
@ -13,6 +13,7 @@ body {
a { a {
color: var(--primary-color); color: var(--primary-color);
text-decoration: none; text-decoration: none;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
@ -23,4 +24,5 @@ a {
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
} }
// Add more global utility classes or base styles // Add more global utility classes or base styles

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,28 +5,27 @@ import { ref, computed } from 'vue';
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
// ... (interfaces remain the same) export type OfflineAction = {
id: string;
timestamp: number;
type: string;
payload: Record<string, unknown>; // Added payload property
};
export type ConflictData = {
localVersion: { data: Record<string, unknown>; timestamp: number; };
serverVersion: { data: Record<string, unknown>; timestamp: number; };
action: OfflineAction;
};
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);
@ -68,7 +67,7 @@ export const useOfflineStore = defineStore('offline', () => {
// The loop should probably pause or handle this conflict before continuing // The loop should probably pause or handle this conflict before continuing
console.warn('Conflict detected for action:', action.id, error); console.warn('Conflict detected for action:', action.id, error);
// Break or decide how to handle queue processing on conflict // Break or decide how to handle queue processing on conflict
break; break;
} else { } else {
console.error('Failed to process offline action:', action.id, error); console.error('Failed to process offline action:', action.id, error);
notificationStore.addNotification({ notificationStore.addNotification({
@ -80,35 +79,35 @@ export const useOfflineStore = defineStore('offline', () => {
} }
isProcessingQueue.value = false; isProcessingQueue.value = false;
}; };
// processAction needs to be implemented with your actual API calls // processAction needs to be implemented with your actual API calls
const processAction = async (action: OfflineAction) => { const processAction = async (action: OfflineAction) => {
console.log('Processing action (TODO: Implement API call):', action); console.log('Processing action (TODO: Implement API call):', action);
// Example: // Example:
// import { apiClient } from '@/services/api'; // import { apiClient } from '@/services/api';
// import { API_ENDPOINTS } from '@/config/api-config'; // import { API_ENDPOINTS } from '@/config/api-config';
// switch (action.type) { // switch (action.type) {
// case 'add': // case 'add':
// // Assuming action.data is { listId: string, itemData: { name: string, quantity?: string } } // // Assuming action.data is { listId: string, itemData: { name: string, quantity?: string } }
// // await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(action.data.listId), action.data.itemData); // // await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(action.data.listId), action.data.itemData);
// break; // break;
// // ... other cases // // ... other cases
// } // }
// Simulate async work // Simulate async work
return new Promise(resolve => setTimeout(resolve, 500)); return new Promise(resolve => setTimeout(resolve, 500));
}; };
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);