From aa6a856413c85a3acdb76bd3194a772fd3edd992 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 27 Mar 2025 15:16:27 +0300 Subject: [PATCH 01/38] add: initial layout --- .../apis/[apiId]/keys/[keyAuthId]/page.tsx | 67 ++++++-- .../[apiId]/keys/[keyAuthId]/virtual-keys.tsx | 159 ++++++++++++++++++ .../components/virtual-table/constants.ts | 4 + .../components/virtual-table/index.tsx | 128 +++++++++++--- .../components/virtual-table/types.ts | 12 +- 5 files changed, 329 insertions(+), 41 deletions(-) create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/virtual-keys.tsx diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/page.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/page.tsx index d3a104279b..a86764dc73 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/page.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/page.tsx @@ -1,12 +1,8 @@ import { getTenantId } from "@/lib/auth"; import { db } from "@/lib/db"; import { notFound } from "next/navigation"; - -import { Navbar as SubMenu } from "@/components/dashboard/navbar"; -import { PageContent } from "@/components/page-content"; -import { navigation } from "../../constants"; -import { Keys } from "./keys"; import { Navigation } from "./navigation"; +import { VirtualKeys } from "./virtual-keys"; export const dynamic = "force-dynamic"; export const runtime = "edge"; @@ -18,7 +14,6 @@ export default async function APIKeysPage(props: { }; }) { const tenantId = getTenantId(); - const keyAuth = await db.query.keyAuth.findFirst({ where: (table, { eq, and, isNull }) => and(eq(table.id, props.params.keyAuthId), isNull(table.deletedAtM)), @@ -27,21 +22,65 @@ export default async function APIKeysPage(props: { api: true, }, }); + if (!keyAuth || keyAuth.workspace.tenantId !== tenantId) { return notFound(); } + // Fetch keys data + const keys = await db.query.keys.findMany({ + where: (table, { and, eq, isNull }) => + and(eq(table.keyAuthId, props.params.keyAuthId), isNull(table.deletedAtM)), + limit: 100, + with: { + identity: { + columns: { + externalId: true, + }, + }, + roles: { + with: { + role: { + with: { + permissions: true, + }, + }, + }, + }, + permissions: true, + }, + }); + + // Transform the data for the client component + const transformedKeys = keys.map((key) => { + const permissions = new Set(key.permissions.map((p) => p.permissionId)); + for (const role of key.roles) { + for (const permission of role.role.permissions) { + permissions.add(permission.permissionId); + } + } + + return { + id: key.id, + keyAuthId: key.keyAuthId, + name: key.name, + start: key.start, + roles: key.roles.length, + enabled: key.enabled, + permissions: permissions.size, + environment: key.environment, + externalId: key.identity?.externalId ?? key.ownerId ?? null, + }; + }); + return (
- - - - -
- -
-
+
); } diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/virtual-keys.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/virtual-keys.tsx new file mode 100644 index 0000000000..8cf7ff38ae --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/virtual-keys.tsx @@ -0,0 +1,159 @@ +"use client"; +import { CreateKeyButton } from "@/components/dashboard/create-key-button"; +import BackButton from "@/components/ui/back-button"; +import { Badge } from "@/components/ui/badge"; +import { VirtualTable } from "@/components/virtual-table/index"; +import type { Column } from "@/components/virtual-table/types"; +import { formatNumber } from "@/lib/fmt"; +import { Key } from "@unkey/icons"; +import { Empty } from "@unkey/ui"; +import { Button } from "@unkey/ui"; +import { ChevronRight } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +type KeyData = { + id: string; + keyAuthId: string; + name: string | null; + start: string | null; + roles: number; + permissions: number; + enabled: boolean; + environment: string | null; + externalId: string | null; +}; + +type Props = { + keys: KeyData[]; + apiId: string; + keyAuthId: string; +}; + +export const VirtualKeys: React.FC = ({ keys, apiId, keyAuthId }) => { + const router = useRouter(); + const [selectedKey, setSelectedKey] = useState(null); + + const handleRowClick = (key: KeyData) => { + setSelectedKey(key); + router.push(`/apis/${apiId}/keys/${key.keyAuthId}/${key.id}`); + }; + + console.log({ keys }); + + const columns = (): Column[] => { + return [ + { + key: "key", + header: "Key", + width: "25%", + headerClassName: "pl-[18px]", + render: (key) => ( +
+
+
+ +
+
+
+ {key.id.substring(0, 8)}... + {key.id.substring(key.id.length - 4)} +
+ + {key.name} +
+
+
+ ), + }, + { + key: "value", + header: "Value", + width: "20%", + render: (key) =>
{key.permissions}
, + }, + { + key: "environment", + header: "Environment", + width: "10%", + render: (key) => ( +
+ {key.environment ? env: {key.environment} : null} +
+ ), + }, + { + key: "permissions", + header: "Permissions", + width: "15%", + render: (key) => ( + + {formatNumber(key.permissions)} Permission + {key.permissions !== 1 ? "s" : ""} + + ), + }, + { + key: "roles", + header: "Roles", + width: "15%", + render: (key) => ( + + {formatNumber(key.roles)} Role{key.roles !== 1 ? "s" : ""} + + ), + }, + { + key: "status", + header: "Status", + width: "10%", + render: (key) =>
{!key.enabled && Disabled}
, + }, + { + key: "actions", + header: "", + width: "5%", + render: () => ( +
+ +
+ ), + }, + ]; + }; + + // If there are no keys, show the empty state + if (keys.length === 0) { + return ( + + + No keys found + Create your first key + + + Go Back + + + ); + } + + return ( +
+ key.id} + config={{ + rowHeight: 52, + layoutMode: "grid", + rowBorders: true, + containerPadding: "px-0", + }} + /> +
+ ); +}; diff --git a/apps/dashboard/components/virtual-table/constants.ts b/apps/dashboard/components/virtual-table/constants.ts index bb044d355f..8882478b54 100644 --- a/apps/dashboard/components/virtual-table/constants.ts +++ b/apps/dashboard/components/virtual-table/constants.ts @@ -7,4 +7,8 @@ export const DEFAULT_CONFIG: TableConfig = { tableBorder: 1, throttleDelay: 350, headerHeight: 40, + layoutMode: "classic", // Default to classic table layout + rowBorders: false, // Default to no borders + containerPadding: "px-2", // Default container padding + rowSpacing: 4, // Default spacing between rows (classic mode) } as const; diff --git a/apps/dashboard/components/virtual-table/index.tsx b/apps/dashboard/components/virtual-table/index.tsx index d72216af0f..f22f70be40 100644 --- a/apps/dashboard/components/virtual-table/index.tsx +++ b/apps/dashboard/components/virtual-table/index.tsx @@ -2,7 +2,6 @@ import { cn } from "@/lib/utils"; import { CaretDown, CaretExpandY, CaretUp, CircleCarretRight } from "@unkey/icons"; import { Fragment, type Ref, forwardRef, useImperativeHandle, useMemo, useRef } from "react"; import { EmptyState } from "./components/empty-state"; -import { LoadingIndicator } from "./components/loading-indicator"; import { DEFAULT_CONFIG } from "./constants"; import { useTableData } from "./hooks/useTableData"; import { useTableHeight } from "./hooks/useTableHeight"; @@ -34,7 +33,10 @@ export type VirtualTableRef = { export const VirtualTable = forwardRef>( function VirtualTable( - { + props: VirtualTableProps, + ref: Ref | undefined, + ) { + const { data: historicData, realtimeData = [], columns, @@ -48,10 +50,11 @@ export const VirtualTable = forwardRef>( selectedClassName, selectedItem, isFetchingNextPage, - }: VirtualTableProps, - ref: Ref | undefined, - ) { + } = props; + + // Merge configs, allowing specific overrides const config = { ...DEFAULT_CONFIG, ...userConfig }; + const isGridLayout = config.layoutMode === "grid"; const parentRef = useRef(null); const containerRef = useRef(null); @@ -67,6 +70,16 @@ export const VirtualTable = forwardRef>( parentRef, }); + const tableClassName = cn( + "w-full", + isGridLayout ? "border-collapse" : "border-separate border-spacing-0", + ); + + const containerClassName = cn( + "overflow-auto relative", + config.containerPadding || "px-2", // Default to px-2 if containerPadding is not specified + ); + // Expose refs and methods to parent components. Primarily used for anchoring log details. useImperativeHandle( ref, @@ -86,7 +99,7 @@ export const VirtualTable = forwardRef>( style={{ height: `${fixedHeight}px` }} ref={containerRef} > - +
{columns.map((column) => ( @@ -119,12 +132,8 @@ export const VirtualTable = forwardRef>( return (
-
-
+
+
{colWidths.map((col, idx) => ( // biome-ignore lint/suspicious/noArrayIndexKey: @@ -186,17 +195,14 @@ export const VirtualTable = forwardRef>( const separator = item as SeparatorItem; if (separator.isSeparator) { return ( - - - - - - + + + ); } @@ -205,9 +211,75 @@ export const VirtualTable = forwardRef>( ? keyExtractor(selectedItem) === keyExtractor(typedItem) : false; + if (isGridLayout) { + // Grid layout: single row with optional border + return ( + onRowClick?.(typedItem)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + onRowClick?.(null); + const activeElement = document.activeElement as HTMLElement; + activeElement?.blur(); + } + if (event.key === "ArrowDown" || event.key === "j") { + event.preventDefault(); + const nextElement = document.querySelector( + `[data-index="${virtualRow.index + 1}"]`, + ) as HTMLElement; + if (nextElement) { + nextElement.focus(); + nextElement.click(); + } + } + if (event.key === "ArrowUp" || event.key === "k") { + event.preventDefault(); + const prevElement = document.querySelector( + `[data-index="${virtualRow.index - 1}"]`, + ) as HTMLElement; + if (prevElement) { + prevElement.focus(); + prevElement.click(); + } + } + }} + className={cn( + "cursor-pointer transition-colors hover:bg-accent/50 focus:outline-none focus:ring-1 focus:ring-opacity-40", + config.rowBorders && "border-b border-gray-4", + rowClassName?.(typedItem), + selectedClassName?.(typedItem, isSelected), + )} + style={{ height: `${config.rowHeight}px` }} + > + {columns.map((column, idx) => ( + + ))} + + ); + } + // Classic layout: fragment with configurable spacer row return ( - + {(config.rowSpacing ?? 4) > 0 && ( + + )} >( }} className={cn( "cursor-pointer transition-colors hover:bg-accent/50 focus:outline-none focus:ring-1 focus:ring-opacity-40", + config.rowBorders && "border-b border-gray-4", // Still allow borders in classic mode rowClassName?.(typedItem), selectedClassName?.(typedItem, isSelected), )} @@ -276,7 +349,12 @@ export const VirtualTable = forwardRef>( />
-
- - Live -
-
+
+ + Live +
+
+ {column.render(typedItem)} +
- {isFetchingNextPage && } + ); diff --git a/apps/dashboard/components/virtual-table/types.ts b/apps/dashboard/components/virtual-table/types.ts index 0f355f9bc6..7a4db31bf3 100644 --- a/apps/dashboard/components/virtual-table/types.ts +++ b/apps/dashboard/components/virtual-table/types.ts @@ -21,14 +21,22 @@ export type Column = { }; }; -export type TableConfig = { +export type TableLayoutMode = "classic" | "grid"; + +export interface TableConfig { rowHeight: number; loadingRows: number; overscan: number; tableBorder: number; throttleDelay: number; headerHeight: number; -}; + + // Layout options + layoutMode?: TableLayoutMode; // 'classic' or 'grid' + rowBorders?: boolean; // Add borders between rows + containerPadding?: string; // Custom padding for container (e.g., 'px-0', 'px-4', 'p-2') + rowSpacing?: number; // Space between rows in pixels (for classic mode) +} export type VirtualTableProps = { data: T[]; From 553e4bdbcdaf839280c39b57eaa7098894652751 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 27 Mar 2025 17:50:05 +0300 Subject: [PATCH 02/38] feat: add new api keys page --- .../apis/[apiId]/keys/[keyAuthId]/page.tsx | 65 +- .../[apiId]/keys/[keyAuthId]/virtual-keys.tsx | 159 ---- .../[keyId]/_components/rbac-buttons.tsx | 53 ++ .../[keyAuthId]/[keyId]/navigation.tsx | 52 ++ .../keys_v2/[keyAuthId]/[keyId]/page.tsx | 410 +++++++++ .../[keyAuthId]/[keyId]/permission-list.tsx | 85 ++ .../[keyId]/settings/delete-key.tsx | 92 ++ .../[keyId]/settings/navigation.tsx | 59 ++ .../[keyAuthId]/[keyId]/settings/page.tsx | 81 ++ .../[keyId]/settings/update-key-enabled.tsx | 108 +++ .../settings/update-key-expiration.tsx | 154 ++++ .../[keyId]/settings/update-key-metadata.tsx | 120 +++ .../[keyId]/settings/update-key-name.tsx | 104 +++ .../[keyId]/settings/update-key-owner-id.tsx | 112 +++ .../[keyId]/settings/update-key-ratelimit.tsx | 200 +++++ .../[keyId]/settings/update-key-remaining.tsx | 307 +++++++ .../[keyId]/verification-table.tsx | 95 +++ .../components/control-cloud/index.tsx | 29 + .../components/logs-datetime/index.tsx | 93 ++ .../components/logs-filters/index.tsx | 128 +++ .../controls/components/logs-refresh.tsx | 17 + .../controls/components/logs-search/index.tsx | 63 ++ .../_components/components/controls/index.tsx | 28 + .../components/table/hooks/use-logs-query.ts | 97 +++ .../components/table/keys-list.tsx | 114 +++ .../components/table/query-logs.schema.ts | 40 + .../components/table/utils/get-row-class.ts | 42 + .../[keyAuthId]/_components/filters.schema.ts | 68 ++ .../_components/hooks/use-filters.ts | 127 +++ .../[keyAuthId]/_components/keys-client.tsx | 15 + .../keys_v2/[keyAuthId]/navigation.tsx | 51 ++ .../keys_v2/[keyAuthId]/new/client.tsx | 799 ++++++++++++++++++ .../keys_v2/[keyAuthId]/new/navigation.tsx | 52 ++ .../[apiId]/keys_v2/[keyAuthId]/new/page.tsx | 42 + .../keys_v2/[keyAuthId]/new/validation.ts | 115 +++ .../apis/[apiId]/keys_v2/[keyAuthId]/page.tsx | 36 + .../components/virtual-table/index.tsx | 8 +- .../api/keys/query-api-keys/get-all-keys.ts | 237 ++++++ .../routers/api/keys/query-api-keys/index.ts | 48 ++ .../routers/api/keys/query-api-keys/schema.ts | 51 ++ apps/dashboard/lib/trpc/routers/index.ts | 2 + 41 files changed, 4341 insertions(+), 217 deletions(-) delete mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/virtual-keys.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/_components/rbac-buttons.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/navigation.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/page.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/permission-list.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/delete-key.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/navigation.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/page.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-enabled.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-expiration.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-metadata.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-name.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-owner-id.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-ratelimit.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-remaining.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/verification-table.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/_components/components/control-cloud/index.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/_components/components/controls/components/logs-datetime/index.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/_components/components/controls/components/logs-filters/index.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/_components/components/controls/components/logs-refresh.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/_components/components/controls/components/logs-search/index.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/_components/components/controls/index.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/_components/components/table/hooks/use-logs-query.ts create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/_components/components/table/keys-list.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/_components/components/table/query-logs.schema.ts create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/_components/components/table/utils/get-row-class.ts create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/_components/filters.schema.ts create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/_components/hooks/use-filters.ts create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/_components/keys-client.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/navigation.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/new/client.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/new/navigation.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/new/page.tsx create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/new/validation.ts create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/page.tsx create mode 100644 apps/dashboard/lib/trpc/routers/api/keys/query-api-keys/get-all-keys.ts create mode 100644 apps/dashboard/lib/trpc/routers/api/keys/query-api-keys/index.ts create mode 100644 apps/dashboard/lib/trpc/routers/api/keys/query-api-keys/schema.ts diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/page.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/page.tsx index a86764dc73..b10e1362b0 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/page.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/page.tsx @@ -2,7 +2,11 @@ import { getTenantId } from "@/lib/auth"; import { db } from "@/lib/db"; import { notFound } from "next/navigation"; import { Navigation } from "./navigation"; -import { VirtualKeys } from "./virtual-keys"; + +import { Navbar as SubMenu } from "@/components/dashboard/navbar"; +import { PageContent } from "@/components/page-content"; +import { navigation } from "../../constants"; +import { Keys } from "./keys"; export const dynamic = "force-dynamic"; export const runtime = "edge"; @@ -27,60 +31,17 @@ export default async function APIKeysPage(props: { return notFound(); } - // Fetch keys data - const keys = await db.query.keys.findMany({ - where: (table, { and, eq, isNull }) => - and(eq(table.keyAuthId, props.params.keyAuthId), isNull(table.deletedAtM)), - limit: 100, - with: { - identity: { - columns: { - externalId: true, - }, - }, - roles: { - with: { - role: { - with: { - permissions: true, - }, - }, - }, - }, - permissions: true, - }, - }); - - // Transform the data for the client component - const transformedKeys = keys.map((key) => { - const permissions = new Set(key.permissions.map((p) => p.permissionId)); - for (const role of key.roles) { - for (const permission of role.role.permissions) { - permissions.add(permission.permissionId); - } - } - - return { - id: key.id, - keyAuthId: key.keyAuthId, - name: key.name, - start: key.start, - roles: key.roles.length, - enabled: key.enabled, - permissions: permissions.size, - environment: key.environment, - externalId: key.identity?.externalId ?? key.ownerId ?? null, - }; - }); - return (
- + + + + +
+ +
+
); } diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/virtual-keys.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/virtual-keys.tsx deleted file mode 100644 index 8cf7ff38ae..0000000000 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/virtual-keys.tsx +++ /dev/null @@ -1,159 +0,0 @@ -"use client"; -import { CreateKeyButton } from "@/components/dashboard/create-key-button"; -import BackButton from "@/components/ui/back-button"; -import { Badge } from "@/components/ui/badge"; -import { VirtualTable } from "@/components/virtual-table/index"; -import type { Column } from "@/components/virtual-table/types"; -import { formatNumber } from "@/lib/fmt"; -import { Key } from "@unkey/icons"; -import { Empty } from "@unkey/ui"; -import { Button } from "@unkey/ui"; -import { ChevronRight } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; - -type KeyData = { - id: string; - keyAuthId: string; - name: string | null; - start: string | null; - roles: number; - permissions: number; - enabled: boolean; - environment: string | null; - externalId: string | null; -}; - -type Props = { - keys: KeyData[]; - apiId: string; - keyAuthId: string; -}; - -export const VirtualKeys: React.FC = ({ keys, apiId, keyAuthId }) => { - const router = useRouter(); - const [selectedKey, setSelectedKey] = useState(null); - - const handleRowClick = (key: KeyData) => { - setSelectedKey(key); - router.push(`/apis/${apiId}/keys/${key.keyAuthId}/${key.id}`); - }; - - console.log({ keys }); - - const columns = (): Column[] => { - return [ - { - key: "key", - header: "Key", - width: "25%", - headerClassName: "pl-[18px]", - render: (key) => ( -
-
-
- -
-
-
- {key.id.substring(0, 8)}... - {key.id.substring(key.id.length - 4)} -
- - {key.name} -
-
-
- ), - }, - { - key: "value", - header: "Value", - width: "20%", - render: (key) =>
{key.permissions}
, - }, - { - key: "environment", - header: "Environment", - width: "10%", - render: (key) => ( -
- {key.environment ? env: {key.environment} : null} -
- ), - }, - { - key: "permissions", - header: "Permissions", - width: "15%", - render: (key) => ( - - {formatNumber(key.permissions)} Permission - {key.permissions !== 1 ? "s" : ""} - - ), - }, - { - key: "roles", - header: "Roles", - width: "15%", - render: (key) => ( - - {formatNumber(key.roles)} Role{key.roles !== 1 ? "s" : ""} - - ), - }, - { - key: "status", - header: "Status", - width: "10%", - render: (key) =>
{!key.enabled && Disabled}
, - }, - { - key: "actions", - header: "", - width: "5%", - render: () => ( -
- -
- ), - }, - ]; - }; - - // If there are no keys, show the empty state - if (keys.length === 0) { - return ( - - - No keys found - Create your first key - - - Go Back - - - ); - } - - return ( -
- key.id} - config={{ - rowHeight: 52, - layoutMode: "grid", - rowBorders: true, - containerPadding: "px-0", - }} - /> -
- ); -}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/_components/rbac-buttons.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/_components/rbac-buttons.tsx new file mode 100644 index 0000000000..f02de98fc9 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/_components/rbac-buttons.tsx @@ -0,0 +1,53 @@ +"use client"; +import { RBACForm } from "@/app/(app)/authorization/_components/rbac-form"; +import type { Permission } from "@unkey/db"; +import { Button } from "@unkey/ui"; +import { useState } from "react"; + +interface RBACButtonsProps { + permissions?: Permission[]; +} + +export function RBACButtons({ permissions = [] }: RBACButtonsProps) { + const [isCreateRoleModalOpen, setIsCreateRoleModalOpen] = useState(false); + const [isCreatePermissionModalOpen, setIsCreatePermissionModalOpen] = useState(false); + + const permissionIds = permissions.map((permission) => permission.id); + + return ( +
+ + + + + + + +
+ ); +} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/navigation.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/navigation.tsx new file mode 100644 index 0000000000..784c049038 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/navigation.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { CopyButton } from "@/components/dashboard/copy-button"; +import { CreateKeyButton } from "@/components/dashboard/create-key-button"; +import { Navbar } from "@/components/navigation/navbar"; +import { Badge } from "@/components/ui/badge"; +import type { Api } from "@unkey/db"; +import { Nodes } from "@unkey/icons"; + +type Key = { + id: string; + keyAuth: { + id: string; + }; +}; + +interface NavigationProps { + api: Api; + apiKey: Key; +} + +export function Navigation({ api, apiKey }: NavigationProps) { + return ( + + }> + APIs + + {api.name} + + + + {apiKey.id} + + + + + {apiKey.id} + + + + + + + ); +} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/page.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/page.tsx new file mode 100644 index 0000000000..ef1f89b7b7 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/page.tsx @@ -0,0 +1,410 @@ +import { type Interval, IntervalSelect } from "@/app/(app)/apis/[apiId]/select"; +import { StackedColumnChart } from "@/components/dashboard/charts"; +import { PageContent } from "@/components/page-content"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Metric } from "@/components/ui/metric"; +import { Separator } from "@/components/ui/separator"; +import { getTenantId } from "@/lib/auth"; +import { clickhouse } from "@/lib/clickhouse"; +import { and, db, eq, isNull, schema } from "@/lib/db"; +import { formatNumber } from "@/lib/fmt"; +import { Empty } from "@unkey/ui"; +import { Button } from "@unkey/ui"; +import { ArrowLeft, Settings2 } from "lucide-react"; +import { Minus } from "lucide-react"; +import ms from "ms"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { RBACButtons } from "./_components/rbac-buttons"; +import { Navigation } from "./navigation"; +import { PermissionList } from "./permission-list"; +import { VerificationTable } from "./verification-table"; + +export default async function APIKeyDetailPage(props: { + params: { + apiId: string; + keyId: string; + keyAuthId: string; + }; + searchParams: { + interval?: Interval; + }; +}) { + const tenantId = getTenantId(); + + const key = await db.query.keys.findFirst({ + where: and(eq(schema.keys.id, props.params.keyId), isNull(schema.keys.deletedAtM)), + with: { + keyAuth: true, + roles: { + with: { + role: { + with: { + permissions: { + with: { + permission: true, + }, + }, + }, + }, + }, + }, + permissions: true, + workspace: { + with: { + roles: { + with: { + permissions: true, + }, + }, + permissions: { + with: { + roles: true, + }, + }, + }, + }, + }, + }); + + if (!key || key.workspace.tenantId !== tenantId) { + return notFound(); + } + + const api = await db.query.apis.findFirst({ + where: (table, { eq, and, isNull }) => + and(eq(table.keyAuthId, key.keyAuthId), isNull(table.deletedAtM)), + }); + if (!api) { + return notFound(); + } + + const interval = props.searchParams.interval ?? "7d"; + + const { getVerificationsPerInterval, start, end, granularity } = prepareInterval(interval); + const query = { + workspaceId: api.workspaceId, + keySpaceId: key.keyAuthId, + keyId: key.id, + start, + end, + }; + const [verifications, latestVerifications, lastUsed] = await Promise.all([ + getVerificationsPerInterval(query), + clickhouse.verifications.logs({ + workspaceId: key.workspaceId, + keySpaceId: key.keyAuthId, + keyId: key.id, + }), + clickhouse.verifications + .latest({ + workspaceId: key.workspaceId, + keySpaceId: key.keyAuthId, + keyId: key.id, + }) + .then((res) => res.val?.at(0)?.time ?? 0), + ]); + + // Sort all verifications by time first + const sortedVerifications = verifications.val!.sort((a, b) => a.time - b.time); + + const successOverTime: { x: string; y: number }[] = []; + const ratelimitedOverTime: { x: string; y: number }[] = []; + const usageExceededOverTime: { x: string; y: number }[] = []; + const disabledOverTime: { x: string; y: number }[] = []; + const insufficientPermissionsOverTime: { x: string; y: number }[] = []; + const expiredOverTime: { x: string; y: number }[] = []; + const forbiddenOverTime: { x: string; y: number }[] = []; + + // Get all unique timestamps + const uniqueDates = [...new Set(sortedVerifications.map((d) => d.time))].sort((a, b) => a - b); + + // Ensure each array has entries for all timestamps with zero counts + for (const timestamp of uniqueDates) { + const x = new Date(timestamp).toISOString(); + successOverTime.push({ x, y: 0 }); + ratelimitedOverTime.push({ x, y: 0 }); + usageExceededOverTime.push({ x, y: 0 }); + disabledOverTime.push({ x, y: 0 }); + insufficientPermissionsOverTime.push({ x, y: 0 }); + expiredOverTime.push({ x, y: 0 }); + forbiddenOverTime.push({ x, y: 0 }); + } + + for (const d of sortedVerifications) { + const x = new Date(d.time).toISOString(); + const index = uniqueDates.indexOf(d.time); + + switch (d.outcome) { + case "": + case "VALID": + successOverTime[index] = { x, y: d.count }; + break; + case "RATE_LIMITED": + ratelimitedOverTime[index] = { x, y: d.count }; + break; + case "USAGE_EXCEEDED": + usageExceededOverTime[index] = { x, y: d.count }; + break; + case "DISABLED": + disabledOverTime[index] = { x, y: d.count }; + break; + case "INSUFFICIENT_PERMISSIONS": + insufficientPermissionsOverTime[index] = { x, y: d.count }; + break; + case "EXPIRED": + expiredOverTime[index] = { x, y: d.count }; + break; + case "FORBIDDEN": + forbiddenOverTime[index] = { x, y: d.count }; + break; + } + } + + const verificationsData = [ + ...successOverTime.map((d) => ({ + ...d, + category: "Successful Verifications", + })), + ...ratelimitedOverTime.map((d) => ({ ...d, category: "Ratelimited" })), + ...usageExceededOverTime.map((d) => ({ ...d, category: "Usage Exceeded" })), + ...disabledOverTime.map((d) => ({ ...d, category: "Disabled" })), + ...insufficientPermissionsOverTime.map((d) => ({ + ...d, + category: "Insufficient Permissions", + })), + ...expiredOverTime.map((d) => ({ ...d, category: "Expired" })), + ...forbiddenOverTime.map((d) => ({ ...d, category: "Forbidden" })), + ]; + + const transientPermissionIds = new Set(); + const connectedRoleIds = new Set(); + for (const role of key.roles) { + connectedRoleIds.add(role.roleId); + } + for (const role of key.workspace.roles) { + if (connectedRoleIds.has(role.id)) { + for (const p of role.permissions) { + transientPermissionIds.add(p.permissionId); + } + } + } + + const stats = { + valid: 0, + ratelimited: 0, + usageExceeded: 0, + disabled: 0, + insufficientPermissions: 0, + expired: 0, + forbidden: 0, + }; + verifications.val!.forEach((v) => { + switch (v.outcome) { + case "VALID": + stats.valid += v.count; + break; + case "RATE_LIMITED": + stats.ratelimited += v.count; + break; + case "USAGE_EXCEEDED": + stats.usageExceeded += v.count; + break; + case "DISABLED": + stats.disabled += v.count; + break; + case "INSUFFICIENT_PERMISSIONS": + stats.insufficientPermissions += v.count; + break; + case "EXPIRED": + stats.expired += v.count; + break; + case "FORBIDDEN": + stats.forbidden += v.count; + } + }); + + const rolesList = key.workspace.roles.map((role) => { + return { + id: role.id, + name: role.name, + isActive: key.roles.some((keyRole) => keyRole.roleId === role.id), + }; + }); + + return ( +
+ + + +
+
+ + Back to API Keys listing + + + + +
+ +
+ + + } + /> + + } + /> + } + /> + + + + +
+

