mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
feat: carve out billing page and show credit ledger
This commit is contained in:
parent
e33fec17db
commit
fde84387f2
13 changed files with 995 additions and 285 deletions
|
|
@ -55,6 +55,10 @@ from api.services.configuration.registry import (
|
|||
ServiceProviders,
|
||||
ServiceType,
|
||||
)
|
||||
from api.services.organization_context import (
|
||||
OrganizationContextResponse,
|
||||
get_organization_context,
|
||||
)
|
||||
from api.services.organization_preferences import (
|
||||
get_organization_preferences,
|
||||
upsert_organization_preferences,
|
||||
|
|
@ -129,6 +133,12 @@ class TelephonyConfigWarningsResponse(BaseModel):
|
|||
telnyx_missing_webhook_public_key_count: int
|
||||
|
||||
|
||||
@router.get("/context", response_model=OrganizationContextResponse)
|
||||
async def get_current_organization_context(user: UserModel = Depends(get_user)):
|
||||
"""Return organization-scoped configuration signals owned by Dograh."""
|
||||
return await get_organization_context(user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/telephony-providers/metadata",
|
||||
response_model=TelephonyProvidersMetadataResponse,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
|
@ -47,6 +47,36 @@ class MPSCreditPurchaseUrlResponse(BaseModel):
|
|||
checkout_url: str
|
||||
|
||||
|
||||
class MPSBillingAccountResponse(BaseModel):
|
||||
id: int
|
||||
organization_id: int
|
||||
billing_mode: str
|
||||
cached_balance_credits: float
|
||||
currency: str
|
||||
|
||||
|
||||
class MPSCreditLedgerEntryResponse(BaseModel):
|
||||
id: int
|
||||
entry_type: str
|
||||
origin: Optional[str] = None
|
||||
credits_delta: float
|
||||
balance_after: float
|
||||
amount_minor: Optional[int] = None
|
||||
amount_currency: Optional[str] = None
|
||||
payment_order_id: Optional[int] = None
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: str
|
||||
|
||||
|
||||
class MPSBillingCreditsResponse(BaseModel):
|
||||
billing_version: Literal["legacy", "v2"]
|
||||
total_credits_used: float = 0.0
|
||||
remaining_credits: float = 0.0
|
||||
total_quota: float = 0.0
|
||||
account: Optional[MPSBillingAccountResponse] = None
|
||||
ledger_entries: List[MPSCreditLedgerEntryResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WorkflowRunUsageResponse(BaseModel):
|
||||
id: int
|
||||
workflow_id: int
|
||||
|
|
@ -149,6 +179,102 @@ async def get_mps_credits(user: UserModel = Depends(get_user)):
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
async def _uses_mps_billing_v2(user: UserModel, organization_id: int) -> bool:
|
||||
resolved = await get_resolved_ai_model_configuration(
|
||||
user_id=user.id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
return (
|
||||
resolved.source == "organization_v2"
|
||||
and resolved.effective.managed_service_version == 2
|
||||
)
|
||||
|
||||
|
||||
async def _legacy_mps_credits_response(user: UserModel) -> MPSBillingCreditsResponse:
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
usage = await mps_service_key_client.get_usage_by_created_by(
|
||||
str(user.provider_id)
|
||||
)
|
||||
else:
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
usage = await mps_service_key_client.get_usage_by_organization(
|
||||
user.selected_organization_id
|
||||
)
|
||||
|
||||
total_used = float(usage.get("total_credits_used", 0.0))
|
||||
total_remaining = float(usage.get("remaining_credits", 0.0))
|
||||
return MPSBillingCreditsResponse(
|
||||
billing_version="legacy",
|
||||
total_credits_used=total_used,
|
||||
remaining_credits=total_remaining,
|
||||
total_quota=total_used + total_remaining,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/billing/credits", response_model=MPSBillingCreditsResponse)
|
||||
async def get_billing_credits(
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""Return legacy MPS credits or v2 billing ledger details for the org."""
|
||||
try:
|
||||
if DEPLOYMENT_MODE == "oss" or not user.selected_organization_id:
|
||||
return await _legacy_mps_credits_response(user)
|
||||
|
||||
organization_id = user.selected_organization_id
|
||||
if not await _uses_mps_billing_v2(user, organization_id):
|
||||
return await _legacy_mps_credits_response(user)
|
||||
|
||||
ledger = await mps_service_key_client.get_credit_ledger(
|
||||
organization_id=organization_id,
|
||||
limit=limit,
|
||||
created_by=str(user.provider_id),
|
||||
)
|
||||
account = ledger.get("account") or {}
|
||||
ledger_entries = ledger.get("ledger_entries") or []
|
||||
balance = float(account.get("cached_balance_credits") or 0.0)
|
||||
total_debits = sum(
|
||||
abs(float(entry.get("credits_delta") or 0.0))
|
||||
for entry in ledger_entries
|
||||
if float(entry.get("credits_delta") or 0.0) < 0
|
||||
)
|
||||
|
||||
return MPSBillingCreditsResponse(
|
||||
billing_version="v2",
|
||||
total_credits_used=total_debits,
|
||||
remaining_credits=balance,
|
||||
total_quota=balance + total_debits,
|
||||
account=MPSBillingAccountResponse(
|
||||
id=int(account["id"]),
|
||||
organization_id=int(account["organization_id"]),
|
||||
billing_mode=str(account["billing_mode"]),
|
||||
cached_balance_credits=balance,
|
||||
currency=str(account.get("currency") or "USD"),
|
||||
),
|
||||
ledger_entries=[
|
||||
MPSCreditLedgerEntryResponse(
|
||||
id=int(entry["id"]),
|
||||
entry_type=str(entry["entry_type"]),
|
||||
origin=entry.get("origin"),
|
||||
credits_delta=float(entry.get("credits_delta") or 0.0),
|
||||
balance_after=float(entry.get("balance_after") or 0.0),
|
||||
amount_minor=entry.get("amount_minor"),
|
||||
amount_currency=entry.get("amount_currency"),
|
||||
payment_order_id=entry.get("payment_order_id"),
|
||||
metadata=entry.get("metadata") or {},
|
||||
created_at=str(entry["created_at"]),
|
||||
)
|
||||
for entry in ledger_entries
|
||||
],
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to fetch billing credits: {exc}")
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/usage/mps-credits/purchase-url",
|
||||
response_model=MPSCreditPurchaseUrlResponse,
|
||||
|
|
@ -185,9 +311,9 @@ async def create_mps_credit_purchase_url(
|
|||
session = await mps_service_key_client.create_credit_purchase_url(
|
||||
organization_id=organization_id,
|
||||
created_by=str(user.provider_id),
|
||||
return_url=f"{UI_APP_URL.rstrip('/')}/reports",
|
||||
return_url=f"{UI_APP_URL.rstrip('/')}/billing",
|
||||
billing_details={
|
||||
"source": "dograh_reports",
|
||||
"source": "dograh_billing",
|
||||
"dograh_user_id": str(user.id),
|
||||
"dograh_provider_id": str(user.provider_id),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -390,6 +390,36 @@ class MPSServiceKeyClient:
|
|||
response=response,
|
||||
)
|
||||
|
||||
async def get_credit_ledger(
|
||||
self,
|
||||
organization_id: int,
|
||||
limit: int = 50,
|
||||
created_by: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Get the MPS v2 billing account balance and recent credit ledger."""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/v1/billing/accounts/{organization_id}/ledger",
|
||||
params={"limit": limit},
|
||||
headers=self._get_headers(
|
||||
organization_id=organization_id,
|
||||
created_by=created_by,
|
||||
),
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
logger.error(
|
||||
"Failed to get MPS credit ledger: "
|
||||
f"{response.status_code} - {response.text}"
|
||||
)
|
||||
raise httpx.HTTPStatusError(
|
||||
f"Failed to get MPS credit ledger: {response.text}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
async def create_correlation_id(
|
||||
self,
|
||||
*,
|
||||
|
|
|
|||
50
api/services/organization_context.py
Normal file
50
api/services/organization_context.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.services.configuration.ai_model_configuration import (
|
||||
get_resolved_ai_model_configuration,
|
||||
)
|
||||
|
||||
|
||||
class OrganizationModelServicesContext(BaseModel):
|
||||
config_source: Literal["organization_v2", "legacy_user_v1", "empty"]
|
||||
has_model_configuration_v2: bool
|
||||
managed_service_version: Optional[int] = None
|
||||
uses_managed_service_v2: bool
|
||||
|
||||
|
||||
class OrganizationContextResponse(BaseModel):
|
||||
organization_id: Optional[int] = None
|
||||
organization_provider_id: Optional[str] = None
|
||||
model_services: OrganizationModelServicesContext
|
||||
|
||||
|
||||
async def get_organization_context(user: UserModel) -> OrganizationContextResponse:
|
||||
organization_id = user.selected_organization_id
|
||||
organization = (
|
||||
await db_client.get_organization_by_id(organization_id)
|
||||
if organization_id
|
||||
else None
|
||||
)
|
||||
|
||||
resolved = await get_resolved_ai_model_configuration(
|
||||
user_id=user.id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
managed_service_version = resolved.effective.managed_service_version
|
||||
|
||||
return OrganizationContextResponse(
|
||||
organization_id=organization_id,
|
||||
organization_provider_id=organization.provider_id if organization else None,
|
||||
model_services=OrganizationModelServicesContext(
|
||||
config_source=resolved.source,
|
||||
has_model_configuration_v2=resolved.source == "organization_v2",
|
||||
managed_service_version=managed_service_version,
|
||||
uses_managed_service_v2=(
|
||||
resolved.source == "organization_v2" and managed_service_version == 2
|
||||
),
|
||||
),
|
||||
)
|
||||
267
ui/src/app/billing/page.tsx
Normal file
267
ui/src/app/billing/page.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"use client";
|
||||
|
||||
import { CircleDollarSign, CreditCard, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPost, getBillingCreditsApiV1OrganizationsBillingCreditsGet } from "@/client/sdk.gen";
|
||||
import type { MpsBillingCreditsResponse } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import { useOrgConfig } from "@/context/OrgConfigContext";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
const formatCredits = (value: number | null | undefined) => (
|
||||
(value ?? 0).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 0,
|
||||
})
|
||||
);
|
||||
|
||||
const formatAmount = (amountMinor?: number | null, currency?: string | null) => {
|
||||
if (amountMinor == null) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: currency || "USD",
|
||||
}).format(amountMinor / 100);
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => (
|
||||
new Date(value).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
);
|
||||
|
||||
export default function BillingPage() {
|
||||
const auth = useAuth();
|
||||
const { config } = useAppConfig();
|
||||
const { orgContext, loading: orgConfigLoading } = useOrgConfig();
|
||||
const [credits, setCredits] = useState<MpsBillingCreditsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [purchasing, setPurchasing] = useState(false);
|
||||
|
||||
const isManagedServiceV2 = Boolean(orgContext?.model_services.uses_managed_service_v2);
|
||||
const isBillingV2 = isManagedServiceV2 && credits?.billing_version === "v2";
|
||||
const canPurchaseCredits = isManagedServiceV2 && config?.deploymentMode !== "oss";
|
||||
const totalQuota = credits?.total_quota ?? 0;
|
||||
const remainingCredits = credits?.remaining_credits ?? 0;
|
||||
const usedCredits = credits?.total_credits_used ?? 0;
|
||||
const usagePercent = totalQuota > 0 ? Math.min(100, Math.round((usedCredits / totalQuota) * 100)) : 0;
|
||||
|
||||
const ledgerEntries = useMemo(() => credits?.ledger_entries ?? [], [credits?.ledger_entries]);
|
||||
|
||||
const fetchCredits = useCallback(async ({ silent = false }: { silent?: boolean } = {}) => {
|
||||
if (auth.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (silent) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getBillingCreditsApiV1OrganizationsBillingCreditsGet({
|
||||
query: { limit: 50 },
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error("Failed to fetch billing credits");
|
||||
}
|
||||
|
||||
setCredits(response.data ?? null);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch billing credits:", error);
|
||||
toast.error("Failed to fetch billing credits");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [auth.isAuthenticated, auth.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredits();
|
||||
}, [fetchCredits]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchCredits({ silent: true });
|
||||
};
|
||||
|
||||
const handlePurchaseCredits = async () => {
|
||||
if (!canPurchaseCredits) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPurchasing(true);
|
||||
try {
|
||||
const response = await createMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPost();
|
||||
const checkoutUrl = response.data?.checkout_url;
|
||||
if (!checkoutUrl) {
|
||||
throw new Error("Missing checkout URL");
|
||||
}
|
||||
window.location.href = checkoutUrl;
|
||||
} catch (error) {
|
||||
console.error("Failed to create credit purchase URL:", error);
|
||||
toast.error("Failed to open checkout");
|
||||
setPurchasing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || orgConfigLoading) {
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-5 w-96 max-w-full" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Skeleton className="h-36 rounded-lg" />
|
||||
<Skeleton className="h-36 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-80 rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Billing</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Credits, balance, and account usage for your organization.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
{canPurchaseCredits && (
|
||||
<Button onClick={handlePurchaseCredits} disabled={purchasing}>
|
||||
<CreditCard className="h-4 w-4 mr-2" />
|
||||
{purchasing ? "Opening..." : "Add Credits"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>{isBillingV2 ? "Credit balance" : "Credits remaining"}</CardDescription>
|
||||
<CardTitle className="flex items-center gap-2 text-3xl">
|
||||
<CircleDollarSign className="h-6 w-6 text-muted-foreground" />
|
||||
{formatCredits(remainingCredits)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">1 credit = 1 cent</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Credits used</CardDescription>
|
||||
<CardTitle className="text-3xl">{formatCredits(usedCredits)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isBillingV2 ? "Recent ledger debit total" : "Current allocation usage"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{isBillingV2 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Credit Ledger</CardTitle>
|
||||
<CardDescription>Recent grants, purchases, and usage debits.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{ledgerEntries.length > 0 ? (
|
||||
<div className="bg-card border rounded-lg overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Origin</TableHead>
|
||||
<TableHead className="text-right">Delta</TableHead>
|
||||
<TableHead className="text-right">Balance</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ledgerEntries.map((entry) => {
|
||||
const delta = entry.credits_delta ?? 0;
|
||||
return (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell>{formatDate(entry.created_at)}</TableCell>
|
||||
<TableCell className="capitalize">{entry.entry_type.replaceAll("_", " ")}</TableCell>
|
||||
<TableCell>{entry.origin || "-"}</TableCell>
|
||||
<TableCell className={`text-right font-medium ${delta >= 0 ? "text-green-600" : "text-destructive"}`}>
|
||||
{delta >= 0 ? "+" : ""}
|
||||
{formatCredits(delta)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{formatCredits(entry.balance_after)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(entry.amount_minor, entry.amount_currency)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
||||
No ledger entries yet
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Credit Usage</CardTitle>
|
||||
<CardDescription>Current legacy MPS credit allocation.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Progress value={usagePercent} />
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{usagePercent}% used</span>
|
||||
<span>{formatCredits(remainingCredits)} of {formatCredits(totalQuota)} remaining</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,8 +12,8 @@ import SpinLoader from "@/components/SpinLoader";
|
|||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { AppConfigProvider } from "@/context/AppConfigContext";
|
||||
import { OnboardingProvider } from "@/context/OnboardingContext";
|
||||
import { OrgConfigProvider } from "@/context/OrgConfigContext";
|
||||
import { TelephonyConfigWarningsProvider } from "@/context/TelephonyConfigWarningsContext";
|
||||
import { UserConfigProvider } from "@/context/UserConfigContext";
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ export default function RootLayout({
|
|||
<AuthProvider>
|
||||
<AppConfigProvider>
|
||||
<Suspense fallback={<SpinLoader />}>
|
||||
<UserConfigProvider>
|
||||
<OrgConfigProvider>
|
||||
<TelephonyConfigWarningsProvider>
|
||||
<OnboardingProvider>
|
||||
<PostHogIdentify />
|
||||
|
|
@ -76,7 +76,7 @@ export default function RootLayout({
|
|||
<ChatwootWidget />
|
||||
</OnboardingProvider>
|
||||
</TelephonyConfigWarningsProvider>
|
||||
</UserConfigProvider>
|
||||
</OrgConfigProvider>
|
||||
</Suspense>
|
||||
</AppConfigProvider>
|
||||
</AuthProvider>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { useCallback, useEffect, useId, useState } from 'react';
|
|||
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen';
|
||||
import type { DailyUsageBreakdownResponse, MpsCreditsResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
|
||||
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen';
|
||||
import type { DailyUsageBreakdownResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
|
||||
import { CallTypeCell } from '@/components/CallTypeCell';
|
||||
import { DailyUsageTable } from '@/components/DailyUsageTable';
|
||||
import { FilterBuilder } from '@/components/filters/FilterBuilder';
|
||||
|
|
@ -15,7 +15,6 @@ import { MediaPreviewButton, MediaPreviewDialog } from '@/components/MediaPrevie
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -39,10 +38,6 @@ export default function UsagePage() {
|
|||
const { organizationPricing } = useUserConfig();
|
||||
const auth = useAuth();
|
||||
|
||||
// MPS credits state
|
||||
const [mpsCredits, setMpsCredits] = useState<MpsCreditsResponse | null>(null);
|
||||
const [isLoadingCredits, setIsLoadingCredits] = useState(true);
|
||||
|
||||
// Usage history state
|
||||
const [usageHistory, setUsageHistory] = useState<UsageHistoryResponse | null>(null);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
|
|
@ -78,21 +73,6 @@ export default function UsagePage() {
|
|||
const [preferencesLoading, setPreferencesLoading] = useState(true);
|
||||
const timezoneSelectId = useId(); // Stable ID for react-select to prevent hydration mismatch
|
||||
|
||||
// Fetch MPS credits
|
||||
const fetchMpsCredits = useCallback(async () => {
|
||||
if (!auth.isAuthenticated) return;
|
||||
try {
|
||||
const response = await getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet();
|
||||
if (response.data) {
|
||||
setMpsCredits(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MPS credits:', error);
|
||||
} finally {
|
||||
setIsLoadingCredits(false);
|
||||
}
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
// Translate the FilterBuilder state into the query-param shape the
|
||||
// backend expects. Shared between the listing fetch and the CSV export
|
||||
// so they stay in lockstep.
|
||||
|
|
@ -251,10 +231,9 @@ export default function UsagePage() {
|
|||
// Initial load - fetch when auth becomes available
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
fetchMpsCredits();
|
||||
fetchUsageHistory(currentPage, appliedFilters);
|
||||
}
|
||||
}, [auth.isAuthenticated, currentPage, appliedFilters, fetchUsageHistory, fetchMpsCredits]);
|
||||
}, [auth.isAuthenticated, currentPage, appliedFilters, fetchUsageHistory]);
|
||||
|
||||
// Fetch daily usage when organizationPricing becomes available
|
||||
useEffect(() => {
|
||||
|
|
@ -428,46 +407,6 @@ export default function UsagePage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* MPS Credits Card */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Dograh Model Credits</CardTitle>
|
||||
<CardDescription>
|
||||
These track usage of Dograh models using Dograh Service Keys.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingCredits ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-muted rounded w-1/4"></div>
|
||||
<div className="h-8 bg-muted rounded"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/3"></div>
|
||||
</div>
|
||||
) : mpsCredits ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div>
|
||||
<p className="text-2xl font-bold">
|
||||
{mpsCredits.total_credits_used.toFixed(2)} <span className="text-lg font-normal text-muted-foreground">/ {mpsCredits.total_quota.toFixed(2)}</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Credits Used</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold">{mpsCredits.remaining_credits.toFixed(2)}</p>
|
||||
<p className="text-sm text-muted-foreground">Remaining</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mpsCredits.total_quota > 0 && (
|
||||
<Progress value={(mpsCredits.total_credits_used / mpsCredits.total_quota) * 100} className="h-3" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No Dograh service keys configured. Set up a service key in your model configuration to see usage.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Daily Usage Table - Only for paid organizations */}
|
||||
{organizationPricing?.price_per_second_usd && (
|
||||
<div className="mb-6">
|
||||
|
|
@ -535,9 +474,9 @@ export default function UsagePage() {
|
|||
<TableHead className="font-semibold">Disposition</TableHead>
|
||||
<TableHead className="font-semibold">Date</TableHead>
|
||||
<TableHead className="font-semibold text-right">Duration</TableHead>
|
||||
<TableHead className="font-semibold text-right">
|
||||
{organizationPricing?.price_per_second_usd ? 'Cost (USD)' : 'Tokens'}
|
||||
</TableHead>
|
||||
{organizationPricing?.price_per_second_usd && (
|
||||
<TableHead className="font-semibold text-right">Cost (USD)</TableHead>
|
||||
)}
|
||||
<TableHead className="font-semibold">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -574,12 +513,14 @@ export default function UsagePage() {
|
|||
<TableCell className="text-right">
|
||||
{formatDuration(run.call_duration_seconds)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{organizationPricing?.price_per_second_usd && run.charge_usd !== undefined && run.charge_usd !== null
|
||||
? `$${run.charge_usd.toFixed(2)}`
|
||||
: run.dograh_token_usage.toLocaleString()
|
||||
}
|
||||
</TableCell>
|
||||
{organizationPricing?.price_per_second_usd && (
|
||||
<TableCell className="text-right font-medium">
|
||||
{run.charge_usd !== undefined && run.charge_usd !== null
|
||||
? `$${run.charge_usd.toFixed(2)}`
|
||||
: '-'
|
||||
}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<MediaPreviewButton
|
||||
recordingUrl={run.recording_url}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -3107,6 +3107,117 @@ export type LoginRequest = {
|
|||
password: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* MPSBillingAccountResponse
|
||||
*/
|
||||
export type MpsBillingAccountResponse = {
|
||||
/**
|
||||
* Id
|
||||
*/
|
||||
id: number;
|
||||
/**
|
||||
* Organization Id
|
||||
*/
|
||||
organization_id: number;
|
||||
/**
|
||||
* Billing Mode
|
||||
*/
|
||||
billing_mode: string;
|
||||
/**
|
||||
* Cached Balance Credits
|
||||
*/
|
||||
cached_balance_credits: number;
|
||||
/**
|
||||
* Currency
|
||||
*/
|
||||
currency: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* MPSBillingCreditsResponse
|
||||
*/
|
||||
export type MpsBillingCreditsResponse = {
|
||||
/**
|
||||
* Billing Version
|
||||
*/
|
||||
billing_version: 'legacy' | 'v2';
|
||||
/**
|
||||
* Total Credits Used
|
||||
*/
|
||||
total_credits_used?: number;
|
||||
/**
|
||||
* Remaining Credits
|
||||
*/
|
||||
remaining_credits?: number;
|
||||
/**
|
||||
* Total Quota
|
||||
*/
|
||||
total_quota?: number;
|
||||
account?: MpsBillingAccountResponse | null;
|
||||
/**
|
||||
* Ledger Entries
|
||||
*/
|
||||
ledger_entries?: Array<MpsCreditLedgerEntryResponse>;
|
||||
};
|
||||
|
||||
/**
|
||||
* MPSCreditLedgerEntryResponse
|
||||
*/
|
||||
export type MpsCreditLedgerEntryResponse = {
|
||||
/**
|
||||
* Id
|
||||
*/
|
||||
id: number;
|
||||
/**
|
||||
* Entry Type
|
||||
*/
|
||||
entry_type: string;
|
||||
/**
|
||||
* Origin
|
||||
*/
|
||||
origin?: string | null;
|
||||
/**
|
||||
* Credits Delta
|
||||
*/
|
||||
credits_delta: number;
|
||||
/**
|
||||
* Balance After
|
||||
*/
|
||||
balance_after: number;
|
||||
/**
|
||||
* Amount Minor
|
||||
*/
|
||||
amount_minor?: number | null;
|
||||
/**
|
||||
* Amount Currency
|
||||
*/
|
||||
amount_currency?: string | null;
|
||||
/**
|
||||
* Payment Order Id
|
||||
*/
|
||||
payment_order_id?: number | null;
|
||||
/**
|
||||
* Metadata
|
||||
*/
|
||||
metadata?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* Created At
|
||||
*/
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* MPSCreditPurchaseUrlResponse
|
||||
*/
|
||||
export type MpsCreditPurchaseUrlResponse = {
|
||||
/**
|
||||
* Checkout Url
|
||||
*/
|
||||
checkout_url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* MPSCreditsResponse
|
||||
*/
|
||||
|
|
@ -3618,6 +3729,43 @@ export type OrganizationAiModelConfigurationV2 = {
|
|||
byok?: ByokaiModelConfiguration | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* OrganizationContextResponse
|
||||
*/
|
||||
export type OrganizationContextResponse = {
|
||||
/**
|
||||
* Organization Id
|
||||
*/
|
||||
organization_id?: number | null;
|
||||
/**
|
||||
* Organization Provider Id
|
||||
*/
|
||||
organization_provider_id?: string | null;
|
||||
model_services: OrganizationModelServicesContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* OrganizationModelServicesContext
|
||||
*/
|
||||
export type OrganizationModelServicesContext = {
|
||||
/**
|
||||
* Config Source
|
||||
*/
|
||||
config_source: 'organization_v2' | 'legacy_user_v1' | 'empty';
|
||||
/**
|
||||
* Has Model Configuration V2
|
||||
*/
|
||||
has_model_configuration_v2: boolean;
|
||||
/**
|
||||
* Managed Service Version
|
||||
*/
|
||||
managed_service_version?: number | null;
|
||||
/**
|
||||
* Uses Managed Service V2
|
||||
*/
|
||||
uses_managed_service_v2: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* OrganizationPreferences
|
||||
*/
|
||||
|
|
@ -9750,6 +9898,45 @@ export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses = {
|
|||
|
||||
export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse = UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses[keyof UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses];
|
||||
|
||||
export type GetCurrentOrganizationContextApiV1OrganizationsContextGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/organizations/context';
|
||||
};
|
||||
|
||||
export type GetCurrentOrganizationContextApiV1OrganizationsContextGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetCurrentOrganizationContextApiV1OrganizationsContextGetError = GetCurrentOrganizationContextApiV1OrganizationsContextGetErrors[keyof GetCurrentOrganizationContextApiV1OrganizationsContextGetErrors];
|
||||
|
||||
export type GetCurrentOrganizationContextApiV1OrganizationsContextGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: OrganizationContextResponse;
|
||||
};
|
||||
|
||||
export type GetCurrentOrganizationContextApiV1OrganizationsContextGetResponse = GetCurrentOrganizationContextApiV1OrganizationsContextGetResponses[keyof GetCurrentOrganizationContextApiV1OrganizationsContextGetResponses];
|
||||
|
||||
export type GetTelephonyProvidersMetadataApiV1OrganizationsTelephonyProvidersMetadataGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
@ -11269,6 +11456,89 @@ export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses = {
|
|||
|
||||
export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponse = GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses[keyof GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses];
|
||||
|
||||
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: {
|
||||
/**
|
||||
* Limit
|
||||
*/
|
||||
limit?: number;
|
||||
};
|
||||
url: '/api/v1/organizations/billing/credits';
|
||||
};
|
||||
|
||||
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetError = GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors[keyof GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors];
|
||||
|
||||
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: MpsBillingCreditsResponse;
|
||||
};
|
||||
|
||||
export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponse = GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses[keyof GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses];
|
||||
|
||||
export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* X-Api-Key
|
||||
*/
|
||||
'X-API-Key'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/organizations/usage/mps-credits/purchase-url';
|
||||
};
|
||||
|
||||
export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostError = CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostErrors[keyof CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostErrors];
|
||||
|
||||
export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: MpsCreditPurchaseUrlResponse;
|
||||
};
|
||||
|
||||
export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponse = CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponses[keyof CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponses];
|
||||
|
||||
export type GetUsageHistoryApiV1OrganizationsUsageRunsGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
|
|||
|
|
@ -136,6 +136,11 @@ const NAV_SECTIONS: SidebarNavSection[] = [
|
|||
url: "/usage",
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
title: "Billing",
|
||||
url: "/billing",
|
||||
icon: CircleDollarSign,
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
url: "/reports",
|
||||
|
|
|
|||
192
ui/src/context/OrgConfigContext.tsx
Normal file
192
ui/src/context/OrgConfigContext.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { client } from '@/client/client.gen';
|
||||
import { getCurrentOrganizationContextApiV1OrganizationsContextGet, getUserConfigurationsApiV1UserConfigurationsUserGet, updateUserConfigurationsApiV1UserConfigurationsUserPut } from '@/client/sdk.gen';
|
||||
import type { OrganizationContextResponse, UserConfigurationRequestResponseSchema } from '@/client/types.gen';
|
||||
import { setupAuthInterceptor } from '@/lib/apiClient';
|
||||
import type { AuthUser } from '@/lib/auth';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
interface TeamPermission {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface OrganizationPricing {
|
||||
price_per_second_usd: number | null;
|
||||
currency: string;
|
||||
billing_enabled: boolean;
|
||||
}
|
||||
|
||||
interface OrgConfigContextType {
|
||||
orgContext: OrganizationContextResponse | null;
|
||||
userConfig: UserConfigurationRequestResponseSchema | null;
|
||||
saveUserConfig: (userConfig: UserConfigurationRequestResponseSchema) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refreshConfig: () => Promise<void>;
|
||||
permissions: TeamPermission[];
|
||||
user: AuthUser | null;
|
||||
organizationPricing: OrganizationPricing | null;
|
||||
}
|
||||
|
||||
const OrgConfigContext = createContext<OrgConfigContextType | null>(null);
|
||||
|
||||
const pricingFromUserConfig = (
|
||||
userConfig: UserConfigurationRequestResponseSchema,
|
||||
): OrganizationPricing | null => {
|
||||
if (!userConfig.organization_pricing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
price_per_second_usd: userConfig.organization_pricing.price_per_second_usd as number | null,
|
||||
currency: (userConfig.organization_pricing.currency as string) || 'USD',
|
||||
billing_enabled: (userConfig.organization_pricing.billing_enabled as boolean) || false,
|
||||
};
|
||||
};
|
||||
|
||||
export function OrgConfigProvider({ children }: { children: ReactNode }) {
|
||||
const [orgContext, setOrgContext] = useState<OrganizationContextResponse | null>(null);
|
||||
const [userConfig, setUserConfig] = useState<UserConfigurationRequestResponseSchema | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [organizationPricing, setOrganizationPricing] = useState<OrganizationPricing | null>(null);
|
||||
const [permissions, setPermissions] = useState<TeamPermission[]>([]);
|
||||
|
||||
const auth = useAuth();
|
||||
|
||||
const authRef = useRef(auth);
|
||||
authRef.current = auth;
|
||||
|
||||
const hasFetchedConfig = useRef(false);
|
||||
const hasFetchedPermissions = useRef(false);
|
||||
|
||||
if (!auth.loading && auth.isAuthenticated) {
|
||||
setupAuthInterceptor(client, auth.getAccessToken);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.loading || hasFetchedPermissions.current) {
|
||||
return;
|
||||
}
|
||||
hasFetchedPermissions.current = true;
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
const currentAuth = authRef.current;
|
||||
if (currentAuth.provider === 'stack' && currentAuth.getSelectedTeam && currentAuth.listPermissions) {
|
||||
const selectedTeam = currentAuth.getSelectedTeam();
|
||||
if (selectedTeam) {
|
||||
try {
|
||||
const perms = await currentAuth.listPermissions(selectedTeam);
|
||||
setPermissions(Array.isArray(perms) ? perms : []);
|
||||
} catch {
|
||||
setPermissions([]);
|
||||
}
|
||||
} else {
|
||||
setPermissions([]);
|
||||
}
|
||||
} else {
|
||||
setPermissions([{ id: 'admin' }]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPermissions();
|
||||
}, [auth.loading, auth.provider]);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
const currentAuth = authRef.current;
|
||||
if (!currentAuth.isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [orgContextResponse, userConfigResponse] = await Promise.all([
|
||||
getCurrentOrganizationContextApiV1OrganizationsContextGet(),
|
||||
getUserConfigurationsApiV1UserConfigurationsUserGet(),
|
||||
]);
|
||||
|
||||
if (orgContextResponse.data) {
|
||||
setOrgContext(orgContextResponse.data);
|
||||
}
|
||||
|
||||
if (userConfigResponse.data) {
|
||||
setUserConfig(userConfigResponse.data);
|
||||
setOrganizationPricing(pricingFromUserConfig(userConfigResponse.data));
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch organization configuration'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.loading || !auth.isAuthenticated || hasFetchedConfig.current) {
|
||||
return;
|
||||
}
|
||||
hasFetchedConfig.current = true;
|
||||
fetchConfig();
|
||||
}, [auth.loading, auth.isAuthenticated, fetchConfig]);
|
||||
|
||||
const saveUserConfig = useCallback(async (userConfigRequest: UserConfigurationRequestResponseSchema) => {
|
||||
if (!authRef.current.isAuthenticated) throw new Error('No authentication available');
|
||||
const response = await updateUserConfigurationsApiV1UserConfigurationsUserPut({
|
||||
body: {
|
||||
...userConfig,
|
||||
...userConfigRequest,
|
||||
} as UserConfigurationRequestResponseSchema,
|
||||
});
|
||||
if (response.error) {
|
||||
let msg = 'Failed to save user configuration';
|
||||
const detail = (response.error as unknown as { detail?: string | { errors: { model: string; message: string }[] } }).detail;
|
||||
if (typeof detail === 'string') {
|
||||
msg = detail;
|
||||
} else if (Array.isArray(detail)) {
|
||||
msg = detail
|
||||
.map((e: { model: string; message: string }) => `${e.model}: ${e.message}`)
|
||||
.join('\n');
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
setUserConfig(response.data);
|
||||
setOrganizationPricing(pricingFromUserConfig(response.data));
|
||||
}
|
||||
}, [userConfig]);
|
||||
|
||||
const refreshConfig = useCallback(async () => {
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
return (
|
||||
<OrgConfigContext.Provider
|
||||
value={{
|
||||
orgContext,
|
||||
userConfig,
|
||||
saveUserConfig,
|
||||
loading,
|
||||
error,
|
||||
refreshConfig,
|
||||
permissions,
|
||||
user: auth.user,
|
||||
organizationPricing,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</OrgConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOrgConfig() {
|
||||
const context = useContext(OrgConfigContext);
|
||||
if (!context) {
|
||||
throw new Error('useOrgConfig must be used within an OrgConfigProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
@ -1,205 +1,3 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { client } from '@/client/client.gen';
|
||||
import { getUserConfigurationsApiV1UserConfigurationsUserGet, updateUserConfigurationsApiV1UserConfigurationsUserPut } from '@/client/sdk.gen';
|
||||
import type { UserConfigurationRequestResponseSchema } from '@/client/types.gen';
|
||||
import { setupAuthInterceptor } from '@/lib/apiClient';
|
||||
import type { AuthUser } from '@/lib/auth';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
|
||||
interface TeamPermission {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface OrganizationPricing {
|
||||
price_per_second_usd: number | null;
|
||||
currency: string;
|
||||
billing_enabled: boolean;
|
||||
}
|
||||
|
||||
interface UserConfigContextType {
|
||||
userConfig: UserConfigurationRequestResponseSchema | null;
|
||||
saveUserConfig: (userConfig: UserConfigurationRequestResponseSchema) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refreshConfig: () => Promise<void>;
|
||||
permissions: TeamPermission[];
|
||||
user: AuthUser | null;
|
||||
organizationPricing: OrganizationPricing | null;
|
||||
}
|
||||
|
||||
const UserConfigContext = createContext<UserConfigContextType | null>(null);
|
||||
|
||||
export function UserConfigProvider({ children }: { children: ReactNode }) {
|
||||
const [userConfig, setUserConfig] = useState<UserConfigurationRequestResponseSchema | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [organizationPricing, setOrganizationPricing] = useState<OrganizationPricing | null>(null);
|
||||
const [permissions, setPermissions] = useState<TeamPermission[]>([]);
|
||||
|
||||
const auth = useAuth();
|
||||
|
||||
// Store auth functions in refs to avoid dependency issues
|
||||
const authRef = useRef(auth);
|
||||
authRef.current = auth;
|
||||
|
||||
// Track initialization
|
||||
const hasFetchedConfig = useRef(false);
|
||||
const hasFetchedPermissions = useRef(false);
|
||||
|
||||
// Register the auth interceptor synchronously during render (not in useEffect)
|
||||
// so it's in place before any child effects fire API calls.
|
||||
// setupAuthInterceptor is idempotent — safe for strict mode double-renders.
|
||||
if (!auth.loading && auth.isAuthenticated) {
|
||||
setupAuthInterceptor(client, auth.getAccessToken);
|
||||
}
|
||||
|
||||
// Fetch permissions once when auth is ready
|
||||
useEffect(() => {
|
||||
if (auth.loading || hasFetchedPermissions.current) {
|
||||
return;
|
||||
}
|
||||
hasFetchedPermissions.current = true;
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
const currentAuth = authRef.current;
|
||||
if (currentAuth.provider === 'stack' && currentAuth.getSelectedTeam && currentAuth.listPermissions) {
|
||||
const selectedTeam = currentAuth.getSelectedTeam();
|
||||
if (selectedTeam) {
|
||||
try {
|
||||
const perms = await currentAuth.listPermissions(selectedTeam);
|
||||
setPermissions(Array.isArray(perms) ? perms : []);
|
||||
} catch {
|
||||
setPermissions([]);
|
||||
}
|
||||
} else {
|
||||
setPermissions([]);
|
||||
}
|
||||
} else {
|
||||
setPermissions([{ id: 'admin' }]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPermissions();
|
||||
}, [auth.loading, auth.provider]);
|
||||
|
||||
// Fetch user config once when auth is ready
|
||||
useEffect(() => {
|
||||
if (auth.loading || !auth.isAuthenticated || hasFetchedConfig.current) {
|
||||
return;
|
||||
}
|
||||
hasFetchedConfig.current = true;
|
||||
|
||||
const fetchUserConfig = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getUserConfigurationsApiV1UserConfigurationsUserGet();
|
||||
|
||||
if (response.data) {
|
||||
setUserConfig(response.data);
|
||||
if (response.data.organization_pricing) {
|
||||
setOrganizationPricing({
|
||||
price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null,
|
||||
currency: response.data.organization_pricing.currency as string || 'USD',
|
||||
billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false
|
||||
});
|
||||
} else {
|
||||
setOrganizationPricing(null);
|
||||
}
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch user configuration'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserConfig();
|
||||
}, [auth.loading, auth.isAuthenticated]);
|
||||
|
||||
const saveUserConfig = useCallback(async (userConfigRequest: UserConfigurationRequestResponseSchema) => {
|
||||
if (!authRef.current.isAuthenticated) throw new Error('No authentication available');
|
||||
const response = await updateUserConfigurationsApiV1UserConfigurationsUserPut({
|
||||
body: {
|
||||
...userConfig,
|
||||
...userConfigRequest
|
||||
} as UserConfigurationRequestResponseSchema,
|
||||
});
|
||||
if (response.error) {
|
||||
let msg = 'Failed to save user configuration';
|
||||
const detail = (response.error as unknown as { detail?: string | { errors: { model: string; message: string }[] } }).detail;
|
||||
if (typeof detail === 'string') {
|
||||
msg = detail;
|
||||
} else if (Array.isArray(detail)) {
|
||||
msg = detail
|
||||
.map((e: { model: string; message: string }) => `${e.model}: ${e.message}`)
|
||||
.join('\n');
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
setUserConfig(response.data!);
|
||||
|
||||
if (response.data?.organization_pricing) {
|
||||
setOrganizationPricing({
|
||||
price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null,
|
||||
currency: response.data.organization_pricing.currency as string || 'USD',
|
||||
billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false
|
||||
});
|
||||
}
|
||||
}, [userConfig]);
|
||||
|
||||
const refreshConfig = useCallback(async () => {
|
||||
const currentAuth = authRef.current;
|
||||
if (!currentAuth.isAuthenticated) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getUserConfigurationsApiV1UserConfigurationsUserGet();
|
||||
|
||||
if (response.data) {
|
||||
setUserConfig(response.data);
|
||||
if (response.data.organization_pricing) {
|
||||
setOrganizationPricing({
|
||||
price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null,
|
||||
currency: response.data.organization_pricing.currency as string || 'USD',
|
||||
billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false
|
||||
});
|
||||
}
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch user configuration'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UserConfigContext.Provider
|
||||
value={{
|
||||
userConfig,
|
||||
saveUserConfig,
|
||||
loading,
|
||||
error,
|
||||
refreshConfig,
|
||||
permissions,
|
||||
user: auth.user,
|
||||
organizationPricing,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</UserConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUserConfig() {
|
||||
const context = useContext(UserConfigContext);
|
||||
if (!context) {
|
||||
throw new Error('useUserConfig must be used within a UserConfigProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
export { OrgConfigProvider as UserConfigProvider, useOrgConfig as useUserConfig } from './OrgConfigContext';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue