diff --git a/apps/dashboard/app/(app)/settings/root-keys/components/create-root-key/components/permission-badge-list.tsx b/apps/dashboard/app/(app)/settings/root-keys/components/create-root-key/components/permission-badge-list.tsx new file mode 100644 index 0000000000..2b4e6c488c --- /dev/null +++ b/apps/dashboard/app/(app)/settings/root-keys/components/create-root-key/components/permission-badge-list.tsx @@ -0,0 +1,175 @@ +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { ChevronDown, XMark } from "@unkey/icons"; +import type { UnkeyPermission } from "@unkey/rbac"; +import { Badge, Button } from "@unkey/ui"; +import { useMemo } from "react"; +import { apiPermissions, workspacePermissions } from "../../../[keyId]/permissions/permissions"; + +type Props = { + apiId: string; + name: string; + selectedPermissions: UnkeyPermission[]; + expandCount: number; + title: string; + removePermission: (permission: string) => void; +}; + +type InfoType = { permission: UnkeyPermission; category: string; action: string }[]; + +const PermissionBadgeList = ({ + apiId, + name, + selectedPermissions, + title, + expandCount, + removePermission, +}: Props) => { + const workspace = workspacePermissions; + const allPermissions = apiId === "workspace" ? workspace : apiPermissions(apiId); + + // Flatten allPermissions into an array of {permission, action} objects + const allPermissionsArray = useMemo( + () => + Object.entries(allPermissions).flatMap(([category, permissions]) => + Object.entries(permissions).map(([action, permissionData]) => ({ + permission: permissionData.permission, + category, + action, + })), + ), + [allPermissions], + ); + + const info = useMemo( + () => findPermission(allPermissionsArray, selectedPermissions), + [allPermissionsArray, selectedPermissions], + ); + if (info.length === 0) { + return null; + } + + return info.length > expandCount ? ( +
+ +
+ ) : ( +
+ + +
+ ); +}; + +const ListBadges = ({ + info, + removePermission, +}: { info: InfoType; removePermission: (permission: string) => void }) => { + const handleRemovePermission = (e: React.MouseEvent, permission: string) => { + e.stopPropagation(); + removePermission(permission); + }; + return ( +
+ {info?.map((permission) => { + if (!permission) { + return null; + } + + return ( + + {permission.action} + + + ); + })} +
+ ); +}; + +type CollapsibleListProps = { + info: InfoType; + title: string; + expandCount: number; + removePermission: (permission: string) => void; + name: string; + className?: string; +} & React.ComponentProps; + +const CollapsibleList = ({ + info, + title, + expandCount, + removePermission, + name, + className, + ...props +}: CollapsibleListProps) => { + return ( + + svg]:rotate-180 w-full", + className, + )} + > + + + + + + + + ); +}; + +const ListTitle = ({ + title, + count, + category, +}: { title: string; count: number; category: string }) => { + return ( +

+ {title} + {category} + + {count} + +

+ ); +}; + +const findPermission = (allPermissions: InfoType, selectedPermissions: UnkeyPermission[]) => { + if (!selectedPermissions || !Array.isArray(selectedPermissions)) { + return []; + } + return selectedPermissions + .map((permission) => { + return allPermissions.find((p) => p.permission === permission); + }) + .filter(Boolean); +}; + +export { PermissionBadgeList }; diff --git a/apps/dashboard/app/(app)/settings/root-keys/components/create-root-key/components/permission-list.tsx b/apps/dashboard/app/(app)/settings/root-keys/components/create-root-key/components/permission-list.tsx index 60f381482a..6cbeb19ddc 100644 --- a/apps/dashboard/app/(app)/settings/root-keys/components/create-root-key/components/permission-list.tsx +++ b/apps/dashboard/app/(app)/settings/root-keys/components/create-root-key/components/permission-list.tsx @@ -2,22 +2,11 @@ import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import type { CheckedState } from "@radix-ui/react-checkbox"; import type { UnkeyPermission } from "@unkey/rbac"; -import { useEffect, useReducer } from "react"; +import { useCallback, useEffect, useMemo, useReducer } from "react"; import { apiPermissions, workspacePermissions } from "../../../[keyId]/permissions/permissions"; import { ExpandableCategory } from "./expandable-category"; import { PermissionToggle } from "./permission-toggle"; -type Props = { - type: "workspace" | "api"; - api?: - | { - id: string; - name: string; - } - | undefined; - onPermissionChange: (permissions: UnkeyPermission[]) => void; -}; - // Helper type for permission list structure type PermissionCategory = Record; type PermissionList = Record; @@ -51,6 +40,7 @@ function computeCheckedStates( // Compute rootChecked const allPermissionNames = getAllPermissionNames(permissionList); let rootChecked: CheckedState = false; + if (selectedPermissions.length === 0) { rootChecked = false; } else if (selectedPermissions.length === allPermissionNames.length) { @@ -60,6 +50,7 @@ function computeCheckedStates( } // Compute categoryChecked const categoryChecked: Record = {}; + Object.entries(permissionList).forEach(([category, allPermissions]) => { const allPermissionNames = Object.values(allPermissions).map(({ permission }) => permission); if (allPermissionNames.every((p) => selectedPermissions.includes(p))) { @@ -70,6 +61,7 @@ function computeCheckedStates( categoryChecked[category] = false; } }); + return { rootChecked, categoryChecked }; } @@ -89,6 +81,7 @@ function permissionReducer(state: PermissionState, action: PermissionAction): Pe ); return { selectedPermissions, rootChecked, categoryChecked }; } + case "TOGGLE_CATEGORY": { const { category, permissionList } = action; const categoryPermissions = getCategoryPermissionNames(permissionList, category); @@ -111,6 +104,7 @@ function permissionReducer(state: PermissionState, action: PermissionAction): Pe ); return { selectedPermissions, rootChecked, categoryChecked }; } + case "TOGGLE_ROOT": { const { permissionList } = action; const allPermissionNames = getAllPermissionNames(permissionList); @@ -126,38 +120,89 @@ function permissionReducer(state: PermissionState, action: PermissionAction): Pe ); return { selectedPermissions, rootChecked, categoryChecked }; } + default: return state; } } -export const PermissionContentList = ({ type, api, onPermissionChange }: Props) => { - const permissionList: PermissionList = - type === "workspace" ? workspacePermissions : api ? apiPermissions(api.id) : {}; +type Props = { + type: "workspace" | "api"; + api?: + | { + id: string; + name: string; + } + | undefined; + onPermissionChange: (permissions: UnkeyPermission[]) => void; + selected: UnkeyPermission[]; +}; + +export const PermissionContentList = ({ type, api, onPermissionChange, selected }: Props) => { + const permissionList: PermissionList = useMemo( + () => (type === "workspace" ? workspacePermissions : api ? apiPermissions(api.id) : {}), + [type, api?.id], + ); + + const initialState = useMemo(() => { + const initState = + type === "workspace" + ? Object.values(workspacePermissions) + .flatMap((category) => + Object.values(category).map(({ permission }) => + selected.includes(permission) ? permission : null, + ), + ) + .filter((permission) => permission !== null) + : api + ? Object.values(apiPermissions(api.id)) + .flatMap((category) => + Object.values(category).map(({ permission }) => + selected.includes(permission) ? permission : null, + ), + ) + .filter((permission) => permission !== null) + : []; + + const { rootChecked, categoryChecked } = computeCheckedStates(initState, permissionList); + + const selectedPermissions = getAllPermissionNames(permissionList).filter((permission) => + initState.includes(permission), + ); + + return { rootChecked, categoryChecked, selectedPermissions }; + }, [type, selected, permissionList, api]); const [state, dispatch] = useReducer(permissionReducer, { - selectedPermissions: [], - categoryChecked: {}, - rootChecked: false, + selectedPermissions: selected.length > 0 ? initialState.selectedPermissions : [], + categoryChecked: selected.length > 0 ? initialState.categoryChecked : {}, + rootChecked: selected.length > 0 ? initialState.rootChecked : false, }); // Notify parent when selectedPermissions changes useEffect(() => { onPermissionChange(state.selectedPermissions); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.selectedPermissions]); - const handleRootChecked = () => { + const handleRootChecked = useCallback(() => { dispatch({ type: "TOGGLE_ROOT", permissionList }); - }; + }, [permissionList]); - const handleCategoryChecked = (category: string) => { - dispatch({ type: "TOGGLE_CATEGORY", category, permissionList }); - }; + const handleCategoryChecked = useCallback( + (category: string) => { + dispatch({ type: "TOGGLE_CATEGORY", category, permissionList }); + }, + [permissionList], + ); - const handlePermissionChecked = (permission: UnkeyPermission) => { - dispatch({ type: "TOGGLE_PERMISSION", permission, permissionList }); - }; + const handlePermissionChecked = useCallback( + (permission: UnkeyPermission) => { + dispatch({ type: "TOGGLE_PERMISSION", permission, permissionList }); + }, + [permissionList], + ); return (
diff --git a/apps/dashboard/app/(app)/settings/root-keys/components/create-root-key/components/permission-sheet.tsx b/apps/dashboard/app/(app)/settings/root-keys/components/create-root-key/components/permission-sheet.tsx index 763097ec06..c1552e4d19 100644 --- a/apps/dashboard/app/(app)/settings/root-keys/components/create-root-key/components/permission-sheet.tsx +++ b/apps/dashboard/app/(app)/settings/root-keys/components/create-root-key/components/permission-sheet.tsx @@ -8,6 +8,8 @@ import { } from "@/components/ui/sheet"; import { ScrollArea } from "@/components/ui/scroll-area"; +import type { UnkeyPermission } from "@unkey/rbac"; +import { Button } from "@unkey/ui"; import { useEffect, useRef, useState } from "react"; import { PermissionContentList } from "./permission-list"; import { SearchPermissions } from "./search-permissions"; @@ -18,15 +20,27 @@ type PermissionSheetProps = { id: string; name: string; }[]; - onChange?: (permissions: string[]) => void; + selectedPermissions: UnkeyPermission[]; + onChange?: (permissions: UnkeyPermission[]) => void; + loadMore?: () => void; + hasNextPage?: boolean; + isFetchingNextPage?: boolean; }; -export const PermissionSheet = ({ children, apis, onChange }: PermissionSheetProps) => { +export const PermissionSheet = ({ + children, + apis, + selectedPermissions, + onChange, + loadMore, + hasNextPage, + isFetchingNextPage, +}: PermissionSheetProps) => { const [open, setOpen] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [search, setSearch] = useState(undefined); const inputRef = useRef(null); - const [workspacePermissions, setWorkspacePermissions] = useState([]); - const [apiPermissions, setApiPermissions] = useState>({}); + const [workspacePermissions, setWorkspacePermissions] = useState([]); + const [apiPermissions, setApiPermissions] = useState>({}); const handleSearchChange = (e: React.ChangeEvent) => { setIsProcessing(true); @@ -39,6 +53,14 @@ export const PermissionSheet = ({ children, apis, onChange }: PermissionSheetPro setOpen(open); }; + const handleApiPermissionChange = (apiId: string, permissions: UnkeyPermission[]) => { + setApiPermissions((prev) => ({ ...prev, [apiId]: permissions })); + }; + + const handleWorkspacePermissionChange = (permissions: UnkeyPermission[]) => { + setWorkspacePermissions(permissions); + }; + // Aggregate all permissions and call onChange useEffect(() => { if (onChange) { @@ -64,31 +86,54 @@ export const PermissionSheet = ({ children, apis, onChange }: PermissionSheetPro /> - -
- {/* Workspace Permissions */} - {/* TODO: Tie In Search */} - - setWorkspacePermissions(permissions.map(String)) - } - /> - {/* From APIs */} -

From APIs

- {apis.map((api) => ( - - setApiPermissions((prev) => ({ ...prev, [api.id]: permissions.map(String) })) - } - /> - ))} +
+
+ +
+ {/* Workspace Permissions */} + {/* TODO: Tie In Search */} + + handleWorkspacePermissionChange(permissions) + } + /> + {/* From APIs */} +

