diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 887dbe6240..6fdabf7ed0 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -5,6 +5,16 @@ import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useTranslations } from "next-intl"; import { getProviderDisplayName } from "@/lib/display/names"; +import ApiKeyFilterBar from "./components/ApiKeyFilterBar"; +import { + isKeyActive, + isExpired, + isRestricted as isKeyRestricted, + classifyKeyStatus, + computeApiKeyCounts, +} from "./apiManagerPageUtils"; +import type { KeyStatus, KeyType } from "./apiManagerPageUtils"; +import { readActiveOnlyPreference, writeActiveOnlyPreference } from "./apiManagerPageStorage"; // Constants for validation const MAX_KEY_NAME_LENGTH = 200; @@ -121,6 +131,11 @@ export default function ApiManagerPageClient() { const [sessionCounts, setSessionCounts] = useState>({}); const [allowKeyReveal, setAllowKeyReveal] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [activeOnly, setActiveOnly] = useState(false); + const [statusFilter, setStatusFilter] = useState(null); + const [typeFilter, setTypeFilter] = useState(null); + const { copied, copy } = useCopyToClipboard(); useEffect(() => { @@ -129,6 +144,14 @@ export default function ApiManagerPageClient() { fetchConnections(); }, []); + useEffect(() => { + setActiveOnly(readActiveOnlyPreference()); + }, []); + + useEffect(() => { + writeActiveOnlyPreference(activeOnly); + }, [activeOnly]); + const fetchModels = async () => { try { const res = await fetch("/v1/models"); @@ -239,6 +262,49 @@ export default function ApiManagerPageClient() { 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 handleClearFilters = () => { + setSearchQuery(""); + setActiveOnly(false); + setStatusFilter(null); + setTypeFilter(null); + }; + const handleCreateKey = async () => { // Validate raw input first, then sanitize const validation = validateKeyName(newKeyName, t); @@ -555,6 +621,21 @@ export default function ApiManagerPageClient() { )} + {/* Filter Bar — shown when there are keys */} + {keys.length > 0 && ( + + )} + {/* Keys List Card */}
@@ -563,7 +644,19 @@ export default function ApiManagerPageClient() { vpn_key
-

{t("registeredKeys")}

+

+ {t("registeredKeys")} + {isFiltered && ( + + ({t("shownOf", { shown: filteredKeys.length, total: keys.length })}) + + )} + {!isFiltered && ( + + ({keys.length}) + + )} +

{keys.length}{" "} {keys.length === 1 @@ -605,6 +698,14 @@ export default function ApiManagerPageClient() { {t("createFirstKey")}

+ ) : filteredKeys.length === 0 ? ( +
+
+ search_off +
+

{t("emptyFilterTitle")}

+ +
) : (
{/* Table Header */} @@ -618,7 +719,7 @@ export default function ApiManagerPageClient() {
{/* Table Rows */} - {keys.map((key) => { + {filteredKeys.map((key) => { const stats = usageStats[key.id]; const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0; const hasConnectionRestrictions = diff --git a/src/app/(dashboard)/dashboard/api-manager/apiManagerPageStorage.ts b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageStorage.ts new file mode 100644 index 0000000000..7bb8626688 --- /dev/null +++ b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageStorage.ts @@ -0,0 +1,41 @@ +export const ACTIVE_ONLY_STORAGE_KEY = "omniroute-api-manager-active-only"; + +interface StorageReader { + getItem(key: string): string | null; +} + +interface StorageWriter extends StorageReader { + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +function getBrowserStorage(): StorageWriter | null { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } +} + +export function parseActiveOnlyPreference(value: string | null | undefined): boolean { + return value === "true"; +} + +export function readActiveOnlyPreference( + storage: StorageReader | null = getBrowserStorage() +): boolean { + if (!storage) return false; + return parseActiveOnlyPreference(storage.getItem(ACTIVE_ONLY_STORAGE_KEY)); +} + +export function writeActiveOnlyPreference( + enabled: boolean, + storage: StorageWriter | null = getBrowserStorage() +): void { + if (!storage) return; + if (enabled) { + storage.setItem(ACTIVE_ONLY_STORAGE_KEY, "true"); + return; + } + storage.removeItem(ACTIVE_ONLY_STORAGE_KEY); +} diff --git a/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts new file mode 100644 index 0000000000..d6dd756900 --- /dev/null +++ b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts @@ -0,0 +1,85 @@ +export type KeyStatus = "active" | "disabled" | "banned" | "expired"; + +// "manage" scope = management key; "restricted" = has model/connection allowlists; +// "standard" = no manage scope and no allowlists. +// Note: a "manage" key with allowlists is still classified as "manage" (manage takes priority). +export type KeyType = "standard" | "manage" | "restricted"; + +export interface ApiKeyShape { + isActive?: boolean; + isBanned?: boolean; + expiresAt?: string | null; + scopes?: string[]; + allowedModels?: string[] | null; + allowedConnections?: string[] | null; +} + +export function isKeyActive(k: ApiKeyShape): boolean { + if (k.isBanned === true) return false; + if (k.isActive === false) return false; + if (k.expiresAt) { + return new Date(k.expiresAt).getTime() > Date.now(); + } + return true; +} + +export function isExpired(k: ApiKeyShape): boolean { + if (!k.expiresAt) return false; + const ts = new Date(k.expiresAt).getTime(); + if (Number.isNaN(ts)) return false; + return ts < Date.now(); +} + +export function isRestricted(k: ApiKeyShape): boolean { + const hasModelRestrictions = Array.isArray(k.allowedModels) && k.allowedModels.length > 0; + const hasConnectionRestrictions = + Array.isArray(k.allowedConnections) && k.allowedConnections.length > 0; + return hasModelRestrictions || hasConnectionRestrictions; +} + +export function classifyKeyStatus(k: ApiKeyShape): KeyStatus { + if (k.isBanned === true) return "banned"; + if (isExpired(k)) return "expired"; + if (k.isActive === false) return "disabled"; + return "active"; +} + +export function classifyKeyType(k: ApiKeyShape): KeyType { + if (Array.isArray(k.scopes) && k.scopes.includes("manage")) return "manage"; + if (isRestricted(k)) return "restricted"; + return "standard"; +} + +export interface ApiKeyCounts { + total: number; + active: number; + disabled: number; + banned: number; + expired: number; + standard: number; + manage: number; + restricted: number; +} + +export function computeApiKeyCounts(keys: ApiKeyShape[]): ApiKeyCounts { + const counts: ApiKeyCounts = { + total: keys.length, + active: 0, + disabled: 0, + banned: 0, + expired: 0, + standard: 0, + manage: 0, + restricted: 0, + }; + + for (const k of keys) { + const status = classifyKeyStatus(k); + counts[status] += 1; + + const type = classifyKeyType(k); + counts[type] += 1; + } + + return counts; +} diff --git a/src/app/(dashboard)/dashboard/api-manager/components/ApiKeyFilterBar.tsx b/src/app/(dashboard)/dashboard/api-manager/components/ApiKeyFilterBar.tsx new file mode 100644 index 0000000000..81a0546648 --- /dev/null +++ b/src/app/(dashboard)/dashboard/api-manager/components/ApiKeyFilterBar.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Card, Input, Toggle } from "@/shared/components"; +import ApiKeyFilterChip from "./ApiKeyFilterChip"; +import type { KeyStatus, KeyType, ApiKeyCounts } from "../apiManagerPageUtils"; + +interface ApiKeyFilterBarProps { + counts: ApiKeyCounts; + searchQuery: string; + onSearchChange: (q: string) => void; + activeOnly: boolean; + onActiveOnlyChange: (v: boolean) => void; + statusFilter: KeyStatus | null; + onStatusChange: (s: KeyStatus | null) => void; + typeFilter: KeyType | null; + onTypeChange: (t: KeyType | null) => void; +} + +export default function ApiKeyFilterBar({ + counts, + searchQuery, + onSearchChange, + activeOnly, + onActiveOnlyChange, + statusFilter, + onStatusChange, + typeFilter, + onTypeChange, +}: ApiKeyFilterBarProps) { + const t = useTranslations("apiManager"); + const tc = useTranslations("common"); + + const statusChips: Array<{ + value: KeyStatus | null; + label: string; + dotColor: string | null; + count: number; + }> = [ + { value: null, label: t("filterAll"), dotColor: null, count: counts.total }, + { + value: "active", + label: t("filterStatusActive"), + dotColor: "bg-green-500", + count: counts.active, + }, + { + value: "disabled", + label: t("filterStatusDisabled"), + dotColor: "bg-gray-500", + count: counts.disabled, + }, + { + value: "banned", + label: t("filterStatusBanned"), + dotColor: "bg-red-500", + count: counts.banned, + }, + { + value: "expired", + label: t("filterStatusExpired"), + dotColor: "bg-amber-500", + count: counts.expired, + }, + ]; + + const typeChips: Array<{ + value: KeyType | null; + label: string; + dotColor: string | null; + count: number; + }> = [ + { value: null, label: t("filterAll"), dotColor: null, count: counts.total }, + { + value: "standard", + label: t("filterTypeStandard"), + dotColor: "bg-slate-500", + count: counts.standard, + }, + { + value: "manage", + label: t("filterTypeManage"), + dotColor: "bg-rose-500", + count: counts.manage, + }, + { + value: "restricted", + label: t("filterTypeRestricted"), + dotColor: "bg-amber-500", + count: counts.restricted, + }, + ]; + + return ( + +
+ {/* Row 1: Search + Active Only toggle */} +
+
+ onSearchChange(e.target.value)} + placeholder={t("searchPlaceholder")} + aria-label={t("searchPlaceholder")} + icon="search" + inputClassName={searchQuery ? "pr-9" : ""} + /> + {searchQuery && ( + + )} +
+ { + onActiveOnlyChange(v); + // When enabling activeOnly, reset statusFilter if it's not "active" + if (v && statusFilter !== null && statusFilter !== "active") { + onStatusChange(null); + } + }} + label={t("activeOnly")} + className="rounded-lg border border-border bg-bg-subtle px-3 py-1.5" + /> +
+ + {/* STATUS + TYPE chips — single row on >=1280px, wraps below on smaller */} +
+
+ + {t("filterStatus")}: + + {statusChips.map((chip) => ( + { + onStatusChange(chip.value); + // If user picks a non-active status chip while activeOnly is on, turn off activeOnly + if (chip.value !== null && chip.value !== "active" && activeOnly) { + onActiveOnlyChange(false); + } + }} + /> + ))} +
+ + +
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/api-manager/components/ApiKeyFilterChip.tsx b/src/app/(dashboard)/dashboard/api-manager/components/ApiKeyFilterChip.tsx new file mode 100644 index 0000000000..471b2f43d2 --- /dev/null +++ b/src/app/(dashboard)/dashboard/api-manager/components/ApiKeyFilterChip.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +interface ApiKeyFilterChipProps { + label: string; + count?: number; + isActive: boolean; + dotColor?: string | null; + onClick: () => void; +} + +export default function ApiKeyFilterChip({ + label, + count, + isActive, + dotColor, + onClick, +}: ApiKeyFilterChipProps) { + return ( + + ); +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index b64dc82d12..ce607d71d2 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1455,7 +1455,22 @@ "managementApiAccess": "Management API Access", "expirationDate": "Expiration Date", "managementAccess": "Management Access", - "allowedConnections": "Allowed Connections" + "allowedConnections": "Allowed Connections", + "searchPlaceholder": "Search by name or token...", + "activeOnly": "Active only", + "filterStatus": "STATUS", + "filterType": "TYPE", + "filterAll": "All", + "filterStatusActive": "Active", + "filterStatusDisabled": "Disabled", + "filterStatusBanned": "Banned", + "filterStatusExpired": "Expired", + "filterTypeStandard": "Standard", + "filterTypeManage": "Manage", + "filterTypeRestricted": "Restricted", + "shownOf": "{shown} of {total} shown", + "emptyFilterTitle": "No keys match your filters", + "emptyFilterClear": "Clear filters" }, "auditLog": { "title": "Audit Log", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index bfdd8d8d17..f95d51e7c7 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -1453,7 +1453,22 @@ "managementApiAccess": "Acesso à API de Gerenciamento", "expirationDate": "Data de Expiração", "managementAccess": "Acesso de Gerenciamento", - "allowedConnections": "Conexões Permitidas" + "allowedConnections": "Conexões Permitidas", + "searchPlaceholder": "Buscar por nome ou token...", + "activeOnly": "Apenas ativas", + "filterStatus": "STATUS", + "filterType": "TIPO", + "filterAll": "Todas", + "filterStatusActive": "Ativa", + "filterStatusDisabled": "Desativada", + "filterStatusBanned": "Banida", + "filterStatusExpired": "Expirada", + "filterTypeStandard": "Padrão", + "filterTypeManage": "Gerenciamento", + "filterTypeRestricted": "Restrita", + "shownOf": "{shown} de {total} exibidas", + "emptyFilterTitle": "Nenhuma chave corresponde aos filtros", + "emptyFilterClear": "Limpar filtros" }, "auditLog": { "title": "Log de Auditoria", diff --git a/tests/unit/api-manager-filters.test.ts b/tests/unit/api-manager-filters.test.ts new file mode 100644 index 0000000000..f49d625dd4 --- /dev/null +++ b/tests/unit/api-manager-filters.test.ts @@ -0,0 +1,336 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + isKeyActive, + isExpired, + isRestricted, + classifyKeyStatus, + classifyKeyType, + computeApiKeyCounts, +} from "../../src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.js"; +import type { ApiKeyShape } from "../../src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +const futureDate = new Date(Date.now() + 86_400_000).toISOString(); // +1 day +const pastDate = new Date(Date.now() - 86_400_000).toISOString(); // -1 day + +function makeKey(overrides: Partial = {}): ApiKeyShape { + return { + isActive: true, + isBanned: false, + expiresAt: null, + scopes: [], + allowedModels: null, + allowedConnections: null, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Scenario 1 — isKeyActive +// --------------------------------------------------------------------------- +describe("isKeyActive", () => { + it("returns true for a fully active key", () => { + assert.equal(isKeyActive(makeKey()), true); + }); + + it("returns false when isBanned is true", () => { + assert.equal(isKeyActive(makeKey({ isBanned: true })), false); + }); + + it("returns false when isActive is false", () => { + assert.equal(isKeyActive(makeKey({ isActive: false })), false); + }); + + it("returns false when expiresAt is in the past", () => { + assert.equal(isKeyActive(makeKey({ expiresAt: pastDate })), false); + }); + + it("returns true when expiresAt is in the future", () => { + assert.equal(isKeyActive(makeKey({ expiresAt: futureDate })), true); + }); + + it("returns false when banned even if isActive true and not expired", () => { + assert.equal( + isKeyActive(makeKey({ isBanned: true, isActive: true, expiresAt: futureDate })), + false + ); + }); +}); + +// --------------------------------------------------------------------------- +// Scenario 2 — isExpired +// --------------------------------------------------------------------------- +describe("isExpired", () => { + it("returns false when expiresAt is null", () => { + assert.equal(isExpired(makeKey({ expiresAt: null })), false); + }); + + it("returns false when expiresAt is in the future", () => { + assert.equal(isExpired(makeKey({ expiresAt: futureDate })), false); + }); + + it("returns true when expiresAt is in the past", () => { + assert.equal(isExpired(makeKey({ expiresAt: pastDate })), true); + }); + + it("returns false for an invalid date string (NaN)", () => { + assert.equal(isExpired(makeKey({ expiresAt: "not-a-date" })), false); + }); +}); + +// --------------------------------------------------------------------------- +// Scenario 3 — isRestricted +// --------------------------------------------------------------------------- +describe("isRestricted", () => { + it("returns false when both allowedModels and allowedConnections are null", () => { + assert.equal(isRestricted(makeKey()), false); + }); + + it("returns false when allowedModels is an empty array", () => { + assert.equal(isRestricted(makeKey({ allowedModels: [] })), false); + }); + + it("returns true when allowedModels has entries", () => { + assert.equal(isRestricted(makeKey({ allowedModels: ["gpt-4"] })), true); + }); + + it("returns true when allowedConnections has entries", () => { + assert.equal(isRestricted(makeKey({ allowedConnections: ["conn-1"] })), true); + }); + + it("returns true when both have entries", () => { + assert.equal( + isRestricted(makeKey({ allowedModels: ["gpt-4"], allowedConnections: ["conn-1"] })), + true + ); + }); +}); + +// --------------------------------------------------------------------------- +// Scenario 4 — classifyKeyStatus (banned takes highest priority) +// --------------------------------------------------------------------------- +describe("classifyKeyStatus", () => { + it("returns 'banned' for a banned key (highest priority)", () => { + assert.equal(classifyKeyStatus(makeKey({ isBanned: true })), "banned"); + }); + + it("returns 'banned' even if also expired", () => { + assert.equal(classifyKeyStatus(makeKey({ isBanned: true, expiresAt: pastDate })), "banned"); + }); + + it("returns 'expired' before 'disabled' when key has past expiry and isActive false", () => { + // expiresAt in the past AND isActive false — expired is checked before disabled + assert.equal(classifyKeyStatus(makeKey({ expiresAt: pastDate, isActive: false })), "expired"); + }); + + it("returns 'expired' for a non-banned, expired key", () => { + assert.equal(classifyKeyStatus(makeKey({ expiresAt: pastDate })), "expired"); + }); + + it("returns 'disabled' when isActive false and not expired/banned", () => { + assert.equal(classifyKeyStatus(makeKey({ isActive: false })), "disabled"); + }); + + it("returns 'active' for a healthy key", () => { + assert.equal(classifyKeyStatus(makeKey()), "active"); + }); + + it("returns 'active' for key with future expiry and no bans", () => { + assert.equal(classifyKeyStatus(makeKey({ expiresAt: futureDate })), "active"); + }); +}); + +// --------------------------------------------------------------------------- +// Scenario 4 continued — classifyKeyType +// --------------------------------------------------------------------------- +describe("classifyKeyType", () => { + it("returns 'standard' for a plain key", () => { + assert.equal(classifyKeyType(makeKey()), "standard"); + }); + + it("returns 'manage' when scopes includes manage", () => { + assert.equal(classifyKeyType(makeKey({ scopes: ["manage"] })), "manage"); + }); + + it("returns 'manage' even if also restricted (manage takes priority)", () => { + assert.equal( + classifyKeyType(makeKey({ scopes: ["manage"], allowedModels: ["gpt-4"] })), + "manage" + ); + }); + + it("returns 'restricted' when has allowedModels and no manage scope", () => { + assert.equal(classifyKeyType(makeKey({ allowedModels: ["gpt-4"] })), "restricted"); + }); + + it("returns 'restricted' when has allowedConnections and no manage scope", () => { + assert.equal(classifyKeyType(makeKey({ allowedConnections: ["c1"] })), "restricted"); + }); +}); + +// --------------------------------------------------------------------------- +// Scenario 5 — computeApiKeyCounts with 10 varied keys +// --------------------------------------------------------------------------- +describe("computeApiKeyCounts", () => { + it("returns all zeros for empty array (edge case)", () => { + const counts = computeApiKeyCounts([]); + assert.equal(counts.total, 0); + assert.equal(counts.active, 0); + assert.equal(counts.banned, 0); + assert.equal(counts.expired, 0); + assert.equal(counts.disabled, 0); + assert.equal(counts.standard, 0); + assert.equal(counts.manage, 0); + assert.equal(counts.restricted, 0); + }); + + it("correctly tallies 10 mixed keys", () => { + const keys: ApiKeyShape[] = [ + makeKey(), // active + standard + makeKey({ expiresAt: futureDate }), // active + standard + makeKey({ scopes: ["manage"] }), // active + manage + makeKey({ allowedModels: ["gpt-4"] }), // active + restricted + makeKey({ isActive: false }), // disabled + standard + makeKey({ isBanned: true }), // banned + standard + makeKey({ expiresAt: pastDate }), // expired + standard + makeKey({ expiresAt: pastDate, allowedModels: ["gpt-4"] }), // expired + restricted (type=restricted) + makeKey({ scopes: ["manage"], allowedConnections: ["c1"] }), // active + manage (manage priority) + makeKey({ allowedConnections: ["c1"] }), // active + restricted + ]; + + const counts = computeApiKeyCounts(keys); + + assert.equal(counts.total, 10); + // statuses: active(6: keys 1,2,3,4,9,10), disabled(1), banned(1), expired(2) + assert.equal(counts.active, 6); + assert.equal(counts.disabled, 1); + assert.equal(counts.banned, 1); + assert.equal(counts.expired, 2); + // types: manage(2), restricted(3), standard(5 — active×2, disabled, banned, expired×1) + assert.equal(counts.manage, 2); + assert.equal(counts.restricted, 3); + assert.equal(counts.standard, 5); + }); +}); + +// --------------------------------------------------------------------------- +// Scenario 6 — filteredKeys logic (inline simulation of useMemo) +// --------------------------------------------------------------------------- +function applyFilters( + keys: ApiKeyShape[], + opts: { + activeOnly?: boolean; + statusFilter?: string | null; + typeFilter?: string | null; + searchQuery?: string; + } +): ApiKeyShape[] { + let list = keys; + if (opts.activeOnly) list = list.filter(isKeyActive); + if (opts.statusFilter === "active") list = list.filter(isKeyActive); + else if (opts.statusFilter === "disabled") list = list.filter((k) => k.isActive === false); + else if (opts.statusFilter === "banned") list = list.filter((k) => k.isBanned === true); + else if (opts.statusFilter === "expired") list = list.filter(isExpired); + if (opts.typeFilter === "manage") list = list.filter((k) => k.scopes?.includes("manage")); + else if (opts.typeFilter === "restricted") list = list.filter(isRestricted); + else if (opts.typeFilter === "standard") + list = list.filter((k) => !k.scopes?.includes("manage") && !isRestricted(k)); + if (opts.searchQuery?.trim()) { + const q = opts.searchQuery.toLowerCase(); + // We cast to any to allow name/key fields for the filter simulation + list = list.filter( + (k) => + ((k as Record)["name"] as string | undefined)?.toLowerCase().includes(q) || + ((k as Record)["key"] as string | undefined)?.toLowerCase().includes(q) + ); + } + return list; +} + +interface TestApiKey extends ApiKeyShape { + name: string; + key: string; +} + +function makeTestKey(name: string, key: string, overrides: Partial = {}): TestApiKey { + return { ...makeKey(overrides), name, key }; +} + +describe("filter composition", () => { + const testKeys: TestApiKey[] = [ + makeTestKey("Alpha", "sk-alpha", {}), + makeTestKey("Beta", "sk-beta", { isBanned: true }), + makeTestKey("Gamma", "sk-gamma", { isActive: false }), + makeTestKey("Delta", "sk-delta", { expiresAt: pastDate }), + makeTestKey("Epsilon", "sk-epsilon", { scopes: ["manage"] }), + ]; + + it("activeOnly=true excludes banned and inactive keys", () => { + const result = applyFilters(testKeys, { activeOnly: true }); + const names = result.map((k) => (k as TestApiKey).name); + assert.ok(names.includes("Alpha"), "Active key should be included"); + assert.ok(names.includes("Epsilon"), "Manage key should be included"); + assert.ok(!names.includes("Beta"), "Banned should be excluded"); + assert.ok(!names.includes("Gamma"), "Disabled should be excluded"); + assert.ok(!names.includes("Delta"), "Expired should be excluded"); + }); + + it("statusFilter='banned' returns only banned keys", () => { + const result = applyFilters(testKeys, { statusFilter: "banned" }); + assert.equal(result.length, 1); + assert.equal((result[0] as TestApiKey).name, "Beta"); + }); + + it("statusFilter='disabled' returns only disabled keys", () => { + const result = applyFilters(testKeys, { statusFilter: "disabled" }); + assert.equal(result.length, 1); + assert.equal((result[0] as TestApiKey).name, "Gamma"); + }); + + it("typeFilter='manage' returns only manage-scoped keys", () => { + const result = applyFilters(testKeys, { typeFilter: "manage" }); + assert.equal(result.length, 1); + assert.equal((result[0] as TestApiKey).name, "Epsilon"); + }); +}); + +// --------------------------------------------------------------------------- +// Scenario 7 — searchQuery filters by name case-insensitively +// --------------------------------------------------------------------------- +describe("searchQuery filtering", () => { + const testKeys: TestApiKey[] = [ + makeTestKey("Production Key", "sk-prod-abc123", {}), + makeTestKey("Development Key", "sk-dev-xyz789", {}), + makeTestKey("Staging", "sk-stg-qwerty", {}), + ]; + + it("matches by name case-insensitively", () => { + const result = applyFilters(testKeys, { searchQuery: "production" }); + assert.equal(result.length, 1); + assert.equal((result[0] as TestApiKey).name, "Production Key"); + }); + + it("matches by key prefix case-insensitively", () => { + const result = applyFilters(testKeys, { searchQuery: "SK-DEV" }); + assert.equal(result.length, 1); + assert.equal((result[0] as TestApiKey).name, "Development Key"); + }); + + it("returns all keys when searchQuery is empty", () => { + const result = applyFilters(testKeys, { searchQuery: "" }); + assert.equal(result.length, 3); + }); + + it("returns empty when no key matches", () => { + const result = applyFilters(testKeys, { searchQuery: "zzznomatch" }); + assert.equal(result.length, 0); + }); + + it("matches multiple keys with partial substring", () => { + const result = applyFilters(testKeys, { searchQuery: "key" }); + assert.equal(result.length, 2); + }); +});