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

@ -437,6 +437,3 @@ async def delete_settlement_record(
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.")
return Response(status_code=status.HTTP_204_NO_CONTENT)
# TODO (remaining from original list):
# (None - GET/POST/PUT/DELETE implemented for Expense/Settlement)

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
import logging
import secrets
from typing import List
load_dotenv()
logger = logging.getLogger(__name__)
@ -13,11 +14,11 @@ class Settings(BaseSettings):
GEMINI_API_KEY: str | None = None
SENTRY_DSN: str | None = None # Sentry DSN for error tracking
# --- JWT Settings ---
# --- JWT Settings --- (SECRET_KEY is used by FastAPI-Users)
SECRET_KEY: str # Must be set via environment variable
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # Default token lifetime: 30 minutes
REFRESH_TOKEN_EXPIRE_MINUTES: int = 10080 # Default refresh token lifetime: 7 days
# ALGORITHM: str = "HS256" # Handled by FastAPI-Users strategy
# ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # Handled by FastAPI-Users strategy
# REFRESH_TOKEN_EXPIRE_MINUTES: int = 10080 # Handled by FastAPI-Users strategy
# --- OCR Settings ---
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
# "https://your-frontend-domain.com",
]
FRONTEND_URL: str = "http://localhost:5173" # URL for the frontend application
# --- API Metadata ---
API_TITLE: str = "Shared Lists API"
@ -79,14 +81,14 @@ Organic Bananas
HEALTH_STATUS_OK: str = "ok"
HEALTH_STATUS_ERROR: str = "error"
# --- Auth Settings ---
OAUTH2_TOKEN_URL: str = "/api/v1/auth/login" # Path to login endpoint
TOKEN_TYPE: str = "bearer" # Default token type for OAuth2
AUTH_HEADER_PREFIX: str = "Bearer" # Prefix for Authorization header
AUTH_HEADER_NAME: str = "WWW-Authenticate" # Name of auth header
AUTH_CREDENTIALS_ERROR: str = "Could not validate credentials"
AUTH_INVALID_CREDENTIALS: str = "Incorrect email or password"
AUTH_NOT_AUTHENTICATED: str = "Not authenticated"
# --- Auth Settings --- (These are largely handled by FastAPI-Users now)
# OAUTH2_TOKEN_URL: str = "/api/v1/auth/login" # FastAPI-Users has its own token URL
# TOKEN_TYPE: str = "bearer"
# AUTH_HEADER_PREFIX: str = "Bearer"
# AUTH_HEADER_NAME: str = "WWW-Authenticate"
# AUTH_CREDENTIALS_ERROR: str = "Could not validate credentials"
# AUTH_INVALID_CREDENTIALS: str = "Incorrect email or password"
# AUTH_NOT_AUTHENTICATED: str = "Not authenticated"
# --- HTTP Status Messages ---
HTTP_400_DETAIL: str = "Bad Request"
@ -104,6 +106,20 @@ Organic Bananas
DB_TRANSACTION_ERROR: str = "Database transaction error"
DB_QUERY_ERROR: str = "Database query error"
# OAuth Settings
GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = ""
GOOGLE_REDIRECT_URI: str = "http://localhost:8000/auth/google/callback"
APPLE_CLIENT_ID: str = ""
APPLE_TEAM_ID: str = ""
APPLE_KEY_ID: str = ""
APPLE_PRIVATE_KEY: str = ""
APPLE_REDIRECT_URI: str = "http://localhost:8000/auth/apple/callback"
# Session Settings
SESSION_SECRET_KEY: str = "your-session-secret-key" # Change this in production
class Config:
env_file = ".env"
env_file_encoding = 'utf-8'

View File

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

View File

@ -3,14 +3,16 @@ import logging
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
from app.api.api_router import api_router
from app.config import settings
from app.core.api_config import API_METADATA, API_TAGS
# Import database and models if needed for startup/shutdown events later
# from . import database, models
from app.auth import fastapi_users, auth_backend
from app.models import User
from app.api.auth.oauth import router as oauth_router
# Initialize Sentry
sentry_sdk.init(
@ -39,6 +41,12 @@ app = FastAPI(
openapi_tags=API_TAGS
)
# Add session middleware for OAuth
app.add_middleware(
SessionMiddleware,
secret_key=settings.SESSION_SECRET_KEY
)
# --- CORS Middleware ---
# Define allowed origins. Be specific in production!
# Use ["*"] for wide open access during early development if needed,
@ -62,7 +70,37 @@ app.add_middleware(
# --- Include API Routers ---
# All API endpoints will be prefixed with /api
# Include FastAPI-Users routes
app.include_router(
fastapi_users.get_auth_router(auth_backend),
prefix="/auth/jwt",
tags=["auth"],
)
app.include_router(
fastapi_users.get_register_router(),
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)
# --- End Include API Routers ---
@ -80,18 +118,18 @@ async def read_root():
# --- Application Startup/Shutdown Events (Optional) ---
# @app.on_event("startup")
# async def startup_event():
# logger.info("Application startup: Connecting to database...")
# # You might perform initial checks or warm-up here
# # await database.engine.connect() # Example check (get_db handles sessions per request)
# logger.info("Application startup complete.")
@app.on_event("startup")
async def startup_event():
logger.info("Application startup: Connecting to database...")
# You might perform initial checks or warm-up here
# await database.engine.connect() # Example check (get_db handles sessions per request)
logger.info("Application startup complete.")
# @app.on_event("shutdown")
# async def shutdown_event():
# logger.info("Application shutdown: Disconnecting from database...")
# # await database.engine.dispose() # Close connection pool
# logger.info("Application shutdown complete.")
@app.on_event("shutdown")
async def shutdown_event():
logger.info("Application shutdown: Disconnecting from database...")
# await database.engine.dispose() # Close connection pool
logger.info("Application shutdown complete.")
# --- End Events ---

View File

@ -45,8 +45,11 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
password_hash = Column(String, nullable=False)
hashed_password = Column(String, nullable=False)
name = Column(String, index=True, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
is_superuser = Column(Boolean, default=False, nullable=False)
is_verified = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships ---

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

@ -5,24 +5,23 @@ import { ref, computed } from 'vue';
import { useStorage } from '@vueuse/core'; // VueUse alternative
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', () => {
// const $q = useQuasar(); // REMOVE
const notificationStore = useNotificationStore();
const isOnline = ref(navigator.onLine);
interface OfflineAction {
id: string;
timestamp: number;
type: string;
// Add other necessary fields
}
interface ConflictData {
localVersion: unknown; // Replace with proper type
serverVersion: unknown; // Replace with proper type
action: OfflineAction;
}
// Use useStorage for reactive localStorage
const pendingActions = useStorage<OfflineAction[]>('offline-actions', []);
@ -83,30 +82,30 @@ export const useOfflineStore = defineStore('offline', () => {
// 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));
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 setupNetworkListeners = () => {
window.addEventListener('online', () => {
isOnline.value = true;
processQueue().catch(err => console.error("Error processing queue on online event:", err));
});
window.addEventListener('offline', () => {
isOnline.value = false;
});
window.addEventListener('online', () => {
isOnline.value = true;
processQueue().catch(err => console.error("Error processing queue on online event:", err));
});
window.addEventListener('offline', () => {
isOnline.value = false;
});
};
setupNetworkListeners(); // Call this once