From APIs

+ {apis.map((api) => ( + + handleApiPermissionChange(api.id, permissions) + } + /> + ))} +
+
- + {hasNextPage ? ( +
+
+ +
+
+ ) : undefined} +
diff --git a/apps/dashboard/app/(app)/settings/root-keys/components/create-root-key/create-rootkey-button.tsx b/apps/dashboard/app/(app)/settings/root-keys/components/create-root-key/create-rootkey-button.tsx index a8728be51e..bdb31b579d 100644 --- a/apps/dashboard/app/(app)/settings/root-keys/components/create-root-key/create-rootkey-button.tsx +++ b/apps/dashboard/app/(app)/settings/root-keys/components/create-root-key/create-rootkey-button.tsx @@ -1,16 +1,17 @@ "use client"; -import { NavbarActionButton } from "@/components/navigation/action-button"; import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { trpc } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; import { Plus } from "@unkey/icons"; -import { unkeyPermissionValidation } from "@unkey/rbac"; +import { type UnkeyPermission, unkeyPermissionValidation } from "@unkey/rbac"; import { Button, FormInput, toast } from "@unkey/ui"; import dynamic from "next/dynamic"; -import type React from "react"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { PermissionBadgeList } from "./components/permission-badge-list"; import { PermissionSheet } from "./components/permission-sheet"; const DynamicDialogContainer = dynamic( @@ -29,20 +30,16 @@ const formSchema = z.object({ }); type Props = { - defaultOpen?: boolean; -}; + className?: string; +} & React.ComponentProps; -export const CreateRootKeyButton = ({ - defaultOpen, - ...rest -}: React.ButtonHTMLAttributes & Props) => { +export const CreateRootKeyButton = ({ ...props }: Props) => { const trpcUtils = trpc.useUtils(); - const [isOpen, setIsOpen] = useState(defaultOpen ?? false); - + const [isOpen, setIsOpen] = useState(false); + const [selectedPermissions, setSelectedPermissions] = useState([]); const { data: apisData, isLoading, - error, fetchNextPage, hasNextPage, isFetchingNextPage, @@ -69,15 +66,12 @@ export const CreateRootKeyButton = ({ register, handleSubmit, setValue, - watch, formState: { errors, isValid, isSubmitting }, } = useForm>({ resolver: zodResolver(formSchema), mode: "onChange", }); - // const selectedPermissions = watch("permissions"); - const key = trpc.rootKey.create.useMutation({ onSuccess() { trpcUtils.settings.rootKeys.query.invalidate(); @@ -87,37 +81,49 @@ export const CreateRootKeyButton = ({ toast.error(err.message); }, }); + function fetchMoreApis() { + if (hasNextPage) { + fetchNextPage(); + } + } async function onSubmit(values: z.infer) { await key.mutateAsync({ name: values.name, permissions: values.permissions, }); + setIsOpen(false); } - const handlePermissionChange = (permissions: string[]) => { - const parsedPermissions = permissions.map((permission) => - unkeyPermissionValidation.parse(permission), - ); - setValue("permissions", parsedPermissions); - }; - + const handlePermissionChange = useCallback( + (permissions: string[]) => { + const parsedPermissions = permissions.map((permission) => + unkeyPermissionValidation.parse(permission), + ); + setSelectedPermissions(parsedPermissions); + setValue("permissions", parsedPermissions); + }, + [setValue], + ); return ( <> - setIsOpen(true)} + variant="primary" + size="md" + className={cn("rounded-lg", props.className)} > New root key - + Create root key @@ -139,8 +144,8 @@ export const CreateRootKeyButton = ({ } >
-
-
+
+
- + @@ -159,6 +171,33 @@ export const CreateRootKeyButton = ({
+ +
+ + handlePermissionChange(selectedPermissions.filter((p) => p !== permission)) + } + /> + {allApis.map((api) => ( + + handlePermissionChange(selectedPermissions.filter((p) => p !== permission)) + } + /> + ))} +
+
);