feat(frontend): add subscription tier selector to admin rate-limits page

Adds tier badge display and dropdown selector to the admin rate-limits
page. Admins can now view and change a user's subscription tier
(FREE/PRO/BUSINESS/ENTERPRISE) with multiplier info. The dropdown calls
POST /api/copilot/admin/rate_limit/tier and re-fetches limits to reflect
the new tier.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zamil Majdy
2026-03-27 16:18:12 +00:00
parent 857a8ef0aa
commit dbfc791357
3 changed files with 123 additions and 9 deletions

View File

@@ -3,18 +3,46 @@
import { useState } from "react";
import { Button } from "@/components/atoms/Button/Button";
import type { UserRateLimitResponse } from "@/app/api/__generated__/models/userRateLimitResponse";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { UsageBar } from "../../components/UsageBar";
const TIERS = ["FREE", "PRO", "BUSINESS", "ENTERPRISE"] as const;
type Tier = (typeof TIERS)[number];
const TIER_MULTIPLIERS: Record<Tier, string> = {
FREE: "1x base limits",
PRO: "10x base limits",
BUSINESS: "30x base limits",
ENTERPRISE: "60x base limits",
};
const TIER_COLORS: Record<Tier, string> = {
FREE: "bg-gray-100 text-gray-700",
PRO: "bg-blue-100 text-blue-700",
BUSINESS: "bg-purple-100 text-purple-700",
ENTERPRISE: "bg-amber-100 text-amber-700",
};
interface Props {
data: UserRateLimitResponse;
onReset: (resetWeekly: boolean) => Promise<void>;
onTierChange?: (newTier: string) => Promise<void>;
/** Override the outer container classes (default: bordered card). */
className?: string;
}
export function RateLimitDisplay({ data, onReset, className }: Props) {
export function RateLimitDisplay({
data,
onReset,
onTierChange,
className,
}: Props) {
const [isResetting, setIsResetting] = useState(false);
const [resetWeekly, setResetWeekly] = useState(false);
const [isChangingTier, setIsChangingTier] = useState(false);
const { toast } = useToast();
const currentTier = ((data as Record<string, unknown>).tier as Tier) ?? "PRO";
async function handleReset() {
const msg = resetWeekly
@@ -30,19 +58,76 @@ export function RateLimitDisplay({ data, onReset, className }: Props) {
}
}
async function handleTierChange(newTier: string) {
if (newTier === currentTier || !onTierChange) return;
if (
!window.confirm(
`Change tier from ${currentTier} to ${newTier}? This will change the user's rate limits.`,
)
)
return;
setIsChangingTier(true);
try {
await onTierChange(newTier);
toast({
title: "Tier updated",
description: `Changed to ${newTier} (${TIER_MULTIPLIERS[newTier as Tier]}).`,
});
} catch {
toast({
title: "Error",
description: "Failed to update tier.",
variant: "destructive",
});
} finally {
setIsChangingTier(false);
}
}
const nothingToReset = resetWeekly
? data.daily_tokens_used === 0 && data.weekly_tokens_used === 0
: data.daily_tokens_used === 0;
return (
<div className={className ?? "rounded-md border bg-white p-6"}>
<h2 className="mb-1 text-lg font-semibold">
Rate Limits for {data.user_email ?? data.user_id}
</h2>
{data.user_email && (
<p className="mb-4 text-xs text-gray-500">User ID: {data.user_id}</p>
)}
{!data.user_email && <div className="mb-4" />}
<div className="mb-4 flex items-start justify-between">
<div>
<h2 className="mb-1 text-lg font-semibold">
Rate Limits for {data.user_email ?? data.user_id}
</h2>
{data.user_email && (
<p className="text-xs text-gray-500">User ID: {data.user_id}</p>
)}
</div>
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${TIER_COLORS[currentTier] ?? "bg-gray-100 text-gray-700"}`}
>
{currentTier}
</span>
</div>
<div className="mb-4 flex items-center gap-3">
<label className="text-sm font-medium text-gray-700">
Subscription Tier
</label>
<select
aria-label="Subscription tier"
value={currentTier}
onChange={(e) => handleTierChange(e.target.value)}
className="rounded-md border bg-white px-3 py-1.5 text-sm"
disabled={isChangingTier || !onTierChange}
>
{TIERS.map((tier) => (
<option key={tier} value={tier}>
{tier} {TIER_MULTIPLIERS[tier]}
</option>
))}
</select>
{isChangingTier && (
<span className="text-xs text-gray-500">Updating...</span>
)}
</div>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">

View File

@@ -14,6 +14,7 @@ export function RateLimitManager() {
handleSearch,
handleSelectUser,
handleReset,
handleTierChange,
} = useRateLimitManager();
return (
@@ -74,7 +75,11 @@ export function RateLimitManager() {
)}
{rateLimitData && (
<RateLimitDisplay data={rateLimitData} onReset={handleReset} />
<RateLimitDisplay
data={rateLimitData}
onReset={handleReset}
onTierChange={handleTierChange}
/>
)}
</div>
);

View File

@@ -199,6 +199,29 @@ export function useRateLimitManager() {
}
}
async function handleTierChange(newTier: string) {
if (!rateLimitData) return;
const response = await fetch(
`/api/copilot/admin/rate_limit/tier`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: rateLimitData.user_id,
tier: newTier,
}),
},
);
if (!response.ok) {
throw new Error("Failed to update tier");
}
// Re-fetch rate limit data to reflect new tier limits
await fetchRateLimit(rateLimitData.user_id);
}
return {
isSearching,
isLoadingRateLimit,
@@ -208,5 +231,6 @@ export function useRateLimitManager() {
handleSearch,
handleSelectUser,
handleReset,
handleTierChange,
};
}