Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
.PHONY: run back front pre-commit uv-install


back:
cd backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000

Expand All @@ -10,3 +13,5 @@ pre-commit:

uv-install:
cd backend && uv sync
run:
docker compose up --build
79 changes: 65 additions & 14 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,76 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from typing import Dict, Any, Optional, List
from app.services.cache import get_revenue_summary
from app.core.auth import authenticate_request as get_current_user
from app.models.auth import AuthenticatedUser
from app.services.reservations import list_properties
import logging

logger = logging.getLogger(__name__)
router = APIRouter()

def _require_tenant(current_user: Any) -> str:
"""Ensure user belongs to a tenant; 403 Forbidden otherwise."""
tenant_id = getattr(current_user, "tenant_id", None)
if not tenant_id:
logger.warning(f"Dashboard access denied - no tenant for user {getattr(current_user, 'email', 'unknown')}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No tenant associated with this account"
)
return tenant_id

@router.get("/dashboard/properties")
async def get_dashboard_properties(
current_user: Any = Depends(get_current_user),
) -> List[Dict[str, Any]]:
"""Properties belonging to the caller's tenant (drives the dashboard selector)."""
tenant_id = _require_tenant(current_user)
try:
return await list_properties(tenant_id)
except Exception as e:
logger.exception(f"Failed to list properties for tenant {tenant_id}: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Property data temporarily unavailable"
)

@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
current_user: dict = Depends(get_current_user)
month: Optional[int] = Query(None, ge=1, le=12, description="Calendar month (1-12)"),
year: Optional[int] = Query(None, ge=2000, le=2100, description="Year (e.g. 2024)"),
current_user: Any = Depends(get_current_user),
) -> Dict[str, Any]:

tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant"

revenue_data = await get_revenue_summary(property_id, tenant_id)

total_revenue_float = float(revenue_data['total'])

tenant_id = _require_tenant(current_user)

if (month is None) != (year is None):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="month and year must be provided together"
)

try:
revenue_data = await get_revenue_summary(property_id, tenant_id, month=month, year=year)
except Exception as e:
logger.exception(f"Revenue lookup failed for tenant={tenant_id} property={property_id}: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Revenue data temporarily unavailable"
)

if revenue_data is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Property not found"
)

return {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
"property_id": revenue_data["property_id"],
"property_name": revenue_data.get("property_name"),
"timezone": revenue_data.get("timezone"),
"total_revenue": revenue_data["total"],
"currency": revenue_data["currency"],
"reservations_count": revenue_data["count"],
"period": {"month": month, "year": year} if month is not None else None,
}
6 changes: 3 additions & 3 deletions backend/app/api/v1/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ async def login(request: LoginRequest):
"user_metadata": {"name": "Sunset Properties Manager"},
"aud": "authenticated",
"created_at": datetime.utcnow().isoformat(),
"exp": datetime.utcnow() + timedelta(hours=24)
"exp": datetime.utcnow() + settings.access_token_expire_timedelta
}

token = jwt.encode(user_data, settings.secret_key, algorithm="HS256")
Expand Down Expand Up @@ -81,7 +81,7 @@ async def login(request: LoginRequest):
"user_metadata": {"name": "Ocean Rentals Manager"},
"aud": "authenticated",
"created_at": datetime.utcnow().isoformat(),
"exp": datetime.utcnow() + timedelta(hours=24)
"exp": datetime.utcnow() + settings.access_token_expire_timedelta
}

token = jwt.encode(user_data, settings.secret_key, algorithm="HS256")
Expand Down Expand Up @@ -152,7 +152,7 @@ async def login(request: LoginRequest):
"email": user.email,
"is_admin": is_admin,
"tenant_id": tenant_id,
"exp": datetime.utcnow() + timedelta(hours=24),
"exp": datetime.utcnow() + settings.access_token_expire_timedelta,
"aud": "authenticated"
}

