diff --git a/rbox/billing/models.py b/rbox/billing/models.py index 264f7b6..926430a 100644 --- a/rbox/billing/models.py +++ b/rbox/billing/models.py @@ -38,16 +38,17 @@ class UserSubscription(models.Model): class UsageRecord(models.Model): id = fields.BigIntField(pk=True) user = fields.ForeignKeyField("models.User", related_name="usage_records") - record_type = fields.CharField(max_length=50) + record_type = fields.CharField(max_length=50, index=True) amount_bytes = fields.BigIntField() resource_type = fields.CharField(max_length=50, null=True) resource_id = fields.IntField(null=True) - timestamp = fields.DatetimeField(auto_now_add=True) + timestamp = fields.DatetimeField(auto_now_add=True, index=True) idempotency_key = fields.CharField(max_length=255, unique=True, null=True) metadata = fields.JSONField(null=True) class Meta: table = "usage_records" + indexes = [("user_id", "record_type", "timestamp")] class UsageAggregate(models.Model): id = fields.IntField(pk=True) @@ -68,21 +69,22 @@ class Invoice(models.Model): user = fields.ForeignKeyField("models.User", related_name="invoices") invoice_number = fields.CharField(max_length=50, unique=True) stripe_invoice_id = fields.CharField(max_length=255, unique=True, null=True) - period_start = fields.DateField() + period_start = fields.DateField(index=True) period_end = fields.DateField() subtotal = fields.DecimalField(max_digits=10, decimal_places=4) tax = fields.DecimalField(max_digits=10, decimal_places=4, default=0) total = fields.DecimalField(max_digits=10, decimal_places=4) currency = fields.CharField(max_length=3, default="USD") - status = fields.CharField(max_length=50, default="draft") + status = fields.CharField(max_length=50, default="draft", index=True) due_date = fields.DateField(null=True) paid_at = fields.DatetimeField(null=True) - created_at = fields.DatetimeField(auto_now_add=True) + created_at = fields.DatetimeField(auto_now_add=True, index=True) updated_at = fields.DatetimeField(auto_now=True) metadata = fields.JSONField(null=True) class Meta: table = "invoices" + indexes = [("user_id", "status", "created_at")] class InvoiceLineItem(models.Model): id = fields.IntField(pk=True) diff --git a/rbox/billing/stripe_client.py b/rbox/billing/stripe_client.py index 1e9211c..6c98f86 100644 --- a/rbox/billing/stripe_client.py +++ b/rbox/billing/stripe_client.py @@ -3,11 +3,17 @@ from decimal import Decimal from typing import Optional, Dict, Any from ..settings import settings -stripe.api_key = settings.STRIPE_SECRET_KEY if hasattr(settings, 'STRIPE_SECRET_KEY') else "" - class StripeClient: @staticmethod + def _ensure_api_key(): + if not stripe.api_key: + if settings.STRIPE_SECRET_KEY: + stripe.api_key = settings.STRIPE_SECRET_KEY + else: + raise ValueError("Stripe API key not configured") + @staticmethod async def create_customer(email: str, name: str, metadata: Dict = None) -> str: + StripeClient._ensure_api_key() customer = stripe.Customer.create( email=email, name=name, @@ -22,6 +28,7 @@ class StripeClient: customer_id: str = None, metadata: Dict = None ) -> stripe.PaymentIntent: + StripeClient._ensure_api_key() return stripe.PaymentIntent.create( amount=amount, currency=currency, @@ -37,6 +44,7 @@ class StripeClient: line_items: list, metadata: Dict = None ) -> stripe.Invoice: + StripeClient._ensure_api_key() for item in line_items: stripe.InvoiceItem.create( customer=customer_id, @@ -58,10 +66,12 @@ class StripeClient: @staticmethod async def finalize_invoice(invoice_id: str) -> stripe.Invoice: + StripeClient._ensure_api_key() return stripe.Invoice.finalize_invoice(invoice_id) @staticmethod async def pay_invoice(invoice_id: str) -> stripe.Invoice: + StripeClient._ensure_api_key() return stripe.Invoice.pay(invoice_id) @staticmethod @@ -69,6 +79,7 @@ class StripeClient: payment_method_id: str, customer_id: str ) -> stripe.PaymentMethod: + StripeClient._ensure_api_key() payment_method = stripe.PaymentMethod.attach( payment_method_id, customer=customer_id @@ -83,6 +94,7 @@ class StripeClient: @staticmethod async def list_payment_methods(customer_id: str, type: str = "card"): + StripeClient._ensure_api_key() return stripe.PaymentMethod.list( customer=customer_id, type=type @@ -94,6 +106,7 @@ class StripeClient: price_id: str, metadata: Dict = None ) -> stripe.Subscription: + StripeClient._ensure_api_key() return stripe.Subscription.create( customer=customer_id, items=[{'price': price_id}], @@ -102,4 +115,5 @@ class StripeClient: @staticmethod async def cancel_subscription(subscription_id: str) -> stripe.Subscription: + StripeClient._ensure_api_key() return stripe.Subscription.delete(subscription_id) diff --git a/rbox/billing/usage_tracker.py b/rbox/billing/usage_tracker.py index 4e57b28..598c57f 100644 --- a/rbox/billing/usage_tracker.py +++ b/rbox/billing/usage_tracker.py @@ -108,15 +108,22 @@ class UsageTracker: @staticmethod async def get_current_storage(user: User) -> int: from ..models import File - files = await File.filter(user=user, is_deleted=False).all() + files = await File.filter(owner=user, is_deleted=False).all() return sum(f.size for f in files) @staticmethod async def get_monthly_usage(user: User, year: int, month: int) -> dict: + from datetime import date + from calendar import monthrange + + start_date = date(year, month, 1) + _, last_day = monthrange(year, month) + end_date = date(year, month, last_day) + aggregates = await UsageAggregate.filter( user=user, - date__year=year, - date__month=month + date__gte=start_date, + date__lte=end_date ).all() if not aggregates: diff --git a/rbox/routers/auth.py b/rbox/routers/auth.py index 4cbc2f9..90c0025 100644 --- a/rbox/routers/auth.py +++ b/rbox/routers/auth.py @@ -2,10 +2,9 @@ from datetime import timedelta from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel -from ..auth import authenticate_user, create_access_token, get_password_hash, get_current_user, get_current_verified_user +from ..auth import authenticate_user, create_access_token, get_password_hash, get_current_user, get_current_verified_user, verify_password from ..models import User from ..schemas import Token, UserCreate, TokenData, UserLoginWith2FA from ..two_factor import ( @@ -19,6 +18,10 @@ router = APIRouter( tags=["auth"], ) +class LoginRequest(BaseModel): + username: str + password: str + class TwoFactorLogin(BaseModel): username: str password: str @@ -65,8 +68,8 @@ async def register_user(user_in: UserCreate): return {"access_token": access_token, "token_type": "bearer"} @router.post("/token", response_model=Token) -async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): - auth_result = await authenticate_user(form_data.username, form_data.password, None) +async def login_for_access_token(login_data: LoginRequest): + auth_result = await authenticate_user(login_data.username, login_data.password, None) if not auth_result: raise HTTPException( @@ -83,7 +86,7 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends( headers={"X-2FA-Required": "true"}, ) - access_token_expires = timedelta(minutes=30) # Use settings + access_token_expires = timedelta(minutes=30) access_token = create_access_token( data={"sub": user.username}, expires_delta=access_token_expires, two_factor_verified=True ) diff --git a/rbox/routers/billing.py b/rbox/routers/billing.py index 45e5551..9a4f102 100644 --- a/rbox/routers/billing.py +++ b/rbox/routers/billing.py @@ -52,25 +52,28 @@ class SubscriptionResponse(BaseModel): @router.get("/usage/current") async def get_current_usage(current_user: User = Depends(get_current_user)): - storage_bytes = await UsageTracker.get_current_storage(current_user) - today = date.today() + try: + storage_bytes = await UsageTracker.get_current_storage(current_user) + today = date.today() - usage_today = await UsageAggregate.get_or_none(user=current_user, date=today) + usage_today = await UsageAggregate.get_or_none(user=current_user, date=today) + + if usage_today: + return { + "storage_gb": round(storage_bytes / (1024**3), 4), + "bandwidth_down_gb_today": round(usage_today.bandwidth_down_bytes / (1024**3), 4), + "bandwidth_up_gb_today": round(usage_today.bandwidth_up_bytes / (1024**3), 4), + "as_of": today.isoformat() + } - if usage_today: return { "storage_gb": round(storage_bytes / (1024**3), 4), - "bandwidth_down_gb_today": round(usage_today.bandwidth_down_bytes / (1024**3), 4), - "bandwidth_up_gb_today": round(usage_today.bandwidth_up_bytes / (1024**3), 4), + "bandwidth_down_gb_today": 0, + "bandwidth_up_gb_today": 0, "as_of": today.isoformat() } - - return { - "storage_gb": round(storage_bytes / (1024**3), 4), - "bandwidth_down_gb_today": 0, - "bandwidth_up_gb_today": 0, - "as_of": today.isoformat() - } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch usage data: {str(e)}") @router.get("/usage/monthly") async def get_monthly_usage( @@ -78,17 +81,27 @@ async def get_monthly_usage( month: Optional[int] = None, current_user: User = Depends(get_current_user) ) -> UsageResponse: - if year is None or month is None: - now = datetime.now() - year = now.year - month = now.month + try: + if year is None or month is None: + now = datetime.now() + year = now.year + month = now.month - usage = await UsageTracker.get_monthly_usage(current_user, year, month) + if not (1 <= month <= 12): + raise HTTPException(status_code=400, detail="Month must be between 1 and 12") + if not (2020 <= year <= 2100): + raise HTTPException(status_code=400, detail="Year must be between 2020 and 2100") - return UsageResponse( - **usage, - period=f"{year}-{month:02d}" - ) + usage = await UsageTracker.get_monthly_usage(current_user, year, month) + + return UsageResponse( + **usage, + period=f"{year}-{month:02d}" + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch monthly usage: {str(e)}") @router.get("/invoices") async def list_invoices( @@ -96,35 +109,45 @@ async def list_invoices( offset: int = 0, current_user: User = Depends(get_current_user) ) -> List[InvoiceResponse]: - invoices = await Invoice.filter(user=current_user).order_by("-created_at").offset(offset).limit(limit).all() + try: + if limit < 1 or limit > 100: + raise HTTPException(status_code=400, detail="Limit must be between 1 and 100") + if offset < 0: + raise HTTPException(status_code=400, detail="Offset must be non-negative") - result = [] - for invoice in invoices: - line_items = await invoice.line_items.all() - result.append(InvoiceResponse( - id=invoice.id, - invoice_number=invoice.invoice_number, - period_start=invoice.period_start, - period_end=invoice.period_end, - subtotal=float(invoice.subtotal), - tax=float(invoice.tax), - total=float(invoice.total), - status=invoice.status, - due_date=invoice.due_date, - paid_at=invoice.paid_at, - line_items=[ - { - "description": item.description, - "quantity": float(item.quantity), - "unit_price": float(item.unit_price), - "amount": float(item.amount), - "type": item.item_type - } - for item in line_items - ] - )) + invoices = await Invoice.filter(user=current_user).order_by("-created_at").offset(offset).limit(limit).all() - return result + result = [] + for invoice in invoices: + line_items = await invoice.line_items.all() + result.append(InvoiceResponse( + id=invoice.id, + invoice_number=invoice.invoice_number, + period_start=invoice.period_start, + period_end=invoice.period_end, + subtotal=float(invoice.subtotal), + tax=float(invoice.tax), + total=float(invoice.total), + status=invoice.status, + due_date=invoice.due_date, + paid_at=invoice.paid_at, + line_items=[ + { + "description": item.description, + "quantity": float(item.quantity), + "unit_price": float(item.unit_price), + "amount": float(item.amount), + "type": item.item_type + } + for item in line_items + ] + )) + + return result + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch invoices: {str(e)}") @router.get("/invoices/{invoice_id}") async def get_invoice( @@ -187,36 +210,46 @@ async def get_subscription(current_user: User = Depends(get_current_user)) -> Su @router.post("/payment-methods/setup-intent") async def create_setup_intent(current_user: User = Depends(get_current_user)): - subscription = await UserSubscription.get_or_none(user=current_user) + try: + from ..settings import settings + if not settings.STRIPE_SECRET_KEY: + raise HTTPException(status_code=503, detail="Payment processing not configured") - if not subscription or not subscription.stripe_customer_id: - customer_id = await StripeClient.create_customer( - email=current_user.email, - name=current_user.username, - metadata={"user_id": str(current_user.id)} + subscription = await UserSubscription.get_or_none(user=current_user) + + if not subscription or not subscription.stripe_customer_id: + customer_id = await StripeClient.create_customer( + email=current_user.email, + name=current_user.username, + metadata={"user_id": str(current_user.id)} + ) + + if not subscription: + subscription = await UserSubscription.create( + user=current_user, + billing_type="pay_as_you_go", + stripe_customer_id=customer_id, + status="active" + ) + else: + subscription.stripe_customer_id = customer_id + await subscription.save() + + import stripe + StripeClient._ensure_api_key() + setup_intent = stripe.SetupIntent.create( + customer=subscription.stripe_customer_id, + payment_method_types=["card"] ) - if not subscription: - subscription = await UserSubscription.create( - user=current_user, - billing_type="pay_as_you_go", - stripe_customer_id=customer_id, - status="active" - ) - else: - subscription.stripe_customer_id = customer_id - await subscription.save() - - import stripe - setup_intent = stripe.SetupIntent.create( - customer=subscription.stripe_customer_id, - payment_method_types=["card"] - ) - - return { - "client_secret": setup_intent.client_secret, - "customer_id": subscription.stripe_customer_id - } + return { + "client_secret": setup_intent.client_secret, + "customer_id": subscription.stripe_customer_id + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to create setup intent: {str(e)}") @router.get("/payment-methods") async def list_payment_methods(current_user: User = Depends(get_current_user)): @@ -238,49 +271,71 @@ async def list_payment_methods(current_user: User = Depends(get_current_user)): async def stripe_webhook(request: Request): import stripe from ..settings import settings - - payload = await request.body() - sig_header = request.headers.get("stripe-signature") + from ..billing.models import BillingEvent try: - event = stripe.Webhook.construct_event( - payload, sig_header, settings.STRIPE_WEBHOOK_SECRET - ) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid payload") - except stripe.error.SignatureVerificationError: - raise HTTPException(status_code=400, detail="Invalid signature") + payload = await request.body() + sig_header = request.headers.get("stripe-signature") - if event["type"] == "invoice.payment_succeeded": - invoice_data = event["data"]["object"] - rbox_invoice_id = invoice_data.get("metadata", {}).get("rbox_invoice_id") + if not settings.STRIPE_WEBHOOK_SECRET: + raise HTTPException(status_code=503, detail="Webhook secret not configured") - if rbox_invoice_id: - invoice = await Invoice.get_or_none(id=int(rbox_invoice_id)) - if invoice: - await InvoiceGenerator.mark_invoice_paid(invoice) - - elif event["type"] == "invoice.payment_failed": - pass - - elif event["type"] == "payment_method.attached": - payment_method = event["data"]["object"] - customer_id = payment_method["customer"] - - subscription = await UserSubscription.get_or_none(stripe_customer_id=customer_id) - if subscription: - await PaymentMethod.create( - user=subscription.user, - stripe_payment_method_id=payment_method["id"], - type=payment_method["type"], - last4=payment_method.get("card", {}).get("last4"), - brand=payment_method.get("card", {}).get("brand"), - exp_month=payment_method.get("card", {}).get("exp_month"), - exp_year=payment_method.get("card", {}).get("exp_year"), - is_default=True + try: + event = stripe.Webhook.construct_event( + payload, sig_header, settings.STRIPE_WEBHOOK_SECRET ) + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid payload: {str(e)}") + except stripe.error.SignatureVerificationError as e: + raise HTTPException(status_code=400, detail=f"Invalid signature: {str(e)}") - return JSONResponse(content={"status": "success"}) + event_id = event.get("id") + existing_event = await BillingEvent.get_or_none(stripe_event_id=event_id) + if existing_event: + return JSONResponse(content={"status": "already_processed"}) + + await BillingEvent.create( + event_type=event["type"], + stripe_event_id=event_id, + data=event["data"], + processed=False + ) + + if event["type"] == "invoice.payment_succeeded": + invoice_data = event["data"]["object"] + rbox_invoice_id = invoice_data.get("metadata", {}).get("rbox_invoice_id") + + if rbox_invoice_id: + invoice = await Invoice.get_or_none(id=int(rbox_invoice_id)) + if invoice: + await InvoiceGenerator.mark_invoice_paid(invoice) + + elif event["type"] == "invoice.payment_failed": + pass + + elif event["type"] == "payment_method.attached": + payment_method = event["data"]["object"] + customer_id = payment_method["customer"] + + subscription = await UserSubscription.get_or_none(stripe_customer_id=customer_id) + if subscription: + await PaymentMethod.create( + user=subscription.user, + stripe_payment_method_id=payment_method["id"], + type=payment_method["type"], + last4=payment_method.get("card", {}).get("last4"), + brand=payment_method.get("card", {}).get("brand"), + exp_month=payment_method.get("card", {}).get("exp_month"), + exp_year=payment_method.get("card", {}).get("exp_year"), + is_default=True + ) + + await BillingEvent.filter(stripe_event_id=event_id).update(processed=True) + return JSONResponse(content={"status": "success"}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Webhook processing failed: {str(e)}") @router.get("/pricing") async def get_pricing(): @@ -310,3 +365,10 @@ async def list_plans(): } for plan in plans ] + +@router.get("/stripe-key") +async def get_stripe_key(): + from ..settings import settings + if not settings.STRIPE_PUBLISHABLE_KEY: + raise HTTPException(status_code=503, detail="Payment processing not configured") + return {"publishable_key": settings.STRIPE_PUBLISHABLE_KEY} diff --git a/static/css/billing.css b/static/css/billing.css index 0bd9e0d..089a354 100644 --- a/static/css/billing.css +++ b/static/css/billing.css @@ -369,3 +369,33 @@ border-radius: 4px; font-size: 1rem; } + +.loading { + text-align: center; + padding: 3rem; + font-size: 1.2rem; + color: #6b7280; +} + +.error-message { + background: #fee2e2; + border: 1px solid #ef4444; + color: #991b1b; + padding: 1rem; + border-radius: 8px; + margin: 1rem 0; +} + +.modal-actions { + display: flex; + gap: 1rem; + margin-top: 1.5rem; + justify-content: flex-end; +} + +#payment-element { + margin: 1.5rem 0; + padding: 1rem; + border: 1px solid #e5e7eb; + border-radius: 8px; +} diff --git a/static/css/code-editor-view.css b/static/css/code-editor-view.css index 23ebb75..c9ccb3d 100644 --- a/static/css/code-editor-view.css +++ b/static/css/code-editor-view.css @@ -1,13 +1,25 @@ -.code-editor-view { - position: absolute; +.code-editor-overlay { + position: fixed; top: 0; left: 0; width: 100%; height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.code-editor-container { + width: 90%; + height: 90%; + max-width: 1400px; + background: white; + border-radius: 8px; display: flex; flex-direction: column; - background: white; - z-index: 100; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); } .code-editor-header { @@ -49,6 +61,16 @@ font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px; background: white; + user-select: text !important; + cursor: text; +} + +.code-editor-body .CodeMirror * { + user-select: text !important; +} + +.code-editor-body .CodeMirror-scroll { + cursor: text; } .code-editor-body .CodeMirror-gutters { diff --git a/static/index.html b/static/index.html index 819cd14..41e895a 100644 --- a/static/index.html +++ b/static/index.html @@ -10,6 +10,7 @@ + diff --git a/static/js/api-contract.js b/static/js/api-contract.js new file mode 100644 index 0000000..5bfa975 --- /dev/null +++ b/static/js/api-contract.js @@ -0,0 +1,42 @@ +export default class APIContract { + static validateResponse(response, schema, logger = null) { + const errors = []; + + for (const [field, expectedType] of Object.entries(schema)) { + if (!(field in response)) { + errors.push(`Missing required field: ${field}`); + } else { + const actualType = typeof response[field]; + if (actualType !== expectedType) { + errors.push( + `Field '${field}' type mismatch: expected ${expectedType}, got ${actualType}` + ); + } + } + } + + if (errors.length > 0) { + const message = `API Contract Violation:\n${errors.join('\n')}`; + logger?.error(message); + throw new Error(message); + } + + return response; + } + + static validateArray(array, itemSchema, logger = null) { + if (!Array.isArray(array)) { + logger?.error('Expected array but got non-array'); + throw new Error('Expected array response'); + } + + return array.map((item, index) => { + try { + return this.validateResponse(item, itemSchema, logger); + } catch (error) { + logger?.error(`Array item ${index} validation failed`, error); + throw error; + } + }); + } +} diff --git a/static/js/api.js b/static/js/api.js index 5ef4022..b31a7b7 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -1,7 +1,11 @@ class APIClient { - constructor(baseURL = '/') { + constructor(baseURL = '/', logger = null, perfMonitor = null, appState = null) { this.baseURL = baseURL; this.token = localStorage.getItem('token'); + this.logger = logger; + this.perfMonitor = perfMonitor; + this.appState = appState; + this.activeRequests = 0; } setToken(token) { @@ -18,76 +22,147 @@ class APIClient { } async request(endpoint, options = {}) { + this.activeRequests++; + this.appState?.setState({ isLoading: true }); + + const startTime = performance.now(); const url = `${this.baseURL}${endpoint}`; - const headers = { - ...options.headers, - }; + const method = options.method || 'GET'; - if (this.token && !options.skipAuth) { - headers['Authorization'] = `Bearer ${this.token}`; - } + try { + this.logger?.debug(`API ${method}: ${endpoint}`); - if (!(options.body instanceof FormData) && options.body) { - headers['Content-Type'] = 'application/json'; - } + const headers = { + ...options.headers, + }; - const config = { - ...options, - headers, - }; - - if (config.body && !(config.body instanceof FormData)) { - config.body = JSON.stringify(config.body); - } - - const response = await fetch(url, config); - - if (response.status === 401) { - this.setToken(null); - window.location.href = '/'; - } - - if (!response.ok) { - let errorData; - try { - errorData = await response.json(); - } catch (e) { - errorData = { message: 'Unknown error' }; + if (this.token && !options.skipAuth) { + headers['Authorization'] = `Bearer ${this.token}`; } - const errorMessage = errorData.detail || errorData.message || 'Request failed'; - document.dispatchEvent(new CustomEvent('show-toast', { - detail: { message: errorMessage, type: 'error' } - })); - throw new Error(errorMessage); - } - if (response.status === 204) { - return null; - } + const config = { + ...options, + headers, + }; - return response.json(); + if (config.body) { + if (config.body instanceof FormData) { + let hasFile = false; + for (let pair of config.body.entries()) { + if (pair[1] instanceof File) { + hasFile = true; + break; + } + } + + if (!hasFile) { + this.logger?.error('FormData without File objects not allowed - JSON only communication enforced'); + throw new Error('FormData is only allowed for file uploads'); + } + + this.logger?.debug('File upload detected, allowing FormData'); + } else if (typeof config.body === 'object') { + headers['Content-Type'] = 'application/json'; + config.body = JSON.stringify(config.body); + this.logger?.debug('Request body serialized to JSON'); + } + } + + const response = await fetch(url, config); + const duration = performance.now() - startTime; + + if (this.perfMonitor) { + this.perfMonitor._recordMetric('api-request', duration); + this.perfMonitor._recordMetric(`api-${method}`, duration); + } + + if (response.status === 401) { + this.logger?.warn('Unauthorized request, clearing token'); + this.setToken(null); + window.location.href = '/'; + } + + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); + } catch (e) { + errorData = { message: 'Unknown error' }; + } + const errorMessage = errorData.detail || errorData.message || 'Request failed'; + + this.logger?.error(`API ${method} ${endpoint} failed: ${errorMessage}`, { + status: response.status, + duration: `${duration.toFixed(2)}ms` + }); + + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: errorMessage, type: 'error' } + })); + throw new Error(errorMessage); + } + + this.logger?.debug(`API ${method} ${endpoint} success`, { + status: response.status, + duration: `${duration.toFixed(2)}ms` + }); + + if (response.status === 204) { + return null; + } + + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + this.logger?.error(`Invalid response content-type: ${contentType}. Expected application/json`); + throw new Error('Server returned non-JSON response'); + } + + return response.json(); + } catch (error) { + this.logger?.error(`API ${method} ${endpoint} exception`, error); + throw error; + } finally { + this.activeRequests--; + if (this.activeRequests === 0) { + this.appState?.setState({ isLoading: false }); + } + } } async register(username, email, password) { + this.logger?.info('Attempting registration', { username, email }); + const data = await this.request('auth/register', { method: 'POST', body: { username, email, password }, skipAuth: true }); + + if (!data || !data.access_token) { + this.logger?.error('Invalid registration response: missing access_token', data); + throw new Error('Invalid registration response'); + } + + this.logger?.info('Registration successful', { username }); this.setToken(data.access_token); return data; } async login(username, password) { - const formData = new FormData(); - formData.append('username', username); - formData.append('password', password); + this.logger?.info('Attempting login', { username }); const data = await this.request('auth/token', { method: 'POST', - body: formData, + body: { username, password }, skipAuth: true }); + + if (!data || !data.access_token) { + this.logger?.error('Invalid login response: missing access_token', data); + throw new Error('Invalid authentication response'); + } + + this.logger?.info('Login successful', { username }); this.setToken(data.access_token); return data; } @@ -328,4 +403,28 @@ class APIClient { } } -export const api = new APIClient(); +export { APIClient }; + +let _sharedInstance = null; + +export function setSharedAPIInstance(instance) { + _sharedInstance = instance; +} + +export const api = new Proxy({}, { + get(target, prop) { + if (!_sharedInstance) { + console.warn('API instance accessed before initialization. This may cause issues with logging and state management.'); + _sharedInstance = new APIClient(); + } + + const instance = _sharedInstance; + const value = instance[prop]; + + if (typeof value === 'function') { + return value.bind(instance); + } + + return value; + } +}); diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..66aa001 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,111 @@ +import Logger from './logger.js'; +import AppState from './state.js'; +import PerformanceMonitor from './perf-monitor.js'; +import LazyLoader from './lazy-loader.js'; +import { APIClient, setSharedAPIInstance } from './api.js'; + +class Application { + constructor() { + try { + this.logger = new Logger({ + level: 'debug', + maxLogs: 200, + enableRemote: false + }); + + this.appState = new AppState({ + currentView: 'files', + currentPage: 'files', + user: null, + isLoading: false, + notifications: [] + }); + + this.perfMonitor = new PerformanceMonitor(this.logger); + + this.api = new APIClient('/', this.logger, this.perfMonitor, this.appState); + + setSharedAPIInstance(this.api); + + this.lazyLoader = new LazyLoader({ + threshold: 0.1, + rootMargin: '50px', + logger: this.logger + }); + + this._setupStateSubscriptions(); + this._makeGloballyAccessible(); + + this.initialized = true; + + this.logger.info('Application initialized successfully', { + version: '1.0.0', + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error('CRITICAL: Application initialization failed', error); + this.initialized = false; + throw error; + } + } + + _setupStateSubscriptions() { + this.appState.subscribe((newState, prevState, action) => { + this.logger.debug('State changed', { + from: prevState, + to: newState, + action + }); + }); + } + + _makeGloballyAccessible() { + if (typeof window !== 'undefined') { + window.app = this; + } + } + + isReady() { + return this.initialized === true; + } + + getLogger() { + if (!this.initialized) { + console.warn('Application not fully initialized'); + } + return this.logger; + } + + getState() { + if (!this.initialized) { + console.warn('Application not fully initialized'); + } + return this.appState; + } + + getAPI() { + if (!this.initialized) { + console.warn('Application not fully initialized'); + } + return this.api; + } + + getPerfMonitor() { + if (!this.initialized) { + console.warn('Application not fully initialized'); + } + return this.perfMonitor; + } + + getLazyLoader() { + if (!this.initialized) { + console.warn('Application not fully initialized'); + } + return this.lazyLoader; + } +} + +const app = new Application(); + +export { Application, app }; +export default app; diff --git a/static/js/components/admin-billing.js b/static/js/components/admin-billing.js index 06d93f6..36ca47d 100644 --- a/static/js/components/admin-billing.js +++ b/static/js/components/admin-billing.js @@ -4,6 +4,8 @@ class AdminBilling extends HTMLElement { this.pricingConfig = []; this.stats = null; this.boundHandleClick = this.handleClick.bind(this); + this.loading = true; + this.error = null; } async connectedCallback() { @@ -18,6 +20,8 @@ class AdminBilling extends HTMLElement { } async loadData() { + this.loading = true; + this.error = null; try { const [pricing, stats] = await Promise.all([ this.fetchPricing(), @@ -26,8 +30,11 @@ class AdminBilling extends HTMLElement { this.pricingConfig = pricing; this.stats = stats; + this.loading = false; } catch (error) { console.error('Failed to load admin billing data:', error); + this.error = error.message || 'Failed to load admin billing data'; + this.loading = false; } } @@ -53,6 +60,16 @@ class AdminBilling extends HTMLElement { } render() { + if (this.loading) { + this.innerHTML = '