feat(dashboard): add search and filters to /dashboard/api-manager (#2628)

Integrated into release/v3.8.3
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-05-23 17:06:53 -03:00
committed by GitHub
parent 9bf7e1a4e2
commit 5726db4af1
8 changed files with 817 additions and 4 deletions

View File

@@ -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<Record<string, number>>({});
const [allowKeyReveal, setAllowKeyReveal] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [activeOnly, setActiveOnly] = useState(false);
const [statusFilter, setStatusFilter] = useState<KeyStatus | null>(null);
const [typeFilter, setTypeFilter] = useState<KeyType | null>(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() {
</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">
@@ -563,7 +644,19 @@ export default function ApiManagerPageClient() {
<span className="material-symbols-outlined text-xl text-amber-500">vpn_key</span>
</div>
<div>
<h3 className="font-semibold">{t("registeredKeys")}</h3>
<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}{" "}
{keys.length === 1
@@ -605,6 +698,14 @@ export default function ApiManagerPageClient() {
{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>
) : (
<div className="flex flex-col border border-border rounded-lg overflow-hidden">
{/* Table Header */}
@@ -618,7 +719,7 @@ export default function ApiManagerPageClient() {
</div>
{/* 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 =

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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 (
<Card padding="sm">
<div className="flex flex-col gap-3">
{/* Row 1: Search + Active Only toggle */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[160px]">
<Input
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("searchPlaceholder")}
aria-label={t("searchPlaceholder")}
icon="search"
inputClassName={searchQuery ? "pr-9" : ""}
/>
{searchQuery && (
<button
onClick={() => onSearchChange("")}
className="absolute inset-y-0 right-0 flex items-center pr-2.5 text-text-muted hover:text-text-primary transition-colors"
aria-label={tc("clear")}
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
)}
</div>
<Toggle
size="sm"
checked={activeOnly}
onChange={(v) => {
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"
/>
</div>
{/* STATUS + TYPE chips — single row on >=1280px, wraps below on smaller */}
<div className="border-t border-border pt-3 flex flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-text-muted mr-1">
{t("filterStatus")}:
</span>
{statusChips.map((chip) => (
<ApiKeyFilterChip
key={chip.value ?? "all"}
label={chip.label}
count={chip.count}
isActive={
statusFilter === chip.value ||
(chip.value === "active" && activeOnly && statusFilter === null)
}
dotColor={chip.dotColor}
onClick={() => {
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);
}
}}
/>
))}
</div>
<div aria-hidden="true" className="hidden xl:block h-6 w-px bg-border self-center" />
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-text-muted mr-1">
{t("filterType")}:
</span>
{typeChips.map((chip) => (
<ApiKeyFilterChip
key={chip.value ?? "all"}
label={chip.label}
count={chip.count}
isActive={typeFilter === chip.value}
dotColor={chip.dotColor}
onClick={() => onTypeChange(chip.value)}
/>
))}
</div>
</div>
</div>
</Card>
);
}

View File

@@ -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 (
<button
onClick={onClick}
className={cn(
"flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
isActive
? "bg-primary text-white border-primary"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/30"
)}
>
{dotColor && <span className={cn("size-2 rounded-full shrink-0", dotColor)} />}
<span>{label}</span>
{count !== undefined && (
<span className={cn("text-[11px]", isActive ? "text-white/80" : "text-text-muted")}>
{count}
</span>
)}
</button>
);
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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> = {}): 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<string, unknown>)["name"] as string | undefined)?.toLowerCase().includes(q) ||
((k as Record<string, unknown>)["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<ApiKeyShape> = {}): 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);
});
});