Spaces:
Runtime error
Runtime error
| "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> | |
| ); | |
| }); | |