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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion apps/maple-research/frontend/src/billing/billingApi.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, spyOn, test } from "bun:test";
import { createZapriteUpgrade, fetchProducts } from "./billingApi";
import { createZapriteUpgrade, fetchBillingApiKeys, fetchProducts } from "./billingApi";

test("fetchProducts rejects an HTTP error response before parsing it as products", async () => {
const previousBillingApiUrl = process.env.VITE_MAPLE_BILLING_API_URL;
Expand Down Expand Up @@ -72,3 +72,43 @@ test("createZapriteUpgrade rejects a checkout URL from the wrong host", async ()
fetchSpy.mockRestore();
}
});

test("fetchBillingApiKeys reads per-key usage with the billing token", async () => {
const previousBillingApiUrl = process.env.VITE_MAPLE_BILLING_API_URL;
process.env.VITE_MAPLE_BILLING_API_URL = "https://billing.example.test";
const body = {
api_keys: [
{
api_key_id: "0b9d6c3e-5f0a-4d43-9a39-2f7c41d1c0aa",
last_used_at: "2026-10-01T12:00:00Z"
}
]
};
const fetchSpy = spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" }
})
)
.mockResolvedValueOnce(new Response("Invalid JWT token", { status: 401 }));
const consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});

try {
await expect(fetchBillingApiKeys("billing-token")).resolves.toEqual(body);
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://billing.example.test/v1/maple/api-keys");
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer billing-token");

// The billing service mints a new token after this error.
await expect(fetchBillingApiKeys("expired-token")).rejects.toThrow("Unauthorized");
} finally {
if (previousBillingApiUrl === undefined) {
delete process.env.VITE_MAPLE_BILLING_API_URL;
} else {
process.env.VITE_MAPLE_BILLING_API_URL = previousBillingApiUrl;
}
fetchSpy.mockRestore();
consoleErrorSpy.mockRestore();
}
});
35 changes: 35 additions & 0 deletions apps/maple-research/frontend/src/billing/billingApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,41 @@ export async function fetchApiCreditBalance(thirdPartyToken: string): Promise<Ap
return response.json();
}

// API Key Usage
/** Billing's view of one API key, matched to the OpenSecret key list by `public_id`. */
export type BillingApiKey = {
api_key_id: string;
/** Latest request that used credits; null when none is recorded. */
last_used_at: string | null;
};

export type BillingApiKeysResponse = {
/** Keys with no recorded use may be absent. */
api_keys: BillingApiKey[];
};

export async function fetchBillingApiKeys(
thirdPartyToken: string
): Promise<BillingApiKeysResponse> {
const response = await fetch(`${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/api-keys`, {
headers: {
Authorization: `Bearer ${thirdPartyToken}`,
"Content-Type": "application/json"
}
});

if (!response.ok) {
const errorText = await response.text();
console.error("Fetch API key usage error:", errorText);
if (response.status === 401) {
throw new Error("Unauthorized");
}
throw new Error(`Failed to fetch API key usage: ${errorText}`);
}

return response.json();
}

