mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +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
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