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

@@ -93,10 +93,11 @@ def validate_booking_rules(
f"Rezervările sunt permise doar între {wh_start}:00 și {wh_end}:00"
)
# c) Check for overlapping bookings
# c) Check for overlapping bookings (only approved block new bookings;
# admin re-validates at approval time to catch conflicts)
query = db.query(Booking).filter(
Booking.space_id == space_id,
Booking.status.in_(["approved", "pending"]),
Booking.status == "approved",
and_(
Booking.start_datetime < end_datetime,
Booking.end_datetime > start_datetime,

View File

@@ -1,10 +1,16 @@
"""Google Calendar integration service."""
from datetime import datetime
import base64
import hashlib
import urllib.parse
import urllib.request
from datetime import datetime, timedelta
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.core.config import settings
@@ -12,16 +18,92 @@ from app.models.booking import Booking
from app.models.google_calendar_token import GoogleCalendarToken
# --- Token Encryption ---
def _get_fernet() -> Fernet:
"""Get Fernet instance derived from SECRET_KEY for token encryption."""
key = hashlib.sha256(settings.secret_key.encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
def encrypt_token(token: str) -> str:
"""Encrypt a token string for secure database storage."""
return _get_fernet().encrypt(token.encode()).decode()
def decrypt_token(encrypted_token: str) -> str:
"""Decrypt a token string from database storage."""
try:
return _get_fernet().decrypt(encrypted_token.encode()).decode()
except InvalidToken:
# Fallback for legacy unencrypted tokens
return encrypted_token
# --- OAuth State (CSRF Prevention) ---
def create_oauth_state(user_id: int) -> str:
"""Create a signed JWT state parameter for CSRF prevention.
Encodes user_id in a short-lived JWT token that will be validated
on the OAuth callback to prevent CSRF attacks.
"""
expire = datetime.utcnow() + timedelta(minutes=10)
payload = {
"sub": str(user_id),
"type": "google_oauth_state",
"exp": expire,
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def verify_oauth_state(state: str) -> Optional[int]:
"""Verify and decode the OAuth state parameter.
Returns user_id if valid, None otherwise.
"""
try:
payload = jwt.decode(
state, settings.secret_key, algorithms=[settings.algorithm]
)
if payload.get("type") != "google_oauth_state":
return None
return int(payload["sub"])
except (JWTError, KeyError, ValueError):
return None
# --- Token Revocation ---
def revoke_google_token(token: str) -> bool:
"""Revoke a Google OAuth token via Google's revocation endpoint."""
try:
params = urllib.parse.urlencode({"token": token})
request = urllib.request.Request(
f"https://oauth2.googleapis.com/revoke?{params}",
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
urllib.request.urlopen(request, timeout=10)
return True
except Exception:
return False
# --- Google Calendar Service ---
def get_google_calendar_service(db: Session, user_id: int):
"""
Get authenticated Google Calendar service for user.
Args:
db: Database session
user_id: User ID
Handles token decryption and automatic refresh of expired tokens.
Returns:
Google Calendar service object or None if not connected
Google Calendar service object or None if not connected/failed
"""
token_record = (
db.query(GoogleCalendarToken)
@@ -32,10 +114,14 @@ def get_google_calendar_service(db: Session, user_id: int):
if not token_record:
return None
# Decrypt tokens from database
access_token = decrypt_token(token_record.access_token)
refresh_token = decrypt_token(token_record.refresh_token)
# Create credentials
creds = Credentials(
token=token_record.access_token,
refresh_token=token_record.refresh_token,
token=access_token,
refresh_token=refresh_token,
token_uri="https://oauth2.googleapis.com/token",
client_id=settings.google_client_id,
client_secret=settings.google_client_secret,
@@ -46,13 +132,13 @@ def get_google_calendar_service(db: Session, user_id: int):
try:
creds.refresh(Request())
# Update tokens in DB
token_record.access_token = creds.token # type: ignore[assignment]
# Update encrypted tokens in DB
token_record.access_token = encrypt_token(creds.token) # type: ignore[assignment]
if creds.expiry:
token_record.token_expiry = creds.expiry # type: ignore[assignment]
db.commit()
except Exception as e:
print(f"Failed to refresh Google token: {e}")
print(f"Failed to refresh Google token for user {user_id}: {e}")
return None
# Build service
@@ -70,11 +156,6 @@ def create_calendar_event(
"""
Create Google Calendar event for booking.
Args:
db: Database session
booking: Booking object
user_id: User ID
Returns:
Google Calendar event ID or None if failed
"""
@@ -83,7 +164,6 @@ def create_calendar_event(
if not service:
return None
# Create event
event = {
"summary": f"{booking.space.name}: {booking.title}",
"description": booking.description or "",
@@ -111,12 +191,6 @@ def update_calendar_event(
"""
Update Google Calendar event for booking.
Args:
db: Database session
booking: Booking object
user_id: User ID
event_id: Google Calendar event ID
Returns:
True if successful, False otherwise
"""
@@ -125,7 +199,6 @@ def update_calendar_event(
if not service:
return False
# Update event
event = {
"summary": f"{booking.space.name}: {booking.title}",
"description": booking.description or "",
@@ -153,11 +226,6 @@ def delete_calendar_event(db: Session, event_id: str, user_id: int) -> bool:
"""
Delete Google Calendar event.
Args:
db: Database session
event_id: Google Calendar event ID
user_id: User ID
Returns:
True if successful, False otherwise
"""
@@ -171,3 +239,65 @@ def delete_calendar_event(db: Session, event_id: str, user_id: int) -> bool:
except Exception as e:
print(f"Failed to delete Google Calendar event: {e}")
return False
def sync_all_bookings(db: Session, user_id: int) -> dict:
"""
Sync all approved future bookings to Google Calendar.
Creates events for bookings without a google_calendar_event_id,
and updates events for bookings that already have one.
Returns:
Summary dict with created/updated/failed counts
"""
service = get_google_calendar_service(db, user_id)
if not service:
return {"error": "Google Calendar not connected or token expired"}
# Get all approved future bookings for the user
bookings = (
db.query(Booking)
.filter(
Booking.user_id == user_id,
Booking.status == "approved",
Booking.start_datetime >= datetime.utcnow(),
)
.all()
)
created = 0
updated = 0
failed = 0
for booking in bookings:
try:
if booking.google_calendar_event_id:
# Update existing event
success = update_calendar_event(
db, booking, user_id, booking.google_calendar_event_id
)
if success:
updated += 1
else:
failed += 1
else:
# Create new event
event_id = create_calendar_event(db, booking, user_id)
if event_id:
booking.google_calendar_event_id = event_id # type: ignore[assignment]
created += 1
else:
failed += 1
except Exception:
failed += 1
db.commit()
return {
"synced": created + updated,
"created": created,
"updated": updated,
"failed": failed,
"total_bookings": len(bookings),
}