diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/history/access-table.tsx b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/history/access-table.tsx deleted file mode 100644 index fa2c8b739e..0000000000 --- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/history/access-table.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; -import { - Table, - TableBody, - TableCaption, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Badge } from "@unkey/ui"; -import { Check } from "lucide-react"; - -type Props = { - verifications: { - time: number; - region: string; - outcome: string; - }[]; -}; - -export const AccessTable: React.FC = ({ verifications }) => { - return ( - - {verifications.length === 0 ? ( - This key was not used yet - ) : null} - - - Time - Region - Valid - - - - {verifications.map((verification, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: I got nothing better right now - - - {new Date(verification.time).toDateString()} - - {new Date(verification.time).toTimeString().split("(").at(0)} - - - {verification.region} - - {verification.outcome === "VALID" ? : {verification.outcome}} - - - ))} - -
- ); -}; diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/navigation.tsx b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/navigation.tsx deleted file mode 100644 index 50764583aa..0000000000 --- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/navigation.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import { Navbar } from "@/components/navigation/navbar"; -import { Gear } from "@unkey/icons"; - -export function Navigation({ keyId }: { keyId: string }) { - return ( - - }> - Settings - Root Keys - - {keyId} - - - - ); -} diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/page-layout.tsx b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/page-layout.tsx deleted file mode 100644 index 5d7cca966d..0000000000 --- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/page-layout.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Metric } from "@/components/ui/metric"; -import { clickhouse } from "@/lib/clickhouse"; -import type { Key } from "@unkey/db"; -import { Card, CardContent, CardHeader, CardTitle } from "@unkey/ui"; -import { ArrowLeft } from "lucide-react"; -import ms from "ms"; -import Link from "next/link"; -import { Suspense } from "react"; - -type Props = { - params: { - keyId: string; - }; - rootKey: Key; - children: React.ReactNode; -}; - -export function PageLayout({ children, rootKey: key, params: { keyId } }: Props) { - return ( -
- - Back to Root Keys listing - - - - - Root Key Information - - - {key.id}} /> - - - - x
}> - - - - - - {children} - - ); -} - -const LastUsed: React.FC<{ - workspaceId: string; - keySpaceId: string; - keyId: string; -}> = async ({ workspaceId, keySpaceId, keyId }) => { - const lastUsed = await clickhouse.verifications - .latest({ workspaceId, keySpaceId, keyId, limit: 1 }) - .then((res) => res.val?.at(0)?.time ?? 0); - - return ( - - ); -}; diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/page.tsx b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/page.tsx deleted file mode 100644 index a068c62911..0000000000 --- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/page.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { PageContent } from "@/components/page-content"; -import { DialogTrigger } from "@/components/ui/dialog"; -import { getAuth } from "@/lib/auth"; -import { clickhouse } from "@/lib/clickhouse"; -import { type Permission, db, eq, schema } from "@/lib/db"; -import { env } from "@/lib/env"; -import { Button, Card, CardContent, CardHeader, CardTitle } from "@unkey/ui"; -import { notFound } from "next/navigation"; -import { AccessTable } from "./history/access-table"; -import { Navigation } from "./navigation"; -import { PageLayout } from "./page-layout"; -import { DialogAddPermissionsForAPI } from "./permissions/add-permission-for-api"; -import { Api } from "./permissions/api"; -import { Legacy } from "./permissions/legacy"; -import { apiPermissions } from "./permissions/permissions"; -import { Workspace } from "./permissions/workspace"; -import { UpdateRootKeyName } from "./update-root-key-name"; - -export const dynamic = "force-dynamic"; - -export default async function RootKeyPage(props: { - params: { keyId: string }; -}) { - const { orgId } = await getAuth(); - - const workspace = await db.query.workspaces.findFirst({ - where: eq(schema.workspaces.orgId, orgId), - with: { - apis: { - where: (table, { isNull }) => isNull(table.deletedAtM), - columns: { - id: true, - name: true, - }, - }, - }, - }); - if (!workspace) { - return notFound(); - } - - const key = await db.query.keys.findFirst({ - where: eq(schema.keys.forWorkspaceId, workspace.id) && eq(schema.keys.id, props.params.keyId), - with: { - permissions: { - with: { - permission: true, - }, - }, - }, - }); - if (!key) { - return notFound(); - } - - const permissions = key.permissions.map((kp) => kp.permission); - - const permissionsByApi = permissions.reduce( - (acc, permission) => { - if (!permission.name.startsWith("api.")) { - return acc; - } - const [_, apiId, _action] = permission.name.split("."); - - if (!acc[apiId]) { - acc[apiId] = []; - } - acc[apiId].push(permission); - return acc; - }, - {} as { [apiId: string]: Permission[] }, - ); - - const { UNKEY_WORKSPACE_ID } = env(); - - const keyForHistory = await db.query.keys.findFirst({ - where: (table, { and, eq, isNull }) => - and( - eq(table.workspaceId, UNKEY_WORKSPACE_ID), - eq(table.forWorkspaceId, workspace.id), - eq(table.id, props.params.keyId), - isNull(table.deletedAtM), - ), - with: { - keyAuth: { - with: { - api: true, - }, - }, - }, - }); - if (!keyForHistory?.keyAuth?.api) { - return notFound(); - } - const history = await clickhouse.verifications - .latest({ - workspaceId: UNKEY_WORKSPACE_ID, - keySpaceId: key.keyAuthId, - keyId: key.id, - limit: 50, - }) - .then((res) => res.val); - - const apis = workspace.apis.map((api) => { - const apiPermissionsStructure = apiPermissions(api.id); - const hasActivePermissions = Object.entries(apiPermissionsStructure).some( - ([_category, allPermissions]) => { - const amountActivePermissions = Object.entries(allPermissions).filter( - ([_action, { description: _description, permission }]) => { - return permissions.some((p) => p.name === permission); - }, - ); - - return amountActivePermissions.length > 0; - }, - ); - - return { - ...api, - hasActivePermissions, - }; - }); - - const apisWithActivePermissions = apis.filter((api) => api.hasActivePermissions); - const apisWithoutActivePermissions = apis.filter((api) => !api.hasActivePermissions); - - return ( -
- - - -
- {permissions.some((p) => p.name === "*") ? ( - - ) : null} - - - - - - {apisWithActivePermissions.map((api) => ( - - ))} - - - {apisWithoutActivePermissions.length > 0 && ( - - - - - - )} - - - -
-
-
-
- ); -} - -function UsageHistoryCard(props: { - accessTableProps: React.ComponentProps; -}) { - return ( - - - Usage History - - - - - - ); -} diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/add-permission-for-api.tsx b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/add-permission-for-api.tsx deleted file mode 100644 index 46a5e86f76..0000000000 --- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/add-permission-for-api.tsx +++ /dev/null @@ -1,121 +0,0 @@ -"use client"; - -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import type { Permission } from "@unkey/db"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@unkey/ui"; -import { type PropsWithChildren, useState } from "react"; -import { PermissionToggle } from "./permission_toggle"; -import { apiPermissions } from "./permissions"; - -export function DialogAddPermissionsForAPI( - props: PropsWithChildren<{ - keyId: string; - apis: { id: string; name: string }[]; - permissions: Permission[]; - }>, -) { - const apisWithoutPermission = props.apis.filter((api) => { - const apiPermissionsStructure = apiPermissions(api.id); - const hasActivePermissions = Object.entries(apiPermissionsStructure).some( - ([_category, allPermissions]) => { - const amountActivePermissions = Object.entries(allPermissions).filter( - ([_action, { description: _description, permission }]) => { - return props.permissions.some((p) => p.name === permission); - }, - ); - - return amountActivePermissions.length > 0; - }, - ); - - return !hasActivePermissions; - }); - - const [selectedApiId, setSelectedApiId] = useState(""); - const selectedApi = props.apis.find((api) => api.id === selectedApiId); - - const isSelectionDisabled = - selectedApi && !apisWithoutPermission.some((api) => api.id === selectedApi.id); - - const options = apisWithoutPermission.reduce( - (map, api) => { - map[api.id] = api.name; - return map; - }, - {} as Record, - ); - - function onOpenChange() { - setSelectedApiId(""); - } - - const placeholderApiId = - selectedApiId !== "" ? selectedApiId : props.apis.length > 0 ? props.apis[0].id : ""; - - return ( - - {/* Trigger should be in here */} - {props.children} - - - - Setup permissions for an API - - - - {placeholderApiId !== "" && ( -
- {placeholderApiId !== "" && - Object.entries(apiPermissions(placeholderApiId)).map( - ([category, allPermissions], idx) => ( -
- {category}{" "} -
- {Object.entries(allPermissions).map( - ([action, { description, permission }]) => { - return ( - p.name === permission) - : false - } - preventDisabling={!selectedApi} - preventEnabling={!selectedApi} - /> - ); - }, - )} -
-
- ), - )} -
- )} -
-
- ); -} diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/api.tsx b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/api.tsx deleted file mode 100644 index 0c164679ac..0000000000 --- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/api.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import type { Permission } from "@unkey/db"; -import { PermissionManagerCard } from "./permission-manager-card"; -import { apiPermissions } from "./permissions"; - -type Props = { - permissions: Permission[]; - keyId: string; - api: { - id: string; - name: string; - }; -}; - -export const Api: React.FC = ({ keyId, api, permissions }) => { - return ( - - ); -}; diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/legacy.tsx b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/legacy.tsx deleted file mode 100644 index 54a1d623cf..0000000000 --- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/legacy.tsx +++ /dev/null @@ -1,84 +0,0 @@ -"use client"; - -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { trpc } from "@/lib/trpc/client"; -import type { Permission } from "@unkey/db"; -import { - Button, - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, - toast, -} from "@unkey/ui"; -import { Loader2 } from "lucide-react"; -import { useRouter } from "next/navigation"; - -type Props = { - permissions: Permission[]; - keyId: string; -}; - -export const Legacy: React.FC = ({ keyId, permissions }) => { - const router = useRouter(); - const removeRole = trpc.rbac.removePermissionFromRootKey.useMutation({ - onSuccess: () => { - toast.success("Role removed", { - description: "Changes may take up to 60 seconds to take effect.", - }); - router.refresh(); - }, - onError(err) { - console.error(err); - toast.error(err.message); - }, - onSettled: () => {}, - }); - - /** - * If they delete it without setting permissions first, they might break themselves in production. - */ - const canSafelyDelete = permissions.filter((p) => p.id !== "*").length >= 1; - - return ( - - - Legacy - - Existing keys were able to perform any action. Please remove this yourself at a convenient - time to increase security. - - - - - - Before you remove the legacy role, you need to add the correct permissions to the key, - otherwise your key might not have sufficient permissions and you might break your - application. - - - - - - - - - ); -}; diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permission-manager-card.tsx b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permission-manager-card.tsx deleted file mode 100644 index 0a499f308a..0000000000 --- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permission-manager-card.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import type { Permission } from "@unkey/db"; -import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@unkey/ui"; -import { Check } from "lucide-react"; -import { PermissionToggle } from "./permission_toggle"; -import type { UnkeyPermissions } from "./permissions"; - -type PermissionManagerCardProps = { - permissions: Permission[]; - keyId: string; - - permissionsStructure: Record; - - permissionManagerTitle: string; - permissionManagerDescription: string; - - // TODO: Add the ability to act like an accordion, initially collapsed (prop should be called `expandable`) -}; - -// TODO: Add a visualization for when there's no active permissions -export function PermissionManagerCard(props: PermissionManagerCardProps) { - const activePermissions = Object.entries(props.permissionsStructure).filter( - ([_category, allPermissions]) => { - const amountActivePermissions = Object.entries(allPermissions).filter( - ([_action, { description: _description, permission }]) => { - return props.permissions.some((p) => p.name === permission); - }, - ); - - return amountActivePermissions.length > 0; - }, - ); - - return ( - - -
- {props.permissionManagerTitle} - - - - - - - - {props.permissionManagerTitle} - {props.permissionManagerDescription} - - -
- {Object.entries(props.permissionsStructure).map(([category, allPermissions]) => ( -
- {category}{" "} -
- {Object.entries(allPermissions).map( - ([action, { description, permission }]) => { - return ( - p.name === permission)} - /> - ); - }, - )} -
-
- ))} -
-
-
-
- {props.permissionManagerDescription} -
- -
- {activePermissions.length === 0 && ( -

- There are no active permissions. To get started, edit the permissions. -

- )} - {activePermissions.map(([category, allPermissions]) => ( -
- {category}{" "} -
- {Object.entries(allPermissions) - .filter(([_action, { description: _description, permission }]) => { - return props.permissions.some((p) => p.name === permission); - }) - .map(([action, { description }]) => { - return ( -
-
- - -
- -

{description}

-
- ); - })} -
-
- ))} -
-
-
- ); -} diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permission_toggle.tsx b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permission_toggle.tsx deleted file mode 100644 index 9bd377c59e..0000000000 --- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permission_toggle.tsx +++ /dev/null @@ -1,114 +0,0 @@ -"use client"; - -import { Label } from "@/components/ui/label"; -import { trpc } from "@/lib/trpc/client"; -import { Checkbox, toast } from "@unkey/ui"; -import { Loader2 } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -type Props = { - rootKeyId: string; - permissionName: string; - label: string; - description: string; - checked: boolean; - preventEnabling?: boolean; - preventDisabling?: boolean; -}; - -export const PermissionToggle: React.FC = ({ - rootKeyId, - permissionName, - label, - checked, - description, - preventEnabling, - preventDisabling, -}) => { - const router = useRouter(); - - const [optimisticChecked, setOptimisticChecked] = useState(checked); - const addPermission = trpc.rbac.addPermissionToRootKey.useMutation({ - onMutate: () => { - setOptimisticChecked(true); - }, - onSuccess: () => { - toast.success("Permission added", { - description: "Changes may take up to 60 seconds to take effect.", - }); - }, - onError(err) { - console.error(err); - toast.error(err.message); - }, - onSettled: () => { - router.refresh(); - }, - }); - const removeRole = trpc.rbac.removePermissionFromRootKey.useMutation({ - onMutate: () => { - setOptimisticChecked(false); - }, - onSuccess: () => { - toast.success("Permission removed", { - description: "Changes may take up to 60 seconds to take effect.", - cancel: { - label: "Undo", - onClick: () => { - addPermission.mutate({ rootKeyId, permission: permissionName }); - }, - }, - }); - }, - onError(err) { - console.error(err); - toast.error(err.message); - }, - onSettled: () => { - router.refresh(); - }, - }); - - return ( -
-
-
-
- {addPermission.isLoading || removeRole.isLoading ? ( - - ) : ( - { - if (checked) { - if (!preventDisabling) { - removeRole.mutate({ rootKeyId, permissionName }); - } - } else { - if (!preventEnabling) { - addPermission.mutate({ - rootKeyId, - permission: permissionName, - }); - } - } - }} - /> - )} - -
-
- -

- {description} -

-
-
- ); -}; diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/workspace.tsx b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/workspace.tsx deleted file mode 100644 index 74ae223950..0000000000 --- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/workspace.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -import type { Permission } from "@unkey/db"; -import { PermissionManagerCard } from "./permission-manager-card"; -import { workspacePermissions } from "./permissions"; - -type Props = { - permissions: Permission[]; - keyId: string; -}; - -export const Workspace: React.FC = ({ keyId, permissions }) => { - return ( - - ); -}; diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/selector.tsx b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/selector.tsx deleted file mode 100644 index 68c1f09bd0..0000000000 --- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/selector.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { ChevronsUpDown } from "lucide-react"; -import Link from "next/link"; -import { useSelectedLayoutSegment } from "next/navigation"; -import type React from "react"; - -type Props = { - keyId: string; -}; - -export const Selector: React.FC = ({ keyId }) => { - const segment = useSelectedLayoutSegment(); - return ( - - - {segment} - - - - - Permissions - - - History - - - - ); -}; diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/update-root-key-name.tsx b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/update-root-key-name.tsx deleted file mode 100644 index f1427d2d7e..0000000000 --- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/update-root-key-name.tsx +++ /dev/null @@ -1,106 +0,0 @@ -"use client"; -import { trpc } from "@/lib/trpc/client"; -import { cn } from "@/lib/utils"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { - Button, - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, - FormInput, - toast, -} from "@unkey/ui"; -import { useRouter } from "next/navigation"; -import { Controller, useForm } from "react-hook-form"; -import { z } from "zod"; - -const formSchema = z.object({ - keyId: z.string(), - name: z - .string() - .transform((e) => (e === "" ? undefined : e)) - .optional(), -}); - -type Props = { - apiKey: { - id: string; - name: string | null; - }; -}; - -export const UpdateRootKeyName: React.FC = ({ apiKey }) => { - const router = useRouter(); - - const { - handleSubmit, - control, - formState: { errors, isValid }, - } = useForm>({ - resolver: zodResolver(formSchema), - mode: "all", - shouldFocusError: true, - delayError: 100, - defaultValues: { - keyId: apiKey.id, - name: apiKey.name ?? "", - }, - }); - - const updateName = trpc.rootKey.update.name.useMutation({ - onSuccess() { - toast.success("Your root key name has been updated!"); - router.refresh(); - }, - onError(err) { - console.error(err); - toast.error(err.message); - }, - }); - - async function onSubmit(values: z.infer) { - await updateName.mutateAsync(values); - } - - return ( -
- - - Name - - -
- - - ( - - )} - /> -
-
- - - -
-
- ); -}; diff --git a/apps/dashboard/app/(app)/settings/root-keys/components/controls/components/root-keys-filters/index.tsx b/apps/dashboard/app/(app)/settings/root-keys/components/controls/components/root-keys-filters/index.tsx index cb828f164b..c139ed9344 100644 --- a/apps/dashboard/app/(app)/settings/root-keys/components/controls/components/root-keys-filters/index.tsx +++ b/apps/dashboard/app/(app)/settings/root-keys/components/controls/components/root-keys-filters/index.tsx @@ -1,8 +1,8 @@ import { FiltersPopover } from "@/components/logs/checkbox/filters-popover"; import { FilterOperatorInput } from "@/components/logs/filter-operator-input"; +import { cn } from "@/lib/utils"; import { BarsFilter } from "@unkey/icons"; import { Button } from "@unkey/ui"; -import { cn } from "@unkey/ui/src/lib/utils"; import { type RootKeysFilterField, diff --git a/apps/dashboard/app/(app)/settings/root-keys/components/root-key/README.md b/apps/dashboard/app/(app)/settings/root-keys/components/root-key/README.md new file mode 100644 index 0000000000..3ae8d3fdf6 --- /dev/null +++ b/apps/dashboard/app/(app)/settings/root-keys/components/root-key/README.md @@ -0,0 +1,102 @@ +# Root Key Components + +This directory contains the refactored root key management components for the Unkey dashboard. + +## Architecture Overview + +The components have been refactored to follow a clean architecture pattern with: + +- **Custom Hooks**: Business logic extracted into reusable hooks +- **Utility Functions**: Shared logic centralized in utility files +- **Constants**: Centralized configuration and messages +- **Smaller Components**: Components broken down into focused, single-responsibility pieces + +## Directory Structure + +``` +root-key/ +├── components/ # UI components +│ ├── expandable-category.tsx +│ ├── highlighted-text.tsx +│ ├── permission-badge-list.tsx +│ ├── permission-list.tsx +│ ├── permission-sheet.tsx +│ ├── permission-toggle.tsx +│ ├── search-input.tsx +│ └── search-permissions.tsx +├── hooks/ # Custom hooks for business logic +│ ├── use-permissions.ts +│ ├── use-permission-sheet.ts +│ ├── use-root-key-dialog.ts +│ └── use-root-key-success.ts +├── utils/ # Utility functions +│ └── permissions.ts +├── constants.ts # Shared constants and messages +├── create-rootkey-button.tsx +├── root-key-dialog.tsx +├── root-key-success.tsx +└── README.md +``` + +## Key Improvements + +### 1. Custom Hooks +- **`usePermissions`**: Manages permission state and logic +- **`usePermissionSheet`**: Handles permission sheet state and search +- **`useRootKeyDialog`**: Manages root key dialog state and API calls +- **`useRootKeySuccess`**: Handles success dialog state and navigation + +### 2. Utility Functions +- **`permissions.ts`**: Centralized permission management utilities +- **`constants.ts`**: Shared constants and user-facing messages + +### 3. Component Refactoring +- **Smaller, focused components**: Each component has a single responsibility +- **Better separation of concerns**: UI logic separated from business logic +- **Improved reusability**: Components are more modular and reusable +- **Enhanced type safety**: Better TypeScript types throughout + +### 4. Code Organization +- **Reduced duplication**: Common logic extracted to utilities and hooks +- **Consistent patterns**: Similar components follow the same patterns +- **Better maintainability**: Easier to understand and modify individual pieces + +## Usage Examples + +### Creating a Root Key +```tsx +import { CreateRootKeyButton } from "./create-rootkey-button"; + + +``` + +### Using Permission Management +```tsx +import { usePermissions } from "./hooks/use-permissions"; + +const { state, handlePermissionToggle } = usePermissions({ + type: "workspace", + selected: permissions, + onPermissionChange: setPermissions, +}); +``` + +### Using Constants +```tsx +import { ROOT_KEY_MESSAGES } from "./constants"; + + +``` + +## Benefits + +1. **Maintainability**: Easier to understand and modify individual components +2. **Reusability**: Hooks and utilities can be reused across different parts of the app +3. **Testability**: Business logic in hooks is easier to test in isolation +4. **Type Safety**: Better TypeScript support with proper types +5. **Performance**: Optimized re-renders with proper dependency management +6. **Consistency**: Standardized patterns across all components + +## Migration Notes + +The refactoring maintains backward compatibility while providing a cleaner architecture. All existing functionality is preserved, but the code is now more organized and maintainable. \ No newline at end of file diff --git a/apps/dashboard/app/(app)/settings/root-keys/components/root-key/components/expandable-category.tsx b/apps/dashboard/app/(app)/settings/root-keys/components/root-key/components/expandable-category.tsx new file mode 100644 index 0000000000..9336712c9f --- /dev/null +++ b/apps/dashboard/app/(app)/settings/root-keys/components/root-key/components/expandable-category.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { CollapsibleTrigger } from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import type { CheckedState } from "@radix-ui/react-checkbox"; +import { CaretRight } from "@unkey/icons"; +import { Checkbox } from "@unkey/ui"; +import { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from "react"; + +export type ExpandableCategoryProps = { + category: string; + description?: string; + checked: CheckedState | undefined; + setChecked: (checked: CheckedState) => void; + count: number; +} & Omit, "children" | "asChild">; + +const ExpandableCategory = forwardRef< + ElementRef, + ExpandableCategoryProps +>(({ category, description, checked, setChecked, count, ...props }, ref) => { + if (count === 0) { + return null; + } + return ( +
+
+ setChecked(next)} + size="lg" + aria-label={`Toggle ${category} permissions`} + /> +
+ svg]:rotate-90 w-full", + props.className, + )} + > +
+

{category}

+

{description}

+
+
+
+ ); +}); + +ExpandableCategory.displayName = "ExpandableCategory"; + +export { ExpandableCategory }; diff --git a/apps/dashboard/app/(app)/settings/root-keys/components/root-key/components/highlighted-text.tsx b/apps/dashboard/app/(app)/settings/root-keys/components/root-key/components/highlighted-text.tsx new file mode 100644 index 0000000000..6ac7ac3155 --- /dev/null +++ b/apps/dashboard/app/(app)/settings/root-keys/components/root-key/components/highlighted-text.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useMemo } from "react"; +import type React from "react"; + +type HighlightedTextProps = { + text: string; + searchValue: string | undefined; +}; + +export function HighlightedText({ text, searchValue }: HighlightedTextProps): React.ReactNode { + const query = searchValue?.trim() ?? ""; + + const parts = useMemo(() => { + if (query === "") { + return [text]; + } + + const escapedSearchValue = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // Add 'u' for better Unicode handling (e.g., case-insensitive matching across locales) + const regex = new RegExp(`(${escapedSearchValue})`, "iu"); + return text.split(regex); + }, [text, query]); + + return parts.map((part, index) => + index % 2 === 1 ? ( + + {part} + + ) : ( + part + ), + ); +} diff --git a/apps/dashboard/app/(app)/settings/root-keys/components/root-key/components/permission-badge-list.tsx b/apps/dashboard/app/(app)/settings/root-keys/components/root-key/components/permission-badge-list.tsx new file mode 100644 index 0000000000..877d0b4e30 --- /dev/null +++ b/apps/dashboard/app/(app)/settings/root-keys/components/root-key/components/permission-badge-list.tsx @@ -0,0 +1,170 @@ +import { SelectedItemsList } from "@/components/selected-item-list"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { CaretRight, Key2 } from "@unkey/icons"; +import type { UnkeyPermission } from "@unkey/rbac"; +import { Badge } from "@unkey/ui"; +import { useMemo } from "react"; +import { ROOT_KEY_CONSTANTS } from "../constants"; +import { apiPermissions, workspacePermissions } from "../permissions"; + +type Props = { + apiId: string; + name: string; + selectedPermissions: UnkeyPermission[]; + expandCount: number; + title: string; + removePermission: (permission: UnkeyPermission) => void; +}; + +type PermissionInfo = { permission: UnkeyPermission; category: string; action: string }[]; + +const PermissionBadgeList = ({ + apiId, + name, + selectedPermissions, + title, + expandCount, + removePermission, +}: Props) => { + // Flatten allPermissions into an array of {permission, action} objects + const allPermissionsArray = useMemo(() => { + const allPermissions = + apiId === ROOT_KEY_CONSTANTS.WORKSPACE ? workspacePermissions : apiPermissions(apiId); + return Object.entries(allPermissions).flatMap(([category, permissions]) => + Object.entries(permissions).map(([action, permissionData]) => ({ + permission: permissionData.permission, + category, + action, + })), + ); + }, [apiId]); + + const info = useMemo( + () => findPermission(allPermissionsArray, selectedPermissions), + [allPermissionsArray, selectedPermissions], + ); + if (info.length === 0) { + return null; + } + + return info.length > expandCount ? ( +
+ +
+ ) : ( +
+ + +
+ ); +}; + +const ListBadges = ({ + info, + removePermission, +}: { info: PermissionInfo; removePermission: (permission: UnkeyPermission) => void }) => { + // Stop propagation to prevent triggering parent collapsible when removing permissions + const handleRemovePermissionClick = (id: string) => { + const permission = info.find((p) => p.permission === id); + if (permission) { + removePermission(permission.permission); + } + }; + return ( + ({ + id: permission.permission, + name: permission.action, + description: permission.category, + }))} + gridCols={2} + onRemoveItem={handleRemovePermissionClick} + renderIcon={() =>