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 = ({
}
>