diff --git a/backend/.env.example b/backend/.env.example index 8e2617d..e59a843 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,7 +18,10 @@ SMTP_PASSWORD= SMTP_FROM_ADDRESS=noreply@space-booking.local SMTP_ENABLED=false +# Frontend URL (used for OAuth callback redirects) +FRONTEND_URL=http://localhost:5173 + # Google Calendar Integration GOOGLE_CLIENT_ID=your_google_client_id_here GOOGLE_CLIENT_SECRET=your_google_client_secret_here -GOOGLE_REDIRECT_URI=https://your-domain.com/api/integrations/google/callback +GOOGLE_REDIRECT_URI=http://localhost:8000/api/integrations/google/callback diff --git a/backend/app/api/bookings.py b/backend/app/api/bookings.py index 91e1a24..846f5e3 100644 --- a/backend/app/api/bookings.py +++ b/backend/app/api/bookings.py @@ -68,9 +68,10 @@ def get_space_bookings( detail="Space not found", ) - # Query bookings in the time range + # Query bookings in the time range (only active bookings) query = db.query(Booking).filter( Booking.space_id == space_id, + Booking.status.in_(["approved", "pending"]), Booking.start_datetime < end, Booking.end_datetime > start, ) @@ -292,6 +293,9 @@ def create_booking( detail=errors[0], # Return first error ) + # Auto-approve if admin, otherwise pending + is_admin = current_user.role == "admin" + # Create booking (with UTC times) booking = Booking( user_id=user_id, @@ -300,7 +304,8 @@ def create_booking( end_datetime=end_datetime_utc, title=booking_data.title, description=booking_data.description, - status="pending", + status="approved" if is_admin else "pending", + approved_by=current_user.id if is_admin else None, # type: ignore[assignment] created_at=datetime.utcnow(), ) @@ -308,26 +313,27 @@ def create_booking( db.commit() db.refresh(booking) - # Notify all admins about the new booking request - admins = db.query(User).filter(User.role == "admin").all() - for admin in admins: - create_notification( - db=db, - user_id=admin.id, # type: ignore[arg-type] - type="booking_created", - title="Noua Cerere de Rezervare", - message=f"Utilizatorul {current_user.full_name} a solicitat rezervarea spațiului {space.name} pentru {booking.start_datetime.strftime('%d.%m.%Y %H:%M')}", - booking_id=booking.id, - ) - # Send email notification to admin - background_tasks.add_task( - send_booking_notification, - booking, - "created", - admin.email, - current_user.full_name, - None, - ) + if not is_admin: + # Notify all admins about the new booking request + admins = db.query(User).filter(User.role == "admin").all() + for admin in admins: + create_notification( + db=db, + user_id=admin.id, # type: ignore[arg-type] + type="booking_created", + title="Noua Cerere de Rezervare", + message=f"Utilizatorul {current_user.full_name} a solicitat rezervarea spațiului {space.name} pentru {booking.start_datetime.strftime('%d.%m.%Y %H:%M')}", + booking_id=booking.id, + ) + # Send email notification to admin + background_tasks.add_task( + send_booking_notification, + booking, + "created", + admin.email, + current_user.full_name, + None, + ) # Return with timezone conversion return BookingResponse.from_booking_with_timezone(booking, user_timezone) @@ -637,6 +643,56 @@ def cancel_booking( admin_router = APIRouter(prefix="/admin/bookings", tags=["admin"]) +@admin_router.get("/all", response_model=list[BookingPendingDetail]) +def get_all_bookings( + status_filter: Annotated[str | None, Query(alias="status")] = None, + space_id: Annotated[int | None, Query()] = None, + user_id: Annotated[int | None, Query()] = None, + start: Annotated[datetime | None, Query(description="Start datetime (ISO format)")] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment] + current_admin: Annotated[User, Depends(get_current_admin)] = None, # type: ignore[assignment] +) -> list[BookingPendingDetail]: + """ + Get all bookings across all users (admin only). + + Returns bookings with user and space details. + + Query parameters: + - **status** (optional): Filter by status (pending/approved/rejected/canceled) + - **space_id** (optional): Filter by space ID + - **user_id** (optional): Filter by user ID + - **start** (optional): Only bookings starting from this datetime + - **limit** (optional): Max results (1-100, default 20) + """ + query = ( + db.query(Booking) + .join(Space, Booking.space_id == Space.id) + .join(User, Booking.user_id == User.id) + ) + + if status_filter is not None: + query = query.filter(Booking.status == status_filter) + + if space_id is not None: + query = query.filter(Booking.space_id == space_id) + + if user_id is not None: + query = query.filter(Booking.user_id == user_id) + + if start is not None: + # Use end_datetime to include bookings still in progress (started but not ended) + query = query.filter(Booking.end_datetime > start) + + bookings = ( + query.order_by(Booking.start_datetime.asc()) + .limit(limit) + .all() + ) + + return [BookingPendingDetail.model_validate(b) for b in bookings] + + @admin_router.get("/pending", response_model=list[BookingPendingDetail]) def get_pending_bookings( space_id: Annotated[int | None, Query()] = None, diff --git a/backend/app/api/google_calendar.py b/backend/app/api/google_calendar.py index 29b6150..60c27c8 100644 --- a/backend/app/api/google_calendar.py +++ b/backend/app/api/google_calendar.py @@ -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 + ), } diff --git a/backend/app/services/booking_service.py b/backend/app/services/booking_service.py index 182844f..78ba187 100644 --- a/backend/app/services/booking_service.py +++ b/backend/app/services/booking_service.py @@ -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, diff --git a/backend/app/services/google_calendar_service.py b/backend/app/services/google_calendar_service.py index f2601fd..a7f0ee9 100644 --- a/backend/app/services/google_calendar_service.py +++ b/backend/app/services/google_calendar_service.py @@ -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), + } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f8d6c4b..b1f3f0c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@fullcalendar/core": "^6.1.0", "@fullcalendar/daygrid": "^6.1.0", "@fullcalendar/interaction": "^6.1.0", + "@fullcalendar/list": "^6.1.20", "@fullcalendar/timegrid": "^6.1.0", "@fullcalendar/vue3": "^6.1.0", "axios": "^1.6.0", @@ -582,6 +583,15 @@ "@fullcalendar/core": "~6.1.20" } }, + "node_modules/@fullcalendar/list": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.20.tgz", + "integrity": "sha512-7Hzkbb7uuSqrXwTyD0Ld/7SwWNxPD6SlU548vtkIpH55rZ4qquwtwYdMPgorHos5OynHA4OUrZNcH51CjrCf2g==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, "node_modules/@fullcalendar/timegrid": { "version": "6.1.20", "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz", diff --git a/frontend/package.json b/frontend/package.json index 29a44b1..79b9351 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@fullcalendar/core": "^6.1.0", "@fullcalendar/daygrid": "^6.1.0", "@fullcalendar/interaction": "^6.1.0", + "@fullcalendar/list": "^6.1.20", "@fullcalendar/timegrid": "^6.1.0", "@fullcalendar/vue3": "^6.1.0", "axios": "^1.6.0", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4e6196c..bc08a14 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -117,7 +117,7 @@ const handleNotificationClick = async (notification: Notification) => { if (notification.booking_id) { closeNotifications() - router.push('/my-bookings') + router.push('/history') } } diff --git a/frontend/src/assets/theme.css b/frontend/src/assets/theme.css index 82f1470..6890906 100644 --- a/frontend/src/assets/theme.css +++ b/frontend/src/assets/theme.css @@ -52,24 +52,24 @@ /* Dark Theme */ [data-theme="dark"] { - --color-bg-primary: #0f0f1a; - --color-bg-secondary: #1a1a2e; - --color-bg-tertiary: #232340; - --color-surface: #1a1a2e; - --color-surface-hover: #232340; - --color-text-primary: #e5e5ef; - --color-text-secondary: #9ca3af; - --color-text-muted: #6b7280; - --color-accent-light: #1e1b4b; - --color-border: #2d2d4a; - --color-border-light: #232340; - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); - --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3); - --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.4); + --color-bg-primary: #0a0a12; + --color-bg-secondary: #151520; + --color-bg-tertiary: #1f1f2e; + --color-surface: #151520; + --color-surface-hover: #1f1f2e; + --color-text-primary: #f0f0f5; + --color-text-secondary: #b8bac5; + --color-text-muted: #8b8d9a; + --color-accent-light: #2a2650; + --color-border: #3a3a52; + --color-border-light: #2d2d42; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5); /* Sidebar - Dark theme */ - --sidebar-bg: #1a1a2e; - --sidebar-text: #a1a1b5; + --sidebar-bg: #151520; + --sidebar-text: #b8bac5; --sidebar-text-active: #ffffff; - --sidebar-hover-bg: rgba(255, 255, 255, 0.08); + --sidebar-hover-bg: rgba(255, 255, 255, 0.1); } diff --git a/frontend/src/components/ActionMenu.vue b/frontend/src/components/ActionMenu.vue new file mode 100644 index 0000000..9829ed8 --- /dev/null +++ b/frontend/src/components/ActionMenu.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/frontend/src/components/ActiveBookings.vue b/frontend/src/components/ActiveBookings.vue index b196232..d8384b3 100644 --- a/frontend/src/components/ActiveBookings.vue +++ b/frontend/src/components/ActiveBookings.vue @@ -26,6 +26,26 @@ :style="{ width: getProgress(booking.start_datetime, booking.end_datetime) + '%' }" /> +
+ + + + + +
@@ -33,7 +53,7 @@ + + diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 15cfd7d..6aed5a7 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -1,13 +1,13 @@