diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/assigned-items-cell.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/assigned-items-cell.tsx index 3df50329d1..a445398eb3 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/assigned-items-cell.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/assigned-items-cell.tsx @@ -1,20 +1,55 @@ import { cn } from "@/lib/utils"; -import { Page2, Tag } from "@unkey/icons"; +import { Key2, Page2, Tag } from "@unkey/icons"; export const AssignedItemsCell = ({ - items, totalCount, + value, isSelected = false, - type, + kind, }: { - items: string[]; totalCount?: number; + value?: string; isSelected?: boolean; - type: "roles" | "slug"; + kind: "roles" | "keys" | "permissions" | "slug"; }) => { - const hasMore = totalCount && totalCount > items.length; - const icon = - type === "roles" ? : ; + const getIcon = () => { + switch (kind) { + case "roles": + return ; + case "keys": + return ; + case "slug": + return ; + default: + throw new Error(`Invalid type: ${kind}`); + } + }; + + const getDisplayText = (count: number) => { + if (count === 1) { + switch (kind) { + case "roles": + return "Role"; + case "keys": + return "Key"; + case "permissions": + return "Permission"; + default: + throw new Error(`Invalid type: ${kind}`); + } + } + + switch (kind) { + case "roles": + return "Roles"; + case "keys": + return "Keys"; + case "permissions": + return "Permissions"; + default: + throw new Error(`Invalid type: ${kind}`); + } + }; const itemClassName = cn( "font-mono rounded-md py-[2px] px-1.5 items-center w-fit flex gap-2 transition-all duration-100 border border-dashed text-grayA-12", @@ -22,15 +57,42 @@ export const AssignedItemsCell = ({ ); const emptyClassName = cn( - "rounded-md py-[2px] px-1.5 items-center w-fit flex gap-2 transition-all duration-100 border border-dashed bg-grayA-2 ", + "rounded-md py-[2px] px-1.5 items-center w-fit flex gap-2 transition-all duration-100 border border-dashed bg-grayA-2", isSelected ? "border-grayA-7 text-grayA-9" : "border-grayA-6 text-grayA-8", ); - if (items.length === 0) { + if (kind === "slug") { + if (!value) { + return ( +
+
+ {getIcon()} + No slug +
+
+ ); + } + + return ( +
+
+ {getIcon()} +
+ {value} +
+
+
+ ); + } + + if (!totalCount) { return ( -
+
- {icon} + {getIcon()} None assigned
@@ -38,22 +100,16 @@ export const AssignedItemsCell = ({ } return ( -
- {items.map((item) => ( -
- {icon} - - {item} - -
- ))} - {hasMore && ( -
- - {totalCount - items.length} more permissions... - +
+
+ {getIcon()} +
+ {totalCount} {getDisplayText(totalCount)}
- )} +
); }; diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/skeletons.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/skeletons.tsx index 612b3d1e0e..304eba0f87 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/skeletons.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/skeletons.tsx @@ -1,5 +1,5 @@ import { cn } from "@/lib/utils"; -import { ChartActivity2, Dots, Page2, Tag } from "@unkey/icons"; +import { ChartActivity2, Dots, Key2, Page2, Tag } from "@unkey/icons"; export const RoleColumnSkeleton = () => (
@@ -27,19 +27,18 @@ export const SlugColumnSkeleton = () => ( export const AssignedKeysColumnSkeleton = () => (
- +
-
- -
-
); export const AssignedToKeysColumnSkeleton = () => ( -
-
+
+
+ +
+
); diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx index 33b107a826..29c47a5175 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx @@ -3,7 +3,7 @@ import { VirtualTable } from "@/components/virtual-table/index"; import type { Column } from "@/components/virtual-table/types"; import type { Permission } from "@/lib/trpc/routers/authorization/permissions/query"; import { BookBookmark, Page2 } from "@unkey/icons"; -import { Badge, Button, Checkbox, Empty } from "@unkey/ui"; +import { Button, Checkbox, Empty } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; import { useCallback, useMemo, useState } from "react"; import { PermissionsTableActions } from "./components/actions/keys-table-action.popover.constants"; @@ -19,7 +19,7 @@ import { SlugColumnSkeleton, } from "./components/skeletons"; import { usePermissionsListQuery } from "./hooks/use-permissions-list-query"; -import { STATUS_STYLES, getRowClassName } from "./utils/get-row-class"; +import { getRowClassName } from "./utils/get-row-class"; export const PermissionsList = () => { const { permissions, isLoading, isLoadingMore, loadMore, totalCount, hasMore } = @@ -104,8 +104,8 @@ export const PermissionsList = () => { width: "20%", render: (permission) => ( ), @@ -116,9 +116,8 @@ export const PermissionsList = () => { width: "20%", render: (permission) => ( ), @@ -127,32 +126,13 @@ export const PermissionsList = () => { key: "assigned_to_keys", header: "Assigned to Keys", width: "20%", - render: (permission) => { - const keyCount = permission.totalConnectedKeys; - - const getKeyText = (count: number): string => { - if (count === 0) { - return "None assigned"; - } - if (count === 1) { - return "1 key"; - } - return `${count} keys`; - }; - - return ( - - {getKeyText(keyCount)} - - ); - }, + render: (permission) => ( + + ), }, { key: "last_updated", diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/hooks/use-upsert-permission.ts b/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/hooks/use-upsert-permission.ts index 58adfcd303..76055a4295 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/hooks/use-upsert-permission.ts +++ b/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/hooks/use-upsert-permission.ts @@ -12,6 +12,7 @@ export const useUpsertPermission = ( const permission = trpc.authorization.permissions.upsert.useMutation({ onSuccess(data) { trpcUtils.authorization.permissions.invalidate(); + trpcUtils.authorization.roles.invalidate(); // Show success toast toast.success(data.isUpdate ? "Permission Updated" : "Permission Created", { description: data.message, diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-role.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-role.tsx index 4f08ac961d..fc058df662 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-role.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-role.tsx @@ -1,6 +1,6 @@ import type { ActionComponentProps } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover"; import { ConfirmPopover } from "@/components/confirmation-popover"; -import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; +import type { RoleBasic } from "@/lib/trpc/routers/authorization/roles/query"; import { zodResolver } from "@hookform/resolvers/zod"; import { TriangleWarning2 } from "@unkey/icons"; import { Button, DialogContainer, FormCheckbox } from "@unkey/ui"; @@ -18,7 +18,7 @@ const deleteRoleFormSchema = z.object({ type DeleteRoleFormValues = z.infer; -type DeleteRoleProps = { roleDetails: Roles } & ActionComponentProps; +type DeleteRoleProps = { roleDetails: RoleBasic } & ActionComponentProps; export const DeleteRole = ({ roleDetails, isOpen, onClose }: DeleteRoleProps) => { const [isConfirmPopoverOpen, setIsConfirmPopoverOpen] = useState(false); diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-role.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-role.tsx index adfc3cca12..6c1f47a554 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-role.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-role.tsx @@ -1,19 +1,19 @@ -import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; +import type { RoleBasic } from "@/lib/trpc/routers/authorization/roles/query"; import { useEffect } from "react"; import { toast } from "sonner"; import { UpsertRoleDialog } from "../../../../upsert-role"; -import { useFetchConnectedKeysAndPerms } from "./hooks/use-fetch-connected-keys-and-perms"; +import { useFetchConnectedKeysAndPermsData } from "./hooks/use-fetch-connected-keys-and-perms"; export const EditRole = ({ role, isOpen, onClose, }: { - role: Roles; + role: RoleBasic; isOpen: boolean; onClose: () => void; }) => { - const { permissions, keys, error } = useFetchConnectedKeysAndPerms(role.roleId); + const { permissions, keys, error } = useFetchConnectedKeysAndPermsData(role.roleId); useEffect(() => { if (error) { @@ -42,7 +42,6 @@ export const EditRole = ({ } } }, [error]); - return ( { - const { data, isLoading, error, refetch } = - trpc.authorization.roles.connectedKeysAndPerms.useQuery( - { - roleId, - }, - { - enabled: Boolean(roleId), - }, - ); +export const useFetchConnectedKeysAndPermsData = (roleId: string) => { + const { calculateLimits } = useRoleLimits(roleId); + const { shouldPrefetch } = calculateLimits(); + + const query = trpc.authorization.roles.connectedKeysAndPerms.useQuery( + { roleId }, + { + enabled: shouldPrefetch && Boolean(roleId), + staleTime: 5 * 60 * 1000, + }, + ); return { - keys: data?.keys || [], - permissions: data?.permissions || [], - isLoading, - error, - refetch, + keys: query.data?.keys || [], + permissions: query.data?.permissions || [], + hasData: Boolean(query.data), + isLoading: query.isLoading, + isError: query.isError, + error: query.error, }; }; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/role-info.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/role-info.tsx index 96571fef32..95942b1bb5 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/role-info.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/role-info.tsx @@ -1,8 +1,8 @@ -import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; +import type { RoleBasic } from "@/lib/trpc/routers/authorization/roles/query"; import { Key2 } from "@unkey/icons"; import { InfoTooltip } from "@unkey/ui"; -export const RoleInfo = ({ roleDetails }: { roleDetails: Roles }) => { +export const RoleInfo = ({ roleDetails }: { roleDetails: RoleBasic }) => { return (
diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.constants.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.constants.tsx index dbb7ff973b..901c3cef23 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.constants.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.constants.tsx @@ -5,7 +5,7 @@ import { } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover"; import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; -import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; +import type { RoleBasic } from "@/lib/trpc/routers/authorization/roles/query"; import { Clone, PenWriting3, Trash } from "@unkey/icons"; import dynamic from "next/dynamic"; import { MAX_KEYS_FETCH_LIMIT } from "../../../upsert-role/components/assign-key/hooks/use-fetch-keys"; @@ -24,7 +24,7 @@ const KeysTableActionPopover = dynamic( ); type RolesTableActionsProps = { - role: Roles; + role: RoleBasic; }; export const RolesTableActions = ({ role }: RolesTableActionsProps) => { @@ -35,7 +35,7 @@ export const RolesTableActions = ({ role }: RolesTableActionsProps) => { }; const getRolesTableActionItems = ( - role: Roles, + role: RoleBasic, trpcUtils: ReturnType, ): MenuItem[] => { return [ diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/assigned-items-cell.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/assigned-items-cell.tsx index 0c755ee601..293762f48d 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/assigned-items-cell.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/assigned-items-cell.tsx @@ -1,20 +1,43 @@ +import { trpc } from "@/lib/trpc/client"; import { cn } from "@/lib/utils"; import { Key2, Page2 } from "@unkey/icons"; export const AssignedItemsCell = ({ - items, - totalCount, - type, + roleId, + kind, isSelected = false, }: { - items: string[]; - totalCount?: number; - type: "keys" | "permissions"; + roleId: string; + kind: "keys" | "permissions"; isSelected?: boolean; }) => { - const hasMore = totalCount && totalCount > items.length; + const { data: keysData, isLoading: keysLoading } = + trpc.authorization.roles.connectedKeys.useQuery( + { roleId }, + { + enabled: kind === "keys", + staleTime: 5 * 60 * 1000, + }, + ); + const { data: permissionsData, isLoading: permissionsLoading } = + trpc.authorization.roles.connectedPerms.useQuery( + { roleId }, + { + enabled: kind === "permissions", + staleTime: 5 * 60 * 1000, + }, + ); + + const data = kind === "keys" ? keysData : permissionsData; + const isLoading = kind === "keys" ? keysLoading : permissionsLoading; + const totalCount = data?.totalCount; + const icon = - type === "keys" ? : ; + kind === "keys" ? ( + + ) : ( + + ); const itemClassName = cn( "font-mono rounded-md py-[2px] px-1.5 items-center w-fit flex gap-2 transition-all duration-100 border border-dashed text-grayA-12", @@ -22,13 +45,24 @@ export const AssignedItemsCell = ({ ); const emptyClassName = cn( - "rounded-md py-[2px] px-1.5 items-center w-fit flex gap-2 transition-all duration-100 border border-dashed bg-grayA-2 ", + "rounded-md py-[2px] px-1.5 items-center w-fit flex gap-2 transition-all duration-100 border border-dashed bg-grayA-2", isSelected ? "border-grayA-7 text-grayA-9" : "border-grayA-6 text-grayA-8", ); - if (items.length === 0) { + if (isLoading) { + return ( +
+
+ {icon} +
+
+
+ ); + } + + if (!totalCount) { return ( -
+
{icon} None assigned @@ -38,20 +72,23 @@ export const AssignedItemsCell = ({ } return ( -
- {items.map((item) => ( -
- {icon} - {item} -
- ))} - {hasMore && ( -
- - {totalCount - items.length} more {type}... - +
+
+ {icon} +
+ {totalCount} {getDisplayText(totalCount, kind)}
- )} +
); }; + +const getDisplayText = (count: number, kind: "keys" | "permissions") => { + if (count === 1) { + return kind === "keys" ? "Key" : "Permission"; + } + return kind === "keys" ? "Keys" : "Permissions"; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/skeletons.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/skeletons.tsx index 99e8b8a504..2358997eb1 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/skeletons.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/skeletons.tsx @@ -20,28 +20,20 @@ export const SlugColumnSkeleton = () => ( ); export const AssignedKeysColumnSkeleton = () => ( -
+
-
-
-
- -
+
); export const PermissionsColumnSkeleton = () => ( -
+
-
- -
-
); diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-role-limits.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-role-limits.ts new file mode 100644 index 0000000000..81e252d924 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-role-limits.ts @@ -0,0 +1,90 @@ +import { trpc } from "@/lib/trpc/client"; + +export type RoleLimitState = { + totalKeys: number; + totalPerms: number; + hasKeyWarning: boolean; + hasPermWarning: boolean; + shouldPrefetch: boolean; + shouldAllowEdit: boolean; +}; + +// `MAX_ATTACH_LIMIT` threshold for role attachments. Beyond this limit: +// - Role editing is disabled to prevent UI performance degradation +// - Warning callouts are shown to inform users of potential slowdowns +// - Prefetching of connected keys/permissions is skipped to reduce API load +export const MAX_ATTACH_LIMIT = 50; + +export const useRoleLimits = (roleId?: string) => { + const trpcUtils = trpc.useUtils(); + + const getKeysPreview = () => { + if (!roleId) { + return null; + } + return trpcUtils.authorization.roles.connectedKeys.getData({ + roleId, + }); + }; + + const getPermsPreview = () => { + if (!roleId) { + return null; + } + return trpcUtils.authorization.roles.connectedPerms.getData({ + roleId, + }); + }; + + const calculateLimits = ( + additionalKeys?: string[], + additionalPerms?: string[], + ): RoleLimitState => { + const keysPreview = getKeysPreview(); + const permsPreview = getPermsPreview(); + + // Calculate totals - use preview data first, fallback to additional arrays + const totalKeys = keysPreview?.totalCount || additionalKeys?.length || 0; + + const totalPerms = permsPreview?.totalCount || additionalPerms?.length || 0; + + // Only show warnings for existing roles (edit mode) + const hasKeyWarning = Boolean(roleId && totalKeys > MAX_ATTACH_LIMIT); + const hasPermWarning = Boolean(roleId && totalPerms > MAX_ATTACH_LIMIT); + + // Should prefetch when both are under limit + const shouldPrefetch = totalKeys <= MAX_ATTACH_LIMIT && totalPerms <= MAX_ATTACH_LIMIT; + + // Should allow editing when both are under limit (or it's create mode) + const shouldAllowEdit = !roleId || shouldPrefetch; + + return { + totalKeys, + totalPerms, + hasKeyWarning, + hasPermWarning, + shouldPrefetch, + shouldAllowEdit, + }; + }; + + const prefetchIfAllowed = async () => { + if (!roleId) { + return; + } + + const { shouldPrefetch } = calculateLimits(); + + if (shouldPrefetch) { + await trpcUtils.authorization.roles.connectedKeysAndPerms.prefetch({ + roleId, + }); + } + }; + + return { + calculateLimits, + prefetchIfAllowed, + MAX_ATTACH_LIMIT, + }; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts index 7d89d995c9..c0ae0e8e8b 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts @@ -1,5 +1,5 @@ import { trpc } from "@/lib/trpc/client"; -import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; +import type { RoleBasic } from "@/lib/trpc/routers/authorization/roles/query"; import { useEffect, useMemo, useState } from "react"; import { rolesFilterFieldConfig, rolesListFilterFieldNames } from "../../../filters.schema"; import { useFilters } from "../../../hooks/use-filters"; @@ -7,7 +7,7 @@ import type { RolesQueryPayload } from "../query-logs.schema"; export function useRolesListQuery() { const [totalCount, setTotalCount] = useState(0); - const [rolesMap, setRolesMap] = useState(() => new Map()); + const [rolesMap, setRolesMap] = useState(() => new Map()); const { filters } = useFilters(); const rolesList = useMemo(() => Array.from(rolesMap.values()), [rolesMap]); @@ -54,7 +54,7 @@ export function useRolesListQuery() { useEffect(() => { if (rolesData) { - const newMap = new Map(); + const newMap = new Map(); rolesData.pages.forEach((page) => { page.roles.forEach((role) => { diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/roles-list.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/roles-list.tsx index 730acbbab0..fb4056b34c 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/roles-list.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/roles-list.tsx @@ -1,7 +1,7 @@ "use client"; import { VirtualTable } from "@/components/virtual-table/index"; import type { Column } from "@/components/virtual-table/types"; -import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; +import type { RoleBasic } from "@/lib/trpc/routers/authorization/roles/query"; import { BookBookmark, Tag } from "@unkey/icons"; import { Button, Checkbox, Empty } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; @@ -23,7 +23,7 @@ import { getRowClassName } from "./utils/get-row-class"; export const RolesList = () => { const { roles, isLoading, isLoadingMore, loadMore, totalCount, hasMore } = useRolesListQuery(); - const [selectedRole, setSelectedRole] = useState(null); + const [selectedRole, setSelectedRole] = useState(null); const [selectedRoles, setSelectedRoles] = useState>(new Set()); const [hoveredRoleName, setHoveredRoleName] = useState(null); @@ -39,7 +39,7 @@ export const RolesList = () => { }); }, []); - const columns: Column[] = useMemo( + const columns: Column[] = useMemo( () => [ { key: "role", @@ -103,23 +103,21 @@ export const RolesList = () => { width: "20%", render: (role) => ( ), }, { key: "permissions", - header: "Permissions", + header: "Assigned Permissions", width: "20%", render: (role) => ( ), }, diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/utils/get-row-class.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/utils/get-row-class.ts index ffe7759263..a2d6856b60 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/utils/get-row-class.ts +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/utils/get-row-class.ts @@ -1,4 +1,4 @@ -import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; +import type { RoleBasic } from "@/lib/trpc/routers/authorization/roles/query"; import { cn } from "@/lib/utils"; export type StatusStyle = { @@ -23,9 +23,9 @@ export const STATUS_STYLES = { focusRing: "focus:ring-accent-7", }; -export const getRowClassName = (log: Roles, selectedLog: Roles | null) => { +export const getRowClassName = (role: RoleBasic, selectedLog: RoleBasic | null) => { const style = STATUS_STYLES; - const isSelected = log.roleId === selectedLog?.roleId; + const isSelected = role.roleId === selectedLog?.roleId; return cn( style.base, diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/key-field.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/key-field.tsx index 5fe65eb1da..0734900b06 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/key-field.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/key-field.tsx @@ -3,6 +3,8 @@ import { FormCombobox } from "@/components/ui/form-combobox"; import type { RoleKey } from "@/lib/trpc/routers/authorization/roles/connected-keys-and-perms"; import { Key2 } from "@unkey/icons"; import { useMemo, useState } from "react"; +import { useRoleLimits } from "../../../table/hooks/use-role-limits"; +import { RoleWarningCallout } from "../warning-callout"; import { createKeyOptions } from "./create-key-options"; import { useFetchKeys } from "./hooks/use-fetch-keys"; import { useSearchKeys } from "./hooks/use-search-keys"; @@ -25,28 +27,29 @@ export const KeyField = ({ assignedKeyDetails, }: KeyFieldProps) => { const [searchValue, setSearchValue] = useState(""); + + const { calculateLimits } = useRoleLimits(roleId); + const { hasKeyWarning, totalKeys } = calculateLimits(value); + const { keys, isFetchingNextPage, hasNextPage, loadMore, isLoading } = useFetchKeys(); const { searchResults, isSearching } = useSearchKeys(searchValue); - // Combine loaded keys with search results, prioritizing search when available const allKeys = useMemo(() => { if (searchValue.trim() && searchResults.length > 0) { - // When searching, use search results return searchResults; } + if (searchValue.trim() && searchResults.length === 0 && !isSearching) { - // No search results found, filter from loaded keys as fallback const searchTerm = searchValue.toLowerCase().trim(); return keys.filter( (key) => key.id.toLowerCase().includes(searchTerm) || key.name?.toLowerCase().includes(searchTerm), ); } - // No search query, use all loaded keys + return keys; }, [keys, searchResults, searchValue, isSearching]); - // Don't show load more when actively searching const showLoadMore = !searchValue.trim() && hasNextPage; const baseOptions = createKeyOptions({ @@ -59,23 +62,19 @@ export const KeyField = ({ const selectableOptions = useMemo(() => { return baseOptions.filter((option) => { - // Always allow the load more option if (option.value === "__load_more__") { return true; } - // Don't show already selected keys if (value.includes(option.value)) { return false; } - // Find the key and check if it's already assigned to this role const key = allKeys.find((k) => k.id === option.value); if (!key) { return true; } - // Filter out keys that already have this role assigned (if roleId provided) if (roleId) { return !key.roles.some((role) => role.id === roleId); } @@ -87,7 +86,6 @@ export const KeyField = ({ const selectedKeys = useMemo(() => { return value .map((keyId) => { - // check selectedKeysData (for pre-loaded edit data) const preLoadedKey = assignedKeyDetails.find((k) => k.id === keyId); if (preLoadedKey) { return { @@ -96,7 +94,6 @@ export const KeyField = ({ }; } - // check loaded keys (for newly added keys) const loadedKey = allKeys.find((k) => k.id === keyId); if (loadedKey) { return { @@ -105,7 +102,6 @@ export const KeyField = ({ }; } - // Third: fallback to ID-only display (ensures key is always removable) return { id: keyId, name: null, @@ -160,7 +156,7 @@ export const KeyField = ({ } variant="default" error={error} - disabled={disabled || isLoading} + disabled={disabled || isLoading || hasKeyWarning} loading={isComboboxLoading} title={ isComboboxLoading @@ -170,22 +166,25 @@ export const KeyField = ({ : undefined } /> - - ({ - ...k, - name: k.name ?? "Unnamed Key", - }))} - disabled={disabled} - onRemoveItem={handleRemoveKey} - renderIcon={() => } - enableTransitions - renderPrimaryText={(key) => - key.id.length > 15 ? `${key.id.slice(0, 8)}...${key.id.slice(-4)}` : key.id - } - renderSecondaryText={(key) => key.name || "Unnamed Key"} - itemHeight="h-12" - /> + {hasKeyWarning ? ( + + ) : ( + ({ + ...k, + name: k.name ?? "Unnamed Key", + }))} + disabled={disabled} + onRemoveItem={handleRemoveKey} + renderIcon={() => } + enableTransitions + renderPrimaryText={(key) => + key.id.length > 15 ? `${key.id.slice(0, 8)}...${key.id.slice(-4)}` : key.id + } + renderSecondaryText={(key) => key.name || "Unnamed Key"} + itemHeight="h-12" + /> + )}
); }; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/permissions-field.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/permissions-field.tsx index b1802b9427..f1b9a19439 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/permissions-field.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/permissions-field.tsx @@ -3,6 +3,8 @@ import { FormCombobox } from "@/components/ui/form-combobox"; import type { RolePermission } from "@/lib/trpc/routers/authorization/roles/connected-keys-and-perms"; import { Page2 } from "@unkey/icons"; import { useMemo, useState } from "react"; +import { useRoleLimits } from "../../../table/hooks/use-role-limits"; +import { RoleWarningCallout } from "../warning-callout"; import { createPermissionOptions } from "./create-permission-options"; import { useFetchPermissions } from "./hooks/use-fetch-permissions"; import { useSearchPermissions } from "./hooks/use-search-permissions"; @@ -15,16 +17,19 @@ type PermissionFieldProps = { roleId?: string; assignedPermsDetails: RolePermission[]; }; - export const PermissionField = ({ value, onChange, error, disabled = false, roleId, - assignedPermsDetails = [], + assignedPermsDetails, }: PermissionFieldProps) => { const [searchValue, setSearchValue] = useState(""); + + const { calculateLimits } = useRoleLimits(roleId); + const { hasPermWarning, totalPerms } = calculateLimits(value); + const { permissions, isFetchingNextPage, hasNextPage, loadMore, isLoading } = useFetchPermissions(); const { searchResults, isSearching } = useSearchPermissions(searchValue); @@ -32,7 +37,6 @@ export const PermissionField = ({ // Combine loaded permissions with search results, prioritizing search when available const allPermissions = useMemo(() => { if (searchValue.trim() && searchResults.length > 0) { - // When searching, use search results return searchResults; } if (searchValue.trim() && searchResults.length === 0 && !isSearching) { @@ -63,12 +67,9 @@ export const PermissionField = ({ const selectableOptions = useMemo(() => { return baseOptions.filter((option) => { - // Always allow the load more option if (option.value === "__load_more__") { return true; } - - // Don't show already selected permissions if (value.includes(option.value)) { return false; } @@ -81,34 +82,36 @@ export const PermissionField = ({ // Filter out permissions that already have this role assigned (if roleId provided) if (roleId) { - return !permission.roles.some((role) => role.id === roleId); + return !permission.roles?.some((role) => role.id === roleId); } - return true; }); }, [baseOptions, allPermissions, roleId, value]); const selectedPermissions = useMemo(() => { return value - .map((id) => { + .map((permId) => { // First: check selectedPermissionsData (for pre-loaded edit data) - const preLoadedPerm = assignedPermsDetails.find((p) => p.id === id); + const preLoadedPerm = assignedPermsDetails.find((p) => p.id === permId); if (preLoadedPerm) { - return preLoadedPerm; + return { + id: preLoadedPerm.id, + name: preLoadedPerm.name, + slug: preLoadedPerm.slug, + }; } // Second: check loaded permissions (for newly added permissions) - const loadedPerm = allPermissions.find((p) => p.id === id); + const loadedPerm = allPermissions.find((p) => p.id === permId); if (loadedPerm) { return loadedPerm; } // Third: fallback return { - id: id, - name: id, - slug: id, - description: null, + id: permId, + name: null, + slug: null, }; }) .filter((perm): perm is NonNullable => perm !== undefined); @@ -160,7 +163,7 @@ export const PermissionField = ({ } variant="default" error={error} - disabled={disabled || isLoading} + disabled={disabled || isLoading || hasPermWarning} loading={isComboboxLoading} title={ isComboboxLoading @@ -170,17 +173,25 @@ export const PermissionField = ({ : undefined } /> - - {/* Selected Permissions Display */} - } - renderPrimaryText={(permission) => permission.name} - enableTransitions - renderSecondaryText={(permission) => permission.slug} - /> + {hasPermWarning ? ( + + ) : ( + selectedPermissions.length > 0 && ( + ({ + ...k, + name: k.name ?? "Unnamed Permission", + }))} + disabled={disabled} + onRemoveItem={handleRemovePermission} + renderIcon={() => } + renderPrimaryText={(permission) => permission.name} + enableTransitions + // This can't cannot happen but we need it to make TS happy + renderSecondaryText={(permission) => permission.slug ?? "Unnamed Slug"} + /> + ) + )}
); }; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/warning-callout.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/warning-callout.tsx new file mode 100644 index 0000000000..49534e7e2a --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/warning-callout.tsx @@ -0,0 +1,32 @@ +import { formatNumber } from "@/lib/fmt"; +import { TriangleWarning } from "@unkey/icons"; +import { InlineLink } from "@unkey/ui"; + +interface RoleWarningCalloutProps { + count: number; + type: "keys" | "permissions"; +} + +export const RoleWarningCallout = ({ count, type }: RoleWarningCalloutProps) => { + const itemText = type === "keys" ? "keys" : "permissions"; + const settingsText = type === "keys" ? "key settings" : "permission settings"; + + return ( +
+
+ +
+
+ Warning: This role has {formatNumber(count)} {itemText}{" "} + assigned. Use the{" "} + {" "} + or {settingsText} to manage these assignments. +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/hooks/use-upsert-role.ts b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/hooks/use-upsert-role.ts index 7ffd914d3b..dc763af2da 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/hooks/use-upsert-role.ts +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/hooks/use-upsert-role.ts @@ -11,8 +11,20 @@ export const useUpsertRole = ( const trpcUtils = trpc.useUtils(); const role = trpc.authorization.roles.upsert.useMutation({ - onSuccess(data) { - trpcUtils.authorization.roles.invalidate(); + async onSuccess(data) { + await Promise.all([ + trpcUtils.authorization.roles.query.invalidate(), + trpcUtils.authorization.permissions.query.refetch(), + trpcUtils.authorization.roles.connectedKeysAndPerms.invalidate({ + roleId: data.roleId, + }), + trpcUtils.authorization.roles.connectedKeys.invalidate({ + roleId: data.roleId, + }), + trpcUtils.authorization.roles.connectedPerms.invalidate({ + roleId: data.roleId, + }), + ]); // Show success toast toast.success(data.isUpdate ? "Role Updated" : "Role Created", { diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx index 7a542f01f9..29a2451370 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx @@ -11,6 +11,7 @@ import { PenWriting3, Plus } from "@unkey/icons"; import { Button, DialogContainer, FormInput, FormTextarea } from "@unkey/ui"; import { useEffect, useState } from "react"; import { Controller, FormProvider } from "react-hook-form"; +import { useRoleLimits } from "../table/hooks/use-role-limits"; import { KeyField } from "./components/assign-key/key-field"; import { PermissionField } from "./components/assign-permission/permissions-field"; import { useUpsertRole } from "./hooks/use-upsert-role"; @@ -35,7 +36,7 @@ const getDefaultValues = (existingRole?: ExistingRole): Partial => { roleName: existingRole.name, roleDescription: existingRole.description || "", keyIds: existingRole.keyIds || [], - permissionIds: existingRole.permissionIds, + permissionIds: existingRole.permissionIds || [], }; } @@ -63,6 +64,8 @@ export const UpsertRoleDialog = ({ const [internalIsOpen, setInternalIsOpen] = useState(false); const isEditMode = Boolean(existingRole?.id); + const { calculateLimits } = useRoleLimits(existingRole?.id); + // Use external state if provided, otherwise use internal state const isDialogOpen = externalIsOpen !== undefined ? externalIsOpen : internalIsOpen; const setIsDialogOpen = @@ -126,7 +129,16 @@ export const UpsertRoleDialog = ({ }); const onSubmit = async (data: FormValues) => { - upsertRoleMutation.mutate(data); + // Calculate limits with current form data + const { hasKeyWarning, hasPermWarning } = calculateLimits(data.keyIds, data.permissionIds); + + const submissionData: FormValues = { + ...data, + keyIds: hasKeyWarning ? undefined : data.keyIds, + permissionIds: hasPermWarning ? undefined : data.permissionIds, + }; + + upsertRoleMutation.mutate(submissionData); }; const handleDialogToggle = (open: boolean) => { @@ -213,6 +225,7 @@ export const UpsertRoleDialog = ({ control={control} render={({ field, fieldState }) => ( ( [...new Set(ids)]) - .optional(); +export const keyIdsSchema = z.array(z.string()).transform((ids) => [...new Set(ids)]); -export const permissionIdsSchema = z - .array(z.string()) - .default([]) - .transform((ids) => [...new Set(ids)]) - .optional(); +export const permissionIdsSchema = z.array(z.string()).transform((ids) => [...new Set(ids)]); export const rbacRoleSchema = z .object({ roleId: z.string().startsWith("role_").optional(), roleName: roleNameSchema, roleDescription: roleDescriptionSchema, - keyIds: keyIdsSchema, - permissionIds: permissionIdsSchema, + keyIds: keyIdsSchema.optional(), + permissionIds: permissionIdsSchema.optional(), }) .strict({ message: "Unknown fields are not allowed in role definition" }); diff --git a/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts b/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts index 281712138b..4d56eb0bde 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts @@ -4,8 +4,6 @@ import { db, sql } from "@/lib/db"; import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; import { z } from "zod"; -const MAX_ITEMS_TO_SHOW = 3; -const ITEM_SEPARATOR = "|||"; export const DEFAULT_LIMIT = 50; export const permissions = z.object({ @@ -15,10 +13,7 @@ export const permissions = z.object({ slug: z.string(), lastUpdated: z.number(), totalConnectedKeys: z.number(), - assignedRoles: z.object({ - items: z.array(z.string()), - totalCount: z.number().optional(), - }), + totalConnectedRoles: z.number(), }); export type Permission = z.infer; @@ -57,21 +52,6 @@ export const queryPermissions = t.procedure p.slug, p.updated_at_m, - -- Roles: get first 3 unique names - ( - SELECT GROUP_CONCAT(sub.name ORDER BY sub.name SEPARATOR ${ITEM_SEPARATOR}) - FROM ( - SELECT DISTINCT r.name - FROM roles_permissions rp - LEFT JOIN roles r ON rp.role_id = r.id - WHERE rp.permission_id = p.id - AND rp.workspace_id = ${workspaceId} - AND r.name IS NOT NULL - ORDER BY r.name - LIMIT ${MAX_ITEMS_TO_SHOW} - ) sub - ) as role_items, - -- Roles: total count ( SELECT COUNT(DISTINCT rp.role_id) @@ -121,7 +101,6 @@ export const queryPermissions = t.procedure description: string | null; slug: string; updated_at_m: number; - role_items: string | null; total_roles: number; total_connected_keys: number; grand_total: number; @@ -141,21 +120,13 @@ export const queryPermissions = t.procedure const items = hasMore ? rows.slice(0, -1) : rows; const permissionsResponseData: Permission[] = items.map((row) => { - // Parse concatenated strings back to arrays - const roleItems = row.role_items - ? row.role_items.split(ITEM_SEPARATOR).filter((item) => item.trim() !== "") - : []; - return { permissionId: row.id, name: row.name || "", description: row.description || "", slug: row.slug || "", lastUpdated: Number(row.updated_at_m) || 0, - assignedRoles: - row.total_roles <= MAX_ITEMS_TO_SHOW - ? { items: roleItems } - : { items: roleItems, totalCount: Number(row.total_roles) }, + totalConnectedRoles: Number(row.total_roles), totalConnectedKeys: Number(row.total_connected_keys) || 0, }; }); diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/keys/connected-keys.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/keys/connected-keys.ts new file mode 100644 index 0000000000..f4e07f959c --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/keys/connected-keys.ts @@ -0,0 +1,30 @@ +import { and, count, db, eq } from "@/lib/db"; +import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { keysRoles } from "@unkey/db/src/schema"; +import { z } from "zod"; + +const assignedKeysResponse = z.object({ + totalCount: z.number(), +}); + +export const queryRoleKeys = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input( + z.object({ + roleId: z.string(), + }), + ) + .output(assignedKeysResponse) + .query(async ({ ctx, input }) => { + const workspaceId = ctx.workspace.id; + const { roleId } = input; + + const result = await db + .select({ count: count() }) + .from(keysRoles) + .where(and(eq(keysRoles.workspaceId, workspaceId), eq(keysRoles.roleId, roleId))); + + return { totalCount: result[0].count }; + }); diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/connected-permissions.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/connected-permissions.ts new file mode 100644 index 0000000000..ed7514b1fd --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/connected-permissions.ts @@ -0,0 +1,32 @@ +import { and, count, db, eq } from "@/lib/db"; +import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { rolesPermissions } from "@unkey/db/src/schema"; +import { z } from "zod"; + +const permissionsResponse = z.object({ + totalCount: z.number(), +}); + +export const queryRolePermissions = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input( + z.object({ + roleId: z.string(), + }), + ) + .output(permissionsResponse) + .query(async ({ ctx, input }) => { + const workspaceId = ctx.workspace.id; + const { roleId } = input; + + const result = await db + .select({ count: count() }) + .from(rolesPermissions) + .where( + and(eq(rolesPermissions.workspaceId, workspaceId), eq(rolesPermissions.roleId, roleId)), + ); + + return { totalCount: result?.[0]?.count ?? 0 }; + }); diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/query.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/query.ts index bc02c2a87b..40efadf624 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/roles/query.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/query.ts @@ -4,29 +4,19 @@ import { db, sql } from "@/lib/db"; import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; import { z } from "zod"; -const MAX_ITEMS_TO_SHOW = 3; -const ITEM_SEPARATOR = "|||"; export const DEFAULT_LIMIT = 50; -export const roles = z.object({ +export const roleBasic = z.object({ roleId: z.string(), name: z.string(), description: z.string(), lastUpdated: z.number(), - assignedKeys: z.object({ - items: z.array(z.string()), - totalCount: z.number().optional(), - }), - permissions: z.object({ - items: z.array(z.string()), - totalCount: z.number().optional(), - }), }); -export type Roles = z.infer; +export type RoleBasic = z.infer; const rolesResponse = z.object({ - roles: z.array(roles), + roles: z.array(roleBasic), hasMore: z.boolean(), total: z.number(), nextCursor: z.number().int().nullish(), @@ -48,114 +38,20 @@ export const queryRoles = t.procedure const keyFilter = buildKeyFilter(keyName, keyId, workspaceId); const permissionFilter = buildPermissionFilter(permissionName, permissionSlug, workspaceId); - // Build filter conditions for total count - const keyFilterForCount = buildKeyFilter(keyName, keyId, workspaceId); - const permissionFilterForCount = buildPermissionFilter( - permissionName, - permissionSlug, - workspaceId, - ); + // Get total count first + const countResult = await db.execute(sql` + SELECT COUNT(*) as total + FROM roles + WHERE workspace_id = ${workspaceId} + ${nameFilter} + ${descriptionFilter} + ${keyFilter} + ${permissionFilter} + `); - const result = await db.execute(sql` - SELECT - r.id, - r.name, - r.description, - r.updated_at_m, - - -- Keys: get first 3 unique names - ( - SELECT GROUP_CONCAT(sub.display_name ORDER BY sub.sort_key SEPARATOR ${ITEM_SEPARATOR}) - FROM ( - SELECT DISTINCT - CASE - WHEN k.name IS NULL OR k.name = '' THEN - CONCAT(SUBSTRING(k.id, 1, 8), '...', RIGHT(k.id, 4)) - ELSE k.name - END as display_name, - COALESCE(k.name, k.id) as sort_key - FROM keys_roles kr - JOIN \`keys\` k ON kr.key_id = k.id - WHERE kr.role_id = r.id - AND kr.workspace_id = ${workspaceId} - ORDER BY sort_key - LIMIT ${MAX_ITEMS_TO_SHOW} - ) sub - ) as key_items, - - -- Keys: total count - ( - SELECT COUNT(DISTINCT kr.key_id) - FROM keys_roles kr - JOIN \`keys\` k ON kr.key_id = k.id - WHERE kr.role_id = r.id - AND kr.workspace_id = ${workspaceId} - ) as total_keys, - - -- Permissions: get first 3 unique names - ( - SELECT GROUP_CONCAT(sub.name ORDER BY sub.name SEPARATOR ${ITEM_SEPARATOR}) - FROM ( - SELECT DISTINCT p.name - FROM roles_permissions rp - JOIN permissions p ON rp.permission_id = p.id - WHERE rp.role_id = r.id - AND rp.workspace_id = ${workspaceId} - AND p.name IS NOT NULL - ORDER BY p.name - LIMIT ${MAX_ITEMS_TO_SHOW} - ) sub - ) as permission_items, - - -- Permissions: total count - ( - SELECT COUNT(DISTINCT rp.permission_id) - FROM roles_permissions rp - JOIN permissions p ON rp.permission_id = p.id - WHERE rp.role_id = r.id - AND rp.workspace_id = ${workspaceId} - AND p.name IS NOT NULL - ) as total_permissions, - - -- Total count of roles (with filters applied) - ( - SELECT COUNT(*) - FROM roles - WHERE workspace_id = ${workspaceId} - ${nameFilter} - ${descriptionFilter} - ${keyFilterForCount} - ${permissionFilterForCount} - ) as grand_total - - FROM ( - SELECT id, name, description, updated_at_m - FROM roles - WHERE workspace_id = ${workspaceId} - ${cursor ? sql`AND updated_at_m < ${cursor}` : sql``} - ${nameFilter} - ${descriptionFilter} - ${keyFilter} - ${permissionFilter} - ORDER BY updated_at_m DESC - LIMIT ${DEFAULT_LIMIT + 1} - ) r - ORDER BY r.updated_at_m DESC -`); + const total = (countResult.rows[0] as { total: number }).total; - const rows = result.rows as { - id: string; - name: string; - description: string | null; - updated_at_m: number; - key_items: string | null; - total_keys: number; - permission_items: string | null; - total_permissions: number; - grand_total: number; - }[]; - - if (rows.length === 0) { + if (total === 0) { return { roles: [], hasMore: false, @@ -164,37 +60,35 @@ export const queryRoles = t.procedure }; } - const total = rows[0].grand_total; + const result = await db.execute(sql` + SELECT id, name, description, updated_at_m + FROM roles + WHERE workspace_id = ${workspaceId} + ${cursor ? sql`AND updated_at_m < ${cursor}` : sql``} + ${nameFilter} + ${descriptionFilter} + ${keyFilter} + ${permissionFilter} + ORDER BY updated_at_m DESC + LIMIT ${DEFAULT_LIMIT + 1} + `); + + const rows = result.rows as { + id: string; + name: string; + description: string | null; + updated_at_m: number; + }[]; + const hasMore = rows.length > DEFAULT_LIMIT; const items = hasMore ? rows.slice(0, -1) : rows; - const rolesResponseData: Roles[] = items.map((row) => { - // Parse concatenated strings back to arrays - const keyItems = row.key_items - ? row.key_items.split(ITEM_SEPARATOR).filter((item) => item.trim() !== "") - : []; - const permissionItems = row.permission_items - ? row.permission_items.split(ITEM_SEPARATOR).filter((item) => item.trim() !== "") - : []; - - return { - roleId: row.id, - name: row.name || "", - description: row.description || "", - lastUpdated: Number(row.updated_at_m) || 0, - assignedKeys: - row.total_keys <= MAX_ITEMS_TO_SHOW - ? { items: keyItems } - : { items: keyItems, totalCount: Number(row.total_keys) }, - permissions: - row.total_permissions <= MAX_ITEMS_TO_SHOW - ? { items: permissionItems } - : { - items: permissionItems, - totalCount: Number(row.total_permissions), - }, - }; - }); + const rolesResponseData: RoleBasic[] = items.map((row) => ({ + roleId: row.id, + name: row.name || "", + description: row.description || "", + lastUpdated: Number(row.updated_at_m) || 0, + })); return { roles: rolesResponseData, @@ -226,7 +120,6 @@ function buildKeyFilter( ) { const conditions = []; - // Handle name filters if (nameFilters && nameFilters.length > 0) { const nameConditions = nameFilters.map((filter) => { const value = filter.value; @@ -270,7 +163,6 @@ function buildKeyFilter( conditions.push(sql`(${sql.join(nameConditions, sql` OR `)})`); } - // Handle ID filters if (idFilters && idFilters.length > 0) { const idConditions = idFilters.map((filter) => { const value = filter.value; @@ -318,7 +210,6 @@ function buildKeyFilter( return sql``; } - // Join name and ID conditions with AND return sql`AND (${sql.join(conditions, sql` AND `)})`; } @@ -352,7 +243,6 @@ function buildFilterConditions( } }); - // Combine conditions with OR return sql`AND (${sql.join(conditions, sql` OR `)})`; } @@ -375,7 +265,6 @@ function buildPermissionFilter( ) { const conditions = []; - // Handle name filters if (nameFilters && nameFilters.length > 0) { const nameConditions = nameFilters.map((filter) => { const value = filter.value; @@ -419,7 +308,6 @@ function buildPermissionFilter( conditions.push(sql`(${sql.join(nameConditions, sql` OR `)})`); } - // Handle slug filters if (slugFilters && slugFilters.length > 0) { const slugConditions = slugFilters.map((filter) => { const value = filter.value; @@ -467,6 +355,5 @@ function buildPermissionFilter( return sql``; } - // Join name and slug conditions with AND return sql`AND (${sql.join(conditions, sql` AND `)})`; } diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/upsert.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/upsert.ts index 47a898c783..0f68948c01 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/roles/upsert.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/upsert.ts @@ -33,6 +33,7 @@ export const upsertRole = t.procedure await db.transaction(async (tx) => { if (isUpdate && input.roleId) { const updateRoleId: string = input.roleId; + // Get the existing role to compare names and verify existence const existingRole = await tx.query.roles.findFirst({ where: (table, { and, eq }) => @@ -80,25 +81,111 @@ export const upsertRole = t.procedure }); }); - // Remove existing role-permission relationships - await tx - .delete(schema.rolesPermissions) - .where( - and( - eq(schema.rolesPermissions.roleId, roleId), - eq(schema.rolesPermissions.workspaceId, ctx.workspace.id), - ), - ); + // Handle permissions - only modify if explicitly provided + if (input.permissionIds !== undefined) { + // Remove existing role-permission relationships + await tx + .delete(schema.rolesPermissions) + .where( + and( + eq(schema.rolesPermissions.roleId, roleId), + eq(schema.rolesPermissions.workspaceId, ctx.workspace.id), + ), + ); - // Remove existing key-role relationships - await tx - .delete(schema.keysRoles) - .where( - and( - eq(schema.keysRoles.roleId, roleId), - eq(schema.keysRoles.workspaceId, ctx.workspace.id), - ), - ); + // Add new permissions if any + if (input.permissionIds.length > 0) { + await tx + .insert(schema.rolesPermissions) + .values( + input.permissionIds.map((permissionId) => ({ + permissionId, + roleId, + workspaceId: ctx.workspace.id, + })), + ) + .catch(() => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to assign permissions to role", + }); + }); + + await insertAuditLogs( + tx, + input.permissionIds.map((permissionId) => ({ + workspaceId: ctx.workspace.id, + event: "authorization.connect_role_and_permission", + actor: { + type: "user", + id: ctx.user.id, + }, + description: `Connected role ${roleId} and permission ${permissionId}`, + resources: [ + { type: "role", id: roleId, name: input.roleName }, + { type: "permission", id: permissionId }, + ], + context: { + userAgent: ctx.audit.userAgent, + location: ctx.audit.location, + }, + })), + ); + } + } + + // Handle keys - only modify if explicitly provided + if (input.keyIds !== undefined) { + // Remove existing key-role relationships + await tx + .delete(schema.keysRoles) + .where( + and( + eq(schema.keysRoles.roleId, roleId), + eq(schema.keysRoles.workspaceId, ctx.workspace.id), + ), + ); + + // Add new keys if any + if (input.keyIds.length > 0) { + await tx + .insert(schema.keysRoles) + .values( + input.keyIds.map((keyId) => ({ + keyId, + roleId, + workspaceId: ctx.workspace.id, + })), + ) + .catch(() => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to assign keys to role", + }); + }); + + await insertAuditLogs( + tx, + input.keyIds.map((keyId) => ({ + workspaceId: ctx.workspace.id, + event: "authorization.connect_role_and_key", + actor: { + type: "user", + id: ctx.user.id, + }, + description: `Connected key ${keyId} and role ${roleId}`, + resources: [ + { type: "key", id: keyId }, + { type: "role", id: roleId, name: input.roleName }, + ], + context: { + userAgent: ctx.audit.userAgent, + location: ctx.audit.location, + }, + })), + ); + } + } await insertAuditLogs(tx, { workspaceId: ctx.workspace.id, @@ -139,7 +226,7 @@ export const upsertRole = t.procedure .insert(schema.roles) .values({ id: roleId, - name: input.roleName, // name maps to db.human_readable + name: input.roleName, description: input.roleDescription, workspaceId: ctx.workspace.id, }) @@ -150,6 +237,90 @@ export const upsertRole = t.procedure }); }); + // For creation, treat undefined as empty array (no associations initially) + const permissionIds = input.permissionIds ?? []; + const keyIds = input.keyIds ?? []; + + // Add role-permission relationships + if (permissionIds.length > 0) { + await tx + .insert(schema.rolesPermissions) + .values( + permissionIds.map((permissionId) => ({ + permissionId, + roleId, + workspaceId: ctx.workspace.id, + })), + ) + .catch(() => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to assign permissions to role", + }); + }); + + await insertAuditLogs( + tx, + permissionIds.map((permissionId) => ({ + workspaceId: ctx.workspace.id, + event: "authorization.connect_role_and_permission", + actor: { + type: "user", + id: ctx.user.id, + }, + description: `Connected role ${roleId} and permission ${permissionId}`, + resources: [ + { type: "role", id: roleId, name: input.roleName }, + { type: "permission", id: permissionId }, + ], + context: { + userAgent: ctx.audit.userAgent, + location: ctx.audit.location, + }, + })), + ); + } + + // Add key-role relationships + if (keyIds.length > 0) { + await tx + .insert(schema.keysRoles) + .values( + keyIds.map((keyId) => ({ + keyId, + roleId, + workspaceId: ctx.workspace.id, + })), + ) + .catch(() => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to assign keys to role", + }); + }); + + await insertAuditLogs( + tx, + keyIds.map((keyId) => ({ + workspaceId: ctx.workspace.id, + event: "authorization.connect_role_and_key", + actor: { + type: "user", + id: ctx.user.id, + }, + description: `Connected key ${keyId} and role ${roleId}`, + resources: [ + { type: "key", id: keyId }, + { type: "role", id: roleId, name: input.roleName }, + ], + context: { + userAgent: ctx.audit.userAgent, + location: ctx.audit.location, + }, + })), + ); + } + await insertAuditLogs(tx, { workspaceId: ctx.workspace.id, event: "role.create", @@ -171,86 +342,6 @@ export const upsertRole = t.procedure }, }); } - - // Add role-permission relationships - if (input.permissionIds && input.permissionIds.length > 0) { - await tx - .insert(schema.rolesPermissions) - .values( - input.permissionIds.map((permissionId) => ({ - permissionId, - roleId, - workspaceId: ctx.workspace.id, - })), - ) - .catch(() => { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to assign permissions to role", - }); - }); - - await insertAuditLogs( - tx, - input.permissionIds.map((permissionId) => ({ - workspaceId: ctx.workspace.id, - event: "authorization.connect_role_and_permission", - actor: { - type: "user", - id: ctx.user.id, - }, - description: `Connected role ${roleId} and permission ${permissionId}`, - resources: [ - { type: "role", id: roleId, name: input.roleName }, - { type: "permission", id: permissionId }, - ], - context: { - userAgent: ctx.audit.userAgent, - location: ctx.audit.location, - }, - })), - ); - } - - // Add key-role relationships - if (input.keyIds && input.keyIds.length > 0) { - await tx - .insert(schema.keysRoles) - .values( - input.keyIds.map((keyId) => ({ - keyId, - roleId, - workspaceId: ctx.workspace.id, - })), - ) - .catch(() => { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to assign keys to role", - }); - }); - - await insertAuditLogs( - tx, - input.keyIds.map((keyId) => ({ - workspaceId: ctx.workspace.id, - event: "authorization.connect_role_and_key", - actor: { - type: "user", - id: ctx.user.id, - }, - description: `Connected key ${keyId} and role ${roleId}`, - resources: [ - { type: "key", id: keyId }, - { type: "role", id: roleId, name: input.roleName }, - ], - context: { - userAgent: ctx.audit.userAgent, - location: ctx.audit.location, - }, - })), - ); - } }); return { diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index e9f11a9190..e0cf974086 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -27,9 +27,11 @@ import { queryPermissions } from "./authorization/permissions/query"; import { upsertPermission } from "./authorization/permissions/upsert"; import { getConnectedKeysAndPerms } from "./authorization/roles/connected-keys-and-perms"; import { deleteRoleWithRelations } from "./authorization/roles/delete"; +import { queryRoleKeys } from "./authorization/roles/keys/connected-keys"; import { queryKeys } from "./authorization/roles/keys/query-keys"; import { searchKeys } from "./authorization/roles/keys/search-key"; import { rolesLlmSearch } from "./authorization/roles/llm-search"; +import { queryRolePermissions } from "./authorization/roles/permissions/connected-permissions"; import { queryRolesPermissions } from "./authorization/roles/permissions/query-permissions"; import { searchRolesPermissions } from "./authorization/roles/permissions/search-permissions"; import { queryRoles } from "./authorization/roles/query"; @@ -218,6 +220,8 @@ export const router = t.router({ delete: deleteRoleWithRelations, llmSearch: rolesLlmSearch, connectedKeysAndPerms: getConnectedKeysAndPerms, + connectedKeys: queryRoleKeys, + connectedPerms: queryRolePermissions, }), }), rbac: t.router({