Verifications

+ +
+ +
+
+ + {verificationsData.some(({ y }) => y > 0) ? ( + + +
+ + + + + + + +
+
+ + = 1000 * 60 * 60 * 24 * 30 + ? "month" + : granularity >= 1000 * 60 * 60 * 24 + ? "day" + : "hour" + } + /> + +
+ ) : ( + + + Not used + This key was not used in the last {interval} + + )} + + {latestVerifications.val && latestVerifications.val.length > 0 ? ( + <> + +

+ Latest Verifications +

+ + + ) : null} + + +
+
+ + {formatNumber(key.roles.length)} Roles{" "} + + + {formatNumber(transientPermissionIds.size)} Permissions + +
+ +
+ +
+
+
+
+ ); +} + +function prepareInterval(interval: Interval) { + const now = new Date(); + + switch (interval) { + case "24h": { + const end = now.setUTCHours(now.getUTCHours() + 1, 0, 0, 0); + const intervalMs = 1000 * 60 * 60 * 24; + return { + start: end - intervalMs, + end, + intervalMs, + granularity: 1000 * 60 * 60, + getVerificationsPerInterval: clickhouse.verifications.perHour, + }; + } + case "7d": { + now.setUTCDate(now.getUTCDate() + 1); + const end = now.setUTCHours(0, 0, 0, 0); + const intervalMs = 1000 * 60 * 60 * 24 * 7; + return { + start: end - intervalMs, + end, + intervalMs, + granularity: 1000 * 60 * 60 * 24, + getVerificationsPerInterval: clickhouse.verifications.perDay, + }; + } + case "30d": { + now.setUTCDate(now.getUTCDate() + 1); + const end = now.setUTCHours(0, 0, 0, 0); + const intervalMs = 1000 * 60 * 60 * 24 * 30; + return { + start: end - intervalMs, + end, + intervalMs, + granularity: 1000 * 60 * 60 * 24, + getVerificationsPerInterval: clickhouse.verifications.perDay, + }; + } + case "90d": { + now.setUTCDate(now.getUTCDate() + 1); + const end = now.setUTCHours(0, 0, 0, 0); + const intervalMs = 1000 * 60 * 60 * 24 * 90; + return { + start: end - intervalMs, + end, + intervalMs, + granularity: 1000 * 60 * 60 * 24, + getVerificationsPerInterval: clickhouse.verifications.perDay, + }; + } + } +} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/permission-list.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/permission-list.tsx new file mode 100644 index 0000000000..9c2fe2a77c --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/permission-list.tsx @@ -0,0 +1,85 @@ +"use client"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { trpc } from "@/lib/trpc/client"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +export type Role = { + id: string; + name: string; + isActive: boolean; +}; + +type PermissionTreeProps = { + roles: Role[]; + keyId: string; +}; + +export function PermissionList({ roles, keyId }: PermissionTreeProps) { + const router = useRouter(); + const connectRole = trpc.rbac.connectRoleToKey.useMutation({ + onMutate: () => { + toast.loading("Connecting role to key"); + }, + onSuccess: () => { + toast.dismiss(); + toast.success("Role connected to key"); + router.refresh(); + }, + onError: (error) => { + toast.dismiss(); + toast.error(error.message); + }, + }); + + const disconnectRole = trpc.rbac.disconnectRoleFromKey.useMutation({ + onMutate: () => { + toast.loading("Disconnecting role from key"); + }, + onSuccess: () => { + toast.dismiss(); + toast.success("Role disconnected from key"); + router.refresh(); + }, + onError: (error) => { + toast.dismiss(); + toast.error(error.message); + }, + }); + + return ( + + +
+ Roles + Manage roles for this key +
+
+ + +
+ {roles.map((role) => ( +
+ { + if (checked) { + connectRole.mutate({ keyId: keyId, roleId: role.id }); + } else { + disconnectRole.mutate({ keyId: keyId, roleId: role.id }); + } + }} + /> +
+ {role.name} +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/delete-key.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/delete-key.tsx new file mode 100644 index 0000000000..31bb5f492f --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/delete-key.tsx @@ -0,0 +1,92 @@ +"use client"; +import { revalidate } from "@/app/actions"; +import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { toast } from "@/components/ui/toaster"; +import { Button } from "@unkey/ui"; +import type React from "react"; +import { useState } from "react"; + +import { Loading } from "@/components/dashboard/loading"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { trpc } from "@/lib/trpc/client"; +import { useRouter } from "next/navigation"; + +type Props = { + apiKey: { + id: string; + }; + keyAuthId: string; +}; + +export const DeleteKey: React.FC = ({ apiKey, keyAuthId }) => { + const router = useRouter(); + const [open, setOpen] = useState(false); + + const deleteKey = trpc.key.delete.useMutation({ + onSuccess() { + revalidate(`/keys/${keyAuthId}/keys`); + toast.success("Key deleted"); + router.push("/apis"); + }, + onError(err) { + console.error(err); + toast.error(err.message); + }, + }); + + return ( + <> + + + Delete + This key will be deleted. This action cannot be undone. + + + + + + + + setOpen(o)}> + + + Delete Key + + This key will be deleted. This action cannot be undone. + + + + + Warning + This action is not reversible. Please be certain. + + + + + + + + + + + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/navigation.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/navigation.tsx new file mode 100644 index 0000000000..0d2a447052 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/navigation.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { CopyButton } from "@/components/dashboard/copy-button"; +import { CreateKeyButton } from "@/components/dashboard/create-key-button"; +import { Navbar } from "@/components/navigation/navbar"; +import { Badge } from "@/components/ui/badge"; +import { Nodes } from "@unkey/icons"; + +type Key = { + id: string; + keyAuth: { + id: string; + api: { + id: string; + name: string; + }; + }; +}; + +interface NavigationProps { + apiId: string; + apiKey: Key; +} + +export function Navigation({ apiId, apiKey }: NavigationProps) { + return ( + + }> + APIs + + {apiKey.keyAuth.api.name} + + + + {apiKey.id} + + + Settings + + + + + {apiKey.id} + + + + + + ); +} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/page.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/page.tsx new file mode 100644 index 0000000000..0babe80daf --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/page.tsx @@ -0,0 +1,81 @@ +import { CopyButton } from "@/components/dashboard/copy-button"; +import { PageContent } from "@/components/page-content"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Code } from "@/components/ui/code"; +import { getTenantId } from "@/lib/auth"; +import { and, db, eq, isNull, schema } from "@/lib/db"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { DeleteKey } from "./delete-key"; +import { Navigation } from "./navigation"; +import { UpdateKeyEnabled } from "./update-key-enabled"; +import { UpdateKeyExpiration } from "./update-key-expiration"; +import { UpdateKeyMetadata } from "./update-key-metadata"; +import { UpdateKeyName } from "./update-key-name"; +import { UpdateKeyOwnerId } from "./update-key-owner-id"; +import { UpdateKeyRatelimit } from "./update-key-ratelimit"; +import { UpdateKeyRemaining } from "./update-key-remaining"; + +type Props = { + params: { + apiId: string; + keyAuthId: string; + keyId: string; + }; +}; + +export default async function SettingsPage(props: Props) { + const tenantId = getTenantId(); + + const key = await db.query.keys.findFirst({ + where: and(eq(schema.keys.id, props.params.keyId), isNull(schema.keys.deletedAtM)), + with: { + workspace: true, + keyAuth: { with: { api: true } }, + }, + }); + if (!key || key.workspace.tenantId !== tenantId) { + return notFound(); + } + + return ( +
+ + + +
+ + API Key Overview + + + + + + + + + + + + Key ID + This is your key id. It's used in some API calls. + + + +
{key.id}
+
+ +
+
+
+
+ +
+
+
+ ); +} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-enabled.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-enabled.tsx new file mode 100644 index 0000000000..186e5ee422 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-enabled.tsx @@ -0,0 +1,108 @@ +"use client"; +import { Loading } from "@/components/dashboard/loading"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Form, FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form"; +import { Switch } from "@/components/ui/switch"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@unkey/ui"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const formSchema = z.object({ + keyId: z.string(), + workspaceId: z.string(), + enabled: z.boolean(), +}); +type Props = { + apiKey: { + id: string; + workspaceId: string; + enabled: boolean; + }; +}; + +export const UpdateKeyEnabled: React.FC = ({ apiKey }) => { + const router = useRouter(); + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "all", + shouldFocusError: true, + delayError: 100, + defaultValues: { + keyId: apiKey.id, + workspaceId: apiKey.workspaceId, + enabled: apiKey.enabled, + }, + }); + const updateEnabled = trpc.key.update.enabled.useMutation({ + onSuccess() { + toast.success("Your key has been updated!"); + router.refresh(); + }, + onError(err) { + console.error(err); + toast.error(err.message); + }, + }); + + async function onSubmit(values: z.infer) { + await updateEnabled.mutateAsync(values); + } + + return ( +
+ + + + Enable Key + + Enable or disable this key. Disabled keys will not verify. + + + +
+ {/* */} + ( + +
+ + { + field.onChange(e); + }} + /> + {" "} + + {form.getValues("enabled") ? "Enabled" : "Disabled"} + +
+
+ )} + /> +
+
+ + + +
+
+ + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-expiration.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-expiration.tsx new file mode 100644 index 0000000000..10191752cd --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-expiration.tsx @@ -0,0 +1,154 @@ +"use client"; +import { Loading } from "@/components/dashboard/loading"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@unkey/ui"; +import { format } from "date-fns"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const currentTime = new Date(); +const oneMinute = currentTime.setMinutes(currentTime.getMinutes() + 0.5); +const formSchema = z.object({ + keyId: z.string(), + enableExpiration: z.boolean(), + expiration: z.coerce.date().min(new Date(oneMinute)).optional(), +}); +type Props = { + apiKey: { + id: string; + workspaceId: string; + expires: Date | null; + }; +}; + +export const UpdateKeyExpiration: React.FC = ({ apiKey }) => { + const router = useRouter(); + + /* This ensures the date shown is in local time and not ISO */ + function convertDate(date: Date | null): string { + if (!date) { + return ""; + } + return format(date, "yyyy-MM-dd'T'HH:mm:ss"); + } + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "all", + shouldFocusError: true, + delayError: 100, + defaultValues: { + keyId: apiKey.id ? apiKey.id : undefined, + enableExpiration: apiKey.expires !== null ? true : false, + }, + }); + + const changeExpiration = trpc.key.update.expiration.useMutation({ + onSuccess() { + toast.success("Your key has been updated!"); + router.refresh(); + }, + onError(err) { + console.error(err); + toast.error(err.message); + }, + }); + + async function onSubmit(values: z.infer) { + await changeExpiration.mutateAsync(values); + } + + return ( +
+ + + + Expiration + Automatically revoke this key after a certain date. + + +
+ ( + + Expiry Date + + + + + This api key will automatically be revoked after the given date. + + + + )} + /> +
+
+ + ( + +
+ + { + field.onChange(e); + }} + /> + {" "} + + {form.getValues("enableExpiration") ? "Enabled" : "Disabled"} + +
+
+ )} + /> + +
+
+
+ + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-metadata.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-metadata.tsx new file mode 100644 index 0000000000..408bdc6e33 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys_v2/[keyAuthId]/[keyId]/settings/update-key-metadata.tsx @@ -0,0 +1,120 @@ +"use client"; +import { Loading } from "@/components/dashboard/loading"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@unkey/ui"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const formSchema = z.object({ + keyId: z.string(), + metadata: z.string(), +}); +type Props = { + apiKey: { + id: string; + meta: string | null; + }; +}; + +export const UpdateKeyMetadata: React.FC = ({ apiKey }) => { + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "all", + shouldFocusError: true, + delayError: 100, + defaultValues: { + keyId: apiKey.id, + metadata: apiKey.meta ?? "", + }, + }); + + const rows = Math.max(3, form.getValues("metadata").split("\n").length); + + const updateMetadata = trpc.key.update.metadata.useMutation({ + onSuccess() { + toast.success("Your metadata has been updated!"); + router.refresh(); + }, + onError(err) { + console.error(err); + toast.error(err.message); + }, + }); + + async function onSubmit(values: z.infer) { + updateMetadata.mutate(values); + } + return ( +
+ + + + Metadata + + Store json, or any other data you want to associate with this key. Whenever you verify + this key, we'll return the metadata to you. + + + +
+ + ( + + +