mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
feat(dashboard): add search and filters to /dashboard/api-manager (#2628)
Integrated into release/v3.8.3
This commit is contained in:
committed by
GitHub
parent
9bf7e1a4e2
commit
5726db4af1
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
336
tests/unit/api-manager-filters.test.ts
Normal file
336
tests/unit/api-manager-filters.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user