mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
refactor(saas): remove the legacy credits engine (FE + Java) (#6687)
Complete legacy-credits teardown ("Group 3"). The per-user/per-team
credit model is fully superseded by PAYG (`wallet_ledger`) — confirmed
no PAYG code references it. Authorized to also remove the `TeamCredit`
pool + its monthly reset.
## Frontend (saas)
- Deleted `saas/hooks/useCredits.ts`, `apiKeys/hooks/useCredits.ts`,
`types/credits.ts`, `apiKeys/UsageSection.tsx`.
- `UseSession.tsx`: removed credit members (`creditBalance`,
`creditSummary`, `hasSufficientCredits`, `updateCredits`,
`refreshCredits`, `fetchCredits`) + the credit types + global
credit-update callback. **Kept** `isPro`/`refreshProStatus` and the
Supabase auth subscription listener.
- `services/apiClient.ts`: removed the dead `x-credits-remaining`
handler + low-credit plumbing (token-refresh / PAYG / 401 logic
untouched).
- Credit refs removed from `ApiKeys.tsx`, `AppConfigModal.tsx`,
`auth/teamSession.ts`.
## Java (:saas)
**Deleted (15):** `UserCredit`(+repo),
`TeamCredit`(+repo)+`TeamCreditService`, `CreditService`,
`CreditHeaderUtils`, `CreditResetScheduler`, `CreditController`,
`CreditInterceptorConfig`, `UnifiedCreditInterceptor`,
`CreditSuccessAdvice`, `CreditErrorAdvice`, `CreditConsumptionResult` (+
the CreditController test).
**Edited — stripped legacy credit side-effects, preserved
auth/role/AI/PAYG logic:**
- `AiCreate`/`AiProxyController`: dropped the
`X-Credits-Remaining`/`X-Credit-Source` response header (its only
consumer, the desktop credit system, was already removed).
- `SaasTeamService`: dropped UserCredit/TeamCredit init on team-create +
seat-update.
- `SupabaseAuthenticationFilter` / `SupabaseSecurityConfig`: dropped
`getOrCreateUserCredits` on signup + the credit field/CORS header.
- `UserRoleService`: dropped `resetCycleAllocationForRoleChange`;
`ROLE_PRO_USER` grant/revoke preserved.
- proprietary `UserRepository`: dropped
`findUsersWithApiKeyButNoCredits()`.
- Tests updated to drop credit mocks/refs.
## Kept / scope
- `isPro` / `is_pro` RPC / `ROLE_PRO_USER` (that's the separate Group-4
/ EE effort) and **all PAYG** are untouched.
- **No DB tables dropped.** `user_credits`/`team_credits` stay until a
later **gated** migration — which this PR unblocks (the JPA entities
that pinned them are gone).
## Verify
`:saas:compileJava` + `:saas:compileTestJava` pass; FE `tsc --noEmit`
(saas) + eslint clean; 0 stray artifacts; no residual source refs to the
deleted classes.
## Follow-up (not in this PR)
`ErrorTrackingService` (+
`UserErrorTracker`/`ProcessingErrorType`/`CreditsProperties`) is now a
dead island — its only callers were the deleted interceptors. Safe to
delete, but it cascades beyond the credit scope, so it's a separate
tidy-up.
Targets `feat/desktop-cloud-saas-reuse`.
This commit is contained in:
@@ -2756,7 +2756,6 @@ tooltip = "Pick colour from screen"
|
||||
title = "Choose colour"
|
||||
|
||||
[common]
|
||||
available = "available"
|
||||
back = "Back"
|
||||
cancel = "Cancel"
|
||||
close = "Close"
|
||||
@@ -2774,7 +2773,6 @@ previous = "Previous"
|
||||
refresh = "Refresh"
|
||||
retry = "Retry"
|
||||
save = "Save"
|
||||
used = "used"
|
||||
|
||||
[compare]
|
||||
clearSelected = "Clear selected"
|
||||
@@ -3020,9 +3018,7 @@ title = "Upgrade Guest Account"
|
||||
upgradeButton = "Upgrade Account"
|
||||
|
||||
[config.apiKeys]
|
||||
chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}"
|
||||
copyKeyAriaLabel = "Copy API key"
|
||||
creditsRemaining = "Credits Remaining"
|
||||
description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one."
|
||||
docsDescription = "Learn more about integrating with Stirling PDF:"
|
||||
docsLink = "API Documentation"
|
||||
@@ -3030,14 +3026,9 @@ docsTitle = "API Documentation"
|
||||
generateError = "We couldn't generate your API key."
|
||||
goToAccount = "Go to Account"
|
||||
guestInfo = "Guest users do not receive API keys. Create an account to get an API key you can use in your applications."
|
||||
includedCredits = "Included credits"
|
||||
intro = "Use your API key to programmatically access Stirling PDF's processing capabilities."
|
||||
label = "API Key"
|
||||
lastApiUse = "Last API Use"
|
||||
nextReset = "Next Reset"
|
||||
overlayMessage = "Generate a key to see credits and available credits"
|
||||
publicKeyAriaLabel = "Public API key"
|
||||
purchasedCredits = "Purchased credits"
|
||||
refreshAriaLabel = "Refresh API key"
|
||||
schemaLink = "API Schema Reference"
|
||||
usage = "Include this key in the X-API-KEY header with all API requests."
|
||||
|
||||
@@ -2730,7 +2730,6 @@ tooltip = "Pick color from screen"
|
||||
title = "Choose color"
|
||||
|
||||
[common]
|
||||
available = "available"
|
||||
back = "Back"
|
||||
cancel = "Cancel"
|
||||
close = "Close"
|
||||
@@ -2748,7 +2747,6 @@ previous = "Previous"
|
||||
refresh = "Refresh"
|
||||
retry = "Retry"
|
||||
save = "Save"
|
||||
used = "used"
|
||||
|
||||
[compare]
|
||||
clearSelected = "Clear selected"
|
||||
@@ -2994,9 +2992,7 @@ title = "Upgrade Guest Account"
|
||||
upgradeButton = "Upgrade Account"
|
||||
|
||||
[config.apiKeys]
|
||||
chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}"
|
||||
copyKeyAriaLabel = "Copy API key"
|
||||
creditsRemaining = "Credits Remaining"
|
||||
description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one."
|
||||
docsDescription = "Learn more about integrating with Stirling PDF:"
|
||||
docsLink = "API Documentation"
|
||||
@@ -3004,14 +3000,9 @@ docsTitle = "API Documentation"
|
||||
generateError = "We couldn't generate your API key."
|
||||
goToAccount = "Go to Account"
|
||||
guestInfo = "Guest users do not receive API keys. Create an account to get an API key you can use in your applications."
|
||||
includedCredits = "Included credits"
|
||||
intro = "Use your API key to programmatically access Stirling PDF's processing capabilities."
|
||||
label = "API Key"
|
||||
lastApiUse = "Last API Use"
|
||||
nextReset = "Next Reset"
|
||||
overlayMessage = "Generate a key to see credits and available credits"
|
||||
publicKeyAriaLabel = "Public API key"
|
||||
purchasedCredits = "Purchased credits"
|
||||
refreshAriaLabel = "Refresh API key"
|
||||
schemaLink = "API Schema Reference"
|
||||
usage = "Include this key in the X-API-KEY header with all API requests."
|
||||
|
||||
@@ -14,12 +14,6 @@ import type {
|
||||
User as SupabaseUser,
|
||||
AuthError,
|
||||
} from "@supabase/supabase-js";
|
||||
import {
|
||||
CreditSummary,
|
||||
SubscriptionInfo,
|
||||
CreditCheckResult,
|
||||
} from "@app/types/credits";
|
||||
import { setGlobalCreditUpdateCallback } from "@app/services/apiClient";
|
||||
import { synchronizeUserUpgrade } from "@app/services/userService";
|
||||
import {
|
||||
syncOAuthAvatar,
|
||||
@@ -70,17 +64,11 @@ interface AuthContextType {
|
||||
isAnonymous: boolean;
|
||||
loading: boolean;
|
||||
error: AuthError | null;
|
||||
creditBalance: number | null;
|
||||
subscription: SubscriptionInfo | null;
|
||||
creditSummary: CreditSummary | null;
|
||||
isPro: boolean | null;
|
||||
profilePictureUrl: string | null;
|
||||
profilePictureMetadata: ProfilePictureMetadata | null;
|
||||
signOut: () => Promise<void>;
|
||||
refreshSession: () => Promise<void>;
|
||||
hasSufficientCredits: (requiredCredits: number) => CreditCheckResult;
|
||||
updateCredits: (newBalance: number) => void;
|
||||
refreshCredits: () => Promise<void>;
|
||||
refreshProStatus: () => Promise<void>;
|
||||
refreshProfilePicture: () => Promise<void>;
|
||||
refreshProfilePictureMetadata: () => Promise<void>;
|
||||
@@ -93,21 +81,11 @@ const AuthContext = createContext<AuthContextType>({
|
||||
isAnonymous: false,
|
||||
loading: true,
|
||||
error: null,
|
||||
creditBalance: null,
|
||||
subscription: null,
|
||||
creditSummary: null,
|
||||
isPro: null,
|
||||
profilePictureUrl: null,
|
||||
profilePictureMetadata: null,
|
||||
signOut: async () => {},
|
||||
refreshSession: async () => {},
|
||||
hasSufficientCredits: () => ({
|
||||
hasSufficientCredits: false,
|
||||
currentBalance: 0,
|
||||
requiredCredits: 0,
|
||||
}),
|
||||
updateCredits: () => {},
|
||||
refreshCredits: async () => {},
|
||||
refreshProStatus: async () => {},
|
||||
refreshProfilePicture: async () => {},
|
||||
refreshProfilePictureMetadata: async () => {},
|
||||
@@ -117,13 +95,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<AuthError | null>(null);
|
||||
const [creditBalance, setCreditBalance] = useState<number | null>(null);
|
||||
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(
|
||||
null,
|
||||
);
|
||||
const [creditSummary, setCreditSummary] = useState<CreditSummary | null>(
|
||||
null,
|
||||
);
|
||||
const [isPro, setIsPro] = useState<boolean | null>(null);
|
||||
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(
|
||||
null,
|
||||
@@ -131,19 +102,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [profilePictureMetadata, setProfilePictureMetadata] =
|
||||
useState<ProfilePictureMetadata | null>(null);
|
||||
|
||||
// Legacy weekly-credits feed (GET /api/v1/credits) is dead. PAYG replaces it via
|
||||
// useWallet() reading /api/v1/payg/wallet. Symbols are kept as no-ops so existing
|
||||
// consumers of useAuth() that still destructure creditBalance / refreshCredits
|
||||
// compile cleanly; values just stay null forever and refreshCredits is a noop.
|
||||
// _ underscore on the param keeps the public signature stable for callers.
|
||||
const fetchCredits = useCallback(async (_sessionToUse?: Session | null) => {
|
||||
/* legacy credit fetch removed — see comment above */
|
||||
}, []);
|
||||
|
||||
const refreshCredits = useCallback(async () => {
|
||||
/* legacy credit refresh removed — useWallet() replaces this */
|
||||
}, []);
|
||||
|
||||
const fetchProStatus = useCallback(
|
||||
async (sessionToUse?: Session | null) => {
|
||||
const currentSession = sessionToUse ?? session;
|
||||
@@ -289,46 +247,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
await fetchProfilePictureMetadata();
|
||||
}, [fetchProfilePictureMetadata]);
|
||||
|
||||
const updateCredits = useCallback(
|
||||
(newBalance: number) => {
|
||||
console.debug("[Auth Debug] Updating credit balance:", {
|
||||
from: creditBalance,
|
||||
to: newBalance,
|
||||
});
|
||||
setCreditBalance(newBalance);
|
||||
// Also update the creditSummary if it exists
|
||||
if (creditSummary) {
|
||||
const updatedSummary: CreditSummary = {
|
||||
...creditSummary,
|
||||
creditsRemaining: newBalance,
|
||||
currentCredits: newBalance,
|
||||
};
|
||||
setCreditSummary(updatedSummary);
|
||||
}
|
||||
},
|
||||
[creditSummary],
|
||||
);
|
||||
|
||||
const hasSufficientCredits = useCallback(
|
||||
(requiredCredits: number): CreditCheckResult => {
|
||||
const currentBalance = creditBalance ?? 0;
|
||||
const hasSufficient = currentBalance >= requiredCredits;
|
||||
console.debug("[Auth Debug] Credit check:", {
|
||||
requiredCredits,
|
||||
currentBalance,
|
||||
hasSufficient,
|
||||
});
|
||||
|
||||
return {
|
||||
hasSufficientCredits: hasSufficient,
|
||||
currentBalance,
|
||||
requiredCredits,
|
||||
shortfall: hasSufficient ? undefined : requiredCredits - currentBalance,
|
||||
};
|
||||
},
|
||||
[creditBalance],
|
||||
);
|
||||
|
||||
const refreshSession = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -372,11 +290,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Set up global credit update callback
|
||||
useEffect(() => {
|
||||
setGlobalCreditUpdateCallback(updateCredits);
|
||||
}, [updateCredits]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
@@ -399,7 +312,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
setSession(data.session);
|
||||
|
||||
// Fetch credits, pro status, profile picture metadata, and profile picture using the session from the response
|
||||
// Fetch pro status, profile picture metadata, and profile picture using the session from the response
|
||||
if (data.session?.user) {
|
||||
// Sync OAuth avatar in background; fetch the picture once the
|
||||
// sync settles instead of guessing with a fixed delay.
|
||||
@@ -413,7 +326,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
.then(() => fetchProfilePicture(data.session));
|
||||
|
||||
await fetchCredits(data.session);
|
||||
await fetchProStatus(data.session);
|
||||
await fetchProfilePictureMetadata(data.session);
|
||||
}
|
||||
@@ -458,10 +370,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Additional handling for specific events
|
||||
if (event === "SIGNED_OUT") {
|
||||
console.debug("[Auth Debug] User signed out, clearing session");
|
||||
// Clear credit data, pro status, profile picture, and metadata on sign out
|
||||
setCreditBalance(null);
|
||||
setCreditSummary(null);
|
||||
setSubscription(null);
|
||||
// Clear pro status, profile picture, and metadata on sign out
|
||||
setIsPro(null);
|
||||
setProfilePictureUrl(null);
|
||||
setProfilePictureMetadata(null);
|
||||
@@ -490,7 +399,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Fetch user data in parallel
|
||||
Promise.all([
|
||||
fetchCredits(newSession),
|
||||
fetchProStatus(newSession),
|
||||
fetchProfilePictureMetadata(newSession),
|
||||
]).then(() => {
|
||||
@@ -506,10 +414,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
} else if (event === "TOKEN_REFRESHED") {
|
||||
console.debug("[Auth Debug] Token refreshed");
|
||||
// Optionally refresh credits, pro status, profile picture metadata, and profile picture on token refresh
|
||||
// Optionally refresh pro status, profile picture metadata, and profile picture on token refresh
|
||||
if (newSession?.user) {
|
||||
Promise.all([
|
||||
fetchCredits(newSession),
|
||||
fetchProStatus(newSession),
|
||||
fetchProfilePictureMetadata(newSession),
|
||||
fetchProfilePicture(newSession),
|
||||
@@ -547,10 +454,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
"[Auth Debug] User upgrade synchronized successfully",
|
||||
);
|
||||
|
||||
// Refresh credits, pro status, profile picture metadata, and profile picture after upgrade
|
||||
// Refresh pro status, profile picture metadata, and profile picture after upgrade
|
||||
if (newSession?.user) {
|
||||
return Promise.all([
|
||||
fetchCredits(newSession),
|
||||
fetchProStatus(newSession),
|
||||
fetchProfilePictureMetadata(newSession),
|
||||
fetchProfilePicture(newSession),
|
||||
@@ -589,17 +495,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
isAnonymous: Boolean(user?.is_anonymous),
|
||||
loading,
|
||||
error,
|
||||
creditBalance,
|
||||
subscription,
|
||||
creditSummary,
|
||||
isPro,
|
||||
profilePictureUrl,
|
||||
profilePictureMetadata,
|
||||
signOut,
|
||||
refreshSession,
|
||||
hasSufficientCredits,
|
||||
updateCredits,
|
||||
refreshCredits,
|
||||
refreshProStatus,
|
||||
refreshProfilePicture,
|
||||
refreshProfilePictureMetadata,
|
||||
|
||||
@@ -17,19 +17,18 @@ import type { TeamAuth } from "@cloud/auth/teamSession";
|
||||
export type { TeamAuth } from "@cloud/auth/teamSession";
|
||||
|
||||
export function useTeamAuth(): TeamAuth {
|
||||
const { user, refreshCredits, refreshSession } = useAuth();
|
||||
const { user, refreshSession } = useAuth();
|
||||
|
||||
// Teams require a signed-in, non-anonymous user — anonymous (guest) sessions
|
||||
// never load or manage teams.
|
||||
const canUseTeams = !!user && !isUserAnonymous(user);
|
||||
|
||||
// After a membership change the user's billing tier may have changed, so
|
||||
// refresh credits + the Supabase session (mirrors the pre-move accept/leave
|
||||
// flow in saas SaaSTeamContext).
|
||||
// refresh the Supabase session (mirrors the pre-move accept/leave flow in
|
||||
// saas SaaSTeamContext).
|
||||
const refreshAfterMembershipChange = useCallback(async () => {
|
||||
await refreshCredits();
|
||||
await refreshSession();
|
||||
}, [refreshCredits, refreshSession]);
|
||||
}, [refreshSession]);
|
||||
|
||||
return { canUseTeams, refreshAfterMembershipChange };
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ interface AppConfigModalProps {
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
|
||||
const { signOut, user, creditBalance, refreshCredits } = useAuth();
|
||||
const { signOut, user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [active, setActive] = useState<NavKey>("overview");
|
||||
@@ -95,35 +95,6 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
window.removeEventListener("appConfig:overlay", handler as EventListener);
|
||||
}, []);
|
||||
|
||||
// When the modal opens to Plan, proactively refresh credits and log values
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
if (active !== "plan") return;
|
||||
console.log(
|
||||
"[AppConfigModal] Opening Plan section. Current creditBalance:",
|
||||
creditBalance,
|
||||
);
|
||||
(async () => {
|
||||
try {
|
||||
await refreshCredits();
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"[AppConfigModal] Failed to refresh credits on Plan open:",
|
||||
e,
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, [opened, active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
if (active !== "plan") return;
|
||||
console.log(
|
||||
"[AppConfigModal] Credit balance updated while viewing Plan:",
|
||||
creditBalance,
|
||||
);
|
||||
}, [opened, active, creditBalance]);
|
||||
|
||||
const colors = useMemo(
|
||||
() => ({
|
||||
navBg: "var(--modal-nav-bg)",
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { Anchor, Group, Stack, Text, Button, Paper } from "@mantine/core";
|
||||
import UsageSection from "@app/components/shared/config/configSections/apiKeys/UsageSection";
|
||||
import ApiKeySection from "@app/components/shared/config/configSections/apiKeys/ApiKeySection";
|
||||
import RefreshModal from "@app/components/shared/config/configSections/apiKeys/RefreshModal";
|
||||
import { useCredits } from "@app/components/shared/config/configSections/apiKeys/hooks/useCredits";
|
||||
import useApiKey from "@app/components/shared/config/configSections/apiKeys/hooks/useApiKey";
|
||||
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -17,7 +15,6 @@ export default function ApiKeys() {
|
||||
const { user } = useAuth();
|
||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||
|
||||
const { data: credits, isLoading: creditsLoading } = useCredits();
|
||||
const {
|
||||
apiKey,
|
||||
isLoading: apiKeyLoading,
|
||||
@@ -25,7 +22,6 @@ export default function ApiKeys() {
|
||||
isRefreshing,
|
||||
error: apiKeyError,
|
||||
refetch,
|
||||
hasAttempted,
|
||||
} = useApiKey();
|
||||
|
||||
const copy = async (text: string, tag: string) => {
|
||||
@@ -53,22 +49,8 @@ export default function ApiKeys() {
|
||||
);
|
||||
};
|
||||
|
||||
const showUsage = Boolean(credits);
|
||||
|
||||
return (
|
||||
<Stack gap={20} p={0}>
|
||||
{showUsage && (
|
||||
<UsageSection
|
||||
apiUsage={credits!}
|
||||
obscured={Boolean(!apiKey && hasAttempted && !isAnonymous)}
|
||||
overlayMessage={t(
|
||||
"config.apiKeys.overlayMessage",
|
||||
"Generate a key to see credits and available credits",
|
||||
)}
|
||||
loading={creditsLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isAnonymous && apiKeyError && (
|
||||
<Text size="sm" c="red.5">
|
||||
{t(
|
||||
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
import React from "react";
|
||||
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
|
||||
import StackedBarChart from "@app/components/shared/charts/StackedBarChart";
|
||||
import { FractionData } from "@app/types/charts";
|
||||
import { ApiCredits as ApiUsage } from "@app/types/credits";
|
||||
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
|
||||
import { formatUTC } from "@app/components/shared/utils/date";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Using shared ApiCredits type as ApiUsage
|
||||
|
||||
interface UsageSectionProps {
|
||||
apiUsage: ApiUsage;
|
||||
obscured?: boolean;
|
||||
overlayMessage?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function UsageSection({
|
||||
apiUsage,
|
||||
obscured,
|
||||
overlayMessage,
|
||||
loading,
|
||||
}: UsageSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const weeklyUsed =
|
||||
apiUsage.weeklyCreditsAllocated - apiUsage.weeklyCreditsRemaining;
|
||||
const boughtUsed =
|
||||
apiUsage.totalBoughtCredits - apiUsage.boughtCreditsRemaining;
|
||||
|
||||
// Totals for overall usage visualization
|
||||
const totalRemaining = Math.max(apiUsage.totalAvailableCredits, 0);
|
||||
|
||||
const formatDate = (iso: string, withTime: boolean) =>
|
||||
formatUTC(iso, withTime);
|
||||
|
||||
// Prepare data for the stacked bar chart
|
||||
const fractions: FractionData[] = [
|
||||
{
|
||||
name: t("config.apiKeys.includedCredits", "Included credits"),
|
||||
numerator: Math.max(0, weeklyUsed),
|
||||
denominator: Math.max(0, apiUsage.weeklyCreditsAllocated),
|
||||
numeratorLabel: t("common.used", "used"),
|
||||
denominatorLabel: t("common.available", "available"),
|
||||
color: "var(--usage-weekly-active)",
|
||||
},
|
||||
{
|
||||
name: t("config.apiKeys.purchasedCredits", "Purchased credits"),
|
||||
numerator: Math.max(0, boughtUsed),
|
||||
denominator: Math.max(0, apiUsage.totalBoughtCredits),
|
||||
numeratorLabel: t("common.used", "used"),
|
||||
denominatorLabel: t("common.available", "available"),
|
||||
color: "var(--usage-bought-active)",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<Paper
|
||||
radius="md"
|
||||
p={18}
|
||||
style={{
|
||||
background: "var(--api-keys-card-bg)",
|
||||
border: "1px solid var(--api-keys-card-border)",
|
||||
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
gap={12}
|
||||
style={{
|
||||
fontFamily:
|
||||
'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>
|
||||
{t("config.apiKeys.creditsRemaining", "Credits Remaining")}:{" "}
|
||||
{loading ? (
|
||||
<SkeletonLoader type="block" width={40} height={14} />
|
||||
) : (
|
||||
totalRemaining
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<StackedBarChart
|
||||
fractions={fractions}
|
||||
width={640}
|
||||
height={22}
|
||||
showLegend={true}
|
||||
tooltipPosition="top"
|
||||
loading={Boolean(loading)}
|
||||
animate={!loading}
|
||||
animationDurationMs={900}
|
||||
ariaLabel={t("config.apiKeys.chartAriaLabel", {
|
||||
includedUsed: Math.max(0, weeklyUsed),
|
||||
includedTotal: Math.max(0, apiUsage.weeklyCreditsAllocated),
|
||||
purchasedUsed: Math.max(0, boughtUsed),
|
||||
purchasedTotal: Math.max(0, apiUsage.totalBoughtCredits),
|
||||
defaultValue:
|
||||
"Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}",
|
||||
})}
|
||||
/>
|
||||
|
||||
<Divider my={4} />
|
||||
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap="lg">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("config.apiKeys.nextReset", "Next Reset")}:{" "}
|
||||
{loading ? (
|
||||
<SkeletonLoader type="block" width={120} height={12} />
|
||||
) : (
|
||||
formatDate(apiUsage.weeklyResetDate, false)
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("config.apiKeys.lastApiUse", "Last API Use")}:{" "}
|
||||
{loading ? (
|
||||
<SkeletonLoader type="block" width={160} height={12} />
|
||||
) : (
|
||||
formatDate(apiUsage.lastApiUsage, true)
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{obscured && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
padding: 16,
|
||||
color: "var(--mantine-color-text)",
|
||||
background: "rgba(16, 18, 27, 0.55)",
|
||||
backdropFilter: "blur(6px)",
|
||||
WebkitBackdropFilter: "blur(6px)",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{overlayMessage ||
|
||||
t(
|
||||
"config.apiKeys.overlayMessage",
|
||||
"Generate a key to see credits and available credits",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { ApiCredits } from "@app/types/credits";
|
||||
import { isUserAnonymous } from "@app/auth/supabase";
|
||||
|
||||
export function useCredits() {
|
||||
const { session, loading, user } = useAuth();
|
||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||
// Gutted hook (legacy /api/v1/credits is dead) — these stay at their initial values; only
|
||||
// hasAttempted toggles, so the rest have no setters.
|
||||
const [data] = useState<ApiCredits | null>(null);
|
||||
const [isLoading] = useState<boolean>(false);
|
||||
const [error] = useState<Error | null>(null);
|
||||
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
|
||||
|
||||
// Legacy weekly-credits endpoint (/api/v1/credits) is dead. PAYG replaces this — the
|
||||
// wallet hook (useWallet) carries the equivalent state via /api/v1/payg/wallet. The
|
||||
// hook surface is preserved for the ApiKeys page consumer (it destructures `data`,
|
||||
// `isLoading`, `error`, `refetch`, `hasAttempted`), but `data` always stays null —
|
||||
// the consumer's usage widget will render its "no data" state.
|
||||
const fetchCredits = useCallback(async () => {
|
||||
setHasAttempted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && session && !hasAttempted && !isAnonymous) {
|
||||
setHasAttempted(true);
|
||||
}
|
||||
}, [loading, session, hasAttempted, isAnonymous]);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: fetchCredits,
|
||||
hasAttempted,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default useCredits;
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
|
||||
/**
|
||||
* Hook for credit management and checking in tools.
|
||||
* Provides easy access to credit balance, subscription info, and validation functions.
|
||||
*/
|
||||
export const useCredits = () => {
|
||||
const {
|
||||
creditBalance,
|
||||
subscription,
|
||||
creditSummary,
|
||||
isPro,
|
||||
hasSufficientCredits,
|
||||
updateCredits,
|
||||
refreshCredits,
|
||||
} = useAuth();
|
||||
|
||||
/**
|
||||
* Get user-friendly credit status message
|
||||
*/
|
||||
const getCreditStatusMessage = (): string => {
|
||||
if (creditBalance === 0) {
|
||||
return "No credits remaining";
|
||||
}
|
||||
if (creditBalance === null) {
|
||||
return "Credits loading...";
|
||||
}
|
||||
return `${creditBalance} credits available`;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
creditBalance,
|
||||
subscription,
|
||||
creditSummary,
|
||||
isPro,
|
||||
|
||||
// Actions
|
||||
refreshCredits,
|
||||
updateCredits,
|
||||
|
||||
// Utilities
|
||||
getCreditStatusMessage,
|
||||
hasSufficientCredits,
|
||||
};
|
||||
};
|
||||
@@ -1,24 +1,12 @@
|
||||
import axios from "axios";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { handleHttpError } from "@app/services/httpErrorHandler";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { openPlanSettings } from "@app/utils/appSettings";
|
||||
import {
|
||||
classifyPaygError,
|
||||
handlePaygError,
|
||||
} from "@app/services/paygErrorInterceptor";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
|
||||
// Global credit update callback - will be set by the AuthProvider
|
||||
let globalCreditUpdateCallback: ((credits: number) => void) | null = null;
|
||||
|
||||
// Function to set the global credit update callback
|
||||
export const setGlobalCreditUpdateCallback = (
|
||||
callback: (credits: number) => void,
|
||||
) => {
|
||||
globalCreditUpdateCallback = callback;
|
||||
};
|
||||
|
||||
// Helper: decode base64url JWT payload safely
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
@@ -46,20 +34,6 @@ const apiClient = axios.create({
|
||||
responseType: "json",
|
||||
});
|
||||
|
||||
const LOW_CREDIT_THRESHOLD = 10;
|
||||
function notifyLowCredits(credits: number) {
|
||||
const title = "Credit balance low";
|
||||
const body = `You have ${credits} credits remaining.`;
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title,
|
||||
body,
|
||||
buttonText: "Top up",
|
||||
buttonCallback: () => openPlanSettings(),
|
||||
isPersistentPopup: true,
|
||||
location: "bottom-right",
|
||||
});
|
||||
}
|
||||
// Request interceptor to add JWT token to all requests
|
||||
apiClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
@@ -136,34 +110,9 @@ function refreshSessionOnce(): ReturnType<typeof supabase.auth.refreshSession> {
|
||||
return inFlightRefresh;
|
||||
}
|
||||
|
||||
// Response interceptor for handling token refresh and credit updates
|
||||
// Response interceptor for handling token refresh
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
// Check for X-Credits-Remaining header and update credits automatically
|
||||
const creditsRemaining = response.headers["x-credits-remaining"];
|
||||
if (creditsRemaining && globalCreditUpdateCallback) {
|
||||
const credits = parseInt(creditsRemaining, 10);
|
||||
if (!isNaN(credits) && credits >= 0) {
|
||||
console.debug(
|
||||
"[API Client] Updating credits from response header:",
|
||||
credits,
|
||||
"for URL:",
|
||||
response.config?.url,
|
||||
);
|
||||
globalCreditUpdateCallback(credits);
|
||||
// Show low-credit toast with top-up button when below threshold
|
||||
if (credits < LOW_CREDIT_THRESHOLD) {
|
||||
notifyLowCredits(credits);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"[API Client] Invalid credits value in response header:",
|
||||
creditsRemaining,
|
||||
);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config || {};
|
||||
const status = error.response?.status;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
export interface ApiCredits {
|
||||
weeklyCreditsRemaining: number;
|
||||
weeklyCreditsAllocated: number;
|
||||
boughtCreditsRemaining: number;
|
||||
totalBoughtCredits: number;
|
||||
totalAvailableCredits: number;
|
||||
weeklyResetDate: string;
|
||||
lastApiUsage: string;
|
||||
}
|
||||
|
||||
export interface CreditSummary {
|
||||
currentCredits: number;
|
||||
maxCredits: number;
|
||||
creditsUsed: number;
|
||||
creditsRemaining: number;
|
||||
resetDate: string; // ISO date string
|
||||
weeklyAllowance: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
id?: string;
|
||||
status: "active" | "inactive" | "cancelled" | "expired";
|
||||
tier: "free" | "basic" | "premium" | "enterprise";
|
||||
startDate?: string; // ISO date string
|
||||
endDate?: string; // ISO date string
|
||||
creditsPerWeek?: number;
|
||||
maxCredits?: number;
|
||||
}
|
||||
|
||||
export interface CreditCheckResult {
|
||||
hasSufficientCredits: boolean;
|
||||
currentBalance: number;
|
||||
requiredCredits: number;
|
||||
shortfall?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user