ai_api / src /app /(dashboard) /dashboard /api-manager /ApiManagerPageClient.tsx
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
126 kB
"use client";
import { useState, useEffect, useMemo, useCallback, memo, useRef, useId } from "react";
import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useLocale, useTranslations } from "next-intl";
import { getProviderDisplayName } from "@/lib/display/names";
import { compareTr, matchesSearch } from "@/shared/utils/turkishText";
import { ENDPOINT_CATEGORIES } from "@/shared/constants/endpointCategories";
import ApiKeyFilterBar from "./components/ApiKeyFilterBar";
import {
isKeyActive,
isExpired,
isRestricted as isKeyRestricted,
classifyKeyStatus,
computeApiKeyCounts,
formatUsdCost,
toLocalDateTimeInputValue,
toggleKeyVisibility,
} from "./apiManagerPageUtils";
import type { KeyStatus, KeyType } from "./apiManagerPageUtils";
import { readActiveOnlyPreference, writeActiveOnlyPreference } from "./apiManagerPageStorage";
import { buildApiKeyCreateScopes, mergeApiKeyPermissionScopes } from "./apiManagerScopes";
import { SELF_ACCOUNT_QUOTA_SCOPE, SELF_USAGE_SCOPE } from "@/shared/constants/selfServiceScopes";
import { UsageLimitSettings } from "./components/UsageLimitSettings";
// Constants for validation
const MAX_KEY_NAME_LENGTH = 200;
const MAX_SELECTED_MODELS = 500;
const CLAUDE_CODE_DEFAULT_MODEL_ID = "cc/*";
const CLAUDE_CODE_DEFAULT_MODEL_NAME = "Claude Code default";
const CLAUDE_CODE_DEFAULT_FAMILIES = [
{ id: "other", label: "other" },
{ id: "fable", label: "fable" },
{ id: "opus", label: "opus" },
{ id: "sonnet", label: "sonnet" },
{ id: "haiku", label: "haiku" },
] as const;
type ClaudeCodeFamilyId = (typeof CLAUDE_CODE_DEFAULT_FAMILIES)[number]["id"];
type ClaudeCodeBlockableFamilyId = Exclude<ClaudeCodeFamilyId, "other">;
const CLAUDE_CODE_FAMILY_BLOCK_PATTERNS: Record<ClaudeCodeBlockableFamilyId, string[]> = {
fable: ["claude-fable*", "fable"],
opus: ["claude-opus*", "opus"],
sonnet: ["claude-sonnet*", "sonnet"],
haiku: ["claude-haiku*", "haiku"],
};
const CLAUDE_CODE_BLOCK_PATTERN_SET = new Set(
Object.values(CLAUDE_CODE_FAMILY_BLOCK_PATTERNS).flat()
);
// Debounce hook for search optimization
function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Sanitize user input to prevent XSS
function sanitizeInput(input: string): string {
return input
.replace(/[<>]/g, "")
.replace(/"/g, "")
.replace(/'/g, "")
.trim()
.slice(0, MAX_KEY_NAME_LENGTH);
}
// Validate key name
function validateKeyName(
name: string,
t: (key: string, values?: Record<string, unknown>) => string
): { valid: boolean; error?: string } {
if (!name || !name.trim()) {
return { valid: false, error: t("keyNameRequired") };
}
if (name.length > MAX_KEY_NAME_LENGTH) {
return { valid: false, error: t("keyNameTooLong", { max: MAX_KEY_NAME_LENGTH }) };
}
// Allow Unicode letters (accented chars), numbers, spaces, hyphens, underscores
if (!/^[\p{L}\p{N}_\-\s]+$/u.test(name)) {
return {
valid: false,
error: t("keyNameInvalid"),
};
}
return { valid: true };
}
interface AccessSchedule {
enabled: boolean;
from: string;
until: string;
days: number[];
tz: string;
}
type StreamDefaultMode = "legacy" | "json";
interface ApiKey {
id: string;
name: string;
key: string;
allowedModels: string[] | null;
blockedModels?: string[] | null;
allowedCombos: string[] | null;
allowedConnections: string[] | null;
noLog?: boolean;
autoResolve?: boolean;
isActive?: boolean;
throttleDelayMs?: number | null;
isBanned?: boolean;
expiresAt?: string | null;
maxSessions?: number;
accessSchedule?: AccessSchedule | null;
rateLimits?: Array<{ limit: number; window: number }> | null;
scopes?: string[];
allowedEndpoints?: string[];
streamDefaultMode?: StreamDefaultMode;
disableNonPublicModels?: boolean;
allowUsageCommand?: boolean;
usageLimitEnabled?: boolean;
dailyUsageLimitUsd?: number | null;
weeklyUsageLimitUsd?: number | null;
allowedQuotas?: string[] | null;
createdAt: string;
}
interface ProviderConnection {
id: string;
name: string;
provider: string;
isActive: boolean;
}
interface KeyUsageStats {
totalRequests: number;
totalCost: number;
lastUsed: string | null;
}
interface Model {
id: string;
owned_by: string;
name?: string;
}
interface ComboOption {
id?: string;
name: string;
models?: unknown[];
}
/** Tuple type for models grouped by provider: [providerName, models[]] */
type ProviderGroup = [provider: string, models: Model[]];
function isClaudeCodeModel(model: Model): boolean {
return (
model.owned_by === "claude" || model.id.startsWith("cc/") || model.id.startsWith("claude/")
);
}
function withClaudeCodeDefaultModel(models: Model[]): Model[] {
if (!models.some(isClaudeCodeModel)) return models;
if (models.some((model) => model.id === CLAUDE_CODE_DEFAULT_MODEL_ID)) return models;
return [
{
id: CLAUDE_CODE_DEFAULT_MODEL_ID,
name: CLAUDE_CODE_DEFAULT_MODEL_NAME,
owned_by: "claude",
},
...models,
];
}
function getBlockedClaudeCodeFamilies(blockedModels: string[]): ClaudeCodeBlockableFamilyId[] {
return (Object.keys(CLAUDE_CODE_FAMILY_BLOCK_PATTERNS) as ClaudeCodeBlockableFamilyId[]).filter(
(familyId) =>
CLAUDE_CODE_FAMILY_BLOCK_PATTERNS[familyId].some((pattern) => blockedModels.includes(pattern))
);
}
function isClaudeCodeFamilyModel(modelId: string, familyId: ClaudeCodeBlockableFamilyId): boolean {
const normalized = modelId.toLowerCase();
return (
normalized === familyId ||
normalized.includes(`/${familyId}`) ||
normalized.includes(`-${familyId}`)
);
}
export default function ApiManagerPageClient() {
const t = useTranslations("apiManager");
const tc = useTranslations("common");
const locale = useLocale();
const newKeyNameInputId = useId();
const createKeyFormRef = useRef<HTMLDivElement | null>(null);
const [keys, setKeys] = useState<ApiKey[]>([]);
const [allModels, setAllModels] = useState<Model[]>([]);
const [allCombos, setAllCombos] = useState<ComboOption[]>([]);
const [allConnections, setAllConnections] = useState<ProviderConnection[]>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [newKeyManageEnabled, setNewKeyManageEnabled] = useState(false);
const [newKeySelfUsageEnabled, setNewKeySelfUsageEnabled] = useState(true);
const [newKeyAccountQuotaEnabled, setNewKeyAccountQuotaEnabled] = useState(false);
const [newKeyAllowUsageCommand, setNewKeyAllowUsageCommand] = useState(false);
const [createdKey, setCreatedKey] = useState<string | null>(null);
const [editingKey, setEditingKey] = useState<ApiKey | null>(null);
const [showPermissionsModal, setShowPermissionsModal] = useState(false);
const [searchModel, setSearchModel] = useState("");
const [pageError, setPageError] = useState<string | null>(null);
const [nameError, setNameError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [usageStats, setUsageStats] = useState<Record<string, KeyUsageStats>>({});
const [sessionCounts, setSessionCounts] = useState<Record<string, number>>({});
const [allowKeyReveal, setAllowKeyReveal] = useState(false);
// Per-row API key visibility toggle (eye / eye-off). Keys default to masked.
// Map id -> fully revealed key string fetched on demand from /api/keys/{id}/reveal.
const [revealedKeys, setRevealedKeys] = useState<Map<string, string>>(new Map());
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set());
const createKeyNameFieldRef = useRef<HTMLDivElement | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [activeOnly, setActiveOnly] = useState(false);
const [statusFilter, setStatusFilter] = useState<KeyStatus | null>(null);
const [typeFilter, setTypeFilter] = useState<KeyType | null>(null);
const [quotaPoolGroup, setQuotaPoolGroup] = useState<Record<string, string>>({});
const { copied, copy } = useCopyToClipboard();
const scrollCreateKeyFormToTop = useCallback(() => {
const scrollContainer = createKeyFormRef.current?.parentElement;
if (scrollContainer instanceof HTMLElement) {
scrollContainer.scrollTop = 0;
}
const input = document.getElementById(newKeyNameInputId);
input?.scrollIntoView({ block: "nearest", inline: "nearest" });
input?.focus({ preventScroll: true });
}, [newKeyNameInputId]);
useEffect(() => {
fetchData();
fetchModels();
fetchCombos();
fetchConnections();
}, []);
useEffect(() => {
if (!showAddModal || !nameError) return;
requestAnimationFrame(() => {
createKeyNameFieldRef.current?.scrollIntoView({ block: "center", behavior: "instant" });
});
}, [nameError, showAddModal]);
useEffect(() => {
setActiveOnly(readActiveOnlyPreference());
}, []);
useEffect(() => {
writeActiveOnlyPreference(activeOnly);
}, [activeOnly]);
useEffect(() => {
let cancelled = false;
const loadQuotaGroups = async () => {
try {
const [poolsRes, groupsRes] = await Promise.all([
fetch("/api/quota/pools"),
fetch("/api/quota/groups"),
]);
if (!poolsRes.ok || !groupsRes.ok) return;
const poolsData = await poolsRes.json();
const groupsData = await groupsRes.json();
const pools: Array<{ id: string; groupId: string }> = Array.isArray(poolsData.pools)
? poolsData.pools
: [];
const groups: Array<{ id: string; name: string }> = Array.isArray(groupsData.groups)
? groupsData.groups
: [];
const groupNameById: Record<string, string> = {};
for (const g of groups) {
groupNameById[g.id] = g.name;
}
const map: Record<string, string> = {};
for (const p of pools) {
if (groupNameById[p.groupId]) {
map[p.id] = groupNameById[p.groupId];
}
}
if (!cancelled) setQuotaPoolGroup(map);
} catch {
// fail open — quota group chips simply won't render
}
};
loadQuotaGroups();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!showAddModal || !nameError) return;
const timeout = window.setTimeout(() => {
scrollCreateKeyFormToTop();
}, 0);
return () => window.clearTimeout(timeout);
}, [showAddModal, nameError, scrollCreateKeyFormToTop]);
const fetchModels = async () => {
try {
const res = await fetch("/v1/models");
if (res.ok) {
const data = await res.json();
setAllModels(data.data || []);
}
} catch (error) {
console.log("Error fetching models:", error);
}
};
const fetchCombos = async () => {
try {
const res = await fetch("/api/combos");
if (res.ok) {
const data = await res.json();
const combos = Array.isArray(data.combos) ? data.combos : [];
setAllCombos(
combos.filter((combo: any) => typeof combo?.name === "string" && combo.name.trim())
);
}
} catch (error) {
console.log("Error fetching combos:", error);
}
};
const fetchConnections = async () => {
try {
const res = await fetch("/api/providers");
if (res.ok) {
const data = await res.json();
setAllConnections(data.connections || []);
}
} catch (error) {
console.log("Error fetching connections:", error);
}
};
const fetchData = async () => {
try {
const res = await fetch("/api/keys");
if (res.ok) {
const data = await res.json();
setKeys(data.keys || []);
setAllowKeyReveal(data.allowKeyReveal === true);
// Fetch usage stats after keys are loaded
fetchUsageStats(data.keys || []);
fetchSessionCounts(data.keys || []);
}
} catch (error) {
console.log("Error fetching keys:", error);
} finally {
setLoading(false);
}
};
const fetchUsageStats = async (apiKeys: ApiKey[]) => {
if (apiKeys.length === 0) return;
try {
// Fetch analytics (accurate aggregated counts) and recent call-logs
// (for lastUsed timestamps) in parallel.
// The previous approach matched call-logs by key.id === log.apiKeyId,
// but these use different ID schemes and never matched, yielding 0.
const [analyticsRes, logsRes] = await Promise.all([
fetch("/api/usage/analytics?range=all"),
fetch("/api/usage/call-logs?limit=1000"),
]);
const analytics = analyticsRes.ok ? await analyticsRes.json() : null;
const byApiKey: any[] = analytics?.byApiKey || [];
const logs = logsRes.ok ? await logsRes.json() : [];
const stats: Record<string, KeyUsageStats> = {};
for (const key of apiKeys) {
// Match analytics entry by unique API Key ID (isolates usage to this specific key instance)
const matches = byApiKey.filter((entry: any) => entry.apiKeyId === key.id);
const totalRequests = matches.reduce(
(sum: number, entry: any) => sum + (Number(entry.requests) || 0),
0
);
const totalCost = matches.reduce((sum: number, entry: any) => {
const cost = Number(entry.cost);
return sum + (Number.isFinite(cost) ? cost : 0);
}, 0);
// Match call logs by unique ID as well for the lastUsed timestamp
// Prefer an exact apiKeyId match; fall back to name match for legacy
// logs that predate per-key IDs (apiKeyId absent).
const lastUsed =
(logs || []).find(
(log: any) => log.apiKeyId === key.id || (!log.apiKeyId && log.apiKeyName === key.name)
)?.timestamp || null;
stats[key.id] = {
totalRequests,
totalCost,
lastUsed,
};
}
setUsageStats(stats);
} catch (e) {
console.log("Error fetching usage stats:", e);
}
};
const fetchSessionCounts = async (apiKeys: ApiKey[]) => {
if (apiKeys.length === 0) {
setSessionCounts({});
return;
}
try {
const res = await fetch("/api/sessions");
if (!res.ok) return;
const data = await res.json();
const byApiKeyRaw =
data && typeof data.byApiKey === "object" && !Array.isArray(data.byApiKey)
? data.byApiKey
: {};
const normalized: Record<string, number> = {};
for (const key of apiKeys) {
const value = byApiKeyRaw[key.id];
normalized[key.id] =
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
}
setSessionCounts(normalized);
} catch (error) {
console.log("Error fetching session counts:", error);
}
};
const clearPageError = useCallback(() => setPageError(null), []);
const keyCounts = useMemo(() => computeApiKeyCounts(keys), [keys]);
const filteredKeys = useMemo(() => {
let list = keys;
// 1. activeOnly toggle (shortcut for the most common case)
if (activeOnly) {
list = list.filter(isKeyActive);
}
// 2. status chip filter
if (statusFilter === "active") list = list.filter(isKeyActive);
else if (statusFilter === "disabled") list = list.filter((k) => k.isActive === false);
else if (statusFilter === "banned") list = list.filter((k) => k.isBanned === true);
else if (statusFilter === "expired") list = list.filter(isExpired);
// 3. type chip filter
if (typeFilter === "manage") list = list.filter((k) => k.scopes?.includes("manage"));
else if (typeFilter === "restricted") list = list.filter(isKeyRestricted);
else if (typeFilter === "standard")
list = list.filter((k) => !k.scopes?.includes("manage") && !isKeyRestricted(k));
// 4. search query (case-insensitive substring on name and key)
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
list = list.filter(
(k) => k.name.toLowerCase().includes(q) || k.key.toLowerCase().includes(q)
);
}
return list;
}, [keys, activeOnly, statusFilter, typeFilter, searchQuery]);
const isFiltered =
activeOnly || statusFilter !== null || typeFilter !== null || searchQuery.trim() !== "";
const isQuotaKey = (k: ApiKey) => Array.isArray(k.allowedQuotas) && k.allowedQuotas.length > 0;
const quotaKeys = filteredKeys.filter(isQuotaKey);
const normalKeys = filteredKeys.filter((k) => !isQuotaKey(k));
const permissionModels = useMemo(() => withClaudeCodeDefaultModel(allModels), [allModels]);
const quotaGroupsForKey = (k: ApiKey): string[] => {
if (!Array.isArray(k.allowedQuotas)) return [];
const seen = new Set<string>();
const result: string[] = [];
for (const poolId of k.allowedQuotas) {
const groupName = quotaPoolGroup[poolId];
if (groupName && !seen.has(groupName)) {
seen.add(groupName);
result.push(groupName);
}
}
return result;
};
const handleClearFilters = () => {
setSearchQuery("");
setActiveOnly(false);
setStatusFilter(null);
setTypeFilter(null);
};
const handleCreateKey = async () => {
// Validate raw input first, then sanitize
const validation = validateKeyName(newKeyName, t);
if (!validation.valid) {
scrollCreateKeyFormToTop();
setNameError(validation.error || t("invalidKeyName"));
return;
}
const sanitizedName = sanitizeInput(newKeyName);
setIsSubmitting(true);
setNameError(null);
setCreateError(null);
try {
const res = await fetch("/api/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: sanitizedName,
scopes: buildApiKeyCreateScopes({
manageEnabled: newKeyManageEnabled,
selfUsageEnabled: newKeySelfUsageEnabled,
selfAccountQuotaEnabled: newKeyAccountQuotaEnabled,
}),
allowUsageCommand: newKeyAllowUsageCommand,
}),
});
const data = await res.json();
if (res.ok) {
setCreatedKey(data.key);
await fetchData();
setNewKeyName("");
setNewKeyManageEnabled(false);
setNewKeySelfUsageEnabled(true);
setNewKeyAccountQuotaEnabled(false);
setNewKeyAllowUsageCommand(false);
setShowAddModal(false);
} else {
setCreateError(data.error || t("failedCreateKey"));
}
} catch (error) {
console.error("Error creating key:", error);
setCreateError(t("failedCreateKeyRetry"));
} finally {
setIsSubmitting(false);
}
};
const handleDeleteKey = async (id: string) => {
if (!id || typeof id !== "string" || !/^[a-zA-Z0-9_-]+$/.test(id)) {
setPageError(t("invalidKeyId"));
return;
}
if (!confirm(t("deleteConfirm"))) return;
setIsSubmitting(true);
clearPageError();
try {
const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, { method: "DELETE" });
if (res.ok) {
setKeys((prev) => prev.filter((k) => k.id !== id));
// Clean up any cached reveal/visibility state for this key.
setRevealedKeys((prev) => {
if (!prev.has(id)) return prev;
const next = new Map(prev);
next.delete(id);
return next;
});
setVisibleKeys((prev) => (prev.has(id) ? toggleKeyVisibility(prev, id) : prev));
} else {
const data = await res.json();
setPageError(data.error || t("failedDeleteKey"));
}
} catch (error) {
console.error("Error deleting key:", error);
setPageError(t("failedDeleteKeyRetry"));
} finally {
setIsSubmitting(false);
}
};
const handleRegenerateKey = async (id: string) => {
if (!id) return;
if (!confirm(t("regenerateConfirm"))) return;
setIsSubmitting(true);
clearPageError();
try {
const res = await fetch(`/api/keys/${encodeURIComponent(id)}/regenerate`, { method: "POST" });
const data = await res.json();
if (res.ok) {
setCreatedKey(data.key);
await fetchData();
} else {
setPageError(data.error || t("failedRegenerateKey"));
}
} catch (error) {
console.error("Error regenerating key:", error);
setPageError(t("failedRegenerateKeyRetry"));
} finally {
setIsSubmitting(false);
}
};
const handleOpenPermissions = (key: ApiKey) => {
if (!key || !key.id) return;
setEditingKey(key);
setShowPermissionsModal(true);
};
const handleCopyExistingKey = async (keyId: string) => {
if (!keyId) return;
try {
const res = await fetch(`/api/keys/${encodeURIComponent(keyId)}/reveal`);
if (!res.ok) {
console.log("Error revealing key:", await res.text());
return;
}
const data = await res.json();
if (typeof data?.key === "string") {
// Cache the revealed value so a subsequent show-toggle does not refetch.
setRevealedKeys((prev) => {
const next = new Map(prev);
next.set(keyId, data.key);
return next;
});
await copy(data.key, `existing_key_${keyId}`);
}
} catch (error) {
console.log("Error copying existing key:", error);
}
};
/**
* Toggle the visibility of one key inline (eye / eye-off button).
* Lazy-fetches the full key from /api/keys/{id}/reveal on the FIRST show,
* then caches it in `revealedKeys` so re-toggling is instant. Hiding only
* flips the visibility set — the cached reveal stays so a re-show is free.
*/
const handleToggleKeyVisibility = async (keyId: string) => {
if (!keyId) return;
const isCurrentlyVisible = visibleKeys.has(keyId);
if (!isCurrentlyVisible && !revealedKeys.has(keyId)) {
try {
const res = await fetch(`/api/keys/${encodeURIComponent(keyId)}/reveal`);
if (!res.ok) {
console.log("Error revealing key:", await res.text());
return;
}
const data = await res.json();
if (typeof data?.key !== "string") return;
setRevealedKeys((prev) => {
const next = new Map(prev);
next.set(keyId, data.key);
return next;
});
} catch (error) {
console.log("Error revealing key:", error);
return;
}
}
setVisibleKeys((prev) => toggleKeyVisibility(prev, keyId));
};
const handleUpdatePermissions = async (
name: string,
allowedModels: string[],
allowedCombos: string[],
noLog: boolean,
allowedConnections: string[],
autoResolve: boolean,
isActive: boolean,
throttleDelayMs: number,
isBanned: boolean,
expiresAt: string | null,
maxSessions: number,
accessSchedule: AccessSchedule | null,
rateLimits: Array<{ limit: number; window: number }> | null,
scopes: string[],
allowedEndpoints: string[],
streamDefaultMode: StreamDefaultMode,
disableNonPublicModels: boolean,
allowUsageCommand: boolean,
usageLimitEnabled: boolean,
dailyUsageLimitUsd: number | null,
weeklyUsageLimitUsd: number | null,
blockedModels: string[]
) => {
if (!editingKey || !editingKey.id) return;
const sanitizedName = sanitizeInput(name);
// Validate models array
if (!Array.isArray(allowedModels)) {
return;
}
// Limit number of selected models to prevent abuse
if (allowedModels.length > MAX_SELECTED_MODELS) {
return;
}
// Validate each model ID
const validModels = allowedModels.filter(
(id) => typeof id === "string" && id.length > 0 && id.length < 200
);
const validBlockedModels = blockedModels.filter(
(id) => typeof id === "string" && id.length > 0 && id.length < 200
);
const validCombos = allowedCombos.filter(
(name) => typeof name === "string" && name.trim().length > 0 && name.length < 200
);
// Validate connections (must be UUIDs)
const validConnections = allowedConnections.filter(
(id) => typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id)
);
const normalizedMaxSessions =
typeof maxSessions === "number" && Number.isFinite(maxSessions)
? Math.max(0, Math.floor(maxSessions))
: 0;
const normalizedThrottleDelayMs =
typeof throttleDelayMs === "number" && Number.isFinite(throttleDelayMs)
? Math.max(0, Math.min(300000, Math.floor(throttleDelayMs)))
: 0;
setIsSubmitting(true);
clearPageError();
try {
const res = await fetch(`/api/keys/${encodeURIComponent(editingKey.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: sanitizedName,
allowedModels: validModels,
blockedModels: validBlockedModels,
allowedCombos: validCombos,
allowedConnections: validConnections,
noLog,
autoResolve,
isActive,
throttleDelayMs: normalizedThrottleDelayMs,
isBanned,
expiresAt,
maxSessions: normalizedMaxSessions,
accessSchedule,
rateLimits,
scopes,
allowedEndpoints,
streamDefaultMode,
disableNonPublicModels,
allowUsageCommand,
usageLimitEnabled,
dailyUsageLimitUsd,
weeklyUsageLimitUsd,
}),
});
if (res.ok) {
await fetchData();
setShowPermissionsModal(false);
setEditingKey(null);
} else {
const data = await res.json();
setPageError(data.error || t("failedUpdatePermissions"));
}
} catch (error) {
console.error("Error updating permissions:", error);
setPageError(t("failedUpdatePermissionsRetry"));
} finally {
setIsSubmitting(false);
}
};
// Debounced search for performance
const debouncedSearchModel = useDebouncedValue(searchModel, 150);
// Group models by provider (issue #2021 — use centralized display helper so
// custom OpenAI-/Anthropic-compatible providers don't leak raw synthetic
// ids like "openai-compatible-chat-<uuid>" into the grouping label)
const modelsByProvider = useMemo((): ProviderGroup[] => {
const grouped: Record<string, Model[]> = {};
for (const model of permissionModels) {
const provider =
getProviderDisplayName(model.owned_by) || model.owned_by || t("unknownProvider");
if (!grouped[provider]) grouped[provider] = [];
grouped[provider].push(model);
}
return Object.entries(grouped).sort((a, b) => compareTr(a[0], b[0]));
}, [permissionModels, t]);
// Filter models based on debounced search
const filteredModelsByProvider = useMemo((): ProviderGroup[] => {
if (!debouncedSearchModel.trim()) return modelsByProvider;
return modelsByProvider
.map(
([provider, models]): ProviderGroup => [
provider,
models.filter(
(m) =>
matchesSearch(m.id, debouncedSearchModel) ||
matchesSearch(m.name || "", debouncedSearchModel) ||
matchesSearch(provider, debouncedSearchModel)
),
]
)
.filter(([, models]) => models.length > 0);
}, [modelsByProvider, debouncedSearchModel]);
if (loading) {
return (
<div className="flex flex-col gap-8">
<CardSkeleton />
<CardSkeleton />
</div>
);
}
return (
<div className="flex flex-col gap-8">
{/* Error Banner */}
{pageError && (
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-lg">
<span className="material-symbols-outlined text-red-500">error</span>
<p className="text-sm text-red-700 dark:text-red-300 flex-1">{pageError}</p>
<button
onClick={clearPageError}
className="text-red-500 hover:text-red-700 transition-colors"
>
<span className="material-symbols-outlined">close</span>
</button>
</div>
)}
{/* Filter Bar — shown when there are keys */}
{keys.length > 0 && (
<ApiKeyFilterBar
counts={keyCounts}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
activeOnly={activeOnly}
onActiveOnlyChange={setActiveOnly}
statusFilter={statusFilter}
onStatusChange={setStatusFilter}
typeFilter={typeFilter}
onTypeChange={setTypeFilter}
/>
)}
{/* Keys List Card */}
<Card>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-amber-500/10 shrink-0">
<span className="material-symbols-outlined text-xl text-amber-500">vpn_key</span>
</div>
<div>
<h3 className="font-semibold">
{t("registeredKeys")}
{isFiltered && (
<span className="ml-1.5 text-sm font-normal text-text-muted">
({t("shownOf", { shown: filteredKeys.length, total: keys.length })})
</span>
)}
{!isFiltered && (
<span className="ml-1.5 text-sm font-normal text-text-muted">
({keys.length})
</span>
)}
</h3>
<p className="text-xs text-text-muted">
{keys.length === 1
? t("keyRegistered", { count: keys.length })
: t("keysRegistered", { count: keys.length })}
</p>
</div>
</div>
<Button
icon="add"
onClick={() => {
setNameError(null);
setCreateError(null);
clearPageError();
setShowAddModal(true);
}}
>
{t("createKey")}
</Button>
</div>
<p className="text-sm text-text-muted mb-4">{t("keysSecurityNote")}</p>
{keys.length === 0 ? (
<div className="text-center py-12 border border-dashed border-border rounded-lg">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
<span className="material-symbols-outlined text-[32px]">vpn_key</span>
</div>
<p className="text-text-main font-medium mb-2">{t("noKeys")}</p>
<p className="text-sm text-text-muted mb-4">{t("noKeysDesc")}</p>
<Button
icon="add"
onClick={() => {
setNameError(null);
setCreateError(null);
setShowAddModal(true);
}}
>
{t("createFirstKey")}
</Button>
</div>
) : filteredKeys.length === 0 ? (
<div className="text-center py-12 border border-dashed border-border rounded-lg">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
<span className="material-symbols-outlined text-[32px]">search_off</span>
</div>
<p className="text-text-main font-medium mb-2">{t("emptyFilterTitle")}</p>
<Button onClick={handleClearFilters}>{t("emptyFilterClear")}</Button>
</div>
) : (
(() => {
const renderKeyRow = (key: ApiKey) => {
const stats = usageStats[key.id];
const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0;
const hasComboRestrictions =
Array.isArray(key.allowedCombos) && key.allowedCombos.length > 0;
const hasConnectionRestrictions =
Array.isArray(key.allowedConnections) && key.allowedConnections.length > 0;
const noLogEnabled = key.noLog === true;
const keyIsActive = key.isActive !== false; // default true
const throttleDelayMs =
typeof key.throttleDelayMs === "number" && key.throttleDelayMs > 0
? key.throttleDelayMs
: 0;
const hasThrottle = throttleDelayMs > 0;
const hasManageScope = Array.isArray(key.scopes) && key.scopes.includes("manage");
const hasJsonStreamDefault = key.streamDefaultMode === "json";
const hasLocalUsageCommand = key.allowUsageCommand === true;
const maxSessions = typeof key.maxSessions === "number" ? key.maxSessions : 0;
const hasSessionLimit = maxSessions > 0;
const activeSessions = sessionCounts[key.id] || 0;
const hasSchedule = key.accessSchedule?.enabled === true;
const keyIsQuota = isQuotaKey(key);
const groups = quotaGroupsForKey(key);
const visibleGroups = groups.slice(0, 3);
const extraGroupCount = groups.length - visibleGroups.length;
return (
<div
key={key.id}
className="grid grid-cols-12 gap-4 px-4 py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0 hover:bg-surface/30 transition-colors group min-w-[760px]"
>
<div className="col-span-2 flex items-center gap-2">
<span
className={`material-symbols-outlined text-sm ${isRestricted ? "text-amber-500" : "text-emerald-500"}`}
>
{isRestricted ? "lock" : "lock_open"}
</span>
<span className="text-sm font-medium truncate" title={key.name}>
{key.name}
</span>
</div>
<div className="col-span-3 flex items-center gap-1.5">
<code className="text-sm text-text-muted font-mono truncate">
{visibleKeys.has(key.id)
? (revealedKeys.get(key.id) ?? key.key)
: key.key}
</code>
{allowKeyReveal ? (
<>
<button
onClick={() => handleToggleKeyVisibility(key.id)}
className="p-1 text-text-muted/60 hover:text-primary transition-colors shrink-0"
title={visibleKeys.has(key.id) ? t("hideKey") : t("showKey")}
aria-label={visibleKeys.has(key.id) ? t("hideKey") : t("showKey")}
aria-pressed={visibleKeys.has(key.id)}
>
<span className="material-symbols-outlined text-[14px]">
{visibleKeys.has(key.id) ? "visibility_off" : "visibility"}
</span>
</button>
<button
onClick={() => handleCopyExistingKey(key.id)}
className="p-1 text-text-muted/60 hover:text-primary transition-colors shrink-0"
title={tc("copy")}
aria-label={tc("copy")}
>
<span className="material-symbols-outlined text-[14px]">
{copied === `existing_key_${key.id}` ? "check" : "content_copy"}
</span>
</button>
</>
) : (
<span
className="p-1 text-text-muted/40 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all shrink-0 cursor-help"
title={t("keyOnlyAvailableAtCreation")}
>
<span className="material-symbols-outlined text-[14px]">lock</span>
</span>
)}
</div>
<div className="col-span-2 flex items-center">
<div className="flex flex-col items-start gap-1">
{/* QUOTA differentiation chips — prepended before existing badges */}
{keyIsQuota && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-violet-500/10 text-violet-600 dark:text-violet-400 text-[11px] font-medium">
{t("quotaModeOnly")}
</span>
)}
{keyIsQuota &&
visibleGroups.map((groupName) => (
<span
key={groupName}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-sky-500/10 text-sky-600 dark:text-sky-400 text-[11px] font-medium truncate max-w-full"
>
{groupName}
</span>
))}
{keyIsQuota && extraGroupCount > 0 && (
<span className="inline-flex items-center px-2 py-0.5 rounded-md bg-sky-500/10 text-sky-600 dark:text-sky-400 text-[11px] font-medium">
+{extraGroupCount}
</span>
)}
{/* Existing badges */}
{isRestricted ? (
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium hover:bg-amber-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">lock</span>
{t("modelsCount", { count: key.allowedModels!.length })}
</button>
) : (
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-green-500/10 text-green-600 dark:text-green-400 text-xs font-medium hover:bg-green-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">lock_open</span>
{t("allModels")}
</button>
)}
{hasConnectionRestrictions && (
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-400 text-xs font-medium hover:bg-blue-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">cable</span>
{key.allowedConnections!.length} conn
</button>
)}
{hasComboRestrictions && (
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-teal-500/10 text-teal-600 dark:text-teal-400 text-xs font-medium hover:bg-teal-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">hub</span>
{key.allowedCombos!.length} combos
</button>
)}
{noLogEnabled && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-violet-500/10 text-violet-600 dark:text-violet-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">
visibility_off
</span>
No-Log
</span>
)}
{key.autoResolve && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">
auto_fix_high
</span>
Auto-Resolve
</span>
)}
{hasJsonStreamDefault && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-sky-500/10 text-sky-600 dark:text-sky-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">data_object</span>
{t("streamDefaultBadge")}
</span>
)}
{hasLocalUsageCommand && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-slate-500/10 text-slate-600 dark:text-slate-300 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">terminal</span>
{t("localUsageCommandBadge")}
</span>
)}
{key.usageLimitEnabled === true && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">paid</span>
USD quota
</span>
)}
{hasSessionLimit && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">group</span>
Sessions: {activeSessions}/{maxSessions}
</span>
)}
{hasThrottle && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-yellow-500/10 text-yellow-700 dark:text-yellow-300 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">speed</span>+
{throttleDelayMs}ms
</span>
)}
{hasManageScope && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-rose-500/10 text-rose-600 dark:text-rose-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">
admin_panel_settings
</span>
manage
</span>
)}
{!keyIsActive && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-500/10 text-red-600 dark:text-red-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">block</span>
{t("disabled")}
</span>
)}
{hasSchedule && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-orange-500/10 text-orange-600 dark:text-orange-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">schedule</span>
{t("scheduleActive")}
</span>
)}
{key.isBanned && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-600/10 text-red-700 dark:text-red-400 text-[11px] font-bold animate-pulse">
<span className="material-symbols-outlined text-[12px]">gavel</span>
BANNED
</span>
)}
{key.expiresAt && new Date(key.expiresAt).getTime() < Date.now() && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-gray-500/10 text-gray-600 dark:text-gray-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">event_busy</span>
EXPIRED
</span>
)}
</div>
</div>
<div className="col-span-2 flex flex-col justify-center">
<span className="text-sm font-medium tabular-nums">
{stats?.totalRequests ?? 0}{" "}
<span className="text-text-muted font-normal text-xs">{t("reqs")}</span>
</span>
{(stats?.totalRequests ?? 0) > 0 && (
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 tabular-nums">
{formatUsdCost(stats?.totalCost ?? 0, locale)}
</span>
)}
{stats?.lastUsed ? (
<span className="text-[10px] text-text-muted">
{t("lastUsedOn", { date: new Date(stats.lastUsed).toLocaleDateString() })}
</span>
) : (
<span className="text-[10px] text-text-muted italic">{t("neverUsed")}</span>
)}
</div>
<div className="col-span-1 flex items-center text-sm text-text-muted">
{new Date(key.createdAt).toLocaleDateString()}
</div>
<div className="col-span-2 flex items-center justify-end gap-1">
<a
href={`/dashboard/costs?range=all&apiKeyIds=${encodeURIComponent(key.id)}&groupBy=model`}
className="p-2 hover:bg-emerald-500/10 rounded text-text-muted hover:text-emerald-500 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all"
title={`View costs for ${key.name}`}
aria-label={`View costs for ${key.name}`}
>
<span className="material-symbols-outlined text-[18px]">payments</span>
</a>
<button
onClick={() => handleRegenerateKey(key.id)}
className="p-2 hover:bg-amber-500/10 rounded text-text-muted hover:text-amber-500 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all"
title={t("regenerateKey")}
>
<span className="material-symbols-outlined text-[18px]">refresh</span>
</button>
<button
onClick={() => handleOpenPermissions(key)}
className="p-2 hover:bg-primary/10 rounded text-text-muted hover:text-primary opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all"
title={t("editPermissions")}
>
<span className="material-symbols-outlined text-[18px]">tune</span>
</button>
<button
onClick={() => handleDeleteKey(key.id)}
className="p-2 hover:bg-red-500/10 rounded text-red-500 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all"
title={t("deleteKey")}
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
</div>
);
};
const tableHeader = (
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-surface/50 border-b border-border text-xs font-semibold text-text-muted uppercase tracking-wider min-w-[760px]">
<div className="col-span-2">{t("name")}</div>
<div className="col-span-3">{t("key")}</div>
<div className="col-span-2">{t("permissions")}</div>
<div className="col-span-2">{t("usage")}</div>
<div className="col-span-1">{t("created")}</div>
<div className="col-span-2 text-right">{t("actions")}</div>
</div>
);
return (
<div className="flex flex-col gap-4">
{normalKeys.length > 0 && (
<div>
{/* Normal keys section heading */}
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-base text-text-muted">
vpn_key
</span>
<span className="text-sm font-medium text-text-main">
{t("normalKeysSection")}
</span>
<span className="inline-flex items-center justify-center px-2 py-0.5 rounded-full bg-surface/80 border border-border text-[11px] font-semibold text-text-muted">
{normalKeys.length}
</span>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
{tableHeader}
{normalKeys.map(renderKeyRow)}
</div>
</div>
</div>
)}
{quotaKeys.length > 0 && (
<div>
{/* Quota keys section heading */}
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-base text-violet-500">
toll
</span>
<span className="text-sm font-medium text-text-main">
{t("quotaKeysSection")}
</span>
<span className="inline-flex items-center justify-center px-2 py-0.5 rounded-full bg-surface/80 border border-border text-[11px] font-semibold text-text-muted">
{quotaKeys.length}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full bg-violet-500/10 text-violet-600 dark:text-violet-400 text-[11px] font-semibold">
{t("quotaPill")}
</span>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
{tableHeader}
{quotaKeys.map(renderKeyRow)}
</div>
</div>
</div>
)}
</div>
);
})()
)}
</Card>
{/* Add Key Modal */}
<Modal
isOpen={showAddModal}
title={t("createKey")}
bodyClassName="p-6 max-h-[calc(100vh-150px)] overflow-y-auto"
onClose={() => {
setShowAddModal(false);
setNewKeyName("");
setNewKeyManageEnabled(false);
setNewKeySelfUsageEnabled(true);
setNewKeyAccountQuotaEnabled(false);
setNewKeyAllowUsageCommand(false);
setNameError(null);
setCreateError(null);
}}
>
<div ref={createKeyFormRef} className="flex flex-col gap-4">
<div ref={createKeyNameFieldRef}>
<label className="text-sm font-medium text-text-main mb-1.5 block">
{t("keyName")}
</label>
<Input
id={newKeyNameInputId}
value={newKeyName}
onChange={(e) => {
setNewKeyName(e.target.value);
setNameError(null);
}}
placeholder={t("keyNamePlaceholder")}
maxLength={MAX_KEY_NAME_LENGTH}
error={nameError}
autoFocus
/>
<p className="text-xs text-text-muted mt-1.5">{t("keyNameDesc")}</p>
</div>
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("managementAccess")}</p>
<p className="text-xs text-text-muted">{t("managementAccessDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={newKeyManageEnabled}
onClick={() => setNewKeyManageEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors shrink-0 ${
newKeyManageEnabled
? "bg-rose-500/15 text-rose-700 dark:text-rose-300 border border-rose-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">admin_panel_settings</span>
{newKeyManageEnabled ? tc("enabled") : tc("disabled")}
</button>
</div>
<div className="flex flex-col gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("selfServiceVisibility")}</p>
<p className="text-xs text-text-muted">{t("selfServiceVisibilityDesc")}</p>
</div>
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm text-text-main">{t("ownUsageVisibility")}</p>
<p className="text-xs text-text-muted">{t("ownUsageVisibilityDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={newKeySelfUsageEnabled}
onClick={() =>
setNewKeySelfUsageEnabled((prev) => {
if (prev) setNewKeyAccountQuotaEnabled(false);
return !prev;
})
}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors shrink-0 ${
newKeySelfUsageEnabled
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">query_stats</span>
{newKeySelfUsageEnabled ? tc("enabled") : tc("disabled")}
</button>
</div>
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm text-text-main">{t("sharedAccountQuotaVisibility")}</p>
<p className="text-xs text-text-muted">{t("sharedAccountQuotaVisibilityDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={newKeyAccountQuotaEnabled}
disabled={!newKeySelfUsageEnabled}
onClick={() => setNewKeyAccountQuotaEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors shrink-0 ${
newKeyAccountQuotaEnabled
? "bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
} ${!newKeySelfUsageEnabled ? "opacity-50 cursor-not-allowed" : ""}`}
>
<span className="material-symbols-outlined text-[14px]">account_balance</span>
{newKeyAccountQuotaEnabled ? tc("enabled") : tc("disabled")}
</button>
</div>
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm text-text-main">{t("localUsageCommand")}</p>
<p className="text-xs text-text-muted">{t("localUsageCommandDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={newKeyAllowUsageCommand}
onClick={() => setNewKeyAllowUsageCommand((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors shrink-0 ${
newKeyAllowUsageCommand
? "bg-sky-500/15 text-sky-700 dark:text-sky-300 border border-sky-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">terminal</span>
{newKeyAllowUsageCommand ? tc("enabled") : tc("disabled")}
</button>
</div>
</div>
{createError && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/30">
<span className="material-symbols-outlined text-red-500 text-sm">error</span>
<p className="text-sm text-red-700 dark:text-red-300 flex-1">{createError}</p>
</div>
)}
<div className="flex gap-2">
<Button
onClick={() => {
setShowAddModal(false);
setNewKeyName("");
setNewKeyManageEnabled(false);
setNewKeySelfUsageEnabled(true);
setNewKeyAccountQuotaEnabled(false);
setNewKeyAllowUsageCommand(false);
setNameError(null);
setCreateError(null);
}}
variant="ghost"
fullWidth
>
{tc("cancel")}
</Button>
<Button
onClick={handleCreateKey}
fullWidth
disabled={!newKeyName.trim()}
loading={isSubmitting}
>
{t("createKey")}
</Button>
</div>
</div>
</Modal>
{/* Created Key Modal */}
<Modal isOpen={!!createdKey} title={t("keyCreated")} onClose={() => setCreatedKey(null)}>
<div className="flex flex-col gap-4">
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-green-600 dark:text-green-400">
check_circle
</span>
<div>
<p className="text-sm text-green-800 dark:text-green-200 font-medium mb-1">
{t("keyCreatedSuccess")}
</p>
<p className="text-sm text-green-700 dark:text-green-300">{t("keyCreatedNote")}</p>
</div>
</div>
</div>
<div className="flex gap-2">
<Input value={createdKey || ""} readOnly className="flex-1 font-mono text-sm" />
<Button
variant="secondary"
icon={copied === "created_key" ? "check" : "content_copy"}
onClick={() => copy(createdKey, "created_key")}
>
{copied === "created_key" ? tc("copied") : tc("copy")}
</Button>
</div>
<Button onClick={() => setCreatedKey(null)} fullWidth>
{t("done")}
</Button>
</div>
</Modal>
{/* Permissions Modal */}
{editingKey && (
<PermissionsModal
key={editingKey.id}
isOpen={showPermissionsModal}
onClose={() => {
setShowPermissionsModal(false);
setEditingKey(null);
}}
apiKey={editingKey}
modelsByProvider={filteredModelsByProvider}
allModels={permissionModels}
allCombos={allCombos}
allConnections={allConnections}
searchModel={searchModel}
onSearchChange={setSearchModel}
onSave={handleUpdatePermissions}
/>
)}
</div>
);
}
// -- Permissions Modal Component (Memoized for Performance) ------------------------------------------
const PermissionsModal = memo(function PermissionsModal({
isOpen,
onClose,
apiKey,
modelsByProvider,
allModels,
allCombos,
allConnections,
searchModel,
onSearchChange,
onSave,
}: {
isOpen: boolean;
onClose: () => void;
apiKey: ApiKey;
modelsByProvider: ProviderGroup[];
allModels: Model[];
allCombos: ComboOption[];
allConnections: ProviderConnection[];
searchModel: string;
onSearchChange: (v: string) => void;
onSave: (
name: string,
models: string[],
combos: string[],
noLog: boolean,
connections: string[],
autoResolve: boolean,
isActive: boolean,
throttleDelayMs: number,
isBanned: boolean,
expiresAt: string | null,
maxSessions: number,
accessSchedule: AccessSchedule | null,
rateLimits: Array<{ limit: number; window: number }> | null,
scopes: string[],
allowedEndpoints: string[],
streamDefaultMode: StreamDefaultMode,
disableNonPublicModels: boolean,
allowUsageCommand: boolean,
usageLimitEnabled: boolean,
dailyUsageLimitUsd: number | null,
weeklyUsageLimitUsd: number | null,
blockedModels: string[]
) => void;
}) {
const t = useTranslations("apiManager");
const tc = useTranslations("common");
// Initialize state from props - component remounts when key prop changes
const initialModels = Array.isArray(apiKey?.allowedModels) ? apiKey.allowedModels : [];
const initialBlockedModels = useMemo(
() => (Array.isArray(apiKey?.blockedModels) ? apiKey.blockedModels : []),
[apiKey?.blockedModels]
);
const initialCombos = Array.isArray(apiKey?.allowedCombos) ? apiKey.allowedCombos : [];
const initialConnections = Array.isArray(apiKey?.allowedConnections)
? apiKey.allowedConnections
: [];
const [keyName, setKeyName] = useState(apiKey?.name ?? "");
const [selectedModels, setSelectedModels] = useState<string[]>(initialModels);
const [blockedClaudeCodeFamilies, setBlockedClaudeCodeFamilies] = useState<
ClaudeCodeBlockableFamilyId[]
>(() => getBlockedClaudeCodeFamilies(initialBlockedModels));
const [claudeCodeFamiliesExpanded, setClaudeCodeFamiliesExpanded] = useState(false);
const [selectedCombos, setSelectedCombos] = useState<string[]>(initialCombos);
const [allowAll, setAllowAll] = useState(initialModels.length === 0);
const [allowAllCombos, setAllowAllCombos] = useState(initialCombos.length === 0);
const [noLogEnabled, setNoLogEnabled] = useState(apiKey?.noLog === true);
const [autoResolveEnabled, setAutoResolveEnabled] = useState(apiKey?.autoResolve === true);
const [keyIsActive, setKeyIsActive] = useState(apiKey?.isActive !== false);
const [throttleDelayMs, setThrottleDelayMs] = useState(
typeof apiKey?.throttleDelayMs === "number" && apiKey.throttleDelayMs > 0
? apiKey.throttleDelayMs
: 0
);
const [keyIsBanned, setKeyIsBanned] = useState(apiKey?.isBanned === true);
const [expiresAt, setExpiresAt] = useState(apiKey?.expiresAt ?? "");
const [manageEnabled, setManageEnabled] = useState(
Array.isArray(apiKey?.scopes) && apiKey.scopes.includes("manage")
);
const [selfUsageEnabled, setSelfUsageEnabled] = useState(
Array.isArray(apiKey?.scopes) && apiKey.scopes.includes(SELF_USAGE_SCOPE)
);
const [selfAccountQuotaEnabled, setSelfAccountQuotaEnabled] = useState(
Array.isArray(apiKey?.scopes) && apiKey.scopes.includes(SELF_ACCOUNT_QUOTA_SCOPE)
);
const [maxSessions, setMaxSessions] = useState(
typeof apiKey?.maxSessions === "number" && apiKey.maxSessions > 0 ? apiKey.maxSessions : 0
);
const [scheduleEnabled, setScheduleEnabled] = useState(apiKey?.accessSchedule?.enabled === true);
const [scheduleFrom, setScheduleFrom] = useState(apiKey?.accessSchedule?.from ?? "08:00");
const [scheduleUntil, setScheduleUntil] = useState(apiKey?.accessSchedule?.until ?? "18:00");
const [scheduleDays, setScheduleDays] = useState<number[]>(
apiKey?.accessSchedule?.days ?? [1, 2, 3, 4, 5]
);
const [scheduleTz, setScheduleTz] = useState(
apiKey?.accessSchedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone
);
const [rateLimits, setRateLimits] = useState<Array<{ limit: number; window: number }>>(
Array.isArray(apiKey?.rateLimits) ? apiKey.rateLimits : []
);
const [streamDefaultMode, setStreamDefaultMode] = useState<StreamDefaultMode>(
apiKey?.streamDefaultMode === "json" ? "json" : "legacy"
);
const [nameError, setNameError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [selectedConnections, setSelectedConnections] = useState<string[]>(initialConnections);
const [allowAllConnections, setAllowAllConnections] = useState(initialConnections.length === 0);
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(() => {
// Expand all providers by default when in restrict mode with existing selections
if (initialModels.length > 0) {
return new Set(modelsByProvider.map(([p]) => p));
}
return new Set();
});
const initialEndpoints = Array.isArray(apiKey?.allowedEndpoints) ? apiKey.allowedEndpoints : [];
const [selectedEndpoints, setSelectedEndpoints] = useState<string[]>(initialEndpoints);
const [allowAllEndpoints, setAllowAllEndpoints] = useState(initialEndpoints.length === 0);
const [disableNonPublicModels, setDisableNonPublicModels] = useState(
apiKey?.disableNonPublicModels === true
);
const [usageCommandEnabled, setUsageCommandEnabled] = useState(
apiKey?.allowUsageCommand === true
);
const [usageLimitEnabled, setUsageLimitEnabled] = useState(apiKey?.usageLimitEnabled === true);
const [dailyUsageLimitUsd, setDailyUsageLimitUsd] = useState(
typeof apiKey?.dailyUsageLimitUsd === "number" && apiKey.dailyUsageLimitUsd > 0
? String(apiKey.dailyUsageLimitUsd)
: ""
);
const [weeklyUsageLimitUsd, setWeeklyUsageLimitUsd] = useState(
typeof apiKey?.weeklyUsageLimitUsd === "number" && apiKey.weeklyUsageLimitUsd > 0
? String(apiKey.weeklyUsageLimitUsd)
: ""
);
const getModelDisplayName = useCallback(
(modelId: string) =>
modelId === CLAUDE_CODE_DEFAULT_MODEL_ID ? CLAUDE_CODE_DEFAULT_MODEL_NAME : modelId,
[]
);
// Memoize callbacks to prevent child re-renders
const handleToggleModel = useCallback(
(modelId: string) => {
if (allowAll) return;
setSelectedModels((prev) => {
if (prev.includes(modelId)) {
if (modelId === CLAUDE_CODE_DEFAULT_MODEL_ID) {
setClaudeCodeFamiliesExpanded(false);
}
return prev.filter((m) => m !== modelId);
}
return [...prev, modelId];
});
},
[allowAll]
);
const handleToggleProvider = useCallback(
(provider: string, models: Model[]) => {
if (allowAll) return;
const modelIds = models.map((m) => m.id);
setSelectedModels((prev) => {
const allSelected = modelIds.every((id) => prev.includes(id));
if (allSelected) {
return prev.filter((m) => !modelIds.includes(m));
}
return [...new Set([...prev, ...modelIds])];
});
},
[allowAll]
);
const handleSelectAll = useCallback(() => {
setAllowAll(true);
setSelectedModels([]);
setBlockedClaudeCodeFamilies([]);
setClaudeCodeFamiliesExpanded(false);
}, []);
const handleRestrictMode = useCallback(() => {
setAllowAll(false);
// Expand all providers when entering restrict mode
const allProviders = new Set(modelsByProvider.map(([p]) => p));
setExpandedProviders(allProviders);
}, [modelsByProvider]);
const handleToggleExpand = useCallback((provider: string) => {
setExpandedProviders((prev) => {
const next = new Set(prev);
if (next.has(provider)) {
next.delete(provider);
} else {
next.add(provider);
}
return next;
});
}, []);
const handleSelectAllModels = useCallback(() => {
const allModelIds = allModels.map((m) => m.id);
setSelectedModels(allModelIds);
setBlockedClaudeCodeFamilies([]);
setClaudeCodeFamiliesExpanded(false);
}, [allModels]);
const handleDeselectAllModels = useCallback(() => {
setSelectedModels([]);
setBlockedClaudeCodeFamilies([]);
setClaudeCodeFamiliesExpanded(false);
}, []);
const handleBlockClaudeCodeFamily = useCallback((familyId: ClaudeCodeBlockableFamilyId) => {
setBlockedClaudeCodeFamilies((prev) => (prev.includes(familyId) ? prev : [...prev, familyId]));
setSelectedModels((prev) =>
prev.filter((modelId) => !isClaudeCodeFamilyModel(modelId, familyId))
);
}, []);
const handleToggleCombo = useCallback(
(comboName: string) => {
if (allowAllCombos) return;
setSelectedCombos((prev) =>
prev.includes(comboName) ? prev.filter((name) => name !== comboName) : [...prev, comboName]
);
},
[allowAllCombos]
);
const handleToggleConnection = useCallback(
(connectionId: string) => {
if (allowAllConnections) return;
setSelectedConnections((prev) =>
prev.includes(connectionId)
? prev.filter((c) => c !== connectionId)
: [...prev, connectionId]
);
},
[allowAllConnections]
);
const handleToggleEndpoint = useCallback(
(categoryId: string) => {
if (allowAllEndpoints) return;
setSelectedEndpoints((prev) =>
prev.includes(categoryId) ? prev.filter((e) => e !== categoryId) : [...prev, categoryId]
);
},
[allowAllEndpoints]
);
const parseUsdLimitInput = useCallback((value: string): number | null => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}, []);
const handleSave = useCallback(() => {
// Clear previous inline errors
setNameError(null);
setSaveError(null);
// Validate name inline before calling onSave
const validation = validateKeyName(keyName, t);
if (!validation.valid) {
setNameError(validation.error || t("invalidKeyName"));
return;
}
// Validate models selection
if (!allowAll && !Array.isArray(selectedModels)) {
setSaveError(t("invalidModelsSelection"));
return;
}
// Limit number of selected models to prevent abuse
if (!allowAll && selectedModels.length > MAX_SELECTED_MODELS) {
setSaveError(t("cannotSelectMoreThanModels", { max: MAX_SELECTED_MODELS }));
return;
}
const schedule: AccessSchedule | null = scheduleEnabled
? {
enabled: true,
from: scheduleFrom,
until: scheduleUntil,
days: scheduleDays,
tz: scheduleTz,
}
: null;
const hasClaudeCodeDefaultSelected =
!allowAll && selectedModels.includes(CLAUDE_CODE_DEFAULT_MODEL_ID);
const blockedModels = initialBlockedModels.filter(
(pattern) => !CLAUDE_CODE_BLOCK_PATTERN_SET.has(pattern)
);
if (hasClaudeCodeDefaultSelected) {
for (const familyId of blockedClaudeCodeFamilies) {
blockedModels.push(...CLAUDE_CODE_FAMILY_BLOCK_PATTERNS[familyId]);
}
}
onSave(
keyName,
allowAll ? [] : selectedModels,
allowAllCombos ? [] : selectedCombos,
noLogEnabled,
allowAllConnections ? [] : selectedConnections,
autoResolveEnabled,
keyIsActive,
throttleDelayMs,
keyIsBanned,
expiresAt || null,
maxSessions,
schedule,
rateLimits.length > 0 ? rateLimits : null,
mergeApiKeyPermissionScopes(apiKey?.scopes, {
manageEnabled,
selfUsageEnabled,
selfAccountQuotaEnabled,
}),
allowAllEndpoints ? [] : selectedEndpoints,
streamDefaultMode,
disableNonPublicModels,
usageCommandEnabled,
usageLimitEnabled,
parseUsdLimitInput(dailyUsageLimitUsd),
parseUsdLimitInput(weeklyUsageLimitUsd),
blockedModels
);
}, [
onSave,
keyName,
allowAll,
selectedModels,
allowAllCombos,
selectedCombos,
noLogEnabled,
allowAllConnections,
selectedConnections,
autoResolveEnabled,
keyIsActive,
throttleDelayMs,
keyIsBanned,
expiresAt,
maxSessions,
manageEnabled,
selfUsageEnabled,
selfAccountQuotaEnabled,
scheduleEnabled,
scheduleFrom,
scheduleUntil,
scheduleDays,
scheduleTz,
rateLimits,
allowAllEndpoints,
selectedEndpoints,
streamDefaultMode,
disableNonPublicModels,
usageCommandEnabled,
usageLimitEnabled,
dailyUsageLimitUsd,
weeklyUsageLimitUsd,
parseUsdLimitInput,
blockedClaudeCodeFamilies,
initialBlockedModels,
apiKey?.scopes,
t,
]);
const selectedCount = selectedModels.length;
const totalModels = allModels.length;
const hasClaudeCodeDefaultSelected =
!allowAll && selectedModels.includes(CLAUDE_CODE_DEFAULT_MODEL_ID);
const orderedSelectedModels = useMemo(() => {
if (!hasClaudeCodeDefaultSelected) return selectedModels;
return [
CLAUDE_CODE_DEFAULT_MODEL_ID,
...selectedModels.filter((modelId) => modelId !== CLAUDE_CODE_DEFAULT_MODEL_ID),
];
}, [hasClaudeCodeDefaultSelected, selectedModels]);
const visibleClaudeCodeFamilies = useMemo(
() =>
CLAUDE_CODE_DEFAULT_FAMILIES.filter(
(family) =>
family.id === "other" ||
!blockedClaudeCodeFamilies.includes(family.id as ClaudeCodeBlockableFamilyId)
),
[blockedClaudeCodeFamilies]
);
return (
<Modal
isOpen={onClose ? isOpen : false}
title={t("permissionsTitle", { name: apiKey?.name || "" })}
onClose={onClose}
>
<div className="flex flex-col gap-4">
{/* Key Name */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("keyName")}</p>
<p className="text-xs text-text-muted">{t("keyNameDesc")}</p>
</div>
<div className="w-48 shrink-0">
<Input
value={keyName}
onChange={(e) => {
setKeyName(e.target.value);
setNameError(null);
}}
placeholder={t("keyNamePlaceholder")}
maxLength={MAX_KEY_NAME_LENGTH}
error={nameError}
/>
</div>
</div>
{/* Inline save error */}
{saveError && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/30">
<span className="material-symbols-outlined text-red-500 text-sm">error</span>
<p className="text-sm text-red-700 dark:text-red-300 flex-1">{saveError}</p>
</div>
)}
{/* Access Mode Toggle */}
<div className="flex gap-2 p-1 bg-surface rounded-lg">
<button
onClick={handleSelectAll}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all ${
allowAll
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
<span className="material-symbols-outlined text-[18px]">lock_open</span>
{t("allowAll")}
</button>
<button
onClick={handleRestrictMode}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all ${
!allowAll
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
<span className="material-symbols-outlined text-[18px]">lock</span>
{t("restrict")}
</button>
</div>
{/* Info Banner */}
<div
className={`flex items-start gap-2 p-3 rounded-lg ${
allowAll
? "bg-green-500/10 border border-green-500/30"
: "bg-amber-500/10 border border-amber-500/30"
}`}
>
<span
className={`material-symbols-outlined text-[18px] ${
allowAll ? "text-green-500" : "text-amber-500"
}`}
>
{allowAll ? "info" : "warning"}
</span>
<p
className={`text-xs ${
allowAll ? "text-green-700 dark:text-green-300" : "text-amber-700 dark:text-amber-300"
}`}
>
{allowAll ? t("allowAllDesc") : t("restrictDesc", { selectedCount, totalModels })}
</p>
</div>
{/* Key Active Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("keyActive")}</p>
<p className="text-xs text-text-muted">{t("keyActiveDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={keyIsActive}
onClick={() => setKeyIsActive((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
keyIsActive
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30"
: "bg-red-500/15 text-red-700 dark:text-red-300 border border-red-500/30"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{keyIsActive ? "check_circle" : "block"}
</span>
{keyIsActive ? tc("enabled") : tc("disabled")}
</button>
</div>
{/* Max Sessions Limit (T08) */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("maxActiveSessions")}</p>
<p className="text-xs text-text-muted">
0 = unlimited. Return 429 when this key exceeds concurrent sticky sessions.
</p>
</div>
<div className="w-32">
<Input
type="number"
min={0}
step={1}
value={String(maxSessions)}
onChange={(e) => {
const parsed = Number.parseInt(e.target.value || "0", 10);
setMaxSessions(Number.isFinite(parsed) && parsed > 0 ? parsed : 0);
}}
/>
</div>
</div>
{/* Soft Throttle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">Throttle Delay</p>
<p className="text-xs text-text-muted">
Add a fixed delay before requests for this key are routed. 0 = no slowdown.
</p>
</div>
<div className="w-36">
<Input
type="number"
min={0}
max={300000}
step={100}
value={String(throttleDelayMs)}
onChange={(e) => {
const parsed = Number.parseInt(e.target.value || "0", 10);
setThrottleDelayMs(
Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 300000) : 0
);
}}
/>
<p className="text-[10px] text-text-muted mt-1">milliseconds</p>
</div>
</div>
{/* Custom Rate Limits */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">
{t("apiManagerCustomRateLimits")}
</p>
<p className="text-xs text-text-muted">{t("apiManagerCustomRateLimitsDesc")}</p>
</div>
<button
type="button"
onClick={() => setRateLimits((prev) => [...prev, { limit: 100, window: 60 }])}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold bg-primary/10 text-primary hover:bg-primary/20 transition-colors shrink-0"
>
<span className="material-symbols-outlined text-[14px]">add</span>
Add Limit
</button>
</div>
{rateLimits.length > 0 && (
<div className="flex flex-col gap-2 pt-2">
{rateLimits.map((rl, index) => (
<div key={index} className="flex gap-2 items-center">
<Input
type="number"
min={1}
value={String(rl.limit)}
onChange={(e) => {
const val = parseInt(e.target.value) || 0;
setRateLimits((prev) => {
const next = [...prev];
next[index].limit = val;
return next;
});
}}
placeholder={t("apiManagerRateLimitRequestsPlaceholder")}
/>
<span className="text-sm text-text-muted shrink-0">
{t("apiManagerRateLimitReqPer")}
</span>
<Input
type="number"
min={1}
value={String(rl.window)}
onChange={(e) => {
const val = parseInt(e.target.value) || 0;
setRateLimits((prev) => {
const next = [...prev];
next[index].window = val;
return next;
});
}}
placeholder={t("apiManagerRateLimitSecondsPlaceholder")}
/>
<span className="text-sm text-text-muted shrink-0">sec</span>
<button
type="button"
onClick={() => setRateLimits((prev) => prev.filter((_, i) => i !== index))}
className="p-2 text-red-500 hover:bg-red-500/10 rounded transition-colors shrink-0"
title={t("apiManagerRemoveLimitTitle")}
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
))}
</div>
)}
</div>
{/* Access Schedule */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("accessSchedule")}</p>
<p className="text-xs text-text-muted">{t("accessScheduleDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={scheduleEnabled}
onClick={() => setScheduleEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors shrink-0 ${
scheduleEnabled
? "bg-orange-500/15 text-orange-700 dark:text-orange-300 border border-orange-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">schedule</span>
{scheduleEnabled ? tc("enabled") : tc("disabled")}
</button>
</div>
{scheduleEnabled && (
<div className="flex flex-col gap-3 pt-1">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-text-muted mb-1 block">{t("scheduleFrom")}</label>
<input
type="time"
value={scheduleFrom}
onChange={(e) => setScheduleFrom(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">{t("scheduleUntil")}</label>
<input
type="time"
value={scheduleUntil}
onChange={(e) => setScheduleUntil(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
/>
</div>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block">{t("scheduleDays")}</label>
<div className="flex gap-1 flex-wrap">
{(
[
[0, t("daySun")],
[1, t("dayMon")],
[2, t("dayTue")],
[3, t("dayWed")],
[4, t("dayThu")],
[5, t("dayFri")],
[6, t("daySat")],
] as [number, string][]
).map(([dayIdx, label]) => {
const selected = scheduleDays.includes(dayIdx);
return (
<button
key={dayIdx}
type="button"
onClick={() =>
setScheduleDays((prev) =>
prev.includes(dayIdx)
? prev.filter((d) => d !== dayIdx)
: [...prev, dayIdx].sort((a, b) => a - b)
)
}
className={`px-2 py-1 text-[11px] font-medium rounded transition-all ${
selected
? "bg-primary text-white"
: "bg-surface border border-border text-text-muted hover:border-primary/50"
}`}
>
{label}
</button>
);
})}
</div>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">
{t("scheduleTimezone")}
</label>
<input
type="text"
value={scheduleTz}
onChange={(e) => setScheduleTz(e.target.value)}
placeholder={t("apiManagerTimezonePlaceholder")}
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main font-mono"
/>
<p className="text-[10px] text-text-muted mt-1">{t("scheduleTimezoneHint")}</p>
</div>
</div>
)}
</div>
{/* Privacy Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("noLogPayloadPrivacy")}</p>
<p className="text-xs text-text-muted">
Disable request/response payload persistence for this API key.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={noLogEnabled}
onClick={() => setNoLogEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
noLogEnabled
? "bg-violet-500/15 text-violet-700 dark:text-violet-300 border border-violet-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{noLogEnabled ? "visibility_off" : "visibility"}
</span>
{noLogEnabled ? tc("enabled") : tc("disabled")}
</button>
</div>
{/* Auto-Resolve Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("autoResolve")}</p>
<p className="text-xs text-text-muted">{t("autoResolveDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={autoResolveEnabled}
onClick={() => setAutoResolveEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
autoResolveEnabled
? "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border border-cyan-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{autoResolveEnabled ? "auto_fix_high" : "auto_fix_normal"}
</span>
{autoResolveEnabled ? tc("enabled") : tc("disabled")}
</button>
</div>
{/* Stream Default Compatibility */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1 min-w-0">
<p className="text-sm font-medium text-text-main">{t("streamDefaultMode")}</p>
<p className="text-xs text-text-muted">{t("streamDefaultModeDesc")}</p>
</div>
<div className="flex gap-1 p-0.5 bg-surface rounded-md shrink-0 w-full sm:w-auto">
<button
type="button"
onClick={() => setStreamDefaultMode("legacy")}
className={`inline-flex flex-1 sm:flex-none items-center justify-center gap-1.5 px-2.5 py-1.5 rounded text-xs font-semibold transition-all ${
streamDefaultMode === "legacy"
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
<span className="material-symbols-outlined text-[14px]">settings_backup_restore</span>
{t("streamDefaultLegacy")}
</button>
<button
type="button"
onClick={() => setStreamDefaultMode("json")}
className={`inline-flex flex-1 sm:flex-none items-center justify-center gap-1.5 px-2.5 py-1.5 rounded text-xs font-semibold transition-all ${
streamDefaultMode === "json"
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
<span className="material-symbols-outlined text-[14px]">data_object</span>
{t("streamDefaultJson")}
</button>
</div>
</div>
{/* Ban Toggle (SECURITY) */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-red-500/20 bg-red-500/5">
<div className="flex flex-col gap-1">
<p className="text-sm font-bold text-red-700 dark:text-red-400">{t("bannedStatus")}</p>
<p className="text-xs text-red-600 dark:text-red-300">
Immediately revoke all access. Used for suspected abuse or compromised keys.
</p>
</div>
<button
role="switch"
aria-checked={keyIsBanned}
onClick={() => setKeyIsBanned((prev) => !prev)}
className={`inline-flex shrink-0 items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-bold transition-colors ${
keyIsBanned
? "bg-red-500 text-white shadow-sm"
: "bg-black/5 dark:bg-white/5 text-text-muted hover:bg-black/10 dark:hover:bg-white/10"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{keyIsBanned ? "block" : "check_circle"}
</span>
{keyIsBanned ? "Banned" : "Active"}
</button>
</div>
{/* Expiration Date */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("expirationDate")}</p>
<p className="text-xs text-text-muted">
Key will automatically stop working after this date.
</p>
</div>
<div className="flex gap-2">
<input
type="datetime-local"
value={toLocalDateTimeInputValue(expiresAt)}
onChange={(e) => {
const val = e.target.value;
if (!val) {
setExpiresAt("");
return;
}
const date = new Date(val);
if (!Number.isNaN(date.getTime())) {
setExpiresAt(date.toISOString());
}
}}
className="min-w-0 flex-1 px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
/>
<button
type="button"
onClick={() => setExpiresAt("")}
disabled={!expiresAt}
className="shrink-0 px-3 py-1.5 text-sm font-medium border border-border rounded-md text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
>
{tc("clear")}
</button>
</div>
</div>
{/* Management Access */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("managementAccess")}</p>
<p className="text-xs text-text-muted">{t("managementAccessDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={manageEnabled}
onClick={() => setManageEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
manageEnabled
? "bg-rose-500/15 text-rose-700 dark:text-rose-300 border border-rose-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">admin_panel_settings</span>
{manageEnabled ? tc("enabled") : tc("disabled")}
</button>
</div>
{/* Self-service Visibility */}
<div className="flex flex-col gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("selfServiceVisibility")}</p>
<p className="text-xs text-text-muted">{t("selfServiceVisibilityDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={selfUsageEnabled}
onClick={() =>
setSelfUsageEnabled((prev) => {
if (prev) setSelfAccountQuotaEnabled(false);
return !prev;
})
}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
selfUsageEnabled
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">query_stats</span>
{t("ownUsageVisibility")} - {selfUsageEnabled ? tc("enabled") : tc("disabled")}
</button>
<p className="text-xs text-text-muted">{t("ownUsageVisibilityDesc")}</p>
<button
type="button"
role="switch"
aria-checked={selfAccountQuotaEnabled}
disabled={!selfUsageEnabled}
onClick={() => setSelfAccountQuotaEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
selfAccountQuotaEnabled
? "bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
} ${!selfUsageEnabled ? "opacity-50 cursor-not-allowed" : ""}`}
>
<span className="material-symbols-outlined text-[14px]">account_balance</span>
{t("sharedAccountQuotaVisibility")} -{" "}
{selfAccountQuotaEnabled ? tc("enabled") : tc("disabled")}
</button>
<p className="text-xs text-text-muted">{t("sharedAccountQuotaVisibilityDesc")}</p>
<button
type="button"
role="switch"
aria-checked={usageCommandEnabled}
onClick={() => setUsageCommandEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
usageCommandEnabled
? "bg-sky-500/15 text-sky-700 dark:text-sky-300 border border-sky-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">terminal</span>
{t("localUsageCommand")} - {usageCommandEnabled ? tc("enabled") : tc("disabled")}
</button>
<p className="text-xs text-text-muted">{t("localUsageCommandDesc")}</p>
<UsageLimitSettings
enabled={usageLimitEnabled}
dailyLimitUsd={dailyUsageLimitUsd}
weeklyLimitUsd={weeklyUsageLimitUsd}
enabledLabel={tc("enabled")}
disabledLabel={tc("disabled")}
onEnabledChange={setUsageLimitEnabled}
onDailyLimitUsdChange={setDailyUsageLimitUsd}
onWeeklyLimitUsdChange={setWeeklyUsageLimitUsd}
/>
</div>
{/* Disable Non-Public Models Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("disableNonPublicModels")}</p>
<p className="text-xs text-text-muted">{t("disableNonPublicModelsDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={disableNonPublicModels}
onClick={() => setDisableNonPublicModels((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
disableNonPublicModels
? "bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{disableNonPublicModels ? "shield_lock" : "shield"}
</span>
{disableNonPublicModels ? tc("yes") : tc("no")}
</button>
</div>
{/* Selected Models Summary (only in restrict mode) */}
{!allowAll && selectedCount > 0 && (
<div className="flex flex-col gap-1.5 p-2 bg-primary/5 rounded-lg border border-primary/20">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-primary">
{t("selectedCount", { count: selectedCount })}
</span>
<div className="flex gap-1">
<button
onClick={handleSelectAllModels}
className="text-[10px] text-primary hover:bg-primary/10 px-1.5 py-0.5 rounded transition-colors"
>
{tc("all")}
</button>
<button
onClick={handleDeselectAllModels}
className="text-[10px] text-red-500 hover:bg-red-500/10 px-1.5 py-0.5 rounded transition-colors"
>
{t("clear")}
</button>
</div>
</div>
<div className="flex flex-wrap gap-1 max-h-28 overflow-y-auto content-start">
{orderedSelectedModels.map((modelId) => {
if (modelId === CLAUDE_CODE_DEFAULT_MODEL_ID) {
return (
<div key={modelId} className="flex flex-col gap-1 basis-full">
<span className="inline-flex w-fit items-center gap-0.5 px-1.5 py-0.5 bg-primary/10 text-text-main text-[10px] rounded border border-primary/35">
<button
type="button"
onClick={() => setClaudeCodeFamiliesExpanded((prev) => !prev)}
className="inline-flex items-center gap-1 font-mono text-text-main"
title="Expand Claude Code families"
aria-expanded={claudeCodeFamiliesExpanded}
>
<span className="truncate max-w-[140px]" title={modelId}>
{getModelDisplayName(modelId)}
</span>
<span className="material-symbols-outlined text-[12px] text-primary">
{claudeCodeFamiliesExpanded ? "expand_less" : "expand_more"}
</span>
</button>
<button
type="button"
onClick={() => handleToggleModel(modelId)}
className="text-text-muted hover:text-red-500 transition-colors"
title="Remove Claude Code default"
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
{claudeCodeFamiliesExpanded && (
<div className="relative ml-2 flex flex-wrap gap-1 pl-5 animate-in fade-in slide-in-from-top-1 duration-150">
<span
aria-hidden="true"
className="pointer-events-none absolute left-1.5 top-0 bottom-1 w-px bg-primary/25"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute left-1.5 top-3 h-px w-3 bg-primary/25"
/>
{visibleClaudeCodeFamilies.map((family) => {
const canBlock = family.id !== "other";
return (
<span
key={family.id}
className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] rounded border ${
canBlock
? "bg-white dark:bg-surface text-text-main border-border"
: "bg-black/5 dark:bg-white/5 text-text-muted border-border"
}`}
title={
canBlock
? `Allow ${family.label} family through Claude Code default`
: "Catch-all for other Claude Code models"
}
>
<span className="font-mono">{family.label}</span>
{canBlock && (
<button
type="button"
onClick={() =>
handleBlockClaudeCodeFamily(
family.id as ClaudeCodeBlockableFamilyId
)
}
className="text-text-muted hover:text-red-500 transition-colors"
title={`Block ${family.label} family`}
>
<span className="material-symbols-outlined text-[12px]">
close
</span>
</button>
)}
</span>
);
})}
</div>
)}
</div>
);
}
return (
<span
key={modelId}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-white dark:bg-surface text-text-main text-[10px] rounded border border-border"
>
<span className="font-mono truncate max-w-[120px]" title={modelId}>
{getModelDisplayName(modelId)}
</span>
<button
type="button"
onClick={() => handleToggleModel(modelId)}
className="text-text-muted hover:text-red-500 transition-colors"
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
);
})}
</div>
</div>
)}
{/* Search and Model Selection (only in restrict mode) */}
{!allowAll && (
<>
<div className="relative">
<Input
value={searchModel}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("searchModels")}
icon="search"
/>
{searchModel && (
<button
onClick={() => onSearchChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-main"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
)}
</div>
<div className="max-h-[280px] overflow-y-auto border border-border rounded-lg divide-y divide-border">
{modelsByProvider.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-text-muted">
<span className="material-symbols-outlined text-2xl mb-1">search_off</span>
<p className="text-xs">{t("noModelsFound")}</p>
</div>
) : (
modelsByProvider.map(([provider, models]) => {
const selectedInProvider = selectedModels.filter((m) =>
models.some((model) => model.id === m)
).length;
const allSelected = models.every((m) => selectedModels.includes(m.id));
const someSelected = selectedInProvider > 0 && !allSelected;
return (
<div key={provider} className="group">
<button
onClick={() => handleToggleExpand(provider)}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-surface/50 transition-colors text-left"
>
<span
className={`material-symbols-outlined text-base transition-transform duration-200 ${
expandedProviders.has(provider) ? "rotate-90" : ""
}`}
>
chevron_right
</span>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className="relative flex items-center cursor-pointer shrink-0"
onClick={(e) => {
e.stopPropagation();
handleToggleProvider(provider, models);
}}
>
<div
className={`w-4 h-4 rounded border-2 transition-colors flex items-center justify-center ${
allSelected
? "bg-primary border-primary"
: someSelected
? "bg-primary/20 border-primary"
: "border-border hover:border-primary/50"
}`}
>
{allSelected && (
<span className="material-symbols-outlined text-white text-[12px]">
check
</span>
)}
{someSelected && !allSelected && (
<span className="material-symbols-outlined text-primary text-[12px]">
remove
</span>
)}
</div>
</div>
<span className="text-xs font-semibold text-text-main truncate">
{provider}
</span>
<span className="text-[10px] text-text-muted bg-surface px-1 py-0.5 rounded shrink-0">
{models.length}
</span>
</div>
{selectedInProvider > 0 && (
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full shrink-0">
{selectedInProvider}
</span>
)}
</button>
{/* Expandable model list */}
{expandedProviders.has(provider) && (
<div className="px-3 pb-2 pl-9">
<div className="flex flex-wrap gap-1">
{models.map((model) => {
const isSelected = selectedModels.includes(model.id);
return (
<button
key={model.id}
onClick={() => handleToggleModel(model.id)}
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-[10px] font-mono transition-all ${
isSelected
? "bg-primary text-white"
: "bg-surface border border-border text-text-muted hover:border-primary/50 hover:text-text-main"
}`}
title={model.id}
>
{getModelDisplayName(model.id)}
</button>
);
})}
</div>
</div>
)}
</div>
);
})
)}
</div>
</>
)}
{/* Allowed Connections Section */}
{allConnections.length > 0 && (
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-text-main">{t("allowedConnections")}</p>
<div className="flex gap-1 p-0.5 bg-surface rounded-md">
<button
onClick={() => {
setAllowAllConnections(true);
setSelectedConnections([]);
}}
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
allowAllConnections
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
All
</button>
<button
onClick={() => setAllowAllConnections(false)}
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
!allowAllConnections
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
Restrict
</button>
</div>
</div>
<p className="text-xs text-text-muted">
{allowAllConnections
? "This key can use any active connection."
: `Restricted to ${selectedConnections.length} connection${selectedConnections.length !== 1 ? "s" : ""}.`}
</p>
{!allowAllConnections && (
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
{Object.entries(
allConnections.reduce<Record<string, ProviderConnection[]>>((acc, conn) => {
const p = conn.provider || "Other";
if (!acc[p]) acc[p] = [];
acc[p].push(conn);
return acc;
}, {})
)
.sort(([a], [b]) => compareTr(a, b))
.map(([provider, conns]) => (
<div key={provider}>
<p className="text-[10px] font-semibold text-text-muted uppercase tracking-wider px-1 py-0.5">
{provider}
</p>
{conns.map((conn) => {
const isSelected = selectedConnections.includes(conn.id);
return (
<button
key={conn.id}
onClick={() => handleToggleConnection(conn.id)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs transition-all ${
isSelected
? "bg-primary/10 text-primary"
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
}`}
>
<div
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
isSelected ? "bg-primary border-primary" : "border-border"
}`}
>
{isSelected && (
<span className="material-symbols-outlined text-white text-[10px]">
check
</span>
)}
</div>
<span className="truncate flex-1">
{conn.name || conn.id.slice(0, 8)}
</span>
{!conn.isActive && (
<span className="text-[9px] text-red-400 shrink-0">inactive</span>
)}
</button>
);
})}
</div>
))}
</div>
)}
</div>
)}
{/* Allowed Combos Section */}
{allCombos.length > 0 && (
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-text-main">Allowed Combos</p>
<div className="flex gap-1 p-0.5 bg-surface rounded-md">
<button
onClick={() => {
setAllowAllCombos(true);
setSelectedCombos([]);
}}
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
allowAllCombos
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
All
</button>
<button
onClick={() => setAllowAllCombos(false)}
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
!allowAllCombos
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
Restrict
</button>
</div>
</div>
<p className="text-xs text-text-muted">
{allowAllCombos
? "This key can use any combo."
: `Restricted to ${selectedCombos.length} combo${selectedCombos.length !== 1 ? "s" : ""}.`}
</p>
{!allowAllCombos && (
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
{allCombos
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((combo) => {
const isSelected = selectedCombos.includes(combo.name);
return (
<button
key={combo.id || combo.name}
onClick={() => handleToggleCombo(combo.name)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs transition-all ${
isSelected
? "bg-primary/10 text-primary"
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
}`}
>
<div
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
isSelected ? "bg-primary border-primary" : "border-border"
}`}
>
{isSelected && (
<span className="material-symbols-outlined text-white text-[10px]">
check
</span>
)}
</div>
<span className="truncate flex-1">{combo.name}</span>
{Array.isArray(combo.models) && (
<span className="text-[10px] text-text-muted shrink-0">
{combo.models.length} models
</span>
)}
</button>
);
})}
</div>
)}
</div>
)}
{/* Allowed Endpoints Section */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("endpointRestrictions")}</p>
<p className="text-xs text-text-muted">
{allowAllEndpoints
? t("allEndpointsAllowed")
: t("endpointsRestricted", {
count: selectedEndpoints.length,
})}
</p>
</div>
<div className="flex gap-1 p-0.5 bg-surface rounded-md">
<button
onClick={() => {
setAllowAllEndpoints(true);
setSelectedEndpoints([]);
}}
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
allowAllEndpoints
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{t("all")}
</button>
<button
onClick={() => setAllowAllEndpoints(false)}
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
!allowAllEndpoints
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{t("restrict")}
</button>
</div>
</div>
{!allowAllEndpoints && (
<div className="flex flex-col gap-1 max-h-48 overflow-y-auto">
{ENDPOINT_CATEGORIES.map((cat) => {
const isSelected = selectedEndpoints.includes(cat.id);
return (
<button
key={cat.id}
onClick={() => handleToggleEndpoint(cat.id)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs transition-all ${
isSelected
? "bg-primary/10 text-primary"
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
}`}
>
<div
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
isSelected ? "bg-primary border-primary" : "border-border"
}`}
>
{isSelected && (
<span className="material-symbols-outlined text-white text-[10px]">
check
</span>
)}
</div>
<span className="truncate flex-1">{cat.label}</span>
<span className="text-[10px] text-text-muted shrink-0 truncate max-w-[140px]">
{cat.description}
</span>
</button>
);
})}
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2">
<Button onClick={handleSave} fullWidth>
{t("savePermissions")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
{tc("cancel")}
</Button>
</div>
</div>
</Modal>
);
});