diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover.constants.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover.constants.tsx
index bd0c91206c..55aa24149f 100644
--- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover.constants.tsx
+++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover.constants.tsx
@@ -1,4 +1,5 @@
import { toast } from "@/components/ui/toaster";
+import { trpc } from "@/lib/trpc/client";
import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema";
import {
ArrowOppositeDirectionY,
@@ -10,6 +11,7 @@ import {
Code,
Gauge,
PenWriting3,
+ Tag,
Trash,
} from "@unkey/icons";
import { DeleteKey } from "./components/delete-key";
@@ -20,9 +22,12 @@ import { EditExternalId } from "./components/edit-external-id";
import { EditKeyName } from "./components/edit-key-name";
import { EditMetadata } from "./components/edit-metadata";
import { EditRatelimits } from "./components/edit-ratelimits";
-import type { MenuItem } from "./keys-table-action.popover";
+import { KeyRbacDialog } from "./components/edit-rbac";
+import { KeysTableActionPopover, type MenuItem } from "./keys-table-action.popover";
export const getKeysTableActionItems = (key: KeyDetails): MenuItem[] => {
+ const trpcUtils = trpc.useUtils();
+
return [
{
id: "override",
@@ -87,6 +92,28 @@ export const getKeysTableActionItems = (key: KeyDetails): MenuItem[] => {
ActionComponent: (props) =>
,
divider: true,
},
+ {
+ id: "edit-rbac",
+ label: "Manage roles and permissions...",
+ icon:
,
+ ActionComponent: (props) => (
+
+ ),
+ prefetch: async () => {
+ await trpcUtils.key.connectedRolesAndPerms.prefetch({
+ keyId: key.id,
+ });
+ },
+ divider: true,
+ },
{
id: "delete-key",
label: "Delete key",
@@ -95,3 +122,12 @@ export const getKeysTableActionItems = (key: KeyDetails): MenuItem[] => {
},
];
};
+
+type KeysTableActionsProps = {
+ keyData: KeyDetails;
+};
+
+export const KeysTableActions = ({ keyData }: KeysTableActionsProps) => {
+ const items = getKeysTableActionItems(keyData);
+ return
;
+};
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover.tsx
index 49862b19c9..a02423bb39 100644
--- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover.tsx
+++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover.tsx
@@ -17,6 +17,7 @@ export type MenuItem = {
disabled?: boolean;
divider?: boolean;
ActionComponent?: FC
;
+ prefetch?: () => Promise;
};
type BaseTableActionPopoverProps = PropsWithChildren<{
@@ -32,22 +33,46 @@ export const KeysTableActionPopover = ({
const [enabledItem, setEnabledItem] = useState();
const [open, setOpen] = useState(false);
const [focusIndex, setFocusIndex] = useState(0);
+ const [prefetchedItems, setPrefetchedItems] = useState>(new Set());
const menuItems = useRef([]);
useEffect(() => {
if (open) {
+ // Prefetch all items that need prefetching and haven't been prefetched yet
+ items
+ .filter((item) => item.prefetch && !prefetchedItems.has(item.id))
+ .forEach(async (item) => {
+ try {
+ await item.prefetch?.();
+ setPrefetchedItems((prev) => new Set(prev).add(item.id));
+ } catch (error) {
+ console.error(`Failed to prefetch data for ${item.id}:`, error);
+ }
+ });
+
const firstEnabledIndex = items.findIndex((item) => !item.disabled);
setFocusIndex(firstEnabledIndex >= 0 ? firstEnabledIndex : 0);
if (firstEnabledIndex >= 0) {
menuItems.current[firstEnabledIndex]?.focus();
}
}
- }, [open, items]);
+ }, [open, items, prefetchedItems]);
const handleActionSelection = (value: string) => {
setEnabledItem(value);
};
+ const handleItemHover = async (item: MenuItem) => {
+ if (item.prefetch && !prefetchedItems.has(item.id)) {
+ try {
+ await item.prefetch();
+ setPrefetchedItems((prev) => new Set([...prev, item.id]));
+ } catch (error) {
+ console.error(`Failed to prefetch data for ${item.id}:`, error);
+ }
+ }
+ };
+
return (
e.stopPropagation()}>
@@ -66,7 +91,7 @@ export const KeysTableActionPopover = ({
)}
{
e.preventDefault();
@@ -107,6 +132,7 @@ export const KeysTableActionPopover = ({
item.disabled && "cursor-not-allowed opacity-50",
item.className,
)}
+ onMouseEnter={() => handleItemHover(item)}
onClick={(e) => {
if (!item.disabled) {
item.onClick?.(e);
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/selection-controls/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/selection-controls/index.tsx
index 732ae2e1fd..a609e712d8 100644
--- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/selection-controls/index.tsx
+++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/selection-controls/index.tsx
@@ -213,7 +213,7 @@ const AnimatedDigit = ({ digit, index }: { digit: string; index: number }) => {
);
};
-const AnimatedCounter = ({ value }: { value: number }) => {
+export const AnimatedCounter = ({ value }: { value: number }) => {
const digits = value.toString().split("");
return (
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/keys-list.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/keys-list.tsx
index 6b884d3c46..980f410785 100644
--- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/keys-list.tsx
+++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/keys-list.tsx
@@ -8,7 +8,6 @@ import { cn } from "@unkey/ui/src/lib/utils";
import dynamic from "next/dynamic";
import Link from "next/link";
import React, { useCallback, useMemo, useState } from "react";
-import { getKeysTableActionItems } from "./components/actions/keys-table-action.popover.constants";
import { VerificationBarChart } from "./components/bar-chart";
import { HiddenValueCell } from "./components/hidden-value";
import { LastUsedCell } from "./components/last-used";
@@ -27,9 +26,9 @@ import { getRowClassName } from "./utils/get-row-class";
const KeysTableActionPopover = dynamic(
() =>
- import("./components/actions/keys-table-action.popover").then((mod) => ({
- default: mod.KeysTableActionPopover,
- })),
+ import("./components/actions/keys-table-action.popover.constants").then(
+ (mod) => mod.KeysTableActions,
+ ),
{
ssr: false,
loading: () => (
@@ -249,13 +248,12 @@ export const KeysList = ({
);
},
},
-
{
key: "action",
header: "",
width: "15%",
render: (key) => {
- return ;
+ return ;
},
},
],
diff --git a/apps/dashboard/app/(app)/authorization/layout.tsx b/apps/dashboard/app/(app)/authorization/layout.tsx
deleted file mode 100644
index 1622c60d9d..0000000000
--- a/apps/dashboard/app/(app)/authorization/layout.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import type * as React from "react";
-
-import { getAuth } from "@/lib/auth";
-import { db } from "@/lib/db";
-import { redirect } from "next/navigation";
-
-export const dynamic = "force-dynamic";
-
-export default async function AuthorizationLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
- const { orgId } = await getAuth();
- const workspace = await db.query.workspaces.findFirst({
- where: (table, { eq }) => eq(table.orgId, orgId),
- });
- if (!workspace) {
- return redirect("/auth/sign-in");
- }
-
- return children;
-}
diff --git a/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/delete-permission.tsx b/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/delete-permission.tsx
deleted file mode 100644
index ef67340121..0000000000
--- a/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/delete-permission.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-"use client";
-import { revalidate } from "@/app/actions";
-import { toast } from "@/components/ui/toaster";
-import { trpc } from "@/lib/trpc/client";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { Button, DialogContainer, Input } from "@unkey/ui";
-import { useRouter } from "next/navigation";
-import { useState } from "react";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-
-type Props = {
- trigger: React.ReactNode;
- permission: {
- id: string;
- name: string;
- };
-};
-
-export const DeletePermission: React.FC = ({ trigger, permission }) => {
- const router = useRouter();
- const [open, setOpen] = useState(false);
-
- const formSchema = z.object({
- name: z.string().refine((v) => v === permission.name, "Please confirm the permission's name"),
- });
-
- type FormValues = z.infer;
-
- const {
- register,
- handleSubmit,
- watch,
- reset,
- formState: { isSubmitting },
- } = useForm({
- mode: "onChange",
- resolver: zodResolver(formSchema),
- defaultValues: {
- name: "",
- },
- });
-
- const isValid = watch("name") === permission.name;
-
- const deletePermission = trpc.rbac.deletePermission.useMutation({
- onSuccess() {
- toast.success("Permission deleted successfully", {
- description: "The permission has been permanently removed",
- });
- revalidate("/authorization/permissions");
- router.push("/authorization/permissions");
- },
- onError(err) {
- toast.error("Failed to delete permission", {
- description: err.message,
- });
- },
- });
-
- const onSubmit = async () => {
- try {
- await deletePermission.mutateAsync({ permissionId: permission.id });
- } catch (error) {
- console.error("Delete error:", error);
- }
- };
-
- const handleOpenChange = (newState: boolean) => {
- setOpen(newState);
- if (!newState) {
- reset();
- }
- };
-
- return (
- <>
- {/* biome-ignore lint/a11y/useKeyWithClickEvents: */}
- handleOpenChange(true)}>{trigger}
-
-
-
- Delete Permission
-
-
- This action cannot be undone – proceed with caution
-
-
- }
- >
-
- Warning:
- This action is not reversible. The permission will be removed from all roles and keys that
- currently use it.
-
-
-
-
- >
- );
-};
diff --git a/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/navigation.tsx b/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/navigation.tsx
deleted file mode 100644
index 0e3b31f182..0000000000
--- a/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/navigation.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-"use client";
-import { NavbarActionButton } from "@/components/navigation/action-button";
-import { Navbar } from "@/components/navigation/navbar";
-import type { Permission } from "@unkey/db";
-import { ShieldKey } from "@unkey/icons";
-import { DeletePermission } from "./delete-permission";
-// Reusable for settings where we only change the link
-export function Navigation({
- permissionId,
- permission,
-}: {
- permissionId: string;
- permission: Permission;
-}) {
- return (
-
- }>
-
- Authorization
-
-
- Permissions
-
-
- {permissionId}
-
-
-
-
- Delete Permission
-
- }
- permission={permission}
- />{" "}
-
-
- );
-}
diff --git a/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/page.tsx b/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/page.tsx
deleted file mode 100644
index 36e6a8ef2f..0000000000
--- a/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/page.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { getAuth } from "@/lib/auth";
-import { db } from "@/lib/db";
-import { notFound, redirect } from "next/navigation";
-import { Navigation } from "./navigation";
-import { PermissionClient } from "./settings-client";
-
-export const revalidate = 0;
-
-type Props = {
- params: {
- permissionId: string;
- };
-};
-
-export default async function RolesPage(props: Props) {
- const { orgId } = await getAuth();
-
- const workspace = await db.query.workspaces.findFirst({
- where: (table, { eq }) => eq(table.orgId, orgId),
- with: {
- permissions: {
- where: (table, { eq }) => eq(table.id, props.params.permissionId),
- with: {
- keys: true,
- roles: {
- with: {
- role: {
- with: {
- keys: {
- columns: {
- keyId: true,
- },
- },
- },
- },
- },
- },
- },
- },
- },
- });
-
- if (!workspace) {
- return redirect("/new");
- }
-
- const permission = workspace.permissions.at(0);
-
- if (!permission) {
- return notFound();
- }
-
- return (
-
- );
-}
diff --git a/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/settings-client.tsx b/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/settings-client.tsx
deleted file mode 100644
index f572d3fef9..0000000000
--- a/apps/dashboard/app/(app)/authorization/permissions/[permissionId]/settings-client.tsx
+++ /dev/null
@@ -1,210 +0,0 @@
-"use client";
-
-import { revalidateTag } from "@/app/actions";
-import { CopyableIDButton } from "@/components/navigation/copyable-id-button";
-import { toast } from "@/components/ui/toaster";
-import { tags } from "@/lib/cache";
-import { trpc } from "@/lib/trpc/client";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { Button, Input, SettingCard } from "@unkey/ui";
-import { validation } from "@unkey/validation";
-import { format } from "date-fns";
-import { useRouter } from "next/navigation";
-import { useState } from "react";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-import { DeletePermission } from "./delete-permission";
-
-const formSchema = z.object({
- name: validation.name,
-});
-
-type FormValues = z.infer
;
-
-type Props = {
- permission: {
- id: string;
- workspaceId: string;
- name: string;
- createdAtM: number;
- updatedAtM?: number | null;
- description?: string | null;
- keys: { keyId: string }[];
- roles?: {
- role?: {
- id?: string;
- deletedAt?: number | null;
- keys?: { keyId: string }[];
- };
- }[];
- };
-};
-
-export const PermissionClient = ({ permission }: Props) => {
- const [isUpdating, setIsUpdating] = useState(false);
- const router = useRouter();
-
- const form = useForm({
- resolver: zodResolver(formSchema),
- defaultValues: {
- name: permission.name,
- },
- });
-
- // Filter out deleted or invalid roles
- const activeRoles = (permission?.roles ?? []).filter(
- (roleRelation) =>
- roleRelation?.role?.id &&
- (roleRelation.role.deletedAt === undefined || roleRelation.role.deletedAt === null),
- );
-
- // Count all unique connected keys
- const connectedKeys = new Set();
- for (const key of permission.keys) {
- connectedKeys.add(key.keyId);
- }
- for (const roleRelation of activeRoles) {
- for (const key of roleRelation?.role?.keys ?? []) {
- connectedKeys.add(key.keyId);
- }
- }
-
- const updateNameMutation = trpc.rbac.updatePermission.useMutation({
- onSuccess() {
- toast.success("Your permission name has been updated!");
- revalidateTag(tags.permission(permission.id));
- router.refresh();
- setIsUpdating(false);
- },
- onError(err) {
- toast.error("Failed to update permission name", {
- description: err.message,
- });
- setIsUpdating(false);
- },
- });
-
- const handleUpdateName = async (values: FormValues) => {
- const newName = values.name;
-
- if (newName === permission.name) {
- return toast.error("Please provide a different name before saving.");
- }
-
- setIsUpdating(true);
- await updateNameMutation.mutateAsync({
- description: permission.description ?? "",
- id: permission.id,
- name: newName,
- });
- };
-
- const watchedName = form.watch("name");
-
- const isNameChanged = watchedName !== permission.name;
- const isNameValid = watchedName && watchedName.trim() !== "";
-
- return (
- <>
-
-
-
- Permission Settings
-
-
-
-
- }
- border="top"
- >
-
-
-
- Save
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Created At
-
- {format(permission.createdAtM, "PPPP")}
-
-
-
-
Updated At
-
- {permission.updatedAtM
- ? format(permission.updatedAtM, "PPPP")
- : "Not updated yet"}
-
-
-
-
Connected Roles
-
{activeRoles.length}
-
-
-
Connected Keys
-
{connectedKeys.size}
-
-
-
-
-
- Deletes this permission along with all its connections
- to roles and keys. This action cannot be undone.
- >
- }
- border="both"
- >
-
-
- Delete Permission...
-
- }
- />
-
-
-
-
- >
- );
-};
diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/control-cloud/index.tsx
new file mode 100644
index 0000000000..e35d0135e3
--- /dev/null
+++ b/apps/dashboard/app/(app)/authorization/permissions/components/control-cloud/index.tsx
@@ -0,0 +1,34 @@
+import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants";
+import { ControlCloud } from "@/components/logs/control-cloud";
+import type { PermissionsFilterField } from "../../filters.schema";
+import { useFilters } from "../../hooks/use-filters";
+
+const FIELD_DISPLAY_NAMES: Record