diff --git a/apps/dashboard/app/(app)/settings/root-keys-v2/filters.schema.ts b/apps/dashboard/app/(app)/settings/root-keys-v2/filters.schema.ts new file mode 100644 index 0000000000..f48c22946a --- /dev/null +++ b/apps/dashboard/app/(app)/settings/root-keys-v2/filters.schema.ts @@ -0,0 +1,70 @@ +import type { FilterValue, StringConfig } from "@/components/logs/validation/filter.types"; +import { parseAsFilterValueArray } from "@/components/logs/validation/utils/nuqs-parsers"; +import { createFilterOutputSchema } from "@/components/logs/validation/utils/structured-output-schema-generator"; +import { z } from "zod"; + +const commonStringOperators = ["is", "contains"] as const; + +export const rootKeysFilterOperatorEnum = z.enum(commonStringOperators); +export type RootKeysFilterOperator = z.infer; + +export type FilterFieldConfigs = { + name: StringConfig; + start: StringConfig; + identity: StringConfig; + permission: StringConfig; +}; + +export const rootKeysFilterFieldConfig: FilterFieldConfigs = { + name: { + type: "string", + operators: [...commonStringOperators], + }, + start: { + // Start of the `key` for security reasons + type: "string", + operators: [...commonStringOperators], + }, + identity: { + // ExternalId of creator user + type: "string", + operators: [...commonStringOperators], + }, + permission: { + type: "string", + operators: [...commonStringOperators], + }, +}; + +const allFilterFieldNames = Object.keys(rootKeysFilterFieldConfig) as (keyof FilterFieldConfigs)[]; + +if (allFilterFieldNames.length === 0) { + throw new Error("rootKeysFilterFieldConfig must contain at least one field definition."); +} + +const [firstFieldName, ...restFieldNames] = allFilterFieldNames; + +export const rootKeysFilterFieldEnum = z.enum([firstFieldName, ...restFieldNames]); +export const rootKeysListFilterFieldNames = allFilterFieldNames; +export type RootKeysFilterField = z.infer; + +export const filterOutputSchema = createFilterOutputSchema( + rootKeysFilterFieldEnum, + rootKeysFilterOperatorEnum, + rootKeysFilterFieldConfig, +); + +export type AllOperatorsUrlValue = { + value: string; + operator: RootKeysFilterOperator; +}; + +export type RootKeysFilterValue = FilterValue; + +export type RootKeysQuerySearchParams = { + [K in RootKeysFilterField]?: AllOperatorsUrlValue[] | null; +}; + +export const parseAsAllOperatorsFilterArray = parseAsFilterValueArray([ + ...commonStringOperators, +]); diff --git a/apps/dashboard/app/(app)/settings/root-keys-v2/navigation.tsx b/apps/dashboard/app/(app)/settings/root-keys-v2/navigation.tsx new file mode 100644 index 0000000000..c74d7a8302 --- /dev/null +++ b/apps/dashboard/app/(app)/settings/root-keys-v2/navigation.tsx @@ -0,0 +1,102 @@ +"use client"; +import { QuickNavPopover } from "@/components/navbar-popover"; +import { Navbar } from "@/components/navigation/navbar"; +import { Badge } from "@/components/ui/badge"; +import { ChevronExpandY, Gear } from "@unkey/icons"; +import { Button, CopyButton } from "@unkey/ui"; +import Link from "next/link"; + +const settingsNavbar = [ + { + id: "general", + href: "general", + text: "General", + }, + { + id: "team", + href: "team", + text: "Team", + }, + { + id: "root-keys-v2", + href: "root-keys-v2", + text: "Root Keys", + }, + { + id: "billing", + href: "billing", + text: "Billing", + }, +]; + +export const Navigation = ({ + workspace, + activePage, +}: { + workspace: { + id: string; + name: string; + }; + activePage: { + href: string; + text: string; + }; +}) => { + return ( +
+ + }> + Settings + + [ + { + id: setting.href, + label: setting.text, + href: `/settings/${setting.href}`, + }, + ])} + shortcutKey="M" + > +
+ {activePage.text} + +
+
+
+
+ + {activePage.href === "general" && ( + + {workspace.id} + + + )} + {activePage.href === "root-keys-v2" && ( + + + + )} + {activePage.href === "billing" && ( + <> + + + + + + + + )} + +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/settings/root-keys-v2/page.tsx b/apps/dashboard/app/(app)/settings/root-keys-v2/page.tsx new file mode 100644 index 0000000000..7d438f976b --- /dev/null +++ b/apps/dashboard/app/(app)/settings/root-keys-v2/page.tsx @@ -0,0 +1,52 @@ +"use client"; +import { trpc } from "@/lib/trpc/client"; +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/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index b8ed2cda35..8e8d24f43d 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -85,6 +85,7 @@ import { disconnectRoleFromKey } from "./rbac/disconnectRoleFromKey"; import { removePermissionFromRootKey } from "./rbac/removePermissionFromRootKey"; import { updatePermission } from "./rbac/updatePermission"; import { updateRole } from "./rbac/updateRole"; +import { queryRootKeys } from "./settings/root-keys/query"; import { cancelSubscription } from "./stripe/cancelSubscription"; import { createSubscription } from "./stripe/createSubscription"; import { uncancelSubscription } from "./stripe/uncancelSubscription"; @@ -121,6 +122,11 @@ export const router = t.router({ name: updateRootKeyName, }), }), + settings: t.router({ + rootKeys: t.router({ + query: queryRootKeys, + }), + }), api: t.router({ create: createApi, delete: deleteApi, diff --git a/apps/dashboard/lib/trpc/routers/settings/root-keys/query.ts b/apps/dashboard/lib/trpc/routers/settings/root-keys/query.ts new file mode 100644 index 0000000000..7158bb81be --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/settings/root-keys/query.ts @@ -0,0 +1,199 @@ +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(), +}); + +const RootKeyResponse = z.object({ + id: z.string(), + 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 + }), + permissions: z.array(PermissionResponse), +}); + +const RootKeysResponse = z.object({ + keys: z.array(RootKeyResponse), + hasMore: z.boolean(), + total: z.number(), + nextCursor: z.number().int().optional(), +}); + +type PermissionResponse = z.infer; +type RootKeysResponse = z.infer; + +export const queryRootKeys = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(queryRootKeysPayload) + .output(RootKeysResponse) + .query(async ({ ctx, input }) => { + // Build base conditions + const baseConditions = [ + eq(schema.keys.forWorkspaceId, ctx.workspace.id), + isNull(schema.keys.deletedAtM), + ]; + + // Add cursor condition for pagination + if (input.cursor) { + baseConditions.push(lt(schema.keys.createdAtM, input.cursor)); + } + + try { + const [totalResult, keysResult] = await Promise.all([ + db + .select({ count: count() }) + .from(schema.keys) + .where(and(...baseConditions)), + 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 + columns: { + id: true, + start: true, + createdAtM: true, + updatedAtM: true, + ownerId: true, + name: true, + }, + with: { + permissions: { + columns: { + permissionId: true, + }, + with: { + permission: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + }, + }), + ]); + + // Check if we have more results + const hasMore = keysResult.length > input.limit; + const keysWithoutExtra = hasMore ? keysResult.slice(0, input.limit) : keysResult; + + // Transform the data to flatten permissions and add summary + const keys = keysWithoutExtra.map((key) => { + const permissions = key.permissions + .map((p) => p.permission) + .filter(Boolean) + .map((permission) => ({ + id: permission.id, + name: permission.name, + })); + + const permissionSummary = categorizePermissions(permissions); + + return { + id: key.id, + start: key.start, + createdAt: key.createdAtM, + lastUpdatedAt: key.updatedAtM, + ownerId: key.ownerId, + name: key.name, + permissionSummary, + permissions, + }; + }); + + const response: RootKeysResponse = { + keys, + hasMore, + total: totalResult[0]?.count ?? 0, + nextCursor: keys.length > 0 ? keys[keys.length - 1].createdAt : undefined, + }; + + return response; + } catch (error) { + console.error("Error querying root keys:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to retrieve root keys due to an error. If this issue persists, please contact support@unkey.dev with the time this occurred.", + }); + } + }); + +const CRITICAL_PERMISSION_PATTERNS = ["delete", "decrypt", "remove"] as const; + +function categorizePermissions(permissions: PermissionResponse[]) { + if (!Array.isArray(permissions)) { + throw new Error("Invalid permissions array"); + } + + const categories: Record = {}; + let hasCriticalPerm = false; + + for (const permission of permissions) { + if (!permission?.name || typeof permission.name !== "string") { + console.warn("Invalid permission object:", permission); + continue; + } + + // Extract category from permission name (e.g., "api.*.create_key" -> "api") + const parts = permission.name.split("."); + if (parts.length < 2) { + console.warn(`Invalid permission format: ${permission.name}`); + continue; + } + + const [identifier] = parts; + let category: string; + + switch (identifier) { + case "api": + category = "API"; + break; + case "ratelimit": + category = "Ratelimit"; + break; + case "rbac": + category = "Permissions"; + break; + case "identity": + category = "Identities"; + break; + default: + category = "Other"; + console.warn(`Unknown permission identifier: ${identifier}`); + } + + categories[category] = (categories[category] || 0) + 1; + + // Check for critical permissions + const permissionName = permission.name.toLowerCase(); + if (CRITICAL_PERMISSION_PATTERNS.some((pattern) => permissionName.includes(pattern))) { + hasCriticalPerm = true; + } + } + + return { + total: permissions.length, + categories, + hasCriticalPerm, + }; +}