Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 103 additions & 2 deletions src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(() => {
Expand All @@ -129,6 +144,14 @@ export default function ApiManagerPageClient() {
fetchConnections();
}, []);

useEffect(() => {
setActiveOnly(readActiveOnlyPreference());
}, []);

useEffect(() => {
writeActiveOnlyPreference(activeOnly);
}, [activeOnly]);
Comment on lines +147 to +153

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current persistence logic for activeOnly has a race condition. On the initial render, activeOnly is false. The second useEffect (line 151) will trigger and write false to localStorage before the first useEffect (line 147) can read the saved value and update the state. This effectively overwrites the user's preference with the default value on every page load.

Consider using a ref to skip the initial write or initializing the state from localStorage using a lazy initializer (though the latter requires careful handling of hydration in Next.js).


const fetchModels = async () => {
try {
const res = await fetch("/v1/models");
Expand Down Expand Up @@ -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));
Comment on lines +275 to +285

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The filtering logic for statusFilter and typeFilter is inconsistent with the classification logic used to compute chip counts in computeApiKeyCounts.

For example, if a key is both banned and isActive: false, classifyKeyStatus (used for counts) returns "banned". However, the filter for "disabled" (line 277) will still include this key because it only checks isActive === false. This results in a mismatch where a chip might show a count of 0 but clicking it displays results.

To ensure consistency, use the classification helpers directly in the filter logic.

Suggested change
// 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));
// 2. status chip filter
if (statusFilter) {
list = list.filter((k) => classifyKeyStatus(k) === statusFilter);
}
// 3. type chip filter
if (typeFilter) {
list = list.filter((k) => classifyKeyType(k) === typeFilter);
}


// 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)
);
}
Comment on lines +288 to +293

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The search filter is performed on every keystroke within a useMemo block. For large lists of API keys, this can lead to UI lag.

Consider using the existing useDebouncedValue hook to debounce the searchQuery before applying it to the filteredKeys computation.


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);
Expand Down Expand Up @@ -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">
Expand All @@ -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
Expand Down Expand Up @@ -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 */}
Expand All @@ -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 =
Comment on lines 724 to 725

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These local variables duplicate logic now available in the isRestricted utility. Additionally, the isRestricted icon logic at line 741 only checks for model restrictions, ignoring connection restrictions, which is inconsistent with the new filter definitions.

Suggested change
const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0;
const hasConnectionRestrictions =
const isRestricted = isKeyRestricted(key);
const hasModelRestrictions = Array.isArray(key.allowedModels) && key.allowedModels.length > 0;
const hasConnectionRestrictions = Array.isArray(key.allowedConnections) && key.allowedConnections.length > 0;

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +17 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The isKeyActive function manually checks expiration logic which is already encapsulated in isExpired. Furthermore, isKeyActive lacks the NaN check for date parsing that isExpired includes.

Refactor to reuse isExpired for consistency and safety.

export function isKeyActive(k: ApiKeyShape): boolean {
  if (k.isBanned === true) return false;
  if (k.isActive === false) return false;
  if (isExpired(k)) return false;
  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;
}
Loading