diff --git a/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/components/assigned-items-cell.tsx b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/components/assigned-items-cell.tsx
new file mode 100644
index 0000000000..9ba96bf79f
--- /dev/null
+++ b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/components/assigned-items-cell.tsx
@@ -0,0 +1,45 @@
+import { cn } from "@/lib/utils";
+import { Page2 } from "@unkey/icons";
+
+export const AssignedItemsCell = ({
+ permissionSummary,
+ isSelected = false,
+}: {
+ permissionSummary: {
+ total: number;
+ categories: Record;
+ };
+ isSelected?: boolean;
+}) => {
+ const { total } = permissionSummary;
+
+ const itemClassName = cn(
+ "font-mono rounded-md py-[2px] px-1.5 items-center w-fit flex gap-2 transition-all duration-100 border border-dashed text-grayA-12",
+ isSelected ? "bg-grayA-4 border-grayA-7" : "bg-grayA-3 border-grayA-6 group-hover:bg-grayA-4",
+ );
+
+ const emptyClassName = cn(
+ "rounded-md py-[2px] px-1.5 items-center w-fit flex gap-2 transition-all duration-100 border border-dashed bg-grayA-2",
+ isSelected ? "border-grayA-7 text-grayA-9" : "border-grayA-6 text-grayA-8",
+ );
+
+ if (total === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
{total} Permissions
+
+
+ );
+};
diff --git a/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/components/critical-perm-warning.tsx b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/components/critical-perm-warning.tsx
new file mode 100644
index 0000000000..f9f19a5860
--- /dev/null
+++ b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/components/critical-perm-warning.tsx
@@ -0,0 +1,39 @@
+import type { RootKey } from "@/lib/trpc/routers/settings/root-keys/query";
+import { InfoTooltip } from "@unkey/ui";
+import { cn } from "@unkey/ui/src/lib/utils";
+
+type CriticalPermissionIndicatorProps = {
+ rootKey: RootKey;
+ isSelected: boolean;
+};
+
+export const CriticalPermissionIndicator = ({
+ rootKey,
+ isSelected,
+}: CriticalPermissionIndicatorProps) => {
+ const hasCriticalPerm = rootKey.permissionSummary.hasCriticalPerm;
+
+ if (!hasCriticalPerm) {
+ return
;
+ }
+ return (
+
+
+ This root key has critical permissions that can permanently destroy data or compromise
+ security.
+
+
+ }
+ >
+
+
+ );
+};
diff --git a/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/components/last-updated.tsx b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/components/last-updated.tsx
new file mode 100644
index 0000000000..0cdf9e6863
--- /dev/null
+++ b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/components/last-updated.tsx
@@ -0,0 +1,50 @@
+import { cn } from "@/lib/utils";
+import { ChartActivity2 } from "@unkey/icons";
+import { Badge, TimestampInfo } from "@unkey/ui";
+import { useRef, useState } from "react";
+import { STATUS_STYLES } from "../utils/get-row-class";
+
+export const LastUpdated = ({
+ isSelected,
+ lastUpdated,
+}: {
+ isSelected: boolean;
+ lastUpdated?: number | null;
+}) => {
+ const badgeRef = useRef(null);
+ const [showTooltip, setShowTooltip] = useState(false);
+
+ return (
+ {
+ setShowTooltip(true);
+ }}
+ onMouseLeave={() => {
+ setShowTooltip(false);
+ }}
+ >
+
+
+
+
+ {lastUpdated ? (
+
+ ) : (
+ "Never used"
+ )}
+
+
+ );
+};
diff --git a/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/components/skeletons.tsx b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/components/skeletons.tsx
new file mode 100644
index 0000000000..c0f364e45d
--- /dev/null
+++ b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/components/skeletons.tsx
@@ -0,0 +1,66 @@
+import { cn } from "@/lib/utils";
+import { ChartActivity2, Dots, Key2, Page2 } from "@unkey/icons";
+
+export const RootKeyColumnSkeleton = () => (
+
+);
+
+export const CreatedAtColumnSkeleton = () => (
+
+);
+export const KeyColumnSkeleton = () => (
+
+);
+
+export const AssignedKeysColumnSkeleton = () => (
+
+);
+
+export const PermissionsColumnSkeleton = () => (
+
+);
+
+export const LastUpdatedColumnSkeleton = () => (
+
+);
+
+export const ActionColumnSkeleton = () => (
+
+
+
+);
diff --git a/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/hooks/use-root-keys-list-query.ts b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/hooks/use-root-keys-list-query.ts
new file mode 100644
index 0000000000..6a2e3816d5
--- /dev/null
+++ b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/hooks/use-root-keys-list-query.ts
@@ -0,0 +1,82 @@
+import { trpc } from "@/lib/trpc/client";
+import type { RootKey } from "@/lib/trpc/routers/settings/root-keys/query";
+import { useEffect, useMemo, useState } from "react";
+import { rootKeysFilterFieldConfig, rootKeysListFilterFieldNames } from "../../../filters.schema";
+import { useFilters } from "../../../hooks/use-filters";
+import type { RootKeysQueryPayload } from "../query-logs.schema";
+
+export function useRootKeysListQuery() {
+ const [totalCount, setTotalCount] = useState(0);
+ const [rootKeysMap, setRootKeysMap] = useState(() => new Map());
+ const { filters } = useFilters();
+
+ const rootKeys = useMemo(() => Array.from(rootKeysMap.values()), [rootKeysMap]);
+
+ const queryParams = useMemo(() => {
+ const params: RootKeysQueryPayload = {
+ ...Object.fromEntries(rootKeysListFilterFieldNames.map((field) => [field, []])),
+ };
+
+ filters.forEach((filter) => {
+ if (!rootKeysListFilterFieldNames.includes(filter.field) || !params[filter.field]) {
+ return;
+ }
+
+ const fieldConfig = rootKeysFilterFieldConfig[filter.field];
+ const validOperators = fieldConfig.operators;
+ if (!validOperators.includes(filter.operator)) {
+ throw new Error("Invalid operator");
+ }
+
+ if (typeof filter.value === "string") {
+ params[filter.field]?.push({
+ operator: filter.operator,
+ value: filter.value,
+ });
+ }
+ });
+
+ return params;
+ }, [filters]);
+
+ const {
+ data: rootKeyData,
+ hasNextPage,
+ fetchNextPage,
+ isFetchingNextPage,
+ isLoading: isLoadingInitial,
+ } = trpc.settings.rootKeys.query.useInfiniteQuery(queryParams, {
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ staleTime: Number.POSITIVE_INFINITY,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ });
+
+ useEffect(() => {
+ if (rootKeyData) {
+ const newMap = new Map();
+
+ rootKeyData.pages.forEach((page) => {
+ page.keys.forEach((rootKey) => {
+ // Use slug as the unique identifier
+ newMap.set(rootKey.id, rootKey);
+ });
+ });
+
+ if (rootKeyData.pages.length > 0) {
+ setTotalCount(rootKeyData.pages[0].total);
+ }
+
+ setRootKeysMap(newMap);
+ }
+ }, [rootKeyData]);
+
+ return {
+ rootKeys,
+ isLoading: isLoadingInitial,
+ hasMore: hasNextPage,
+ loadMore: fetchNextPage,
+ isLoadingMore: isFetchingNextPage,
+ totalCount,
+ };
+}
diff --git a/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/query-logs.schema.ts b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/query-logs.schema.ts
new file mode 100644
index 0000000000..171b7d5746
--- /dev/null
+++ b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/query-logs.schema.ts
@@ -0,0 +1,25 @@
+import { z } from "zod";
+import { rootKeysFilterOperatorEnum, rootKeysListFilterFieldNames } from "../../filters.schema";
+
+const filterItemSchema = z.object({
+ operator: rootKeysFilterOperatorEnum,
+ value: z.string(),
+});
+
+const baseFilterArraySchema = z.array(filterItemSchema).nullish();
+
+const filterFieldsSchema = rootKeysListFilterFieldNames.reduce(
+ (acc, fieldName) => {
+ acc[fieldName] = baseFilterArraySchema;
+ return acc;
+ },
+ {} as Record,
+);
+
+const baseRootKeysSchema = z.object(filterFieldsSchema);
+
+export const rootKeysQueryPayload = baseRootKeysSchema.extend({
+ cursor: z.number().nullish(),
+});
+
+export type RootKeysQueryPayload = z.infer;
diff --git a/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/root-keys-list.tsx b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/root-keys-list.tsx
new file mode 100644
index 0000000000..f69e7ad920
--- /dev/null
+++ b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/root-keys-list.tsx
@@ -0,0 +1,227 @@
+"use client";
+import { HiddenValueCell } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/hidden-value";
+import { VirtualTable } from "@/components/virtual-table/index";
+import type { Column } from "@/components/virtual-table/types";
+import type { RootKey } from "@/lib/trpc/routers/settings/root-keys/query";
+import { BookBookmark, Dots, Key2 } from "@unkey/icons";
+import { Button, Empty, InfoTooltip, TimestampInfo } from "@unkey/ui";
+import { cn } from "@unkey/ui/src/lib/utils";
+import { useMemo, useState } from "react";
+import { AssignedItemsCell } from "./components/assigned-items-cell";
+import { CriticalPermissionIndicator } from "./components/critical-perm-warning";
+import { LastUpdated } from "./components/last-updated";
+import {
+ ActionColumnSkeleton,
+ AssignedKeysColumnSkeleton,
+ CreatedAtColumnSkeleton,
+ KeyColumnSkeleton,
+ LastUpdatedColumnSkeleton,
+ RootKeyColumnSkeleton,
+} from "./components/skeletons";
+import { useRootKeysListQuery } from "./hooks/use-root-keys-list-query";
+import { getRowClassName } from "./utils/get-row-class";
+
+export const RootKeysList = () => {
+ const { rootKeys, isLoading, isLoadingMore, loadMore, totalCount, hasMore } =
+ useRootKeysListQuery();
+ const [selectedRootKey, setSelectedRootKey] = useState(null);
+
+ const columns: Column[] = useMemo(
+ () => [
+ {
+ key: "root_key",
+ header: "Key",
+ width: "15%",
+ headerClassName: "pl-[18px]",
+ render: (rootKey) => {
+ const isSelected = rootKey.id === selectedRootKey?.id;
+ const iconContainer = (
+
+
+
+ );
+ return (
+
+
+ {iconContainer}
+
+
+ {rootKey.name ?? "Unnamed Root Key"}
+
+
+
+
+
+ );
+ },
+ },
+ {
+ key: "key",
+ header: "Key",
+ width: "15%",
+ render: (rootKey) => (
+
+ This is the first part of the key to visually match it. We don't store the full key
+ for security reasons.
+
+ }
+ >
+
+
+ ),
+ },
+ {
+ key: "created_at",
+ header: "Created At",
+ width: "15%",
+ render: (rootKey) => {
+ return (
+
+ );
+ },
+ },
+ {
+ key: "permissions",
+ header: "Permissions",
+ width: "20%",
+ render: (rootKey) => (
+
+ ),
+ },
+ {
+ key: "last_updated",
+ header: "Last Updated",
+ width: "20%",
+ render: (rootKey) => {
+ return (
+
+ );
+ },
+ },
+ {
+ key: "action",
+ header: "",
+ width: "auto",
+ render: () => {
+ return (
+
+
+
+ );
+ },
+ },
+ ],
+ [selectedRootKey?.id],
+ );
+
+ return (
+ rootKey.roleId}
+ rowClassName={(rootKey) => getRowClassName(rootKey, selectedRootKey)}
+ loadMoreFooterProps={{
+ hide: isLoading,
+ buttonText: "Load more root keys",
+ hasMore,
+ countInfoText: (
+
+ Showing {rootKeys.length}
+ of
+ {totalCount}
+ root keys
+
+ ),
+ }}
+ emptyState={
+
+
+
+ No Root Keys Found
+
+ There are no root keys configured yet. Create your first role to start managing
+ permissions and access control.
+
+
+
+
+
+ Learn about Root Keys
+
+
+
+
+
+ }
+ config={{
+ rowHeight: 52,
+ layoutMode: "grid",
+ rowBorders: true,
+ containerPadding: "px-0",
+ }}
+ renderSkeletonRow={({ columns, rowHeight }) =>
+ columns.map((column) => (
+
+ {column.key === "root_key" && }
+ {column.key === "key" && }
+ {column.key === "created_at" && }
+ {column.key === "permissions" && }
+ {column.key === "last_updated" && }
+ {column.key === "action" && }
+
+ ))
+ }
+ />
+ );
+};
diff --git a/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/utils/get-row-class.ts b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/utils/get-row-class.ts
new file mode 100644
index 0000000000..37f73f41d3
--- /dev/null
+++ b/apps/dashboard/app/(app)/settings/root-keys-v2/components/table/utils/get-row-class.ts
@@ -0,0 +1,38 @@
+import type { RootKey } from "@/lib/trpc/routers/settings/root-keys/query";
+import { cn } from "@/lib/utils";
+
+export type StatusStyle = {
+ base: string;
+ hover: string;
+ selected: string;
+ badge: {
+ default: string;
+ selected: string;
+ };
+ focusRing: string;
+};
+
+export const STATUS_STYLES = {
+ base: "text-grayA-9",
+ hover: "hover:text-accent-11 dark:hover:text-accent-12 hover:bg-grayA-2",
+ selected: "text-accent-12 bg-grayA-2 hover:text-accent-12",
+ badge: {
+ default: "bg-grayA-3 text-grayA-11 group-hover:bg-grayA-5 border-transparent",
+ selected: "bg-grayA-5 text-grayA-12 hover:bg-grayA-5 border-grayA-3",
+ },
+ focusRing: "focus:ring-accent-7",
+};
+
+export const getRowClassName = (log: RootKey, selectedRow: RootKey | null) => {
+ const style = STATUS_STYLES;
+ const isSelected = log.id === selectedRow?.id;
+
+ return cn(
+ style.base,
+ style.hover,
+ "group rounded",
+ "focus:outline-none focus:ring-1 focus:ring-opacity-40",
+ style.focusRing,
+ isSelected && style.selected,
+ );
+};
diff --git a/apps/dashboard/app/(app)/settings/root-keys-v2/hooks/use-filters.ts b/apps/dashboard/app/(app)/settings/root-keys-v2/hooks/use-filters.ts
new file mode 100644
index 0000000000..0cda8afb4e
--- /dev/null
+++ b/apps/dashboard/app/(app)/settings/root-keys-v2/hooks/use-filters.ts
@@ -0,0 +1,106 @@
+import { useQueryStates } from "nuqs";
+import { useCallback, useMemo } from "react";
+import {
+ type AllOperatorsUrlValue,
+ type RootKeysFilterField,
+ type RootKeysFilterValue,
+ type RootKeysQuerySearchParams,
+ parseAsAllOperatorsFilterArray,
+ rootKeysFilterFieldConfig,
+ rootKeysListFilterFieldNames,
+} from "../filters.schema";
+
+export const queryParamsPayload = Object.fromEntries(
+ rootKeysListFilterFieldNames.map((field) => [field, parseAsAllOperatorsFilterArray]),
+) as { [K in RootKeysFilterField]: typeof parseAsAllOperatorsFilterArray };
+
+export const useFilters = () => {
+ const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload, {
+ history: "push",
+ });
+
+ const filters = useMemo(() => {
+ const activeFilters: RootKeysFilterValue[] = [];
+
+ for (const field of rootKeysListFilterFieldNames) {
+ const value = searchParams[field];
+ if (!Array.isArray(value)) {
+ continue;
+ }
+
+ for (const filterItem of value) {
+ if (filterItem && typeof filterItem.value === "string" && filterItem.operator) {
+ const baseFilter: RootKeysFilterValue = {
+ id: crypto.randomUUID(),
+ field: field,
+ operator: filterItem.operator,
+ value: filterItem.value,
+ };
+ activeFilters.push(baseFilter);
+ }
+ }
+ }
+
+ return activeFilters;
+ }, [searchParams]);
+
+ const updateFilters = useCallback(
+ (newFilters: RootKeysFilterValue[]) => {
+ const newParams: Partial = Object.fromEntries(
+ rootKeysListFilterFieldNames.map((field) => [field, null]),
+ );
+
+ const filtersByField = new Map();
+ rootKeysListFilterFieldNames.forEach((field) => filtersByField.set(field, []));
+
+ newFilters.forEach((filter) => {
+ if (!rootKeysListFilterFieldNames.includes(filter.field)) {
+ throw new Error(`Invalid filter field: ${filter.field}`);
+ }
+
+ const fieldConfig = rootKeysFilterFieldConfig[filter.field];
+ if (!fieldConfig.operators.includes(filter.operator)) {
+ throw new Error(`Invalid operator '${filter.operator}' for field '${filter.field}'`);
+ }
+
+ if (typeof filter.value !== "string") {
+ throw new Error(`Filter value must be a string for field '${filter.field}'`);
+ }
+
+ const fieldFilters = filtersByField.get(filter.field);
+ if (!fieldFilters) {
+ throw new Error(`Failed to get filters for field '${filter.field}'`);
+ }
+
+ fieldFilters.push({
+ value: filter.value,
+ operator: filter.operator,
+ });
+ });
+
+ // Set non-empty filter arrays in params
+ filtersByField.forEach((fieldFilters, field) => {
+ if (fieldFilters.length > 0) {
+ newParams[field] = fieldFilters;
+ }
+ });
+
+ setSearchParams(newParams);
+ },
+ [setSearchParams],
+ );
+
+ const removeFilter = useCallback(
+ (id: string) => {
+ const newFilters = filters.filter((f) => f.id !== id);
+ updateFilters(newFilters);
+ },
+ [filters, updateFilters],
+ );
+
+ return {
+ filters,
+ removeFilter,
+ updateFilters,
+ };
+};
diff --git a/apps/dashboard/app/(app)/settings/root-keys-v2/page.tsx b/apps/dashboard/app/(app)/settings/root-keys-v2/page.tsx
index 7d438f976b..f01e66ff16 100644
--- a/apps/dashboard/app/(app)/settings/root-keys-v2/page.tsx
+++ b/apps/dashboard/app/(app)/settings/root-keys-v2/page.tsx
@@ -1,12 +1,8 @@
"use client";
-import { trpc } from "@/lib/trpc/client";
+import { RootKeysList } from "./components/table/root-keys-list";
import { Navigation } from "./navigation";
export default function RootKeysPage() {
- const { data, isLoading, error } = trpc.settings.rootKeys.query.useQuery({
- limit: 10,
- });
-
return (
-
-
Root Keys
-
- {isLoading &&
Loading root keys...
}
-
- {error && (
-
- Error loading root keys: {error.message}
-
- )}
-
- {data && (
-
-
- Showing {data.keys.length} of {data.total} keys
- {data.hasMore && " (more available)"}
-
-
-
- {data.keys.map((key) => (
-
- {JSON.stringify(key, null, 2)}
-
- ))}
-
-
- )}
+
+
);
diff --git a/apps/dashboard/lib/trpc/routers/settings/root-keys/query.ts b/apps/dashboard/lib/trpc/routers/settings/root-keys/query.ts
index 7158bb81be..ef0c275fa7 100644
--- a/apps/dashboard/lib/trpc/routers/settings/root-keys/query.ts
+++ b/apps/dashboard/lib/trpc/routers/settings/root-keys/query.ts
@@ -1,13 +1,9 @@
+import { rootKeysQueryPayload } from "@/app/(app)/settings/root-keys-v2/components/table/query-logs.schema";
import { and, count, db, desc, eq, isNull, lt, schema } from "@/lib/db";
import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
-const queryRootKeysPayload = z.object({
- limit: z.number().int().min(1).max(100).default(50),
- cursor: z.number().int().optional(),
-});
-
const PermissionResponse = z.object({
id: z.string(),
name: z.string(),
@@ -18,12 +14,11 @@ const RootKeyResponse = z.object({
start: z.string(),
createdAt: z.number(),
lastUpdatedAt: z.number().nullable(),
- ownerId: z.string().nullable(),
name: z.string().nullable(),
permissionSummary: z.object({
total: z.number(),
- categories: z.record(z.number()), // { "API": 4, "Keys": 6, "Ratelimit": 2 }
- hasCriticalPerm: z.boolean(), // delete, decrypt permissions
+ categories: z.record(z.number()),
+ hasCriticalPerm: z.boolean(),
}),
permissions: z.array(PermissionResponse),
});
@@ -37,12 +32,15 @@ const RootKeysResponse = z.object({
type PermissionResponse = z.infer
;
type RootKeysResponse = z.infer;
+export type RootKey = z.infer;
+
+export const LIMIT = 50;
export const queryRootKeys = t.procedure
.use(requireUser)
.use(requireWorkspace)
.use(withRatelimit(ratelimit.read))
- .input(queryRootKeysPayload)
+ .input(rootKeysQueryPayload)
.output(RootKeysResponse)
.query(async ({ ctx, input }) => {
// Build base conditions
@@ -52,7 +50,7 @@ export const queryRootKeys = t.procedure
];
// Add cursor condition for pagination
- if (input.cursor) {
+ if (input.cursor && typeof input.cursor === "number") {
baseConditions.push(lt(schema.keys.createdAtM, input.cursor));
}
@@ -65,13 +63,12 @@ export const queryRootKeys = t.procedure
db.query.keys.findMany({
where: and(...baseConditions),
orderBy: [desc(schema.keys.createdAtM)],
- limit: input.limit + 1, // Get one extra to check if there are more
+ limit: LIMIT + 1, // Get one extra to check if there are more
columns: {
id: true,
start: true,
createdAtM: true,
updatedAtM: true,
- ownerId: true,
name: true,
},
with: {
@@ -93,8 +90,8 @@ export const queryRootKeys = t.procedure
]);
// Check if we have more results
- const hasMore = keysResult.length > input.limit;
- const keysWithoutExtra = hasMore ? keysResult.slice(0, input.limit) : keysResult;
+ const hasMore = keysResult.length > LIMIT;
+ const keysWithoutExtra = hasMore ? keysResult.slice(0, LIMIT) : keysResult;
// Transform the data to flatten permissions and add summary
const keys = keysWithoutExtra.map((key) => {
@@ -113,7 +110,6 @@ export const queryRootKeys = t.procedure
start: key.start,
createdAt: key.createdAtM,
lastUpdatedAt: key.updatedAtM,
- ownerId: key.ownerId,
name: key.name,
permissionSummary,
permissions,
@@ -156,17 +152,22 @@ function categorizePermissions(permissions: PermissionResponse[]) {
// Extract category from permission name (e.g., "api.*.create_key" -> "api")
const parts = permission.name.split(".");
- if (parts.length < 2) {
+ if (parts.length < 3) {
console.warn(`Invalid permission format: ${permission.name}`);
continue;
}
- const [identifier] = parts;
+ const [identifier, , action] = parts;
let category: string;
switch (identifier) {
case "api":
- category = "API";
+ // Separate API permissions from key permissions
+ if (action.includes("key")) {
+ category = "Keys";
+ } else {
+ category = "API";
+ }
break;
case "ratelimit":
category = "Ratelimit";