feat: complete UI/UX overhaul - dashboard unification, calendar UX, mobile optimization

- Dashboard redesign as command center with filters, quick actions, inline approve/reject
- Reusable components: BookingRow, BookingFilters, ActionMenu, BookingPreviewModal, BookingEditModal
- Calendar: drag & drop reschedule, eventClick preview modal, grid/list toggle
- Mobile: segmented control bookings/calendar toggle, compact pills, responsive layout
- Collapsible filters with active count badge
- Smart menu positioning with Teleport
- Calendar/list bidirectional data sync
- Navigation: unified History page, removed AdminPending
- Google Calendar OAuth integration
- Dark mode contrast improvements, breadcrumb navigation
- useLocalStorage composable for state persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-02-12 15:34:47 +00:00
parent a4d3f862d2
commit d245c72757
36 changed files with 5275 additions and 1569 deletions

View File

@@ -1,7 +1,9 @@
"""Google Calendar integration endpoints."""
import urllib.parse
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import RedirectResponse
from google_auth_oauthlib.flow import Flow
from sqlalchemy.orm import Session
@@ -9,9 +11,35 @@ from app.core.config import settings
from app.core.deps import get_current_user, get_db
from app.models.google_calendar_token import GoogleCalendarToken
from app.models.user import User
from app.services.google_calendar_service import (
create_oauth_state,
decrypt_token,
encrypt_token,
revoke_google_token,
sync_all_bookings,
verify_oauth_state,
)
router = APIRouter()
GOOGLE_SCOPES = [
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/calendar.events",
]
def _get_client_config() -> dict:
"""Build Google OAuth client configuration."""
return {
"web": {
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"redirect_uris": [settings.google_redirect_uri],
}
}
@router.get("/integrations/google/connect")
def connect_google(
@@ -20,7 +48,9 @@ def connect_google(
"""
Start Google OAuth flow.
Returns authorization URL that user should visit to grant access.
Returns an authorization URL with a signed state parameter for CSRF prevention.
The state encodes the user's identity so the callback can identify the user
without requiring an auth header (since it's a browser redirect from Google).
"""
if not settings.google_client_id or not settings.google_client_secret:
raise HTTPException(
@@ -28,29 +58,23 @@ def connect_google(
detail="Google Calendar integration not configured",
)
# Create signed state with user_id for CSRF prevention
state = create_oauth_state(int(current_user.id)) # type: ignore[arg-type]
try:
flow = Flow.from_client_config(
{
"web": {
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"redirect_uris": [settings.google_redirect_uri],
}
},
scopes=[
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/calendar.events",
],
_get_client_config(),
scopes=GOOGLE_SCOPES,
redirect_uri=settings.google_redirect_uri,
)
authorization_url, state = flow.authorization_url(
access_type="offline", include_granted_scopes="true", prompt="consent"
authorization_url, _ = flow.authorization_url(
access_type="offline",
include_granted_scopes="true",
prompt="consent",
state=state,
)
# Note: In production, store state in session/cache and validate it in callback
return {"authorization_url": authorization_url, "state": state}
except Exception as e:
raise HTTPException(
@@ -61,85 +85,113 @@ def connect_google(
@router.get("/integrations/google/callback")
def google_callback(
code: Annotated[str, Query()],
state: Annotated[str, Query()],
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> dict[str, str]:
code: Annotated[str | None, Query()] = None,
state: Annotated[str | None, Query()] = None,
error: Annotated[str | None, Query()] = None,
) -> RedirectResponse:
"""
Handle Google OAuth callback.
Exchange authorization code for tokens and store them.
This endpoint receives the browser redirect from Google after authorization.
User identity is verified via the signed state parameter (no auth header needed).
After processing, redirects to the frontend settings page with status.
"""
frontend_settings = f"{settings.frontend_url}/settings"
# Handle user denial or Google error
if error:
msg = urllib.parse.quote(error)
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message={msg}"
)
if not code or not state:
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message=Missing+code+or+state"
)
# Verify state and extract user_id (CSRF protection)
user_id = verify_oauth_state(state)
if user_id is None:
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message=Invalid+or+expired+state"
)
# Verify user exists
user = db.query(User).filter(User.id == user_id).first()
if not user:
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message=User+not+found"
)
if not settings.google_client_id or not settings.google_client_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Google Calendar integration not configured",
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message=Not+configured"
)
try:
flow = Flow.from_client_config(
{
"web": {
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"redirect_uris": [settings.google_redirect_uri],
}
},
scopes=[
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/calendar.events",
],
_get_client_config(),
scopes=GOOGLE_SCOPES,
redirect_uri=settings.google_redirect_uri,
state=state,
)
# Exchange code for tokens
# Exchange authorization code for tokens
flow.fetch_token(code=code)
credentials = flow.credentials
# Store tokens
# Encrypt tokens for secure database storage
encrypted_access = encrypt_token(credentials.token)
encrypted_refresh = (
encrypt_token(credentials.refresh_token)
if credentials.refresh_token
else ""
)
# Store or update tokens
token_record = (
db.query(GoogleCalendarToken)
.filter(GoogleCalendarToken.user_id == current_user.id)
.filter(GoogleCalendarToken.user_id == user_id)
.first()
)
if token_record:
token_record.access_token = credentials.token # type: ignore[assignment]
token_record.refresh_token = credentials.refresh_token # type: ignore[assignment]
token_record.access_token = encrypted_access # type: ignore[assignment]
token_record.refresh_token = encrypted_refresh # type: ignore[assignment]
token_record.token_expiry = credentials.expiry # type: ignore[assignment]
else:
token_record = GoogleCalendarToken(
user_id=current_user.id, # type: ignore[arg-type]
access_token=credentials.token,
refresh_token=credentials.refresh_token,
user_id=user_id, # type: ignore[arg-type]
access_token=encrypted_access,
refresh_token=encrypted_refresh,
token_expiry=credentials.expiry,
)
db.add(token_record)
db.commit()
return {"message": "Google Calendar connected successfully"}
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=connected"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"OAuth failed: {str(e)}",
msg = urllib.parse.quote(str(e))
return RedirectResponse(
url=f"{frontend_settings}?google_calendar=error&message={msg}"
)
@router.delete("/integrations/google/disconnect")
@router.post("/integrations/google/disconnect")
def disconnect_google(
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> dict[str, str]:
"""
Disconnect Google Calendar.
Disconnect Google Calendar and revoke access.
Removes stored tokens for the current user.
Attempts to revoke the token with Google, then removes stored tokens.
Token revocation is best-effort - local tokens are always deleted.
"""
token_record = (
db.query(GoogleCalendarToken)
@@ -148,12 +200,56 @@ def disconnect_google(
)
if token_record:
# Attempt to revoke the token with Google (best-effort)
try:
access_token = decrypt_token(token_record.access_token)
revoke_google_token(access_token)
except Exception:
pass # Delete locally even if revocation fails
db.delete(token_record)
db.commit()
return {"message": "Google Calendar disconnected"}
@router.post("/integrations/google/sync")
def sync_google_calendar(
db: Annotated[Session, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
) -> dict:
"""
Manually sync all approved future bookings to Google Calendar.
Creates calendar events for bookings that don't have one yet,
and updates existing events for bookings that do.
Returns a summary of sync results (created/updated/failed counts).
"""
# Check if connected
token_record = (
db.query(GoogleCalendarToken)
.filter(GoogleCalendarToken.user_id == current_user.id)
.first()
)
if not token_record:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Google Calendar not connected. Please connect first.",
)
result = sync_all_bookings(db, int(current_user.id)) # type: ignore[arg-type]
if "error" in result:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result["error"],
)
return result
@router.get("/integrations/google/status")
def google_status(
db: Annotated[Session, Depends(get_db)],
@@ -162,7 +258,8 @@ def google_status(
"""
Check Google Calendar connection status.
Returns whether user has connected their Google Calendar account.
Returns whether user has connected their Google Calendar account
and when the current token expires.
"""
token_record = (
db.query(GoogleCalendarToken)
@@ -172,5 +269,9 @@ def google_status(
return {
"connected": token_record is not None,
"expires_at": token_record.token_expiry.isoformat() if token_record and token_record.token_expiry else None,
"expires_at": (
token_record.token_expiry.isoformat()
if token_record and token_record.token_expiry
else None
),
}