Expand Down
82 changes: 66 additions & 16 deletions backend/app/api/v1/persistent_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
"""

import logging
import jwt
from datetime import datetime
from typing import Dict, Any, List
from fastapi import APIRouter, Depends, HTTPException, status, Request
from pydantic import BaseModel, Field

from ...config import settings
from ...core.auth import authenticate_request
from ...core.persistent_sessions import (
PersistentSessionManager,
Expand All @@ -25,8 +27,8 @@
# Request/Response Models
class SessionValidationRequest(BaseModel):
session_id: str = Field(..., description="Session ID to validate")
device_id: str = Field(..., description="Device ID for validation")
user_id: str = Field(..., description="User ID for validation")
device_id: str = Field(default="default-device", description="Device ID for validation")
user_id: str = Field(default="", description="User ID for validation")

class SessionValidationResponse(BaseModel):
valid: bool = Field(..., description="Whether the session is valid")
Expand Down Expand Up @@ -159,22 +161,30 @@ async def create_session_endpoint(
@router.post("/refresh-session")
async def refresh_session_endpoint(
request: SessionValidationRequest,
http_request: Request,
user: AuthenticatedUser = Depends(authenticate_request)
http_request: Request
):
"""
Refresh session tokens for a persistent session
"""
try:
logger.info(f"Refreshing session {request.session_id} for user {user.email}")

# Ensure the requesting user matches the session user
if request.user_id != user.id:
auth_header = http_request.headers.get("authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot refresh session for different user"
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token required for refresh"
)

token = auth_header[7:]
# Decode claims without enforcing expiration check
payload = {}
try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"], options={"verify_exp": False})
user_id = payload.get('id') or request.user_id
except Exception:
user_id = request.user_id

logger.info(f"Refreshing session {request.session_id} for user {user_id}")

# Extract new tokens from request
auth_header = http_request.headers.get("authorization")
if not auth_header or not auth_header.startswith("Bearer "):
Expand All @@ -192,12 +202,52 @@ async def refresh_session_endpoint(
)

if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found or update failed"
)

return {"success": True, "message": "Session refreshed successfully"}
logger.info(f"Session {request.session_id} not found in DB - auto-provisioning session for {user_id}")
try:
await PersistentSessionManager.create_session(
user_id=user_id,
tenant_id=payload.get("app_metadata", {}).get("tenant_id") or payload.get("tenant_id") or "",
device_id=request.device_id or "default-device",
access_token=new_access_token
)
except Exception as create_err:
logger.warning(f"Could not auto-create session in DB: {create_err}")

# Mint a fresh JWT token with extended expiration
try:
user_email = payload.get("email")
if not user_email:
if user_id == "user-ocean":
user_email = "ocean@propertyflow.com"
elif user_id == "user-sunset":
user_email = "sunset@propertyflow.com"

tenant_id = payload.get("app_metadata", {}).get("tenant_id") or payload.get("tenant_id")
if not tenant_id:
if user_id == "user-ocean":
tenant_id = "tenant-b"
elif user_id == "user-sunset":
tenant_id = "tenant-a"

fresh_claims = {
"id": payload.get("id") or user_id,
"email": user_email or "",
"app_metadata": {"role": "user", "tenant_id": tenant_id or "tenant-a"},
"user_metadata": payload.get("user_metadata") or {"name": "User"},
"exp": datetime.utcnow() + settings.access_token_expire_timedelta,
"aud": "authenticated"
}
fresh_access_token = jwt.encode(fresh_claims, settings.secret_key, algorithm="HS256")
logger.info(f"Minted fresh token for {user_id} with exp {fresh_claims['exp']} (in {settings.access_token_expire_seconds}s)")
except Exception as e:
logger.error(f"Failed to mint fresh token: {e}", exc_info=True)
fresh_access_token = new_access_token

return {
"success": True,
"access_token": fresh_access_token,
"message": "Session refreshed successfully"
}

except HTTPException:
raise
Expand Down
6 changes: 6 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ class Settings(BaseSettings):
database_url: str = "postgresql://postgres:postgres@db:5432/propertyflow"
redis_url: str = "redis://redis:6379/0"
secret_key: str = "debug_challenge_secret"
access_token_expire_seconds: int = 60 # Centralized token expiration in seconds (default 24h: 86400s)

@property
def access_token_expire_timedelta(self):
from datetime import timedelta
return timedelta(seconds=self.access_token_expire_seconds)

# Optional legacy settings
supabase_url: Optional[str] = None
Expand Down
11 changes: 9 additions & 2 deletions backend/app/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,11 @@ async def authenticate_request(
# Check cache first
if token_hash in auth_cache:
cached_data = auth_cache[token_hash]
if datetime.now().timestamp() - cached_data["timestamp"] < CACHE_DURATION:
is_cache_fresh = datetime.now().timestamp() - cached_data["timestamp"] < CACHE_DURATION
token_exp = cached_data.get("exp")
is_token_unexpired = token_exp is None or datetime.utcnow().timestamp() < token_exp

if is_cache_fresh and is_token_unexpired:
cached_user = cached_data["user"]
# If not, force a refresh to get proper tenant isolation
if not cached_user.tenant_id:
Expand All @@ -95,7 +99,7 @@ async def authenticate_request(
)
return cached_user
else:
# Remove expired cache entry
logger.info(f"AUTH: Token cache expired or token payload exp passed ({token_hash}) - forcing JWT re-verification")
del auth_cache[token_hash]

logger.info(f"AUTH: Starting authentication - Token hash: {token_hash}, Token preview: {token[:20]}...")
Expand Down Expand Up @@ -123,6 +127,7 @@ def __init__(self, payload):
self.app_metadata = payload.get('app_metadata', {})
self.user_metadata = payload.get('user_metadata', {})
self.raw_app_metadata = payload.get('app_metadata', {})
self.tenant_id = self.app_metadata.get('tenant_id') or payload.get('tenant_id') or 'tenant-a'

user = MockUser(payload)

Expand Down Expand Up @@ -278,9 +283,11 @@ def __init__(self, payload):
)

# Cache the authentication result
token_exp_claim = payload.get("exp") if 'payload' in locals() and isinstance(payload, dict) else None
auth_cache[token_hash] = {
"user": auth_user,
"timestamp": datetime.now().timestamp(),
"exp": token_exp_claim,
}

# Clean up old cache entries (keep cache size manageable)
Expand Down
Loading