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 ( +
+
+ + None assigned +
+
+ ); + } + + 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. + + + + + + + +
+ } + 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";