fastapi-users, oauth, docker support, cleanup
This commit is contained in:
parent
29682b7e9c
commit
1c08e57afd
@ -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
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}"
|
||||||
|
)
|
@ -437,6 +437,3 @@ async def delete_settlement_record(
|
|||||||
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
90
be/app/auth.py
Normal 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)
|
@ -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'
|
||||||
|
@ -163,6 +163,3 @@ async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, ex
|
|||||||
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)
|
|
@ -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 ---
|
||||||
|
|
||||||
|
|
||||||
|
@ -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 ---
|
||||||
|
@ -11,3 +11,9 @@ 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
|
@ -1,9 +1,6 @@
|
|||||||
# 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
|
||||||
@ -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
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;"]
|
@ -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
|
@ -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>
|
||||||
@ -65,7 +58,8 @@
|
|||||||
<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<{
|
||||||
@ -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;
|
||||||
|
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>
|
@ -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">
|
||||||
|
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>
|
@ -8,30 +8,20 @@
|
|||||||
<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>
|
||||||
@ -45,8 +35,10 @@
|
|||||||
</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,15 +124,20 @@ 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 */
|
|
||||||
|
.alert.form-error-text {
|
||||||
|
/* For general error message */
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@ -145,18 +146,23 @@ const onSubmit = async () => {
|
|||||||
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>
|
@ -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') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
|
@ -5,24 +5,23 @@ 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', []);
|
||||||
|
Loading…
Reference in New Issue
Block a user