export async function fetchApiCreditSettings(thirdPartyToken: string): Promise<ApiCreditSettings> {
const response = await fetch(
`${import.meta.env.VITE_MAPLE_BILLING_API_URL}/v1/maple/api-credits/settings`,
Expand Down
6 changes: 6 additions & 0 deletions apps/maple-research/frontend/src/billing/billingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ import {
updateTeamName,
fetchApiCreditBalance,
fetchApiCreditSettings,
fetchBillingApiKeys,
purchaseApiCredits,
purchaseApiCreditsZaprite,
updateApiCreditSettings,
ApiCreditBalance,
ApiCreditSettings,
BillingApiKeysResponse,
PurchaseCreditsRequest,
PurchaseCreditsZapriteRequest,
CheckoutResponse,
Expand Down Expand Up @@ -331,6 +333,10 @@ export class BillingService {
return this.executeWithToken((token) => fetchApiCreditBalance(token));
}

async getApiKeys(): Promise<BillingApiKeysResponse> {
return this.executeWithToken((token) => fetchBillingApiKeys(token));
}

async getApiCreditSettings(): Promise<ApiCreditSettings> {
return this.executeWithToken((token) => fetchApiCreditSettings(token));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { afterEach, describe, expect, spyOn, test } from "bun:test";
import {
createMemoryHistory,
createRootRoute,
createRouter,
RouterContextProvider
} from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer";
import * as sdk from "@mapleai/sdk";
import * as billingService from "@/billing/billingService";
import { SettingsNavigationLockContext } from "@/contexts/SettingsNavigationLockContext";
import { ApiKeysSettings } from "./ApiKeysSettings";
import type { ApiKeyLastUsed, ApiKeySummary } from "./useApiKeys";

const USED_AT = "2026-10-01T12:00:00Z";
const API_KEYS: ApiKeySummary[] = [
{
name: "Used key",
created_at: "2026-09-01T12:00:00Z",
public_id: "0b9d6c3e-5f0a-4d43-9a39-2f7c41d1c0aa"
},
{
name: "Unused key",
created_at: "2026-08-01T12:00:00Z",
public_id: "6142db59-fc0c-413d-8792-579fc1457fe2"
}
];

function textContent(node: ReactTestInstance): string {
return node.children
.map((child) => (typeof child === "string" ? child : textContent(child)))
.join("");
}

describe("API key last used", () => {
let renderer: ReactTestRenderer | null = null;
const cleanup: Array<() => void> = [];
afterEach(() => {
if (renderer) act(() => renderer?.unmount());
renderer = null;
cleanup
.splice(0)
.reverse()
.forEach((restore) => restore());
});

async function mount(lastUsed?: ApiKeyLastUsed) {
const authSpy = spyOn(sdk, "useOpenSecret").mockReturnValue({
auth: { user: { user: { id: "account-fixture" } }, loading: false },
listApiKeys: async () => ({ keys: API_KEYS }),
deleteApiKey: async () => {}
} as unknown as ReturnType<typeof sdk.useOpenSecret>);
const billingSpy = spyOn(billingService, "getBillingService").mockReturnValue({
getApiKeys: () => Promise.reject(new Error("billing unavailable"))
} as unknown as billingService.BillingService);
cleanup.push(
() => authSpy.mockRestore(),
() => billingSpy.mockRestore()
);

const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: 0 } }
});
client.setQueryData(["apiKeys"], API_KEYS);
if (lastUsed) client.setQueryData(["apiKeyLastUsed"], lastUsed);
cleanup.push(() => client.clear());

const router = createRouter({
routeTree: createRootRoute(),
history: createMemoryHistory({ initialEntries: ["/"] }),
isServer: false,
origin: "http://localhost"
});
await router.load();

await act(async () => {
renderer = create(
<RouterContextProvider router={router}>
<QueryClientProvider client={client}>
<SettingsNavigationLockContext.Provider
value={{ isNavigationLocked: false, setLock: () => {} }}
>
<ApiKeysSettings />
</SettingsNavigationLockContext.Provider>
</QueryClientProvider>
</RouterContextProvider>
);
});
return textContent(renderer!.root);
}

test("shows each key's latest billed use from billing", async () => {
const content = await mount(new Map([[API_KEYS[0].public_id!, USED_AT]]));
const usedAt = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
}).format(new Date(USED_AT));

expect(content).toContain(`Last used ${usedAt}`);
expect(content).toContain("No use recorded yet");
expect(content).toContain("most recent request that used credits");
});

test("leaves last used out when billing usage is unavailable", async () => {
const content = await mount();

expect(content).toContain("Used key");
expect(content).not.toContain("Last used");
expect(content).not.toContain("No use recorded yet");
});
});
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { useState } from "react";
import { Link, useBlocker } from "@tanstack/react-router";
import { useOpenSecret } from "@mapleai/sdk";
import { AlertCircle, Calendar, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
import { AlertCircle, Calendar, Clock, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useSettingsNavigationLock } from "@/contexts/SettingsNavigationLockContext";
import { SettingsSection } from "../SettingsPage";
import { useApiKeys } from "./useApiKeys";
import { useApiKeyLastUsed, useApiKeys } from "./useApiKeys";

const API_KEY_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", {
month: "short",
Expand All @@ -23,6 +23,8 @@ function formatDate(dateString: string) {
export function ApiKeysSettings() {
const { deleteApiKey } = useOpenSecret();
const { data: apiKeys, isLoading, error, refetch } = useApiKeys();
// Usage comes from billing; without it, the list simply omits last-used details.
const { data: lastUsed } = useApiKeyLastUsed();
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
Expand Down Expand Up @@ -92,6 +94,8 @@ export function ApiKeysSettings() {
<div className="divide-y rounded-lg border border-border/70">
{apiKeys.map((apiKey) => {
const isConfirmingDelete = pendingDelete === apiKey.name;
const lastUsedAt =
lastUsed && apiKey.public_id ? (lastUsed.get(apiKey.public_id) ?? null) : undefined;

return (
<div key={apiKey.name} className="p-3 sm:p-4">
Expand All @@ -102,6 +106,14 @@ export function ApiKeysSettings() {
<Calendar className="h-3 w-3" />
Created {formatDate(apiKey.created_at)}
</p>
{lastUsedAt !== undefined && (
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{lastUsedAt
? `Last used ${formatDate(lastUsedAt)}`
: "No use recorded yet"}
</p>
)}
</div>
{!isConfirmingDelete && (
<Button
Expand Down Expand Up @@ -165,6 +177,12 @@ export function ApiKeysSettings() {
<div className="space-y-1.5 text-xs text-muted-foreground">
<p>API keys allow you to integrate Maple into applications and workflows.</p>
<p>Keep keys secure and never share them publicly. Treat them like passwords.</p>
{lastUsed && !!apiKeys?.length && (
<p>
Last used shows a key&apos;s most recent request that used credits and can take a
moment to update.
</p>
)}
</div>
</div>
</SettingsSection>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { useOpenSecret } from "@mapleai/sdk";
import { getBillingService } from "@/billing/billingService";

export type ApiKeySummary = {
name: string;
created_at: string;
/** Non-secret key ID that billing reports usage by. Absent from older servers. */
public_id?: string;
};

/** Each key's latest request that used credits, by `public_id`. */
export type ApiKeyLastUsed = Map<string, string | null>;

export function useApiKeys() {
const { auth, listApiKeys } = useOpenSecret();

Expand All @@ -20,3 +26,16 @@ export function useApiKeys() {
enabled: !!auth.user && !auth.loading
});
}

export function useApiKeyLastUsed() {
const { auth } = useOpenSecret();

return useQuery<ApiKeyLastUsed>({
queryKey: ["apiKeyLastUsed"],
queryFn: async () => {
const { api_keys } = await getBillingService().getApiKeys();
return new Map(api_keys.map((key) => [key.api_key_id, key.last_used_at]));
},
enabled: !!auth.user && !auth.loading
});
}
36 changes: 36 additions & 0 deletions sdk/rust/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,10 @@ pub struct ConfirmAccountDeletionRequest {
pub struct ApiKey {
pub name: String,
pub created_at: DateTime<Utc>,
/// Non-secret key ID that billing uses to report per-key usage. `None`
/// from servers that predate it.
#[serde(default)]
pub public_id: Option<Uuid>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -512,6 +516,10 @@ pub struct ApiKeyCreateResponse {
pub key: String, // UUID format with dashes, only returned on creation
pub name: String,
pub created_at: DateTime<Utc>,
/// Non-secret key ID that billing uses to report per-key usage. `None`
/// from servers that predate it.
#[serde(default)]
pub public_id: Option<Uuid>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -1441,6 +1449,34 @@ mod tests {
);
}

#[test]
fn api_key_public_id_is_optional_for_older_servers() {
let listed: ApiKeyListResponse = serde_json::from_value(json!({
"keys": [
{ "name": "old", "created_at": "2026-10-11T00:00:00Z" },
{
"name": "new",
"created_at": "2026-10-11T00:00:00Z",
"public_id": "0b9d6c3e-5f0a-4d43-9a39-2f7c41d1c0aa"
}
]
}))
.unwrap();
assert_eq!(listed.keys[0].public_id, None);
assert_eq!(
listed.keys[1].public_id,
Some(Uuid::parse_str("0b9d6c3e-5f0a-4d43-9a39-2f7c41d1c0aa").unwrap())
);

let created: ApiKeyCreateResponse = serde_json::from_value(json!({
"key": "6142db59-fc0c-413d-8792-579fc1457fe2",
"name": "old",
"created_at": "2026-10-11T00:00:00Z"
}))
.unwrap();
assert_eq!(created.public_id, None);
}

#[test]
fn credential_update_response_tolerates_missing_message() {
let response: CredentialUpdateResponse =
Expand Down
Loading
Loading