diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/rbac-dialog-content.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/rbac-dialog-content.tsx deleted file mode 100644 index 9631ff95d7..0000000000 --- a/apps/dashboard/app/(app)/apis/[apiId]/_components/rbac-dialog-content.tsx +++ /dev/null @@ -1,157 +0,0 @@ -"use client"; - -import { Badge } from "@/components/ui/badge"; -import { formatNumber } from "@/lib/fmt"; -import { trpc } from "@/lib/trpc/client"; -import { Button, Loading } from "@unkey/ui"; -import dynamic from "next/dynamic"; - -const PermissionList = dynamic( - () => - import("../keys/[keyAuthId]/[keyId]/components/rbac/permissions").then((mod) => ({ - default: mod.PermissionList, - })), - { ssr: false }, -); - -const RBACButtons = dynamic( - () => - import("../keys/[keyAuthId]/[keyId]/components/rbac/rbac-buttons").then((mod) => ({ - default: mod.RBACButtons, - })), - { ssr: false }, -); - -type Props = { - keyId: string; - keyspaceId: string; -}; - -export function RBACDialogContent({ keyId, keyspaceId }: Props) { - const trpcUtils = trpc.useUtils(); - - const { - data: permissionsData, - isLoading, - isRefetching, - error, - } = trpc.key.fetchPermissions.useQuery({ - keyId, - keyspaceId, - }); - - const { transientPermissionIds, rolesList } = calculatePermissionData(permissionsData); - - if (isLoading) { - return ( -
- -
- ); - } - - if (error || !permissionsData) { - return ( -
-
Could not retrieve permission data
-
- There was an error loading the permissions for this key. Please try again or contact - support if the issue persists. -
- -
- ); - } - - return ( -
-
-
- - {formatNumber(permissionsData.roles.length)} Roles{" "} - - - {formatNumber(transientPermissionIds.size)} Permissions - -
- -
-
- {keyId ? ( - - ) : ( -
-
No key selected
-
- )} -
-
- ); -} - -type WorkspaceRole = { - id: string; - name: string; - permissions: { permissionId: string }[]; -}; - -type PermissionsResponse = { - roles: { roleId: string }[]; - workspace: { roles: WorkspaceRole[]; permissions: { roles: unknown } }; -}; - -function calculatePermissionData(permissionsData?: PermissionsResponse) { - const transientPermissionIds = new Set(); - const rolesList: { id: string; name: string; isActive: boolean }[] = []; - - if (!permissionsData) { - return { transientPermissionIds, rolesList }; - } - - // Mimic the original implementation logic - const connectedRoleIds = new Set(); - - for (const role of permissionsData.roles) { - connectedRoleIds.add(role.roleId); - } - - for (const role of permissionsData.workspace.roles) { - if (connectedRoleIds.has(role.id)) { - for (const p of role.permissions) { - transientPermissionIds.add(p.permissionId); - } - } - } - - // Build roles list matching the original format - const roles = permissionsData.workspace.roles.map((role: { id: string; name: string }) => { - return { - id: role.id, - name: role.name, - isActive: permissionsData.roles.some( - (keyRole: { roleId: string }) => keyRole.roleId === role.id, - ), - }; - }); - - return { - transientPermissionIds, - rolesList: roles, - }; -} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/api-id-navbar.tsx b/apps/dashboard/app/(app)/apis/[apiId]/api-id-navbar.tsx index e6cd6a72cd..d9296b1778 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/api-id-navbar.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/api-id-navbar.tsx @@ -6,9 +6,8 @@ import { CopyableIDButton } from "@/components/navigation/copyable-id-button"; import { Navbar } from "@/components/navigation/navbar"; import { useIsMobile } from "@/hooks/use-mobile"; import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; -import { ChevronExpandY, Gauge, Gear, Plus, ShieldKey } from "@unkey/icons"; +import { ChevronExpandY, Gauge, Gear, Plus } from "@unkey/icons"; import dynamic from "next/dynamic"; -import { useState } from "react"; import { getKeysTableActionItems } from "./keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover.constants"; const CreateKeyDialog = dynamic( @@ -27,10 +26,6 @@ const CreateKeyDialog = dynamic( }, ); -const DialogContainer = dynamic(() => import("@unkey/ui").then((mod) => mod.DialogContainer), { - ssr: false, -}); - const KeysTableActionPopover = dynamic( () => import( @@ -47,19 +42,6 @@ const KeysTableActionPopover = dynamic( }, ); -const RBACDialogContent = dynamic( - () => import("./_components/rbac-dialog-content").then((mod) => mod.RBACDialogContent), - { - ssr: false, - loading: () => ( - - - Permissions - - ), - }, -); - export const ApisNavbar = ({ api, apis, @@ -86,10 +68,6 @@ export const ApisNavbar = ({ keyData?: KeyDetails | null; }) => { const isMobile = useIsMobile(); - const [showRBAC, setShowRBAC] = useState(false); - - const keyId = keyData?.id || ""; - const keyspaceId = api.keyAuthId || ""; return ( <> @@ -163,15 +141,6 @@ export const ApisNavbar = ({ {keyData?.id ? (
- - setShowRBAC(true)} - disabled={!(keyId && keyspaceId)} - > - - Permissions - - @@ -193,17 +162,6 @@ export const ApisNavbar = ({ )}
- {showRBAC && ( - setShowRBAC(false)} - title="Key Permissions & Roles" - subTitle="Manage access control for this API key with role-based permissions" - className="max-w-[800px] max-h-[90vh] overflow-y-auto" - > - - - )} ); }; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/rbac/permissions.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/rbac/permissions.tsx deleted file mode 100644 index 30a91dfe8f..0000000000 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/rbac/permissions.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { trpc } from "@/lib/trpc/client"; -import { Checkbox } from "@unkey/ui"; -import { toast } from "sonner"; - -export type Role = { - id: string; - name: string; - isActive: boolean; -}; - -type PermissionTreeProps = { - roles: Role[]; - keyId: string; -}; - -export function PermissionList({ roles, keyId }: PermissionTreeProps) { - const trpcUtils = trpc.useUtils(); - - const invalidatePermissions = () => { - trpcUtils.key.fetchPermissions.invalidate(); - }; - - const connectRole = trpc.rbac.connectRoleToKey.useMutation({ - onMutate: () => { - toast.loading("Connecting role to key"); - }, - onSuccess: () => { - toast.dismiss(); - toast.success("Role connected to key"); - - invalidatePermissions(); - }, - onError: (error) => { - toast.dismiss(); - toast.error(error.message); - }, - }); - - const disconnectRole = trpc.rbac.disconnectRoleFromKey.useMutation({ - onMutate: () => { - toast.loading("Disconnecting role from key"); - }, - onSuccess: () => { - toast.dismiss(); - toast.success("Role disconnected from key"); - - invalidatePermissions(); - }, - onError: (error) => { - toast.dismiss(); - toast.error(error.message); - }, - }); - - return ( - - -
- Roles - Manage roles for this key -
-
- -
- {roles.map((role) => ( -
- { - if (checked) { - connectRole.mutate({ keyId: keyId, roleId: role.id }); - } else { - disconnectRole.mutate({ keyId: keyId, roleId: role.id }); - } - }} - /> -
- {role.name} -
-
- ))} - - {roles.length === 0 && ( -
No roles available
- )} -
-
-
- ); -} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/rbac/rbac-buttons.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/rbac/rbac-buttons.tsx deleted file mode 100644 index f02de98fc9..0000000000 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/components/rbac/rbac-buttons.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; -import { RBACForm } from "@/app/(app)/authorization/_components/rbac-form"; -import type { Permission } from "@unkey/db"; -import { Button } from "@unkey/ui"; -import { useState } from "react"; - -interface RBACButtonsProps { - permissions?: Permission[]; -} - -export function RBACButtons({ permissions = [] }: RBACButtonsProps) { - const [isCreateRoleModalOpen, setIsCreateRoleModalOpen] = useState(false); - const [isCreatePermissionModalOpen, setIsCreatePermissionModalOpen] = useState(false); - - const permissionIds = permissions.map((permission) => permission.id); - - return ( -
- - - - - - - -
- ); -} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-permission/create-permission-options.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-permission/create-permission-options.tsx new file mode 100644 index 0000000000..8564fa977b --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-permission/create-permission-options.tsx @@ -0,0 +1,163 @@ +import { StatusBadge } from "@/app/(app)/apis/[apiId]/settings/components/status-badge"; +import { Badge } from "@/components/ui/badge"; +import { HandHoldingKey, Lock } from "@unkey/icons"; +import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@unkey/ui"; + +type Permission = { + id: string; + name: string; + description: string | null; + slug: string; + roles: { + id: string; + name: string; + }[]; +}; + +type PermissionSelectorProps = { + permissions: Permission[]; + hasNextPage?: boolean; + isFetchingNextPage: boolean; + roleId?: string; + loadMore: () => void; +}; + +export function createPermissionOptions({ + permissions, + hasNextPage, + isFetchingNextPage, + roleId, + loadMore, +}: PermissionSelectorProps) { + const options = permissions.map((permission) => ({ + label: ( + + + +
+
+ +
+
+
+
+
+ + {permission.name} + + {permission.roles.find((item) => item.id === roleId) && ( + } + /> + )} +
+ + {permission.slug} + +
+
+ {permission.description && ( + + {permission.description} + + )} +
+
+
+ +
+ {/* Header */} +
+ Permission Details +
+ {/* Content */} +
+
+
Permission ID
+
{permission.id}
+
+
+
Name
+
{permission.name}
+
+
+
Slug
+
{permission.slug}
+
+ {permission.description && ( +
+
Description
+
{permission.description}
+
+ )} + {permission.roles.length > 0 && ( +
+
Roles
+
+ {permission.roles.map((role) => ( + + {role.name} + + ))} +
+
+ )} +
+
+
+
+
+ ), + selectedLabel: ( +
+
+
+ +
+ + {permission.id.length > 15 + ? `${permission.id.slice(0, 8)}...${permission.id.slice(-4)}` + : permission.id} + +
+ {permission.name} +
+ ), + value: permission.id, + searchValue: `${permission.id} ${permission.name} ${permission.slug} ${ + permission.description || "" + }`.trim(), + })); + + if (hasNextPage) { + options.push({ + label: ( + + ), + value: "__load_more__", + selectedLabel: <>, + searchValue: "", + }); + } + + return options; +} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-permission/hooks/use-fetch-keys-permissions.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-permission/hooks/use-fetch-keys-permissions.ts new file mode 100644 index 0000000000..4aaffe4a4b --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-permission/hooks/use-fetch-keys-permissions.ts @@ -0,0 +1,60 @@ +"use client"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { useMemo } from "react"; + +export const useFetchPermissions = (limit = 50) => { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + trpc.authorization.roles.permissions.query.useInfiniteQuery( + { + limit, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + onError(err) { + if (err.data?.code === "NOT_FOUND") { + toast.error("Failed to Load Permissions", { + description: + "We couldn't find any permissions for this workspace. Please try again or contact support@unkey.dev.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We were unable to load permissions. Please try again or contact support@unkey.dev", + }); + } else { + toast.error("Failed to Load Permissions", { + description: + err.message || + "An unexpected error occurred. Please try again or contact support@unkey.dev", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } + }, + }, + ); + + const permissions = useMemo(() => { + if (!data?.pages) { + return []; + } + return data.pages.flatMap((page) => page.permissions); + }, [data?.pages]); + + const loadMore = () => { + if (!isFetchingNextPage && hasNextPage) { + fetchNextPage(); + } + }; + + return { + permissions, + isLoading, + isFetchingNextPage, + hasNextPage, + loadMore, + }; +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-permission/hooks/use-search-keys-permissions.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-permission/hooks/use-search-keys-permissions.ts new file mode 100644 index 0000000000..c7c3248263 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-permission/hooks/use-search-keys-permissions.ts @@ -0,0 +1,34 @@ +import { trpc } from "@/lib/trpc/client"; +import { useEffect, useMemo, useState } from "react"; + +export const useSearchPermissions = (query: string, debounceMs = 300) => { + const [debouncedQuery, setDebouncedQuery] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(query.trim()); + }, debounceMs); + + return () => clearTimeout(timer); + }, [query, debounceMs]); + + const { data, isLoading, error } = trpc.authorization.roles.permissions.search.useQuery( + { query: debouncedQuery }, + { + enabled: debouncedQuery.length > 0, // Only search when there's a debounced query + staleTime: 30_000, + }, + ); + + const searchResults = useMemo(() => { + return data?.permissions || []; + }, [data?.permissions]); + + const isSearching = query.trim() !== debouncedQuery || (debouncedQuery.length > 0 && isLoading); + + return { + searchResults, + isSearching, + searchError: error, + }; +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-permission/permissions-field.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-permission/permissions-field.tsx new file mode 100644 index 0000000000..2e3255517d --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-permission/permissions-field.tsx @@ -0,0 +1,246 @@ +import { StatusBadge } from "@/app/(app)/apis/[apiId]/settings/components/status-badge"; +import { SelectedItemsList } from "@/components/selected-item-list"; +import { FormCombobox } from "@/components/ui/form-combobox"; +import type { KeyPermission, KeyRole } from "@/lib/trpc/routers/key/rbac/connected-roles-and-perms"; +import { HandHoldingKey } from "@unkey/icons"; +import { InfoTooltip } from "@unkey/ui"; +import { useMemo, useState } from "react"; +import { useWatch } from "react-hook-form"; +import { createPermissionOptions } from "./create-permission-options"; +import { useFetchPermissions } from "./hooks/use-fetch-keys-permissions"; +import { useSearchPermissions } from "./hooks/use-search-keys-permissions"; + +type PermissionFieldProps = { + value: string[]; + onChange: (ids: string[]) => void; + error?: string; + disabled?: boolean; + assignedPermsDetails: KeyPermission[]; + assignedRoleDetails: KeyRole[]; +}; + +export const PermissionField = ({ + value, + onChange, + assignedRoleDetails, + error, + disabled = false, + assignedPermsDetails = [], +}: PermissionFieldProps) => { + const [searchValue, setSearchValue] = useState(""); + const { permissions, isFetchingNextPage, hasNextPage, loadMore } = useFetchPermissions(); + const { searchResults, isSearching } = useSearchPermissions(searchValue); + + // Watch roleIds from form context + const selectedRoleIds = useWatch({ name: "roleIds", defaultValue: [] }); + + // Calculate permissions inherited from currently selected roles + const inheritedPermissions = useMemo(() => { + const inherited = new Map(); + + assignedPermsDetails.forEach((permission) => { + if ( + permission.source === "role" && + permission.roleId && + selectedRoleIds.includes(permission.roleId) + ) { + inherited.set(permission.id, permission); + } + }); + + return inherited; + }, [assignedPermsDetails, selectedRoleIds]); + + // All effective permissions (inherited + direct) + const allEffectivePermissionIds = useMemo(() => { + return new Set([...inheritedPermissions.keys(), ...value]); + }, [inheritedPermissions, value]); + + // Combine loaded permissions with search results + const allPermissions = useMemo(() => { + if (searchValue.trim() && searchResults.length > 0) { + return searchResults; + } + if (searchValue.trim() && searchResults.length === 0 && !isSearching) { + const searchTerm = searchValue.toLowerCase().trim(); + return permissions.filter( + (permission) => + permission.id.toLowerCase().includes(searchTerm) || + permission.name.toLowerCase().includes(searchTerm) || + permission.slug.toLowerCase().includes(searchTerm) || + permission.description?.toLowerCase().includes(searchTerm), + ); + } + return permissions; + }, [permissions, searchResults, searchValue, isSearching]); + + const showLoadMore = !searchValue.trim() && hasNextPage; + + const baseOptions = createPermissionOptions({ + permissions: allPermissions, + hasNextPage: showLoadMore, + isFetchingNextPage, + loadMore, + }); + + const selectableOptions = useMemo(() => { + return baseOptions.filter((option) => { + if (option.value === "__load_more__") { + return true; + } + + // Don't show permissions that are already effective (inherited or direct) + if (allEffectivePermissionIds.has(option.value)) { + return false; + } + + return true; + }); + }, [baseOptions, allEffectivePermissionIds]); + + // Combined list for display: inherited permissions + direct permissions + const displayPermissions = useMemo(() => { + const permissionsList: Array = []; + + // Add inherited permissions + inheritedPermissions.forEach((permission) => { + permissionsList.push({ + ...permission, + isInherited: true, + }); + }); + + // Add direct permissions + value.forEach((permissionId) => { + // Skip if already added as inherited + if (inheritedPermissions.has(permissionId)) { + return; + } + + // Check if it's a known direct permission from original data + const directPermission = assignedPermsDetails.find( + (p) => p.id === permissionId && p.source === "direct", + ); + + if (directPermission) { + permissionsList.push({ + ...directPermission, + isInherited: false, + }); + return; + } + + // Check loaded permissions (newly added) + const loadedPerm = allPermissions.find((p) => p.id === permissionId); + if (loadedPerm) { + permissionsList.push({ + ...loadedPerm, + source: "direct" as const, + isInherited: false, + }); + return; + } + + // Fallback for unknown permissions + permissionsList.push({ + id: permissionId, + name: permissionId, + slug: permissionId, + description: null, + source: "direct" as const, + isInherited: false, + }); + }); + + // Sort: inherited first, then direct + return permissionsList.sort((a, b) => { + if (a.isInherited && !b.isInherited) { + return -1; + } + if (!a.isInherited && b.isInherited) { + return 1; + } + return a.name.localeCompare(b.name); + }); + }, [inheritedPermissions, value, assignedPermsDetails, allPermissions]); + + const handleRemovePermission = (permissionId: string) => { + // Cannot remove inherited permissions + if (inheritedPermissions.has(permissionId)) { + return; + } + + // Remove from direct permissions + onChange(value.filter((id) => id !== permissionId)); + }; + + const handleAddPermission = (permissionId: string) => { + // Don't add if already inherited or directly assigned + if (allEffectivePermissionIds.has(permissionId)) { + return; + } + + onChange([...value, permissionId]); + setSearchValue(""); + }; + + return ( +
+ setSearchValue(e.currentTarget.value)} + onSelect={handleAddPermission} + placeholder={ +
+ Select permissions +
+ } + searchPlaceholder="Search permissions by name, ID, slug, or description..." + emptyMessage={ + isSearching ? ( +
Searching...
+ ) : ( +
No permissions found
+ ) + } + variant="default" + error={error} + disabled={disabled} + /> + + !permission.isInherited} + renderIcon={() => ( +
+ +
+ )} + renderPrimaryText={(permission) => permission.name} + renderSecondaryText={(permission) => permission.slug} + renderBadge={(permission) => + permission.isInherited && ( + r.id === permission.roleId)?.name + }`} + > + + + ) + } + /> +
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-role/create-key-options.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-role/create-key-options.tsx new file mode 100644 index 0000000000..0b85e5eee1 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-role/create-key-options.tsx @@ -0,0 +1,152 @@ +import { StatusBadge } from "@/app/(app)/apis/[apiId]/settings/components/status-badge"; +import { Badge } from "@/components/ui/badge"; +import { Lock, Tag } from "@unkey/icons"; +import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@unkey/ui"; + +type Role = { + id: string; + name: string; + description?: string | null; + keys: { + id: string; + name: string | null; + }[]; + permissions: { + id: string; + name: string; + }[]; +}; + +type RoleSelectorProps = { + roles: Role[]; + hasNextPage?: boolean; + isFetchingNextPage: boolean; + keyId?: string; + previouslySelectedRoleIds: string[]; + loadMore: () => void; +}; + +export function createRoleOptions({ + roles, + hasNextPage, + isFetchingNextPage, + previouslySelectedRoleIds, + keyId, + loadMore, +}: RoleSelectorProps) { + const options = roles.map((role) => ({ + label: ( + + + +
+
+ +
+
+
+ {role.name} + {(previouslySelectedRoleIds.includes(role.id) || + role.keys.find((item) => item.id === keyId)) && ( + } + /> + )} +
+ + {role.description || `${role.permissions.length} permissions`} + +
+
+
+ +
+ {/* Header */} +
+ Role Details +
+ {/* Content */} +
+
+
Role Name
+
{role.name}
+
+ {role.description && ( +
+
Description
+
{role.description}
+
+ )} +
+
Role ID
+
{role.id}
+
+ {role.permissions.length > 0 && ( +
+
Permissions
+
+ {role.permissions.map((permission) => ( + + {permission.name} + + ))} +
+
+ )} +
+
+
+
+
+ ), + selectedLabel: ( +
+
+
+ +
+ + {role.name} + +
+ + {role.description || `${role.permissions.length} permissions`} + +
+ ), + value: role.id, + searchValue: `${role.name} ${role.description || ""} ${role.id}`.trim(), + })); + + if (hasNextPage) { + options.push({ + label: ( + + ), + value: "__load_more__", + selectedLabel: <>, + searchValue: "", + }); + } + + return options; +} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-role/hooks/use-fetch-keys-roles.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-role/hooks/use-fetch-keys-roles.ts new file mode 100644 index 0000000000..0eb7234d0a --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-role/hooks/use-fetch-keys-roles.ts @@ -0,0 +1,60 @@ +"use client"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { useMemo } from "react"; + +export const useFetchKeysRoles = (limit = 50) => { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + trpc.key.update.rbac.roles.query.useInfiniteQuery( + { + limit, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + onError(err) { + if (err.data?.code === "NOT_FOUND") { + toast.error("Failed to Load Roles", { + description: + "We couldn't find any roles for this workspace. Please try again or contact support@unkey.dev.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We were unable to load roles. Please try again or contact support@unkey.dev", + }); + } else { + toast.error("Failed to Load Roles", { + description: + err.message || + "An unexpected error occurred. Please try again or contact support@unkey.dev", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } + }, + }, + ); + + const roles = useMemo(() => { + if (!data?.pages) { + return []; + } + return data.pages.flatMap((page) => page.roles); + }, [data?.pages]); + + const loadMore = () => { + if (!isFetchingNextPage && hasNextPage) { + fetchNextPage(); + } + }; + + return { + roles, + isLoading, + isFetchingNextPage, + hasNextPage, + loadMore, + }; +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-role/hooks/use-search-keys-roles.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-role/hooks/use-search-keys-roles.ts new file mode 100644 index 0000000000..5815771293 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-role/hooks/use-search-keys-roles.ts @@ -0,0 +1,34 @@ +import { trpc } from "@/lib/trpc/client"; +import { useEffect, useMemo, useState } from "react"; + +export const useSearchKeysRoles = (query: string, debounceMs = 300) => { + const [debouncedQuery, setDebouncedQuery] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(query.trim()); + }, debounceMs); + + return () => clearTimeout(timer); + }, [query, debounceMs]); + + const { data, isLoading, error } = trpc.key.update.rbac.roles.search.useQuery( + { query: debouncedQuery }, + { + enabled: debouncedQuery.length > 0, // Only search when there's a debounced query + staleTime: 30_000, + }, + ); + + const searchResults = useMemo(() => { + return data?.roles || []; + }, [data?.roles]); + + const isSearching = query.trim() !== debouncedQuery || (debouncedQuery.length > 0 && isLoading); + + return { + searchResults, + isSearching, + searchError: error, + }; +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-role/role-field.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-role/role-field.tsx new file mode 100644 index 0000000000..c5ac853403 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/assign-role/role-field.tsx @@ -0,0 +1,163 @@ +import { SelectedItemsList } from "@/components/selected-item-list"; +import { FormCombobox } from "@/components/ui/form-combobox"; +import type { KeyRole } from "@/lib/trpc/routers/key/rbac/connected-roles-and-perms"; +import { Tag } from "@unkey/icons"; +import { useMemo, useState } from "react"; +import { createRoleOptions } from "./create-key-options"; +import { useFetchKeysRoles } from "./hooks/use-fetch-keys-roles"; +import { useSearchKeysRoles } from "./hooks/use-search-keys-roles"; + +type RoleFieldProps = { + value: string[]; + onChange: (ids: string[]) => void; + error?: string; + disabled?: boolean; + keyId?: string; + assignedRoleDetails: KeyRole[]; +}; + +export const RoleField = ({ + value, + onChange, + error, + disabled = false, + keyId, + assignedRoleDetails, +}: RoleFieldProps) => { + const [searchValue, setSearchValue] = useState(""); + const { roles, isFetchingNextPage, hasNextPage, loadMore } = useFetchKeysRoles(); + const { searchResults, isSearching } = useSearchKeysRoles(searchValue); + + // Combine loaded roles with search results, prioritizing search when available + const allRoles = useMemo(() => { + if (searchValue.trim() && searchResults.length > 0) { + return searchResults; + } + if (searchValue.trim() && searchResults.length === 0 && !isSearching) { + const searchTerm = searchValue.toLowerCase().trim(); + return roles.filter( + (role) => + role.id.toLowerCase().includes(searchTerm) || + role.name?.toLowerCase().includes(searchTerm) || + role.description?.toLowerCase().includes(searchTerm), + ); + } + return roles; + }, [roles, searchResults, searchValue, isSearching]); + + const showLoadMore = !searchValue.trim() && hasNextPage; + + const baseOptions = createRoleOptions({ + roles: allRoles, + hasNextPage: showLoadMore, + isFetchingNextPage, + keyId, + previouslySelectedRoleIds: assignedRoleDetails.map((r) => r.id), + loadMore, + }); + + const selectableOptions = useMemo(() => { + return baseOptions.filter((option) => { + if (option.value === "__load_more__") { + return true; + } + + // Don't show already selected roles (based on current form state) + if (value.includes(option.value)) { + return false; + } + + return true; + }); + }, [baseOptions, value]); // Removed keyId and allRoles dependencies + + const selectedRoles = useMemo(() => { + return value + .map((roleId) => { + // Check assignedRoleDetails first (for pre-loaded edit data) + const preLoadedRole = assignedRoleDetails.find((r) => r.id === roleId); + if (preLoadedRole) { + return { + id: preLoadedRole.id, + name: preLoadedRole.name, + description: preLoadedRole.description, + }; + } + + // Check loaded roles (for newly added roles) + const loadedRole = allRoles.find((r) => r.id === roleId); + if (loadedRole) { + return { + id: loadedRole.id, + name: loadedRole.name, + description: loadedRole.description, + }; + } + + // Fallback to ID-only display + return { + id: roleId, + name: null, + description: null, + }; + }) + .filter((role): role is NonNullable => role !== undefined); + }, [value, allRoles, assignedRoleDetails]); + + const handleRemoveRole = (roleId: string) => { + onChange(value.filter((id) => id !== roleId)); + }; + + const handleAddRole = (roleId: string) => { + if (!value.includes(roleId)) { + onChange([...value, roleId]); + } + setSearchValue(""); + }; + + return ( +
+ setSearchValue(e.currentTarget.value)} + onSelect={handleAddRole} + placeholder={ +
+ Select roles +
+ } + searchPlaceholder="Search roles by name or description..." + emptyMessage={ + isSearching ? ( +
Searching...
+ ) : ( +
No roles found
+ ) + } + variant="default" + error={error} + disabled={disabled} + /> + + ({ + name: r.name ?? "", + description: r.description ?? "", + id: r.id, + }))} + disabled={disabled} + onRemoveItem={handleRemoveRole} + renderIcon={() => ( +
+ +
+ )} + renderPrimaryText={(role) => role.name || "Unnamed Role"} + renderSecondaryText={(role) => ("description" in role && role.description) || role.id} + /> +
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/granted-access.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/granted-access.tsx new file mode 100644 index 0000000000..b7268f8ba9 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/granted-access.tsx @@ -0,0 +1,135 @@ +import { HandHoldingKey } from "@unkey/icons"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useMemo, useState } from "react"; + +export const GrantedAccess = ({ + slugs = [], + totalCount, + isLoading, +}: { + slugs?: string[]; + totalCount?: number; + isLoading: boolean; +}) => { + const [stableSlugs, setStableSlugs] = useState([]); + + useEffect(() => { + if (!isLoading && slugs) { + setStableSlugs((prev) => { + const newSlugsSet = new Set(slugs); + const prevSlugsSet = new Set(prev); + + const retained = prev.filter((slug) => newSlugsSet.has(slug)); + + const newOnes = slugs.filter((slug) => !prevSlugsSet.has(slug)); + + return [...retained, ...newOnes]; + }); + } + }, [slugs, isLoading]); + + const memoizedSlugs = useMemo(() => { + return stableSlugs.map((slug, index) => ( + + + {slug} + + )); + }, [stableSlugs]); + + if (stableSlugs.length === 0 && !isLoading) { + return null; + } + + return ( + + +
Granted Access
+ + {isLoading ? "..." : totalCount} + +
+ + + + + + {isLoading ? ( + + {[1, 2, 3].map((i) => ( + + ))} + + ) : stableSlugs.length > 0 ? ( + memoizedSlugs + ) : ( + + No permissions selected + + )} + + + + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/hooks/use-fetch-permission-slugs.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/hooks/use-fetch-permission-slugs.tsx new file mode 100644 index 0000000000..eb09d39a40 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/components/hooks/use-fetch-permission-slugs.tsx @@ -0,0 +1,49 @@ +"use client"; +import { trpc } from "@/lib/trpc/client"; +import type { KeyPermission } from "@/lib/trpc/routers/key/rbac/connected-roles-and-perms"; +import { useMemo } from "react"; + +export const useFetchPermissionSlugs = ( + roleIds: string[] = [], + directPermissionIds: string[] = [], // Changed parameter name to be explicit + allPermissions: KeyPermission[] = [], + enabled = true, +) => { + // Calculate all effective permissions: role-inherited + direct + const allEffectivePermissionIds = useMemo(() => { + // Get permissions from currently selected roles + const rolePermissionIds = new Set(); + allPermissions.forEach((permission) => { + if ( + permission.source === "role" && + permission.roleId && + roleIds.includes(permission.roleId) + ) { + rolePermissionIds.add(permission.id); + } + }); + + // Combine role permissions and direct permissions, removing duplicates + const combined = new Set([...rolePermissionIds, ...directPermissionIds]); + return Array.from(combined); + }, [roleIds, directPermissionIds, allPermissions]); + + const { data, isLoading, error, refetch } = trpc.key.queryPermissionSlugs.useQuery( + { + roleIds, + permissionIds: directPermissionIds, + }, + { + enabled, + }, + ); + + return { + data, + isLoading, + error, + refetch, + hasData: !isLoading && data !== undefined, + allEffectivePermissionIds, // Export this in case components need it + }; +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/index.tsx new file mode 100644 index 0000000000..6975576291 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/index.tsx @@ -0,0 +1,255 @@ +"use client"; +import { NavbarActionButton } from "@/components/navigation/action-button"; +import { Navbar } from "@/components/navigation/navbar"; +import { usePersistedForm } from "@/hooks/use-persisted-form"; + +import { trpc } from "@/lib/trpc/client"; +import type { KeyPermission, KeyRole } from "@/lib/trpc/routers/key/rbac/connected-roles-and-perms"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { PenWriting3 } from "@unkey/icons"; +import { Button, DialogContainer } from "@unkey/ui"; +import { useEffect, useMemo, useState } from "react"; +import { Controller, FormProvider } from "react-hook-form"; +import { useUpdateKeyRbac } from "../hooks/use-edit-rbac"; +import { KeyInfo } from "../key-info"; +import { PermissionField } from "./components/assign-permission/permissions-field"; +import { RoleField } from "./components/assign-role/role-field"; +import { GrantedAccess } from "./components/granted-access"; +import { useFetchPermissionSlugs } from "./components/hooks/use-fetch-permission-slugs"; +import { type FormValues, updateKeyRbacSchema } from "./update-key-rbac.schema"; + +const FORM_STORAGE_KEY = "unkey_key_rbac_form_state"; + +type ExistingKey = { + id: string; + name?: string; + roleIds: string[]; + permissionIds: string[]; +}; + +const DIALOG_CONFIG = { + title: "Manage roles and permissions", + subtitle: "Assign roles or permissions to control what this key can do", + buttonText: "Update roles and permissions", + footerText: "This key will be updated immediately", + triggerTitle: "Manage roles and permissions", +}; + +type KeyRbacDialogProps = { + existingKey: ExistingKey; + triggerButton?: boolean; + isOpen?: boolean; + onClose?: () => void; +}; + +const getDefaultValues = ( + existingKey: ExistingKey, + apiData?: { roles: KeyRole[]; permissions: KeyPermission[] }, +): FormValues => { + // Separate direct permissions from role-inherited permissions + const directPermissions = + apiData?.permissions.filter((p) => p.source === "direct").map((p) => p.id) ?? []; + + return { + keyId: existingKey.id, + roleIds: apiData?.roles.map((r) => r.id) ?? existingKey.roleIds ?? [], + directPermissionIds: directPermissions, + }; +}; + +export const KeyRbacDialog = ({ + existingKey, + triggerButton, + isOpen: externalIsOpen, + onClose: externalOnClose, +}: KeyRbacDialogProps) => { + const { data: connectedRolesAndPerms, isLoading } = trpc.key.connectedRolesAndPerms.useQuery({ + keyId: existingKey.id, + }); + + const [internalIsOpen, setInternalIsOpen] = useState(false); + + const isDialogOpen = externalIsOpen !== undefined ? externalIsOpen : internalIsOpen; + const setIsDialogOpen = + externalOnClose !== undefined + ? (open: boolean) => !open && externalOnClose() + : setInternalIsOpen; + + const storageKey = `${FORM_STORAGE_KEY}_${existingKey.id}`; + const methods = usePersistedForm( + storageKey, + { + resolver: zodResolver(updateKeyRbacSchema), + mode: "onChange", + shouldFocusError: true, + shouldUnregister: true, + defaultValues: getDefaultValues(existingKey), + }, + "memory", + ); + + const { + formState: { isValid }, + handleSubmit, + reset, + clearPersistedData, + saveCurrentValues, + loadSavedValues, + control, + watch, + } = methods; + + const watchedRoleIds = watch("roleIds"); + const watchedDirectPermissionIds = watch("directPermissionIds"); + + // Calculate all effective permissions for GrantedAccess component + const allEffectivePermissionIds = useMemo(() => { + const rolePermissions = + connectedRolesAndPerms?.permissions + .filter((p) => p.source === "role" && p.roleId && watchedRoleIds.includes(p.roleId)) + .map((p) => p.id) ?? []; + + return [...rolePermissions, ...watchedDirectPermissionIds]; + }, [connectedRolesAndPerms?.permissions, watchedRoleIds, watchedDirectPermissionIds]); + + const { data: dataSlugs, isLoading: isSlugsLoading } = useFetchPermissionSlugs( + watchedRoleIds, + allEffectivePermissionIds, // Pass all effective permissions + connectedRolesAndPerms?.permissions ?? [], + isDialogOpen, + ); + + // Reset form data when dialog opens + useEffect(() => { + if (!isDialogOpen || isLoading) { + return; + } + + const loadData = async () => { + const hasSavedData = await loadSavedValues(); + if (!hasSavedData) { + const defaultValues = getDefaultValues(existingKey, connectedRolesAndPerms); + reset(defaultValues); + } + }; + + loadData(); + }, [existingKey, reset, loadSavedValues, isDialogOpen, connectedRolesAndPerms, isLoading]); + + const updateKeyRbacMutation = useUpdateKeyRbac(() => { + clearPersistedData(); + reset(getDefaultValues(existingKey)); + setIsDialogOpen(false); + }); + + const onSubmit = async (data: FormValues) => { + updateKeyRbacMutation.mutate(data); + }; + + const handleDialogToggle = (open: boolean) => { + if (!open) { + saveCurrentValues(); + } + setIsDialogOpen(open); + }; + + if (isLoading && isDialogOpen) { + return ( + +
Loading key data...
+
+ ); + } + + const defaultTrigger = ( + setIsDialogOpen(true)}> + + {DIALOG_CONFIG.triggerTitle} + + ); + + return ( + <> + {triggerButton && {defaultTrigger}} + +
+ } + /> + + +
{DIALOG_CONFIG.footerText}
+ + } + > +
+ +
+
+
+ ( + + )} + /> + ( + + )} + /> + +
+ + + + + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/update-key-rbac.schema.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/update-key-rbac.schema.ts new file mode 100644 index 0000000000..35ee0c5653 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/update-key-rbac.schema.ts @@ -0,0 +1,15 @@ +// update-key-rbac.schema.ts +import type { KeyPermission } from "@/lib/trpc/routers/key/rbac/connected-roles-and-perms"; +import { z } from "zod"; + +export const updateKeyRbacSchema = z.object({ + keyId: z.string().min(1, "Key ID is required"), + roleIds: z.array(z.string()), + directPermissionIds: z.array(z.string()), +}); + +export type FormValues = z.infer; + +export interface DisplayPermission extends KeyPermission { + isInherited: boolean; +} diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-rbac.ts b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-rbac.ts new file mode 100644 index 0000000000..23bbc6b96b --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-rbac.ts @@ -0,0 +1,91 @@ +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; + +export const useUpdateKeyRbac = ( + onSuccess: (data: { + keyId: string; + success: boolean; + rolesAssigned: number; + directPermissionsAssigned: number; + totalEffectivePermissions: number; + }) => void, +) => { + const trpcUtils = trpc.useUtils(); + + const updateKeyRbac = trpc.key.update.rbac.update.useMutation({ + onSuccess(data) { + trpcUtils.key.connectedRolesAndPerms.invalidate(); + trpcUtils.api.keys.list.invalidate(); + + const { rolesAssigned, directPermissionsAssigned, totalEffectivePermissions } = data; + + const parts = []; + if (rolesAssigned > 0) { + parts.push(`${rolesAssigned} role${rolesAssigned > 1 ? "s" : ""}`); + } + if (directPermissionsAssigned > 0) { + parts.push( + `${directPermissionsAssigned} direct permission${ + directPermissionsAssigned > 1 ? "s" : "" + }`, + ); + } + + const description = + parts.length > 0 + ? `Updated with ${parts.join( + " and ", + )} (${totalEffectivePermissions} total effective permissions)` + : "All roles and permissions have been removed from this key"; + + toast.success("Key RBAC Updated", { + description, + duration: 5000, + }); + + onSuccess(data); + }, + + onError(err) { + console.error("Key RBAC update failed:", err); + + if (err.data?.code === "NOT_FOUND") { + toast.error("Key Update Failed", { + description: + "We are unable to find the correct key. Please try again or contact support@unkey.dev.", + }); + } else if (err.data?.code === "BAD_REQUEST") { + let description = "Please check your selections and try again."; + + if (err.message.includes("roles")) { + description = + "One or more selected roles do not exist in this workspace. Please refresh and try again."; + } else if (err.message.includes("permissions")) { + description = + "One or more selected permissions do not exist in this workspace. Please refresh and try again."; + } + + toast.error("Invalid Selection", { + description, + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We are unable to update RBAC for this key. Please try again or contact support@unkey.dev", + }); + } else { + toast.error("Failed to Update Key RBAC", { + description: + err.message || + "An unexpected error occurred while updating roles and permissions. Please try again or contact support@unkey.dev", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } + }, + }); + + return updateKeyRbac; +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/key-info.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/key-info.tsx index 73909967fb..a51883dcd4 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/key-info.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/key-info.tsx @@ -1,8 +1,11 @@ -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; import { Key2 } from "@unkey/icons"; import { InfoTooltip } from "@unkey/ui"; -export const KeyInfo = ({ keyDetails }: { keyDetails: KeyDetails }) => { +export const KeyInfo = ({ + keyDetails, +}: { + keyDetails: { name?: string | null; id: string }; +}) => { return (
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}
- - - -
- 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. -

- -
-
-

- Type {permission.name} to - confirm -

- -
-
- - - ); -}; 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 -
- -
-
- - Used in API calls. Changing this may affect your access control requests. -
- } - border="top" - > -
- - -
- - - - -
- -
-
-
- - -
-
-

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 = { + name: "Name", + description: "Description", + roleId: "Role ID", + roleName: "Role name", + slug: "Slug", +} as const; + +const formatFieldName = (field: string): string => { + if (field in FIELD_DISPLAY_NAMES) { + return FIELD_DISPLAY_NAMES[field as PermissionsFilterField]; + } + + return field.charAt(0).toUpperCase() + field.slice(1); +}; + +export const PermissionsListControlCloud = () => { + const { filters, updateFilters, removeFilter } = useFilters(); + + return ( + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/controls/components/logs-filters/index.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/controls/components/logs-filters/index.tsx new file mode 100644 index 0000000000..49848e5286 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/controls/components/logs-filters/index.tsx @@ -0,0 +1,95 @@ +import { FiltersPopover } from "@/components/logs/checkbox/filters-popover"; +import { FilterOperatorInput } from "@/components/logs/filter-operator-input"; +import { BarsFilter } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { + type PermissionsFilterField, + permissionsFilterFieldConfig, + permissionsListFilterFieldNames, +} from "../../../../filters.schema"; +import { useFilters } from "../../../../hooks/use-filters"; + +const FIELD_DISPLAY_CONFIG: Record = { + name: { label: "Name", shortcut: "n" }, + description: { label: "Description", shortcut: "d" }, + slug: { label: "Slug", shortcut: "k" }, + roleName: { label: "Role name", shortcut: "p" }, + roleId: { label: "Role ID", shortcut: "m" }, +} as const; + +export const LogsFilters = () => { + const { filters, updateFilters } = useFilters(); + + // Generate filter items dynamically from schema + const filterItems = permissionsListFilterFieldNames.map((fieldName) => { + const fieldConfig = permissionsFilterFieldConfig[fieldName]; + const displayConfig = FIELD_DISPLAY_CONFIG[fieldName]; + + if (!displayConfig) { + throw new Error(`Missing display configuration for field: ${fieldName}`); + } + + const options = fieldConfig.operators.map((op) => ({ + id: op, + label: op, + })); + + const activeFilter = filters.find((f) => f.field === fieldName); + + return { + id: fieldName, + label: displayConfig.label, + shortcut: displayConfig.shortcut, + component: ( + { + // Remove existing filters for this field + const filtersWithoutCurrent = filters.filter((f) => f.field !== fieldName); + + // Add new filter + updateFilters([ + ...filtersWithoutCurrent, + { + field: fieldName, + id: crypto.randomUUID(), + operator, + value: text, + }, + ]); + }} + /> + ), + }; + }); + + return ( + +
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/controls/components/logs-search/index.tsx new file mode 100644 index 0000000000..88ebd2ccbc --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/controls/components/logs-search/index.tsx @@ -0,0 +1,64 @@ +import { LogsLLMSearch } from "@/components/logs/llm-search"; +import { transformStructuredOutputToFilters } from "@/components/logs/validation/utils/transform-structured-output-filter-format"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { useFilters } from "../../../../hooks/use-filters"; + +export const PermissionSearch = () => { + const { filters, updateFilters } = useFilters(); + + const queryLLMForStructuredOutput = trpc.authorization.permissions.llmSearch.useMutation({ + onSuccess(data) { + if (data?.filters.length === 0 || !data) { + toast.error( + "Please provide more specific search criteria. Your query requires additional details for accurate results.", + { + duration: 8000, + important: true, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + }, + ); + return; + } + const transformedFilters = transformStructuredOutputToFilters(data, filters); + updateFilters(transformedFilters); + }, + onError(error) { + const errorMessage = `Unable to process your search request${ + error.message ? `: ${error.message}` : "." + } Please try again or refine your search criteria.`; + toast.error(errorMessage, { + duration: 8000, + important: true, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + className: "font-medium", + }); + }, + }); + + return ( + + queryLLMForStructuredOutput.mutateAsync({ + query, + }) + } + /> + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/controls/index.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/controls/index.tsx new file mode 100644 index 0000000000..b94665b9eb --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/controls/index.tsx @@ -0,0 +1,14 @@ +import { ControlsContainer, ControlsLeft } from "@/components/logs/controls-container"; +import { LogsFilters } from "./components/logs-filters"; +import { PermissionSearch } from "./components/logs-search"; + +export function PermissionListControls() { + return ( + + + + + + + ); +} diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/delete-permission.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/delete-permission.tsx new file mode 100644 index 0000000000..88e0bc5bc4 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/delete-permission.tsx @@ -0,0 +1,161 @@ +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 { Permission } from "@/lib/trpc/routers/authorization/permissions/query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { TriangleWarning2 } from "@unkey/icons"; +import { Button, DialogContainer, FormCheckbox } from "@unkey/ui"; +import { useRef, useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { z } from "zod"; +import { useDeletePermission } from "./hooks/use-delete-permission"; +import { PermissionInfo } from "./permission-info"; + +const deletePermissionFormSchema = z.object({ + confirmDeletion: z.boolean().refine((val) => val === true, { + message: "Please confirm that you want to permanently delete this permission", + }), +}); + +type DeletePermissionFormValues = z.infer; + +type DeletePermissionProps = { + permissionDetails: Permission; +} & ActionComponentProps; + +export const DeletePermission = ({ permissionDetails, isOpen, onClose }: DeletePermissionProps) => { + const [isConfirmPopoverOpen, setIsConfirmPopoverOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const deleteButtonRef = useRef(null); + + const methods = useForm({ + resolver: zodResolver(deletePermissionFormSchema), + mode: "onChange", + shouldFocusError: true, + shouldUnregister: true, + defaultValues: { + confirmDeletion: false, + }, + }); + + const { + formState: { errors }, + control, + watch, + } = methods; + + const confirmDeletion = watch("confirmDeletion"); + + const deletePermission = useDeletePermission(() => { + onClose(); + }); + + const handleDialogOpenChange = (open: boolean) => { + if (isConfirmPopoverOpen) { + // If confirm popover is active don't let this trigger outer popover + if (!open) { + return; + } + } else { + if (!open) { + onClose(); + } + } + }; + + const handleDeleteButtonClick = () => { + setIsConfirmPopoverOpen(true); + }; + + const performPermissionDeletion = async () => { + try { + setIsLoading(true); + await deletePermission.mutateAsync({ + permissionIds: permissionDetails.permissionId, + }); + } catch { + // `useDeletePermission` already shows a toast, but we still need to + // prevent unhandled‐rejection noise in the console. + } finally { + setIsLoading(false); + } + }; + + return ( + <> + +
+ + +
+ Changes may take up to 60s to propagate globally +
+
+ } + > + +
+
+
+
+
+ +
+
+ Warning: deleting this permission will detach + it from all assigned keys and roles and remove its configuration. This action cannot + be undone. The keys and roles themselves will remain available, but any usage + history or references to this permission will be permanently lost. +
+
+ ( + + )} + /> + + + + + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/edit-permission.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/edit-permission.tsx new file mode 100644 index 0000000000..5bff88d8d3 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/edit-permission.tsx @@ -0,0 +1,25 @@ +import type { Permission } from "@/lib/trpc/routers/authorization/permissions/query"; +import { UpsertPermissionDialog } from "../../../../upsert-permission"; + +export const EditPermission = ({ + permission, + isOpen, + onClose, +}: { + permission: Permission; + isOpen: boolean; + onClose: () => void; +}) => { + return ( + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/hooks/use-delete-permission.ts b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/hooks/use-delete-permission.ts new file mode 100644 index 0000000000..6cc624a721 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/hooks/use-delete-permission.ts @@ -0,0 +1,59 @@ +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; + +export const useDeletePermission = ( + onSuccess: (data: { + permissionIds: string[] | string; + message: string; + }) => void, +) => { + const trpcUtils = trpc.useUtils(); + const deletePermission = trpc.authorization.permissions.delete.useMutation({ + onSuccess(_, variables) { + trpcUtils.authorization.permissions.invalidate(); + const permissionCount = variables.permissionIds.length; + const isPlural = permissionCount > 1; + toast.success(isPlural ? "Permissions Deleted" : "Permission Deleted", { + description: isPlural + ? `${permissionCount} permissions have been successfully removed from your workspace.` + : "The permission has been successfully removed from your workspace.", + }); + onSuccess({ + permissionIds: variables.permissionIds, + message: isPlural + ? `${permissionCount} permissions deleted successfully` + : "Permission deleted successfully", + }); + }, + onError(err) { + if (err.data?.code === "NOT_FOUND") { + toast.error("Permission(s) Not Found", { + description: + "One or more permissions you're trying to delete no longer exist or you don't have access to them.", + }); + } else if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid Request", { + description: err.message || "Please provide at least one permission to delete.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We encountered an issue while deleting your permissions. Please try again later or contact support.", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } else { + toast.error("Failed to Delete Permission(s)", { + description: err.message || "An unexpected error occurred. Please try again later.", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } + }, + }); + return deletePermission; +}; diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/permission-info.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/permission-info.tsx new file mode 100644 index 0000000000..3e021f77f9 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/permission-info.tsx @@ -0,0 +1,31 @@ +import type { Permission } from "@/lib/trpc/routers/authorization/permissions/query"; +import { HandHoldingKey } from "@unkey/icons"; +import { InfoTooltip } from "@unkey/ui"; + +export const PermissionInfo = ({ + permissionDetails, +}: { + permissionDetails: Permission; +}) => { + return ( +
+
+ +
+
+
{permissionDetails.name}
+ +
+ {permissionDetails.description} +
+
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/keys-table-action.popover.constants.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/keys-table-action.popover.constants.tsx new file mode 100644 index 0000000000..4ddf2aeda6 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/keys-table-action.popover.constants.tsx @@ -0,0 +1,53 @@ +"use client"; +import type { MenuItem } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover"; +import { KeysTableActionPopover } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover"; +import { toast } from "@/components/ui/toaster"; +import type { Permission } from "@/lib/trpc/routers/authorization/permissions/query"; +import { Clone, PenWriting3, Trash } from "@unkey/icons"; +import { DeletePermission } from "./components/delete-permission"; +import { EditPermission } from "./components/edit-permission"; + +type PermissionsTableActionsProps = { + permission: Permission; +}; + +export const PermissionsTableActions = ({ permission }: PermissionsTableActionsProps) => { + const getPermissionsTableActionItems = (permission: Permission): MenuItem[] => { + return [ + { + id: "edit-permission", + label: "Edit permission...", + icon: , + ActionComponent: (props) => , + }, + { + id: "copy", + label: "Copy permission", + className: "mt-1", + icon: , + onClick: () => { + navigator.clipboard + .writeText(JSON.stringify(permission)) + .then(() => { + toast.success("Permission data copied to clipboard"); + }) + .catch((error) => { + console.error("Failed to copy to clipboard:", error); + toast.error("Failed to copy to clipboard"); + }); + }, + divider: true, + }, + { + id: "delete-permision", + label: "Delete permission", + icon: , + ActionComponent: (props) => , + }, + ]; + }; + + const menuItems = getPermissionsTableActionItems(permission); + + return ; +}; 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 new file mode 100644 index 0000000000..976f8f40d7 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/assigned-items-cell.tsx @@ -0,0 +1,63 @@ +import { cn } from "@/lib/utils"; +import { HandHoldingKey, Tag } from "@unkey/icons"; + +export const AssignedItemsCell = ({ + items, + totalCount, + isSelected = false, + type, +}: { + items: string[]; + totalCount?: number; + isSelected?: boolean; + type: "roles" | "slug"; +}) => { + const hasMore = totalCount && totalCount > items.length; + const icon = + type === "roles" ? ( + + ) : ( + + ); + + 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", + isSelected ? "bg-grayA-4 border-grayA-7" : "bg-grayA-3 border-grayA-6 group-hover:bg-grayA-4", + ); + + 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 ", + isSelected ? "border-grayA-7 text-grayA-9" : "border-grayA-6 text-grayA-8", + ); + + if (items.length === 0) { + return ( +
+
+ {icon} + None assigned +
+
+ ); + } + + return ( +
+ {items.map((item) => ( +
+ {icon} + + {item} + +
+ ))} + {hasMore && ( +
+ + {totalCount - items.length} more roles... + +
+ )} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/last-updated.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/last-updated.tsx new file mode 100644 index 0000000000..369b8472f0 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/last-updated.tsx @@ -0,0 +1,47 @@ +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { ChartActivity2 } from "@unkey/icons"; +import { TimestampInfo } from "@unkey/ui"; +import { useRef, useState } from "react"; +import { STATUS_STYLES } from "../utils/get-row-class"; + +export const LastUpdated = ({ + isSelected, + lastUpdated, +}: { + isSelected: boolean; + lastUpdated: number; +}) => { + const badgeRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + + return ( + { + setShowTooltip(true); + }} + onMouseLeave={() => { + setShowTooltip(false); + }} + > +
+ +
+
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/selection-controls/index.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/selection-controls/index.tsx new file mode 100644 index 0000000000..26aaddfa41 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/selection-controls/index.tsx @@ -0,0 +1,107 @@ +import { AnimatedCounter } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/selection-controls"; +import { ConfirmPopover } from "@/components/confirmation-popover"; +import { Trash, XMark } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { AnimatePresence, motion } from "framer-motion"; +import { useRef, useState } from "react"; +import { useDeletePermission } from "../actions/components/hooks/use-delete-permission"; + +type SelectionControlsProps = { + selectedPermissions: Set; + setSelectedPermissions: (keys: Set) => void; +}; + +export const SelectionControls = ({ + selectedPermissions, + setSelectedPermissions, +}: SelectionControlsProps) => { + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + const deleteButtonRef = useRef(null); + + const deletePermission = useDeletePermission(() => { + setSelectedPermissions(new Set()); + }); + + const handleDeleteButtonClick = () => { + setIsDeleteConfirmOpen(true); + }; + + const performPermissionDelete = () => { + deletePermission.mutate({ + permissionIds: Array.from(selectedPermissions), + }); + }; + + return ( + <> + + {selectedPermissions.size > 0 && ( + +
+
+ +
selected
+
+
+ + +
+
+
+ )} +
+ + 1 ? "these permissions" : "this permission" + } will be permanently deleted.`} + confirmButtonText={`Delete permission${selectedPermissions.size > 1 ? "s" : ""}`} + cancelButtonText="Cancel" + variant="danger" + /> + + ); +}; 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 new file mode 100644 index 0000000000..a6faaaab52 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/skeletons.tsx @@ -0,0 +1,63 @@ +import { cn } from "@/lib/utils"; +import { ChartActivity2, Dots, HandHoldingKey, Tag } from "@unkey/icons"; + +export const RoleColumnSkeleton = () => ( +
+
+
+ +
+
+
+
+
+
+
+); + +export const SlugColumnSkeleton = () => ( +
+
+ +
+
+
+); + +export const AssignedKeysColumnSkeleton = () => ( +
+
+ +
+
+
+ +
+
+
+); + +export const AssignedToKeysColumnSkeleton = () => ( +
+
+
+); + +export const LastUpdatedColumnSkeleton = () => ( +
+ +
+
+); + +export const ActionColumnSkeleton = () => ( + +); diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/hooks/use-permissions-list-query.ts b/apps/dashboard/app/(app)/authorization/permissions/components/table/hooks/use-permissions-list-query.ts new file mode 100644 index 0000000000..b88d1ab2bc --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/hooks/use-permissions-list-query.ts @@ -0,0 +1,86 @@ +import { trpc } from "@/lib/trpc/client"; +import type { Permission } from "@/lib/trpc/routers/authorization/permissions/query"; +import { useEffect, useMemo, useState } from "react"; +import { + permissionsFilterFieldConfig, + permissionsListFilterFieldNames, +} from "../../../filters.schema"; +import { useFilters } from "../../../hooks/use-filters"; +import type { PermissionsQueryPayload } from "../query-logs.schema"; + +export function usePermissionsListQuery() { + const [totalCount, setTotalCount] = useState(0); + const [permissionsMap, setPermissionsMap] = useState(() => new Map()); + const { filters } = useFilters(); + + const permissionsList = useMemo(() => Array.from(permissionsMap.values()), [permissionsMap]); + + const queryParams = useMemo(() => { + const params: PermissionsQueryPayload = { + ...Object.fromEntries(permissionsListFilterFieldNames.map((field) => [field, []])), + }; + + filters.forEach((filter) => { + if (!permissionsListFilterFieldNames.includes(filter.field) || !params[filter.field]) { + return; + } + + const fieldConfig = permissionsFilterFieldConfig[filter.field]; + const validOperators = fieldConfig.operators; + + if (!validOperators.includes(filter.operator)) { + throw new Error("Invalid operator"); + } + + if (typeof filter.value === "string") { + params[filter.field]?.push({ + operator: filter.operator, + value: filter.value, + }); + } + }); + + return params; + }, [filters]); + + const { + data: permissionsData, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + isLoading: isLoadingInitial, + } = trpc.authorization.permissions.query.useInfiniteQuery(queryParams, { + getNextPageParam: (lastPage) => lastPage.nextCursor, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + useEffect(() => { + if (permissionsData) { + const newMap = new Map(); + + permissionsData.pages.forEach((page) => { + page.permissions.forEach((permission) => { + // Use permissionId as the unique identifier + newMap.set(permission.permissionId, permission); + }); + }); + + if (permissionsData.pages.length > 0) { + setTotalCount(permissionsData.pages[0].total); + } + + setPermissionsMap(newMap); + } + }, [permissionsData]); + + return { + permissions: permissionsList, + isLoading: isLoadingInitial, + hasMore: hasNextPage, + loadMore: fetchNextPage, + isLoadingMore: isFetchingNextPage, + totalCount, + }; +} 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 new file mode 100644 index 0000000000..decd7ea49f --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx @@ -0,0 +1,266 @@ +"use client"; +import { Badge } from "@/components/ui/badge"; +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, HandHoldingKey } from "@unkey/icons"; +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"; +import { AssignedItemsCell } from "./components/assigned-items-cell"; +import { LastUpdated } from "./components/last-updated"; +import { SelectionControls } from "./components/selection-controls"; +import { + ActionColumnSkeleton, + AssignedKeysColumnSkeleton, + AssignedToKeysColumnSkeleton, + LastUpdatedColumnSkeleton, + RoleColumnSkeleton, + SlugColumnSkeleton, +} from "./components/skeletons"; +import { usePermissionsListQuery } from "./hooks/use-permissions-list-query"; +import { STATUS_STYLES, getRowClassName } from "./utils/get-row-class"; + +export const PermissionsList = () => { + const { permissions, isLoading, isLoadingMore, loadMore, totalCount, hasMore } = + usePermissionsListQuery(); + const [selectedPermission, setSelectedPermission] = useState(null); + const [selectedPermissions, setSelectedPermissions] = useState>(new Set()); + const [hoveredPermissionName, setHoveredPermissionName] = useState(null); + + const toggleSelection = useCallback((permissionName: string) => { + setSelectedPermissions((prevSelected) => { + const newSelected = new Set(prevSelected); + if (newSelected.has(permissionName)) { + newSelected.delete(permissionName); + } else { + newSelected.add(permissionName); + } + return newSelected; + }); + }, []); + + const columns: Column[] = useMemo( + () => [ + { + key: "permission", + header: "Permission", + width: "20%", + headerClassName: "pl-[18px]", + render: (permission) => { + const isSelected = selectedPermissions.has(permission.name); + const isHovered = hoveredPermissionName === permission.name; + + const iconContainer = ( +
setHoveredPermissionName(permission.name)} + onMouseLeave={() => setHoveredPermissionName(null)} + > + {!isSelected && !isHovered && ( + + )} + {(isSelected || isHovered) && ( + toggleSelection(permission.name)} + /> + )} +
+ ); + + return ( +
+
+ {iconContainer} +
+
+ {permission.name} +
+ {permission.description ? ( + + {permission.description} + + ) : ( + + No description + + )} +
+
+
+ ); + }, + }, + { + key: "slug", + header: "Slug", + width: "20%", + render: (permission) => ( + + ), + }, + { + key: "used_in_roles", + header: "Used in Roles", + width: "20%", + render: (permission) => ( + + ), + }, + { + 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)} + + ); + }, + }, + { + key: "last_updated", + header: "Last Updated", + width: "12%", + render: (permission) => { + return ( + + ); + }, + }, + { + key: "action", + header: "", + width: "15%", + render: (permission) => { + return ; + }, + }, + ], + [selectedPermissions, toggleSelection, hoveredPermissionName, selectedPermission?.permissionId], + ); + + return ( + permission.permissionId} + rowClassName={(permission) => getRowClassName(permission, selectedPermission)} + loadMoreFooterProps={{ + hide: isLoading, + buttonText: "Load more permissions", + hasMore, + headerContent: ( + + ), + countInfoText: ( +
+ Showing {permissions.length} + of + {totalCount} + permissions +
+ ), + }} + emptyState={ +
+ + + No Permissions Found + + There are no permissions configured yet. Create your first permission to start + managing permissions and access control. + + + + + + + +
+ } + config={{ + rowHeight: 52, + layoutMode: "grid", + rowBorders: true, + containerPadding: "px-0", + }} + renderSkeletonRow={({ columns, rowHeight }) => + columns.map((column) => ( + + {column.key === "permission" && } + {column.key === "slug" && } + {column.key === "used_in_roles" && } + {column.key === "assigned_to_keys" && } + {column.key === "last_updated" && } + {column.key === "action" && } + + )) + } + /> + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/query-logs.schema.ts b/apps/dashboard/app/(app)/authorization/permissions/components/table/query-logs.schema.ts new file mode 100644 index 0000000000..61fc9603ba --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/query-logs.schema.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { + permissionsFilterOperatorEnum, + permissionsListFilterFieldNames, +} from "../../filters.schema"; +const filterItemSchema = z.object({ + operator: permissionsFilterOperatorEnum, + value: z.string(), +}); + +const baseFilterArraySchema = z.array(filterItemSchema).nullish(); + +const filterFieldsSchema = permissionsListFilterFieldNames.reduce( + (acc, fieldName) => { + acc[fieldName] = baseFilterArraySchema; + return acc; + }, + {} as Record, +); + +const basePermissionsSchema = z.object(filterFieldsSchema); + +export const permissionsQueryPayload = basePermissionsSchema.extend({ + cursor: z.number().nullish(), +}); + +export type PermissionsQueryPayload = z.infer; diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/utils/get-row-class.ts b/apps/dashboard/app/(app)/authorization/permissions/components/table/utils/get-row-class.ts new file mode 100644 index 0000000000..8c3ceda35c --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/utils/get-row-class.ts @@ -0,0 +1,38 @@ +import type { Permission } from "@/lib/trpc/routers/authorization/permissions/query"; +import { cn } from "@/lib/utils"; + +export type StatusStyle = { + base: string; + hover: string; + selected: string; + badge: { + default: string; + selected: string; + }; + focusRing: string; +}; + +export const STATUS_STYLES = { + base: "text-grayA-9", + hover: "hover:text-accent-11 dark:hover:text-accent-12 hover:bg-grayA-2", + selected: "text-accent-12 bg-grayA-2 hover:text-accent-12", + badge: { + default: "bg-grayA-3 text-grayA-11 group-hover:bg-grayA-5 border-transparent", + selected: "bg-grayA-5 text-grayA-12 hover:bg-grayA-5 border-grayA-3", + }, + focusRing: "focus:ring-accent-7", +}; + +export const getRowClassName = (log: Permission, selectedLog: Permission | null) => { + const style = STATUS_STYLES; + const isSelected = log.permissionId === selectedLog?.permissionId; + + return cn( + style.base, + style.hover, + "group rounded", + "focus:outline-none focus:ring-1 focus:ring-opacity-40", + style.focusRing, + isSelected && style.selected, + ); +}; 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 new file mode 100644 index 0000000000..58adfcd303 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/hooks/use-upsert-permission.ts @@ -0,0 +1,57 @@ +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; + +export const useUpsertPermission = ( + onSuccess: (data: { + permissionId?: string; + isUpdate: boolean; + message: string; + }) => void, +) => { + const trpcUtils = trpc.useUtils(); + const permission = trpc.authorization.permissions.upsert.useMutation({ + onSuccess(data) { + trpcUtils.authorization.permissions.invalidate(); + // Show success toast + toast.success(data.isUpdate ? "Permission Updated" : "Permission Created", { + description: data.message, + }); + onSuccess(data); + }, + onError(err) { + if (err.data?.code === "CONFLICT") { + toast.error("Permission Already Exists", { + description: + err.message || "A permission with this name or slug already exists in your workspace.", + }); + } else if (err.data?.code === "NOT_FOUND") { + toast.error("Permission Not Found", { + description: + "The permission you're trying to update no longer exists or you don't have access to it.", + }); + } else if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid Permission Configuration", { + description: `Please check your permission settings. ${err.message || ""}`, + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We encountered an issue while saving your permission. Please try again later or contact support.", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } else { + toast.error("Failed to Save Permission", { + description: err.message || "An unexpected error occurred. Please try again later.", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } + }, + }); + return permission; +}; diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/index.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/index.tsx new file mode 100644 index 0000000000..abd5bc2001 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/index.tsx @@ -0,0 +1,229 @@ +"use client"; +import { NavbarActionButton } from "@/components/navigation/action-button"; +import { Navbar } from "@/components/navigation/navbar"; +import { usePersistedForm } from "@/hooks/use-persisted-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { PenWriting3, Plus } from "@unkey/icons"; +import { Button, DialogContainer, FormInput, FormTextarea } from "@unkey/ui"; +import { useEffect, useState } from "react"; +import { FormProvider } from "react-hook-form"; +import { useUpsertPermission } from "./hooks/use-upsert-permission"; +import { type PermissionFormValues, permissionSchema } from "./upsert-permission.schema"; + +const FORM_STORAGE_KEY = "unkey_upsert_permission_form_state"; + +type ExistingPermission = { + id: string; + name: string; + slug: string; + description?: string; +}; + +const getDefaultValues = ( + existingPermission?: ExistingPermission, +): Partial => { + if (existingPermission) { + return { + permissionId: existingPermission.id, + name: existingPermission.name, + slug: existingPermission.slug, + description: existingPermission.description || "", + }; + } + + return { + name: "", + slug: "", + description: "", + }; +}; + +type UpsertPermissionDialogProps = { + existingPermission?: ExistingPermission; + triggerButton?: boolean; + isOpen?: boolean; + onClose?: () => void; +}; + +export const UpsertPermissionDialog = ({ + existingPermission, + triggerButton, + isOpen: externalIsOpen, + onClose: externalOnClose, +}: UpsertPermissionDialogProps) => { + const [internalIsOpen, setInternalIsOpen] = useState(false); + const isEditMode = Boolean(existingPermission?.id); + + // Use external state if provided, otherwise use internal state + const isDialogOpen = externalIsOpen !== undefined ? externalIsOpen : internalIsOpen; + const setIsDialogOpen = + externalOnClose !== undefined + ? (open: boolean) => !open && externalOnClose() + : setInternalIsOpen; + + const storageKey = isEditMode + ? `${FORM_STORAGE_KEY}_edit_${existingPermission?.id}` + : FORM_STORAGE_KEY; + + const formId = `upsert-permission-form-${existingPermission?.id || "new"}`; + + const methods = usePersistedForm( + storageKey, + { + resolver: zodResolver(permissionSchema), + mode: "onChange", + shouldFocusError: true, + shouldUnregister: true, + }, + "memory", + ); + + const { + register, + formState: { errors, isValid }, + handleSubmit, + reset, + clearPersistedData, + saveCurrentValues, + loadSavedValues, + } = methods; + + useEffect(() => { + if (!isDialogOpen) { + return; + } + + const loadData = async () => { + if (existingPermission) { + // Edit mode + const hasSavedData = await loadSavedValues(); + if (!hasSavedData) { + const editValues = getDefaultValues(existingPermission); + reset(editValues); + } + } else { + // Create mode + const hasSavedData = await loadSavedValues(); + if (!hasSavedData) { + reset(getDefaultValues()); + } + } + }; + + loadData(); + }, [existingPermission, reset, loadSavedValues, isDialogOpen]); + + const upsertPermissionMutation = useUpsertPermission(() => { + clearPersistedData(); + reset(getDefaultValues()); + setIsDialogOpen(false); + }); + + const onSubmit = async (data: PermissionFormValues) => { + if (isEditMode && !data.permissionId) { + console.error("Edit mode requires permissionId"); + return; + } + + upsertPermissionMutation.mutate(data); + }; + + const handleDialogToggle = (open: boolean) => { + if (!open) { + saveCurrentValues(); + } + setIsDialogOpen(open); + }; + + const dialogConfig = { + title: isEditMode ? "Edit permission" : "Create new permission", + subtitle: isEditMode + ? "Update permission details" + : "Define a new permission for your application", + buttonText: isEditMode ? "Update permission" : "Create new permission", + footerText: isEditMode + ? "Changes will be applied immediately" + : "This permission will be created immediately", + triggerTitle: isEditMode ? "Edit permission" : "Create new permission", + }; + + const defaultTrigger = ( + setIsDialogOpen(true)}> + {isEditMode ? : } + {dialogConfig.triggerTitle} + + ); + + return ( + <> + {triggerButton && {defaultTrigger}} + +
+ {/* Hidden input for permissionId in edit mode */} + {isEditMode && existingPermission?.id && ( + + )} + + +
{dialogConfig.footerText}
+
+ } + > +
+ + + + + +
+ + + + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/upsert-permission.schema.ts b/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/upsert-permission.schema.ts new file mode 100644 index 0000000000..3ecf8f5bfe --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/upsert-permission.schema.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +export const permissionNameSchema = z + .string() + .trim() + .min(2, { message: "Permission name must be at least 2 characters long" }) + .max(60, { message: "Permission name cannot exceed 60 characters" }) + .refine((name) => !name.match(/^\s|\s$/), { + message: "Permission name cannot start or end with whitespace", + }) + .refine((name) => !name.match(/\s{2,}/), { + message: "Permission name cannot contain consecutive spaces", + }); + +export const permissionSlugSchema = z + .string() + .trim() + .min(2, { message: "Permission slug must be at least 2 characters long" }) + .max(50, { message: "Permission slug cannot exceed 50 characters" }); + +export const permissionDescriptionSchema = z + .string() + .trim() + .max(200, { message: "Permission description cannot exceed 200 characters" }) + .optional(); + +export const permissionSchema = z + .object({ + permissionId: z.string().startsWith("perm_").optional(), + name: permissionNameSchema, + slug: permissionSlugSchema, + description: permissionDescriptionSchema, + }) + .strict({ + message: "Unknown fields are not allowed in permission definition", + }); + +export type PermissionFormValues = z.infer; diff --git a/apps/dashboard/app/(app)/authorization/permissions/empty.tsx b/apps/dashboard/app/(app)/authorization/permissions/empty.tsx deleted file mode 100644 index a552d9b293..0000000000 --- a/apps/dashboard/app/(app)/authorization/permissions/empty.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; -import { Plus } from "@unkey/icons"; -import { Button, Empty } from "@unkey/ui"; -import { useState } from "react"; -import { RBACForm } from "../_components/rbac-form"; - -export const EmptyPermissions = () => { - const [open, setOpen] = useState(false); - return ( - <> - - - No permissions found - - Permissions define specific actions that API keys can perform.
- Add permissions to build granular access control for your resources. -
- - - -
- - - ); -}; diff --git a/apps/dashboard/app/(app)/authorization/permissions/filters.schema.ts b/apps/dashboard/app/(app)/authorization/permissions/filters.schema.ts new file mode 100644 index 0000000000..5846b0e19b --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/filters.schema.ts @@ -0,0 +1,74 @@ +import type { FilterValue, StringConfig } from "@/components/logs/validation/filter.types"; +import { parseAsFilterValueArray } from "@/components/logs/validation/utils/nuqs-parsers"; +import { createFilterOutputSchema } from "@/components/logs/validation/utils/structured-output-schema-generator"; +import { z } from "zod"; + +const commonStringOperators = ["is", "contains", "startsWith", "endsWith"] as const; +export const permissionsFilterOperatorEnum = z.enum(commonStringOperators); +export type PermissionsFilterOperator = z.infer; + +export type FilterFieldConfigs = { + description: StringConfig; + name: StringConfig; + slug: StringConfig; + roleId: StringConfig; + roleName: StringConfig; +}; + +export const permissionsFilterFieldConfig: FilterFieldConfigs = { + name: { + type: "string", + operators: [...commonStringOperators], + }, + description: { + type: "string", + operators: [...commonStringOperators], + }, + slug: { + type: "string", + operators: [...commonStringOperators], + }, + roleId: { + type: "string", + operators: [...commonStringOperators], + }, + roleName: { + type: "string", + operators: [...commonStringOperators], + }, +}; + +const allFilterFieldNames = Object.keys( + permissionsFilterFieldConfig, +) as (keyof FilterFieldConfigs)[]; + +if (allFilterFieldNames.length === 0) { + throw new Error("permissionsFilterFieldConfig must contain at least one field definition."); +} + +const [firstFieldName, ...restFieldNames] = allFilterFieldNames; + +export const permissionsFilterFieldEnum = z.enum([firstFieldName, ...restFieldNames]); +export const permissionsListFilterFieldNames = allFilterFieldNames; +export type PermissionsFilterField = z.infer; + +export const filterOutputSchema = createFilterOutputSchema( + permissionsFilterFieldEnum, + permissionsFilterOperatorEnum, + permissionsFilterFieldConfig, +); + +export type AllOperatorsUrlValue = { + value: string; + operator: PermissionsFilterOperator; +}; + +export type PermissionsFilterValue = FilterValue; + +export type PermissionsQuerySearchParams = { + [K in PermissionsFilterField]?: AllOperatorsUrlValue[] | null; +}; + +export const parseAsAllOperatorsFilterArray = parseAsFilterValueArray([ + ...commonStringOperators, +]); diff --git a/apps/dashboard/app/(app)/authorization/permissions/hooks/use-filters.ts b/apps/dashboard/app/(app)/authorization/permissions/hooks/use-filters.ts new file mode 100644 index 0000000000..f7404b147d --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/hooks/use-filters.ts @@ -0,0 +1,106 @@ +import { useQueryStates } from "nuqs"; +import { useCallback, useMemo } from "react"; +import { + type AllOperatorsUrlValue, + type PermissionsFilterField, + type PermissionsFilterValue, + type PermissionsQuerySearchParams, + parseAsAllOperatorsFilterArray, + permissionsFilterFieldConfig, + permissionsListFilterFieldNames, +} from "../filters.schema"; + +export const queryParamsPayload = Object.fromEntries( + permissionsListFilterFieldNames.map((field) => [field, parseAsAllOperatorsFilterArray]), +) as { [K in PermissionsFilterField]: typeof parseAsAllOperatorsFilterArray }; + +export const useFilters = () => { + const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload, { + history: "push", + }); + + const filters = useMemo(() => { + const activeFilters: PermissionsFilterValue[] = []; + + for (const field of permissionsListFilterFieldNames) { + const value = searchParams[field]; + if (!Array.isArray(value)) { + continue; + } + + for (const filterItem of value) { + if (filterItem && typeof filterItem.value === "string" && filterItem.operator) { + const baseFilter: PermissionsFilterValue = { + id: crypto.randomUUID(), + field: field, + operator: filterItem.operator, + value: filterItem.value, + }; + activeFilters.push(baseFilter); + } + } + } + + return activeFilters; + }, [searchParams]); + + const updateFilters = useCallback( + (newFilters: PermissionsFilterValue[]) => { + const newParams: Partial = Object.fromEntries( + permissionsListFilterFieldNames.map((field) => [field, null]), + ); + + const filtersByField = new Map(); + permissionsListFilterFieldNames.forEach((field) => filtersByField.set(field, [])); + + newFilters.forEach((filter) => { + if (!permissionsListFilterFieldNames.includes(filter.field)) { + throw new Error(`Invalid filter field: ${filter.field}`); + } + + const fieldConfig = permissionsFilterFieldConfig[filter.field]; + if (!fieldConfig.operators.includes(filter.operator)) { + throw new Error(`Invalid operator '${filter.operator}' for field '${filter.field}'`); + } + + if (typeof filter.value !== "string") { + throw new Error(`Filter value must be a string for field '${filter.field}'`); + } + + const fieldFilters = filtersByField.get(filter.field); + if (!fieldFilters) { + throw new Error(`Failed to get filters for field '${filter.field}'`); + } + + fieldFilters.push({ + value: filter.value, + operator: filter.operator, + }); + }); + + // Set non-empty filter arrays in params + filtersByField.forEach((fieldFilters, field) => { + if (fieldFilters.length > 0) { + newParams[field] = fieldFilters; + } + }); + + setSearchParams(newParams); + }, + [setSearchParams], + ); + + const removeFilter = useCallback( + (id: string) => { + const newFilters = filters.filter((f) => f.id !== id); + updateFilters(newFilters); + }, + [filters, updateFilters], + ); + + return { + filters, + removeFilter, + updateFilters, + }; +}; diff --git a/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx b/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx index 3f5837df6b..02de121cbe 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx @@ -1,58 +1,32 @@ "use client"; import { NavbarActionButton } from "@/components/navigation/action-button"; import { Navbar } from "@/components/navigation/navbar"; -import { formatNumber } from "@/lib/fmt"; -import { ShieldKey } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -import { useState } from "react"; -import { RBACForm } from "../_components/rbac-form"; +import { Plus, ShieldKey } from "@unkey/icons"; +import dynamic from "next/dynamic"; -export function Navigation({ - numberOfPermissions, -}: { - numberOfPermissions: number; -}) { - const [open, setOpen] = useState(false); +const UpsertPermissionDialog = dynamic( + () => import("./components/upsert-permission").then((mod) => mod.UpsertPermissionDialog), + { + ssr: false, + loading: () => ( + + + Create new permission + + ), + }, +); + +export function Navigation() { return ( - <> - - }> - - Authorization - - - Permissions - - - - - setOpen(true)} - > - Create New Permission - - - - - + + } className="flex-1 w-full"> + Authorization + + Permissions + + + + ); } diff --git a/apps/dashboard/app/(app)/authorization/permissions/page.tsx b/apps/dashboard/app/(app)/authorization/permissions/page.tsx index 2601f8f713..28f89e7905 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/page.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/page.tsx @@ -1,119 +1,18 @@ -import { Navbar as SubMenu } from "@/components/dashboard/navbar"; -import { PageContent } from "@/components/page-content"; -import { Badge } from "@/components/ui/badge"; -import { getAuth } from "@/lib/auth"; -import { asc, db } from "@/lib/db"; -import { formatNumber } from "@/lib/fmt"; -import { permissions } from "@unkey/db/src/schema"; -import { Button } from "@unkey/ui"; -import { ChevronRight } from "lucide-react"; -import Link from "next/link"; -import { redirect } from "next/navigation"; -import { navigation } from "../constants"; -import { EmptyPermissions } from "./empty"; +"use client"; +import { PermissionsListControlCloud } from "./components/control-cloud"; +import { PermissionListControls } from "./components/controls"; +import { PermissionsList } from "./components/table/permissions-list"; import { Navigation } from "./navigation"; -export const revalidate = 0; -export default async function RolesPage() { - const { orgId } = await getAuth(); - - const workspace = await db.query.workspaces.findFirst({ - where: (table, { and, eq, isNull }) => and(eq(table.orgId, orgId), isNull(table.deletedAtM)), - with: { - permissions: { - orderBy: [asc(permissions.name)], - with: { - keys: { - with: { - key: { - columns: { - deletedAtM: true, - }, - }, - }, - }, - roles: { - with: { - role: true, - }, - }, - }, - }, - }, - }); - - if (!workspace) { - return redirect("/new"); - } - - const activeRoles = await db.query.roles.findMany({ - where: (table, { and, eq }) => - and( - eq(table.workspaceId, workspace.id), // Use workspace ID from the fetched workspace - ), - columns: { - id: true, - }, - }); - - const activeRoleIds = new Set(activeRoles.map((role) => role.id)); - - /** - * Filter out all the soft deleted keys and roles - */ - workspace.permissions = workspace.permissions.map((permission) => { - // Filter out deleted keys - permission.keys = permission.keys.filter(({ key }) => key.deletedAtM === null); - - permission.roles = permission.roles.filter( - ({ role }) => role?.id && activeRoleIds.has(role.id), - ); - - return permission; - }); +export default function RolesPage() { return (
- - - -
-
- {workspace.permissions.length === 0 ? ( - - ) : ( -
    - {workspace.permissions.map((p) => ( - -
    -
    {p.name}
    - {p.description} -
    -
    - - {formatNumber(p.roles.length)} Role - {p.roles.length !== 1 ? "s" : ""} - - - {formatNumber(p.keys.length)} Key - {p.keys.length !== 1 ? "s" : ""} - -
    -
    - -
    - - ))} -
- )} -
-
-
+ +
+ + + +
); } diff --git a/apps/dashboard/app/(app)/authorization/roles/[roleId]/delete-role.tsx b/apps/dashboard/app/(app)/authorization/roles/[roleId]/delete-role.tsx deleted file mode 100644 index b71d2505ee..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/[roleId]/delete-role.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; -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; - role: { - id: string; - name: string; - }; -}; - -export const DeleteRole = ({ trigger, role }: Props) => { - const router = useRouter(); - const [open, setOpen] = useState(false); - - const formSchema = z.object({ - name: z.string().refine((v) => v === role.name, "Please confirm the role'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") === role.name; - - const deleteRole = trpc.rbac.deleteRole.useMutation({ - onSuccess() { - toast.success("Role deleted successfully", { - description: "The role has been permanently removed", - }); - router.push("/authorization/roles"); - }, - onError(err) { - toast.error("Failed to delete role", { - description: err.message, - }); - }, - }); - - const onSubmit = async () => { - try { - await deleteRole.mutateAsync({ roleId: role.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}
- - -
- This action cannot be undone – proceed with caution -
-
- } - > -

- Warning: - This role will be deleted, and keys with this role will be disconnected from all - permissions granted by this role. This action is not reversible. -

- -
-
-

- Type {role.name} to confirm -

- -
-
- - - ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/[roleId]/navigation.tsx b/apps/dashboard/app/(app)/authorization/roles/[roleId]/navigation.tsx deleted file mode 100644 index 50e0d84b8a..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/[roleId]/navigation.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; -import { NavbarActionButton } from "@/components/navigation/action-button"; -import { Navbar } from "@/components/navigation/navbar"; -import type { Role } from "@unkey/db"; -import { ShieldKey } from "@unkey/icons"; -import { useState } from "react"; -import { RBACForm } from "../../_components/rbac-form"; -import { DeleteRole } from "./delete-role"; - -export function Navigation({ role }: { role: Role }) { - const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); - - return ( - - }> - Authorization - Roles - - {role.id} - - - - setIsUpdateModalOpen(true)}> - Update Role - - - - Delete Role - - } - /> - - - - ); -} diff --git a/apps/dashboard/app/(app)/authorization/roles/[roleId]/page.tsx b/apps/dashboard/app/(app)/authorization/roles/[roleId]/page.tsx deleted file mode 100644 index 027cd7dc70..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/[roleId]/page.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { getAuth } from "@/lib/auth"; -import { db } from "@/lib/db"; -import { notFound } from "next/navigation"; -import { Navigation } from "./navigation"; -import { RoleClient } from "./settings-client"; -import type { NestedPermissions } from "./settings-client"; - -export const revalidate = 0; - -type Props = { - params: { - roleId: string; - }; -}; - -function sortNestedPermissions(nested: NestedPermissions) { - const shallowPermissions: NestedPermissions = {}; - const nestedPermissions: NestedPermissions = {}; - - for (const [key, value] of Object.entries(nested)) { - if (Object.keys(value.permissions).length > 0) { - nestedPermissions[key] = value; - } else { - shallowPermissions[key] = value; - } - } - - const sortedShallowKeys = Object.keys(shallowPermissions).sort(); - const sortedNestedKeys = Object.keys(nestedPermissions).sort(); - - const sortedObject: NestedPermissions = {}; - - for (const key of sortedShallowKeys) { - sortedObject[key] = shallowPermissions[key]; - } - - for (const key of sortedNestedKeys) { - sortedObject[key] = nestedPermissions[key]; - } - - return sortedObject; -} - -export default async function RolePage(props: Props) { - const { orgId } = await getAuth(); - - const role = await db.query.roles.findFirst({ - where: (table, { eq }) => eq(table.id, props.params.roleId), - with: { - permissions: { - with: { - permission: true, - }, - }, - workspace: { - with: { - permissions: true, - }, - }, - keys: { - with: { - key: true, - }, - }, - }, - }); - if (!role || !role.workspace) { - return notFound(); - } - if (role.workspace.orgId !== orgId) { - return notFound(); - } - - const permissions = role.workspace.permissions; - - // Filter role's permissions to only include active ones - const activeRolePermissionIds = new Set( - role.permissions - .filter((rp) => permissions.some((p) => p.id === rp.permissionId)) - .map((rp) => rp.permissionId), - ); - - const sortedPermissions = permissions.sort((a, b) => { - const aParts = a.name.split("."); - const bParts = b.name.split("."); - if (aParts.length !== bParts.length) { - return aParts.length - bParts.length; - } - return a.name.localeCompare(b.name); - }); - - const nested: NestedPermissions = {}; - - for (const permission of sortedPermissions) { - let n = nested; - const parts = permission.name.split("."); - - for (let i = 0; i < parts.length; i++) { - const p = parts[i]; - - if (!(p in n)) { - n[p] = { - id: permission.id, - name: permission.name, - description: permission.description, - checked: activeRolePermissionIds.has(permission.id), - part: p, - permissions: {}, - path: parts.slice(0, i).join("."), - }; - } - - n = n[p].permissions; - } - } - - const sortedNestedPermissions = sortNestedPermissions(nested); - - return ( -
- - activeRolePermissionIds.has(p.permissionId)), - }} - activeKeys={role.keys.filter(({ key }) => key.deletedAtM === null)} - sortedNestedPermissions={sortedNestedPermissions} - /> -
- ); -} diff --git a/apps/dashboard/app/(app)/authorization/roles/[roleId]/permission-toggle.tsx b/apps/dashboard/app/(app)/authorization/roles/[roleId]/permission-toggle.tsx deleted file mode 100644 index e920313072..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/[roleId]/permission-toggle.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -import { toast } from "@/components/ui/toaster"; -import { trpc } from "@/lib/trpc/client"; -import { Checkbox } from "@unkey/ui"; -import { Loader2 } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -type Props = { - permissionId: string; - roleId: string; - checked: boolean; -}; - -export const PermissionToggle: React.FC = ({ roleId, permissionId, checked }) => { - const router = useRouter(); - - const [optimisticChecked, setOptimisticChecked] = useState(checked); - const connect = trpc.rbac.connectPermissionToRole.useMutation({ - onMutate: () => { - setOptimisticChecked(true); - toast.loading("Adding Permission"); - }, - onSuccess: () => { - toast.success("Permission added", { - description: "Changes may take up to 60 seconds to take effect.", - cancel: { - label: "Undo", - onClick: () => { - disconnect.mutate({ roleId, permissionId }); - }, - }, - }); - }, - onError(err) { - console.error(err); - toast.error(err.message); - }, - onSettled: () => { - router.refresh(); - }, - }); - const disconnect = trpc.rbac.disconnectPermissionFromRole.useMutation({ - onMutate: () => { - setOptimisticChecked(false); - toast.loading("Removing Permission"); - }, - onSuccess: () => { - toast.success("Permission removed", { - description: "Changes may take up to 60 seconds to take effect.", - cancel: { - label: "Undo", - onClick: () => { - connect.mutate({ roleId, permissionId }); - }, - }, - }); - }, - onError(err) { - console.error(err); - toast.error(err.message); - }, - onSettled: () => { - router.refresh(); - }, - }); - if (connect.isLoading || disconnect.isLoading) { - return ; - } - return ( - { - if (optimisticChecked) { - disconnect.mutate({ roleId, permissionId }); - } else { - connect.mutate({ roleId, permissionId }); - } - }} - /> - ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/[roleId]/settings-client.tsx b/apps/dashboard/app/(app)/authorization/roles/[roleId]/settings-client.tsx deleted file mode 100644 index 0bb515fb03..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/[roleId]/settings-client.tsx +++ /dev/null @@ -1,215 +0,0 @@ -"use client"; - -import { revalidateTag } from "@/app/actions"; -import { CopyableIDButton } from "@/components/navigation/copyable-id-button"; -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 { toast } from "sonner"; -import { z } from "zod"; -import { DeleteRole } from "./delete-role"; -import { Tree } from "./tree"; - -const formSchema = z.object({ - name: validation.name, -}); - -type FormValues = z.infer; - -export type NestedPermission = { - id: string; - checked: boolean; - description: string | null; - name: string; - part: string; - path: string; - permissions: NestedPermissions; -}; - -export type NestedPermissions = Record; - -type RoleClientProps = { - role: { - id: string; - name: string; - description?: string | null; - createdAtM?: number; - updatedAtM?: number | null; - permissions: { permissionId: string }[]; - }; - activeKeys: { keyId: string }[]; - sortedNestedPermissions: NestedPermissions; -}; - -export const RoleClient = ({ role, activeKeys, sortedNestedPermissions }: RoleClientProps) => { - const [isUpdating, setIsUpdating] = useState(false); - const router = useRouter(); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - name: role.name, - }, - }); - - const updateRoleMutation = trpc.rbac.updateRole.useMutation({ - onSuccess() { - toast.success("Your role name has been updated!"); - revalidateTag(tags.role(role.id)); - router.refresh(); - setIsUpdating(false); - }, - onError(err) { - toast.error("Failed to update role name", { - description: err.message, - }); - setIsUpdating(false); - }, - }); - - const handleUpdateName = async (values: FormValues) => { - const newName = values.name; - - if (newName === role.name) { - return toast.error("Please provide a different name before saving."); - } - - setIsUpdating(true); - await updateRoleMutation.mutateAsync({ - description: role.description ?? "", - id: role.id, - name: newName, - }); - }; - - const watchedName = form.watch("name"); - - const isNameChanged = watchedName !== role.name; - const isNameValid = watchedName && watchedName.trim() !== ""; - - // Get the count of active permissions for this role - const activePermissionsCount = role.permissions.length; - - return ( -
-
-
- Role Settings -
- -
-
- The name of this role used to identify it in API calls and the UI.
- } - border="top" - > -
- - -
- - - - -
- -
-
-
- - -
-
-

Created At

-

- {role.createdAtM ? format(role.createdAtM, "PPPP") : "Unknown"} -

-
-
-

Updated At

-

- {role.updatedAtM ? format(role.updatedAtM, "PPPP") : "Not updated yet"} -

-
-
-

Permissions

-

{activePermissionsCount}

-
-
-

Connected Keys

-

{activeKeys.length}

-
-
-
- - - {Object.keys(sortedNestedPermissions).length > 0 ? ( - - ) : ( -
-

- There are no permissions configured for this role. Permissions need to be created - first before they can be assigned to roles. -

-
- )} -
- - - Deletes this role along with all its connections to permissions and keys. This action - cannot be undone. - - } - border="both" - > -
- - Delete Role... - - } - /> -
-
-
-
- ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/[roleId]/tree.tsx b/apps/dashboard/app/(app)/authorization/roles/[roleId]/tree.tsx deleted file mode 100644 index e078aac2f9..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/[roleId]/tree.tsx +++ /dev/null @@ -1,160 +0,0 @@ -"use client"; - -import { Switch } from "@/components/ui/switch"; -import { ChevronRight } from "@unkey/icons"; -import { CopyButton, InfoTooltip } from "@unkey/ui"; -import type React from "react"; -import { useEffect, useState } from "react"; -import { PermissionToggle } from "./permission-toggle"; - -export const revalidate = 0; - -export type NestedPermission = { - id: string; - checked: boolean; - description: string | null; - name: string; - part: string; - path: string; - permissions: NestedPermissions; -}; - -export type NestedPermissions = Record; - -type Props = { - nestedPermissions: NestedPermissions; - role: { - id: string; - }; -}; - -export const Tree: React.FC = ({ nestedPermissions, role }) => { - const [openAll, setOpenAll] = useState(false); - const entries = Object.entries(nestedPermissions); - - return ( -
-
-
-
-
-
{openAll ? "Collapse" : "Expand"} All
- -
-
- -
- {entries.map(([k, p]) => ( - - ))} -
-
-
- ); -}; - -export const RecursivePermission: React.FC< - NestedPermission & { - k: string; - roleId: string; - openAll: boolean; - depth: number; - } -> = ({ k, openAll, id, name, permissions, roleId, checked, description, depth }) => { - const [open, setOpen] = useState(openAll); - const [hover, setHover] = useState(false); - - useEffect(() => { - setOpen(openAll); - }, [openAll]); - - const children = Object.values(permissions); - const hasChildren = children.length > 0; - - const getBgColor = (isHovering: boolean) => { - if (!isHovering) { - return ""; - } - return "bg-grayA-3"; - }; - - if (!hasChildren) { - return ( - -
{name}
-
- -
-
- } - position={{ side: "top", align: "start" }} - > -
-
- -
{k}
-
- {description &&

{description}

} -
- - ); - } - - return ( -
-
setHover(true)} - onMouseLeave={() => setHover(false)} - > - {/* biome-ignore lint/a11y/useKeyWithClickEvents: Simplified click handler */} -
setOpen(!open)} - > - - -
{k}
- -
- {children.length} -
-
-
- - {open && ( -
- {Object.entries(permissions).map(([k2, p]) => ( - - ))} -
- )} -
- ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/control-cloud/index.tsx new file mode 100644 index 0000000000..45df55b6f4 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/control-cloud/index.tsx @@ -0,0 +1,35 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import { ControlCloud } from "@/components/logs/control-cloud"; +import type { RolesFilterField } from "../../filters.schema"; +import { useFilters } from "../../hooks/use-filters"; + +const FIELD_DISPLAY_NAMES: Record = { + name: "Name", + description: "Description", + permissionSlug: "Permission slug", + permissionName: "Permission name", + keyId: "Key ID", + keyName: "Key name", +} as const; + +const formatFieldName = (field: string): string => { + if (field in FIELD_DISPLAY_NAMES) { + return FIELD_DISPLAY_NAMES[field as RolesFilterField]; + } + + return field.charAt(0).toUpperCase() + field.slice(1); +}; + +export const RolesListControlCloud = () => { + const { filters, updateFilters, removeFilter } = useFilters(); + + return ( + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/controls/components/logs-filters/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/controls/components/logs-filters/index.tsx new file mode 100644 index 0000000000..ddbefe1791 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/controls/components/logs-filters/index.tsx @@ -0,0 +1,96 @@ +import { FiltersPopover } from "@/components/logs/checkbox/filters-popover"; +import { FilterOperatorInput } from "@/components/logs/filter-operator-input"; +import { BarsFilter } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { + type RolesFilterField, + rolesFilterFieldConfig, + rolesListFilterFieldNames, +} from "../../../../filters.schema"; +import { useFilters } from "../../../../hooks/use-filters"; + +const FIELD_DISPLAY_CONFIG: Record = { + name: { label: "Name", shortcut: "n" }, + description: { label: "Description", shortcut: "d" }, + permissionSlug: { label: "Permission slug", shortcut: "p" }, + permissionName: { label: "Permission name", shortcut: "m" }, + keyId: { label: "Key ID", shortcut: "k" }, + keyName: { label: "Key name", shortcut: "y" }, +} as const; + +export const LogsFilters = () => { + const { filters, updateFilters } = useFilters(); + + // Generate filter items dynamically from schema + const filterItems = rolesListFilterFieldNames.map((fieldName) => { + const fieldConfig = rolesFilterFieldConfig[fieldName]; + const displayConfig = FIELD_DISPLAY_CONFIG[fieldName]; + + if (!displayConfig) { + throw new Error(`Missing display configuration for field: ${fieldName}`); + } + + const options = fieldConfig.operators.map((op) => ({ + id: op, + label: op, + })); + + const activeFilter = filters.find((f) => f.field === fieldName); + + return { + id: fieldName, + label: displayConfig.label, + shortcut: displayConfig.shortcut, + component: ( + { + // Remove existing filters for this field + const filtersWithoutCurrent = filters.filter((f) => f.field !== fieldName); + + // Add new filter + updateFilters([ + ...filtersWithoutCurrent, + { + field: fieldName, + id: crypto.randomUUID(), + operator, + value: text, + }, + ]); + }} + /> + ), + }; + }); + + return ( + +
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/controls/components/logs-search/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/controls/components/logs-search/index.tsx new file mode 100644 index 0000000000..03280904ae --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/controls/components/logs-search/index.tsx @@ -0,0 +1,64 @@ +import { LogsLLMSearch } from "@/components/logs/llm-search"; +import { transformStructuredOutputToFilters } from "@/components/logs/validation/utils/transform-structured-output-filter-format"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { useFilters } from "../../../../hooks/use-filters"; + +export const RolesSearch = () => { + const { filters, updateFilters } = useFilters(); + + const queryLLMForStructuredOutput = trpc.authorization.roles.llmSearch.useMutation({ + onSuccess(data) { + if (data?.filters.length === 0 || !data) { + toast.error( + "Please provide more specific search criteria. Your query requires additional details for accurate results.", + { + duration: 8000, + important: true, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + }, + ); + return; + } + const transformedFilters = transformStructuredOutputToFilters(data, filters); + updateFilters(transformedFilters); + }, + onError(error) { + const errorMessage = `Unable to process your search request${ + error.message ? `: ${error.message}` : "." + } Please try again or refine your search criteria.`; + toast.error(errorMessage, { + duration: 8000, + important: true, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + className: "font-medium", + }); + }, + }); + + return ( + + queryLLMForStructuredOutput.mutateAsync({ + query, + }) + } + /> + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/controls/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/controls/index.tsx new file mode 100644 index 0000000000..8a6471f97a --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/controls/index.tsx @@ -0,0 +1,14 @@ +import { ControlsContainer, ControlsLeft } from "@/components/logs/controls-container"; +import { LogsFilters } from "./components/logs-filters"; +import { RolesSearch } from "./components/logs-search"; + +export function RoleListControls() { + return ( + + + + + + + ); +} 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 new file mode 100644 index 0000000000..65684abf89 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-role.tsx @@ -0,0 +1,159 @@ +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 { zodResolver } from "@hookform/resolvers/zod"; +import { TriangleWarning2 } from "@unkey/icons"; +import { Button, DialogContainer, FormCheckbox } from "@unkey/ui"; +import { useRef, useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { z } from "zod"; +import { useDeleteRole } from "./hooks/use-delete-role"; +import { RoleInfo } from "./role-info"; + +const deleteRoleFormSchema = z.object({ + confirmDeletion: z.boolean().refine((val) => val === true, { + message: "Please confirm that you want to permanently delete this role", + }), +}); + +type DeleteRoleFormValues = z.infer; + +type DeleteRoleProps = { roleDetails: Roles } & ActionComponentProps; + +export const DeleteRole = ({ roleDetails, isOpen, onClose }: DeleteRoleProps) => { + const [isConfirmPopoverOpen, setIsConfirmPopoverOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const deleteButtonRef = useRef(null); + + const methods = useForm({ + resolver: zodResolver(deleteRoleFormSchema), + mode: "onChange", + shouldFocusError: true, + shouldUnregister: true, + defaultValues: { + confirmDeletion: false, + }, + }); + + const { + formState: { errors }, + control, + watch, + } = methods; + + const confirmDeletion = watch("confirmDeletion"); + + const deleteRole = useDeleteRole(() => { + onClose(); + }); + + const handleDialogOpenChange = (open: boolean) => { + if (isConfirmPopoverOpen) { + // If confirm popover is active don't let this trigger outer popover + if (!open) { + return; + } + } else { + if (!open) { + onClose(); + } + } + }; + + const handleDeleteButtonClick = () => { + setIsConfirmPopoverOpen(true); + }; + + const performRoleDeletion = async () => { + try { + setIsLoading(true); + await deleteRole.mutateAsync({ + roleIds: roleDetails.roleId, + }); + } catch { + // `useDeleteRole` already shows a toast, but we still need to + // prevent unhandled‐rejection noise in the console. + } finally { + setIsLoading(false); + } + }; + + return ( + <> + +
+ + +
+ Changes may take up to 60s to propagate globally +
+
+ } + > + +
+
+
+
+
+ +
+
+ Warning: deleting this role will detach it from + all assigned keys and permissions and remove its configuration. This action cannot + be undone. The permissions and keys themselves will remain available, but any usage + history or references to this role will be permanently lost. +
+
+ ( + + )} + /> + + + + + + ); +}; 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 new file mode 100644 index 0000000000..adfc3cca12 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-role.tsx @@ -0,0 +1,61 @@ +import type { Roles } 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"; + +export const EditRole = ({ + role, + isOpen, + onClose, +}: { + role: Roles; + isOpen: boolean; + onClose: () => void; +}) => { + const { permissions, keys, error } = useFetchConnectedKeysAndPerms(role.roleId); + + useEffect(() => { + if (error) { + if (error.data?.code === "NOT_FOUND") { + toast.error("Role Not Found", { + description: "The requested role doesn't exist or you don't have access to it.", + }); + } else if (error.data?.code === "FORBIDDEN") { + toast.error("Access Denied", { + description: + "You don't have permission to view this role. Please contact your administrator.", + }); + } else if (error.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We were unable to load role details. Please try again or contact support@unkey.dev", + }); + } else { + toast.error("Failed to Load Role Details", { + description: error.message || "An unexpected error occurred. Please try again.", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } + } + }, [error]); + + return ( + key.id), + permissionIds: permissions.map((permission) => permission.id), + name: role.name, + description: role.description, + assignedKeysDetails: keys ?? [], + assignedPermsDetails: permissions ?? [], + }} + isOpen={isOpen} + onClose={onClose} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-delete-role.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-delete-role.ts new file mode 100644 index 0000000000..ec9e18b04a --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-delete-role.ts @@ -0,0 +1,58 @@ +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; + +export const useDeleteRole = ( + onSuccess: (data: { roleIds: string[] | string; message: string }) => void, +) => { + const trpcUtils = trpc.useUtils(); + const deleteRole = trpc.authorization.roles.delete.useMutation({ + onSuccess(_, variables) { + trpcUtils.authorization.roles.invalidate(); + + const roleCount = variables.roleIds.length; + const isPlural = roleCount > 1; + + toast.success(isPlural ? "Roles Deleted" : "Role Deleted", { + description: isPlural + ? `${roleCount} roles have been successfully removed from your workspace.` + : "The role has been successfully removed from your workspace.", + }); + + onSuccess({ + roleIds: variables.roleIds, + message: isPlural ? `${roleCount} roles deleted successfully` : "Role deleted successfully", + }); + }, + onError(err) { + if (err.data?.code === "NOT_FOUND") { + toast.error("Role(s) Not Found", { + description: + "One or more roles you're trying to delete no longer exist or you don't have access to them.", + }); + } else if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid Request", { + description: err.message || "Please provide at least one role to delete.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We encountered an issue while deleting your roles. Please try again later or contact support.", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } else { + toast.error("Failed to Delete Role(s)", { + description: err.message || "An unexpected error occurred. Please try again later.", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } + }, + }); + + return deleteRole; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-fetch-connected-keys-and-perms.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-fetch-connected-keys-and-perms.ts new file mode 100644 index 0000000000..4b43c320fe --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-fetch-connected-keys-and-perms.ts @@ -0,0 +1,22 @@ +"use client"; +import { trpc } from "@/lib/trpc/client"; + +export const useFetchConnectedKeysAndPerms = (roleId: string) => { + const { data, isLoading, error, refetch } = + trpc.authorization.roles.connectedKeysAndPerms.useQuery( + { + roleId, + }, + { + enabled: Boolean(roleId), + }, + ); + + return { + keys: data?.keys || [], + permissions: data?.permissions || [], + isLoading, + error, + refetch, + }; +}; 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 new file mode 100644 index 0000000000..96571fef32 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/role-info.tsx @@ -0,0 +1,29 @@ +import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; +import { Key2 } from "@unkey/icons"; +import { InfoTooltip } from "@unkey/ui"; + +export const RoleInfo = ({ roleDetails }: { roleDetails: Roles }) => { + return ( +
+
+ +
+
+
+ {roleDetails.name ?? "Unnamed Role"} +
+ +
+ {roleDetails.description} +
+
+
+
+ ); +}; 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 new file mode 100644 index 0000000000..8fb80b183a --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.constants.tsx @@ -0,0 +1,61 @@ +"use client"; +import type { MenuItem } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover"; +import { KeysTableActionPopover } 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 { Clone, PenWriting3, Trash } from "@unkey/icons"; +import { DeleteRole } from "./components/delete-role"; +import { EditRole } from "./components/edit-role"; + +type RolesTableActionsProps = { + role: Roles; +}; + +export const RolesTableActions = ({ role }: RolesTableActionsProps) => { + const trpcUtils = trpc.useUtils(); + + const getRolesTableActionItems = (role: Roles): MenuItem[] => { + return [ + { + id: "edit-role", + label: "Edit role...", + icon: , + ActionComponent: (props) => , + prefetch: async () => { + await trpcUtils.authorization.roles.connectedKeysAndPerms.prefetch({ + roleId: role.roleId, + }); + }, + }, + { + id: "copy", + label: "Copy role", + className: "mt-1", + icon: , + onClick: () => { + navigator.clipboard + .writeText(JSON.stringify(role)) + .then(() => { + toast.success("Role data copied to clipboard"); + }) + .catch((error) => { + console.error("Failed to copy to clipboard:", error); + toast.error("Failed to copy to clipboard"); + }); + }, + divider: true, + }, + { + id: "delete-role", + label: "Delete role", + icon: , + ActionComponent: (props) => , + }, + ]; + }; + + const menuItems = getRolesTableActionItems(role); + + 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 new file mode 100644 index 0000000000..c78f3e3128 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/assigned-items-cell.tsx @@ -0,0 +1,61 @@ +import { cn } from "@/lib/utils"; +import { HandHoldingKey, Key2 } from "@unkey/icons"; + +export const AssignedItemsCell = ({ + items, + totalCount, + type, + isSelected = false, +}: { + items: string[]; + totalCount?: number; + type: "keys" | "permissions"; + isSelected?: boolean; +}) => { + const hasMore = totalCount && totalCount > items.length; + const icon = + type === "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", + isSelected ? "bg-grayA-4 border-grayA-7" : "bg-grayA-3 border-grayA-6 group-hover:bg-grayA-4", + ); + + 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 ", + isSelected ? "border-grayA-7 text-grayA-9" : "border-grayA-6 text-grayA-8", + ); + + if (items.length === 0) { + return ( +
+
+ {icon} + None assigned +
+
+ ); + } + + return ( +
+ {items.map((item) => ( +
+ {icon} + {item} +
+ ))} + {hasMore && ( +
+ + {totalCount - items.length} more {type}... + +
+ )} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/last-updated.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/last-updated.tsx new file mode 100644 index 0000000000..369b8472f0 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/last-updated.tsx @@ -0,0 +1,47 @@ +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { ChartActivity2 } from "@unkey/icons"; +import { TimestampInfo } from "@unkey/ui"; +import { useRef, useState } from "react"; +import { STATUS_STYLES } from "../utils/get-row-class"; + +export const LastUpdated = ({ + isSelected, + lastUpdated, +}: { + isSelected: boolean; + lastUpdated: number; +}) => { + const badgeRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + + return ( + { + setShowTooltip(true); + }} + onMouseLeave={() => { + setShowTooltip(false); + }} + > +
+ +
+
+ +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/selection-controls/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/selection-controls/index.tsx new file mode 100644 index 0000000000..068625af5d --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/selection-controls/index.tsx @@ -0,0 +1,104 @@ +import { AnimatedCounter } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/selection-controls"; +import { ConfirmPopover } from "@/components/confirmation-popover"; +import { Trash, XMark } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { AnimatePresence, motion } from "framer-motion"; +import { useRef, useState } from "react"; +import { useDeleteRole } from "../actions/components/hooks/use-delete-role"; + +type SelectionControlsProps = { + selectedRoles: Set; + setSelectedRoles: (keys: Set) => void; +}; + +export const SelectionControls = ({ selectedRoles, setSelectedRoles }: SelectionControlsProps) => { + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + const deleteButtonRef = useRef(null); + + const deleteRole = useDeleteRole(() => { + setSelectedRoles(new Set()); + }); + + const handleDeleteButtonClick = () => { + setIsDeleteConfirmOpen(true); + }; + + const performRoleDeletion = () => { + deleteRole.mutate({ + roleIds: Array.from(selectedRoles), + }); + }; + + return ( + <> + + {selectedRoles.size > 0 && ( + +
+
+ +
selected
+
+
+ + +
+
+
+ )} +
+ + 1 ? "these roles" : "this role" + } will be permanently deleted.`} + confirmButtonText={`Delete role${selectedRoles.size > 1 ? "s" : ""}`} + cancelButtonText="Cancel" + variant="danger" + /> + + ); +}; 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 new file mode 100644 index 0000000000..69ef3ac60b --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/skeletons.tsx @@ -0,0 +1,65 @@ +import { cn } from "@/lib/utils"; +import { ChartActivity2, Dots, HandHoldingKey, Key2, Tag } from "@unkey/icons"; + +export const RoleColumnSkeleton = () => ( +
+
+
+ +
+
+
+
+
+
+
+); + +export const SlugColumnSkeleton = () => ( +
+); + +export const AssignedKeysColumnSkeleton = () => ( +
+
+ +
+
+
+ +
+
+
+); + +export const PermissionsColumnSkeleton = () => ( +
+
+ +
+
+
+ +
+
+
+); + +export const LastUpdatedColumnSkeleton = () => ( +
+ +
+
+); + +export const ActionColumnSkeleton = () => ( + +); 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 new file mode 100644 index 0000000000..7d89d995c9 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts @@ -0,0 +1,82 @@ +import { trpc } from "@/lib/trpc/client"; +import type { Roles } 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"; +import type { RolesQueryPayload } from "../query-logs.schema"; + +export function useRolesListQuery() { + const [totalCount, setTotalCount] = useState(0); + const [rolesMap, setRolesMap] = useState(() => new Map()); + const { filters } = useFilters(); + + const rolesList = useMemo(() => Array.from(rolesMap.values()), [rolesMap]); + + const queryParams = useMemo(() => { + const params: RolesQueryPayload = { + ...Object.fromEntries(rolesListFilterFieldNames.map((field) => [field, []])), + }; + + filters.forEach((filter) => { + if (!rolesListFilterFieldNames.includes(filter.field) || !params[filter.field]) { + return; + } + + const fieldConfig = rolesFilterFieldConfig[filter.field]; + const validOperators = fieldConfig.operators; + if (!validOperators.includes(filter.operator)) { + throw new Error("Invalid operator"); + } + + if (typeof filter.value === "string") { + params[filter.field]?.push({ + operator: filter.operator, + value: filter.value, + }); + } + }); + + return params; + }, [filters]); + + const { + data: rolesData, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + isLoading: isLoadingInitial, + } = trpc.authorization.roles.query.useInfiniteQuery(queryParams, { + getNextPageParam: (lastPage) => lastPage.nextCursor, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + useEffect(() => { + if (rolesData) { + const newMap = new Map(); + + rolesData.pages.forEach((page) => { + page.roles.forEach((role) => { + // Use slug as the unique identifier + newMap.set(role.roleId, role); + }); + }); + + if (rolesData.pages.length > 0) { + setTotalCount(rolesData.pages[0].total); + } + + setRolesMap(newMap); + } + }, [rolesData]); + + return { + roles: rolesList, + isLoading: isLoadingInitial, + hasMore: hasNextPage, + loadMore: fetchNextPage, + isLoadingMore: isFetchingNextPage, + totalCount, + }; +} diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/query-logs.schema.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/query-logs.schema.ts new file mode 100644 index 0000000000..17ef520a35 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/query-logs.schema.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import { rolesFilterOperatorEnum, rolesListFilterFieldNames } from "../../filters.schema"; + +const filterItemSchema = z.object({ + operator: rolesFilterOperatorEnum, + value: z.string(), +}); + +const baseFilterArraySchema = z.array(filterItemSchema).nullish(); + +const filterFieldsSchema = rolesListFilterFieldNames.reduce( + (acc, fieldName) => { + acc[fieldName] = baseFilterArraySchema; + return acc; + }, + {} as Record, +); + +const baseRolesSchema = z.object(filterFieldsSchema); + +export const rolesQueryPayload = baseRolesSchema.extend({ + cursor: z.number().nullish(), +}); + +export type RolesQueryPayload = z.infer; 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 new file mode 100644 index 0000000000..730acbbab0 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/roles-list.tsx @@ -0,0 +1,229 @@ +"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 { BookBookmark, Tag } from "@unkey/icons"; +import { Button, Checkbox, Empty } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useCallback, useMemo, useState } from "react"; +import { RolesTableActions } from "./components/actions/keys-table-action.popover.constants"; +import { AssignedItemsCell } from "./components/assigned-items-cell"; +import { LastUpdated } from "./components/last-updated"; +import { SelectionControls } from "./components/selection-controls"; +import { + ActionColumnSkeleton, + AssignedKeysColumnSkeleton, + LastUpdatedColumnSkeleton, + PermissionsColumnSkeleton, + RoleColumnSkeleton, + SlugColumnSkeleton, +} from "./components/skeletons"; +import { useRolesListQuery } from "./hooks/use-roles-list-query"; +import { getRowClassName } from "./utils/get-row-class"; + +export const RolesList = () => { + const { roles, isLoading, isLoadingMore, loadMore, totalCount, hasMore } = useRolesListQuery(); + const [selectedRole, setSelectedRole] = useState(null); + const [selectedRoles, setSelectedRoles] = useState>(new Set()); + const [hoveredRoleName, setHoveredRoleName] = useState(null); + + const toggleSelection = useCallback((roleName: string) => { + setSelectedRoles((prevSelected) => { + const newSelected = new Set(prevSelected); + if (newSelected.has(roleName)) { + newSelected.delete(roleName); + } else { + newSelected.add(roleName); + } + return newSelected; + }); + }, []); + + const columns: Column[] = useMemo( + () => [ + { + key: "role", + header: "Role", + width: "20%", + headerClassName: "pl-[18px]", + render: (role) => { + const isSelected = selectedRoles.has(role.name); + const isHovered = hoveredRoleName === role.name; + + const iconContainer = ( +
setHoveredRoleName(role.name)} + onMouseLeave={() => setHoveredRoleName(null)} + > + {!isSelected && !isHovered && } + {(isSelected || isHovered) && ( + toggleSelection(role.name)} + /> + )} +
+ ); + + return ( +
+
+ {iconContainer} +
+
+ {role.name} +
+ {role.description ? ( + + {role.description} + + ) : ( + + No description + + )} +
+
+
+ ); + }, + }, + { + key: "assignedKeys", + header: "Assigned Keys", + width: "20%", + render: (role) => ( + + ), + }, + { + key: "permissions", + header: "Permissions", + width: "20%", + render: (role) => ( + + ), + }, + { + key: "last_updated", + header: "Last Updated", + width: "12%", + render: (role) => { + return ( + + ); + }, + }, + { + key: "action", + header: "", + width: "15%", + render: (role) => { + return ; + }, + }, + ], + [selectedRoles, toggleSelection, hoveredRoleName, selectedRole?.roleId], + ); + + return ( + role.roleId} + rowClassName={(role) => getRowClassName(role, selectedRole)} + loadMoreFooterProps={{ + hide: isLoading, + buttonText: "Load more roles", + hasMore, + headerContent: ( + + ), + countInfoText: ( +
+ Showing {roles.length} + of + {totalCount} + roles +
+ ), + }} + emptyState={ +
+ + + No Roles Found + + There are no roles configured yet. Create your first role to start managing + permissions and access control. + + + + + + + +
+ } + config={{ + rowHeight: 52, + layoutMode: "grid", + rowBorders: true, + containerPadding: "px-0", + }} + renderSkeletonRow={({ columns, rowHeight }) => + columns.map((column) => ( + + {column.key === "role" && } + {column.key === "slug" && } + {column.key === "assignedKeys" && } + {column.key === "permissions" && } + {column.key === "last_updated" && } + {column.key === "action" && } + + )) + } + /> + ); +}; 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 new file mode 100644 index 0000000000..ffe7759263 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/utils/get-row-class.ts @@ -0,0 +1,38 @@ +import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; +import { cn } from "@/lib/utils"; + +export type StatusStyle = { + base: string; + hover: string; + selected: string; + badge: { + default: string; + selected: string; + }; + focusRing: string; +}; + +export const STATUS_STYLES = { + base: "text-grayA-9", + hover: "hover:text-accent-11 dark:hover:text-accent-12 hover:bg-grayA-2", + selected: "text-accent-12 bg-grayA-2 hover:text-accent-12", + badge: { + default: "bg-grayA-3 text-grayA-11 group-hover:bg-grayA-5 border-transparent", + selected: "bg-grayA-5 text-grayA-12 hover:bg-grayA-5 border-grayA-3", + }, + focusRing: "focus:ring-accent-7", +}; + +export const getRowClassName = (log: Roles, selectedLog: Roles | null) => { + const style = STATUS_STYLES; + const isSelected = log.roleId === selectedLog?.roleId; + + return cn( + style.base, + style.hover, + "group rounded", + "focus:outline-none focus:ring-1 focus:ring-opacity-40", + style.focusRing, + isSelected && style.selected, + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/create-key-options.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/create-key-options.tsx new file mode 100644 index 0000000000..8b052496c8 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/create-key-options.tsx @@ -0,0 +1,140 @@ +import { StatusBadge } from "@/app/(app)/apis/[apiId]/settings/components/status-badge"; +import { Badge } from "@/components/ui/badge"; +import { Key2, Lock } from "@unkey/icons"; +import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@unkey/ui"; + +type Key = { + id: string; + name: string | null; + roles: { + id: string; + name: string; + }[]; +}; + +type KeySelectorProps = { + keys: Key[]; + hasNextPage?: boolean; + isFetchingNextPage: boolean; + roleId?: string; + loadMore: () => void; +}; + +export function createKeyOptions({ + keys, + hasNextPage, + isFetchingNextPage, + roleId, + loadMore, +}: KeySelectorProps) { + const options = keys.map((key) => ({ + label: ( + + + +
+
+ +
+
+
+ + {key.name || "Unnamed Key"} + + {key.roles.find((item) => item.id === roleId) && ( + } + /> + )} +
+ + {key.id.length > 15 ? `${key.id.slice(0, 8)}...${key.id.slice(-4)}` : key.id} + +
+
+
+ +
+ {/* Header */} +
+ Key Details +
+ {/* Content */} +
+
+
Key ID
+
{key.id}
+
+
+
Name
+
{key.name || "No name set"}
+
+ {key.roles.length > 0 && ( +
+
Roles
+
+ {key.roles.map((role) => ( + + {role.name} + + ))} +
+
+ )} +
+
+
+
+
+ ), + selectedLabel: ( +
+
+
+ +
+ + {key.id.length > 15 ? `${key.id.slice(0, 8)}...${key.id.slice(-4)}` : key.id} + +
+ + {key.name || "Unnamed Key"} + +
+ ), + value: key.id, + searchValue: `${key.id} ${key.name || ""}`.trim(), + })); + + if (hasNextPage) { + options.push({ + label: ( + + ), + value: "__load_more__", + selectedLabel: <>, + searchValue: "", + }); + } + + return options; +} diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/hooks/use-fetch-keys.ts b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/hooks/use-fetch-keys.ts new file mode 100644 index 0000000000..2186740c88 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/hooks/use-fetch-keys.ts @@ -0,0 +1,60 @@ +"use client"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { useMemo } from "react"; + +export const useFetchKeys = (limit = 50) => { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + trpc.authorization.roles.keys.query.useInfiniteQuery( + { + limit, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + onError(err) { + if (err.data?.code === "NOT_FOUND") { + toast.error("Failed to Load Keys", { + description: + "We couldn't find any keys for this workspace. Please try again or contact support@unkey.dev.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We were unable to load keys. Please try again or contact support@unkey.dev", + }); + } else { + toast.error("Failed to Load Keys", { + description: + err.message || + "An unexpected error occurred. Please try again or contact support@unkey.dev", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } + }, + }, + ); + + const keys = useMemo(() => { + if (!data?.pages) { + return []; + } + return data.pages.flatMap((page) => page.keys); + }, [data?.pages]); + + const loadMore = () => { + if (!isFetchingNextPage && hasNextPage) { + fetchNextPage(); + } + }; + + return { + keys, + isLoading, + isFetchingNextPage, + hasNextPage, + loadMore, + }; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/hooks/use-search-keys.ts b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/hooks/use-search-keys.ts new file mode 100644 index 0000000000..7fc22cbb04 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/hooks/use-search-keys.ts @@ -0,0 +1,34 @@ +import { trpc } from "@/lib/trpc/client"; +import { useEffect, useMemo, useState } from "react"; + +export const useSearchKeys = (query: string, debounceMs = 300) => { + const [debouncedQuery, setDebouncedQuery] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(query.trim()); + }, debounceMs); + + return () => clearTimeout(timer); + }, [query, debounceMs]); + + const { data, isLoading, error } = trpc.authorization.roles.keys.search.useQuery( + { query: debouncedQuery }, + { + enabled: debouncedQuery.length > 0, // Only search when there's a debounced query + staleTime: 30_000, + }, + ); + + const searchResults = useMemo(() => { + return data?.keys || []; + }, [data?.keys]); + + const isSearching = query.trim() !== debouncedQuery || (debouncedQuery.length > 0 && isLoading); + + return { + searchResults, + isSearching, + searchError: error, + }; +}; 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 new file mode 100644 index 0000000000..bfe22f6ed5 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/key-field.tsx @@ -0,0 +1,195 @@ +import { FormCombobox } from "@/components/ui/form-combobox"; +import type { RoleKey } from "@/lib/trpc/routers/authorization/roles/connected-keys-and-perms"; +import { Key2, XMark } from "@unkey/icons"; +import { useMemo, useState } from "react"; +import { createKeyOptions } from "./create-key-options"; +import { useFetchKeys } from "./hooks/use-fetch-keys"; +import { useSearchKeys } from "./hooks/use-search-keys"; + +type KeyFieldProps = { + value: string[]; + onChange: (ids: string[]) => void; + error?: string; + disabled?: boolean; + roleId?: string; + assignedKeyDetails: RoleKey[]; +}; + +export const KeyField = ({ + value, + onChange, + error, + disabled = false, + roleId, + assignedKeyDetails, +}: KeyFieldProps) => { + const [searchValue, setSearchValue] = useState(""); + const { keys, isFetchingNextPage, hasNextPage, loadMore } = 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({ + keys: allKeys, + hasNextPage: showLoadMore, + isFetchingNextPage, + roleId, + loadMore, + }); + + 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); + } + + return true; + }); + }, [baseOptions, allKeys, roleId, value]); + + const selectedKeys = useMemo(() => { + return value + .map((keyId) => { + // First: check selectedKeysData (for pre-loaded edit data) + const preLoadedKey = assignedKeyDetails.find((k) => k.id === keyId); + if (preLoadedKey) { + return { + id: preLoadedKey.id, + name: preLoadedKey.name, + }; + } + + // Second: check loaded keys (for newly added keys) + const loadedKey = allKeys.find((k) => k.id === keyId); + if (loadedKey) { + return { + id: loadedKey.id, + name: loadedKey.name, + }; + } + + // Third: fallback to ID-only display (ensures key is always removable) + return { + id: keyId, + name: null, + }; + }) + .filter((key): key is NonNullable => key !== undefined); + }, [value, allKeys, assignedKeyDetails]); + + const handleRemoveKey = (keyId: string) => { + onChange(value.filter((id) => id !== keyId)); + }; + + return ( +
+ setSearchValue(e.currentTarget.value)} + onSelect={(val) => { + if (val === "__load_more__") { + return; + } + // Add the selected key to the array + if (!value.includes(val)) { + onChange([...value, val]); + } + // Clear search after selection + setSearchValue(""); + }} + placeholder={ +
+ Select keys +
+ } + searchPlaceholder="Search keys by name or ID..." + emptyMessage={ + isSearching ? ( +
Searching...
+ ) : ( +
No keys found
+ ) + } + variant="default" + error={error} + disabled={disabled} + /> + + {/* Selected Keys Display */} + {selectedKeys.length > 0 && ( +
+
+ {selectedKeys.map((key) => ( +
+
+ +
+
+ + {key.id.length > 15 ? `${key.id.slice(0, 8)}...${key.id.slice(-4)}` : key.id} + + + {key.name || "Unnamed Key"} + +
+ {!disabled && ( + + )} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/create-permission-options.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/create-permission-options.tsx new file mode 100644 index 0000000000..8564fa977b --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/create-permission-options.tsx @@ -0,0 +1,163 @@ +import { StatusBadge } from "@/app/(app)/apis/[apiId]/settings/components/status-badge"; +import { Badge } from "@/components/ui/badge"; +import { HandHoldingKey, Lock } from "@unkey/icons"; +import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@unkey/ui"; + +type Permission = { + id: string; + name: string; + description: string | null; + slug: string; + roles: { + id: string; + name: string; + }[]; +}; + +type PermissionSelectorProps = { + permissions: Permission[]; + hasNextPage?: boolean; + isFetchingNextPage: boolean; + roleId?: string; + loadMore: () => void; +}; + +export function createPermissionOptions({ + permissions, + hasNextPage, + isFetchingNextPage, + roleId, + loadMore, +}: PermissionSelectorProps) { + const options = permissions.map((permission) => ({ + label: ( + + + +
+
+ +
+
+
+
+
+ + {permission.name} + + {permission.roles.find((item) => item.id === roleId) && ( + } + /> + )} +
+ + {permission.slug} + +
+
+ {permission.description && ( + + {permission.description} + + )} +
+
+
+ +
+ {/* Header */} +
+ Permission Details +
+ {/* Content */} +
+
+
Permission ID
+
{permission.id}
+
+
+
Name
+
{permission.name}
+
+
+
Slug
+
{permission.slug}
+
+ {permission.description && ( +
+
Description
+
{permission.description}
+
+ )} + {permission.roles.length > 0 && ( +
+
Roles
+
+ {permission.roles.map((role) => ( + + {role.name} + + ))} +
+
+ )} +
+
+
+
+
+ ), + selectedLabel: ( +
+
+
+ +
+ + {permission.id.length > 15 + ? `${permission.id.slice(0, 8)}...${permission.id.slice(-4)}` + : permission.id} + +
+ {permission.name} +
+ ), + value: permission.id, + searchValue: `${permission.id} ${permission.name} ${permission.slug} ${ + permission.description || "" + }`.trim(), + })); + + if (hasNextPage) { + options.push({ + label: ( + + ), + value: "__load_more__", + selectedLabel: <>, + searchValue: "", + }); + } + + return options; +} diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/hooks/use-fetch-permissions.ts b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/hooks/use-fetch-permissions.ts new file mode 100644 index 0000000000..4aaffe4a4b --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/hooks/use-fetch-permissions.ts @@ -0,0 +1,60 @@ +"use client"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { useMemo } from "react"; + +export const useFetchPermissions = (limit = 50) => { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + trpc.authorization.roles.permissions.query.useInfiniteQuery( + { + limit, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + onError(err) { + if (err.data?.code === "NOT_FOUND") { + toast.error("Failed to Load Permissions", { + description: + "We couldn't find any permissions for this workspace. Please try again or contact support@unkey.dev.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We were unable to load permissions. Please try again or contact support@unkey.dev", + }); + } else { + toast.error("Failed to Load Permissions", { + description: + err.message || + "An unexpected error occurred. Please try again or contact support@unkey.dev", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } + }, + }, + ); + + const permissions = useMemo(() => { + if (!data?.pages) { + return []; + } + return data.pages.flatMap((page) => page.permissions); + }, [data?.pages]); + + const loadMore = () => { + if (!isFetchingNextPage && hasNextPage) { + fetchNextPage(); + } + }; + + return { + permissions, + isLoading, + isFetchingNextPage, + hasNextPage, + loadMore, + }; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/hooks/use-search-permissions.ts b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/hooks/use-search-permissions.ts new file mode 100644 index 0000000000..c7c3248263 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/hooks/use-search-permissions.ts @@ -0,0 +1,34 @@ +import { trpc } from "@/lib/trpc/client"; +import { useEffect, useMemo, useState } from "react"; + +export const useSearchPermissions = (query: string, debounceMs = 300) => { + const [debouncedQuery, setDebouncedQuery] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(query.trim()); + }, debounceMs); + + return () => clearTimeout(timer); + }, [query, debounceMs]); + + const { data, isLoading, error } = trpc.authorization.roles.permissions.search.useQuery( + { query: debouncedQuery }, + { + enabled: debouncedQuery.length > 0, // Only search when there's a debounced query + staleTime: 30_000, + }, + ); + + const searchResults = useMemo(() => { + return data?.permissions || []; + }, [data?.permissions]); + + const isSearching = query.trim() !== debouncedQuery || (debouncedQuery.length > 0 && isLoading); + + return { + searchResults, + isSearching, + searchError: error, + }; +}; 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 new file mode 100644 index 0000000000..6381f419b7 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/permissions-field.tsx @@ -0,0 +1,194 @@ +import { FormCombobox } from "@/components/ui/form-combobox"; +import type { RolePermission } from "@/lib/trpc/routers/authorization/roles/connected-keys-and-perms"; +import { HandHoldingKey, XMark } from "@unkey/icons"; +import { useMemo, useState } from "react"; +import { createPermissionOptions } from "./create-permission-options"; +import { useFetchPermissions } from "./hooks/use-fetch-permissions"; +import { useSearchPermissions } from "./hooks/use-search-permissions"; + +type PermissionFieldProps = { + value: string[]; + onChange: (ids: string[]) => void; + error?: string; + disabled?: boolean; + roleId?: string; + assignedPermsDetails: RolePermission[]; +}; + +export const PermissionField = ({ + value, + onChange, + error, + disabled = false, + roleId, + assignedPermsDetails = [], +}: PermissionFieldProps) => { + const [searchValue, setSearchValue] = useState(""); + const { permissions, isFetchingNextPage, hasNextPage, loadMore } = useFetchPermissions(); + const { searchResults, isSearching } = useSearchPermissions(searchValue); + + // 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) { + // No search results found, filter from loaded permissions as fallback + const searchTerm = searchValue.toLowerCase().trim(); + return permissions.filter( + (permission) => + permission.id.toLowerCase().includes(searchTerm) || + permission.name.toLowerCase().includes(searchTerm) || + permission.slug.toLowerCase().includes(searchTerm) || + permission.description?.toLowerCase().includes(searchTerm), + ); + } + // No search query, use all loaded permissions + return permissions; + }, [permissions, searchResults, searchValue, isSearching]); + + // Don't show load more when actively searching + const showLoadMore = !searchValue.trim() && hasNextPage; + + const baseOptions = createPermissionOptions({ + permissions: allPermissions, + hasNextPage: showLoadMore, + isFetchingNextPage, + roleId, + loadMore, + }); + + 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; + } + + // Find the permission and check if it's already assigned to this role + const permission = allPermissions.find((p) => p.id === option.value); + if (!permission) { + return true; + } + + // Filter out permissions that already have this role assigned (if roleId provided) + if (roleId) { + return !permission.roles.some((role) => role.id === roleId); + } + + return true; + }); + }, [baseOptions, allPermissions, roleId, value]); + + const selectedPermissions = useMemo(() => { + return value + .map((id) => { + // First: check selectedPermissionsData (for pre-loaded edit data) + const preLoadedPerm = assignedPermsDetails.find((p) => p.id === id); + if (preLoadedPerm) { + return preLoadedPerm; + } + + // Second: check loaded permissions (for newly added permissions) + const loadedPerm = allPermissions.find((p) => p.id === id); + if (loadedPerm) { + return loadedPerm; + } + + // Third: fallback + return { + id: id, + name: id, + slug: id, + description: null, + }; + }) + .filter((perm): perm is NonNullable => perm !== undefined); + }, [value, allPermissions, assignedPermsDetails]); + + const handleRemovePermission = (permissionId: string) => { + onChange(value.filter((id) => id !== permissionId)); + }; + + return ( +
+ setSearchValue(e.currentTarget.value)} + onSelect={(val) => { + if (val === "__load_more__") { + return; + } + // Add the selected permission to the array + if (!value.includes(val)) { + onChange([...value, val]); + } + // Clear search after selection + setSearchValue(""); + }} + placeholder={ +
+ Select permissions +
+ } + searchPlaceholder="Search permissions by name, ID, slug, or description..." + emptyMessage={ + isSearching ? ( +
Searching...
+ ) : ( +
No permissions found
+ ) + } + variant="default" + error={error} + disabled={disabled} + /> + + {/* Selected Permissions Display */} + {selectedPermissions.length > 0 && ( +
+
+ {selectedPermissions.map((permission) => ( +
+
+ +
+
+ + {permission.name} + + + {permission.slug} + +
+ {!disabled && ( + + )} +
+ ))} +
+
+ )} +
+ ); +}; 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 new file mode 100644 index 0000000000..7ffd914d3b --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/hooks/use-upsert-role.ts @@ -0,0 +1,60 @@ +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; + +export const useUpsertRole = ( + onSuccess: (data: { + roleId?: string; + isUpdate: boolean; + message: string; + }) => void, +) => { + const trpcUtils = trpc.useUtils(); + + const role = trpc.authorization.roles.upsert.useMutation({ + onSuccess(data) { + trpcUtils.authorization.roles.invalidate(); + + // Show success toast + toast.success(data.isUpdate ? "Role Updated" : "Role Created", { + description: data.message, + }); + + onSuccess(data); + }, + onError(err) { + if (err.data?.code === "CONFLICT") { + toast.error("Role Already Exists", { + description: err.message || "A role with this name already exists in your workspace.", + }); + } else if (err.data?.code === "NOT_FOUND") { + toast.error("Role Not Found", { + description: + "The role you're trying to update no longer exists or you don't have access to it.", + }); + } else if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid Role Configuration", { + description: `Please check your role settings. ${err.message || ""}`, + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We encountered an issue while saving your role. Please try again later or contact support.", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } else { + toast.error("Failed to Save Role", { + description: err.message || "An unexpected error occurred. Please try again later.", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } + }, + }); + + return role; +}; 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 new file mode 100644 index 0000000000..7a542f01f9 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx @@ -0,0 +1,242 @@ +"use client"; +import { NavbarActionButton } from "@/components/navigation/action-button"; +import { Navbar } from "@/components/navigation/navbar"; +import { usePersistedForm } from "@/hooks/use-persisted-form"; +import type { + RoleKey, + RolePermission, +} from "@/lib/trpc/routers/authorization/roles/connected-keys-and-perms"; +import { zodResolver } from "@hookform/resolvers/zod"; +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 { KeyField } from "./components/assign-key/key-field"; +import { PermissionField } from "./components/assign-permission/permissions-field"; +import { useUpsertRole } from "./hooks/use-upsert-role"; +import { type FormValues, rbacRoleSchema } from "./upsert-role.schema"; + +const FORM_STORAGE_KEY = "unkey_upsert_role_form_state"; + +type ExistingRole = { + id: string; + name: string; + description?: string; + keyIds: string[]; + permissionIds?: string[]; + assignedKeysDetails: RoleKey[]; + assignedPermsDetails: RolePermission[]; +}; + +const getDefaultValues = (existingRole?: ExistingRole): Partial => { + if (existingRole) { + return { + roleId: existingRole.id, + roleName: existingRole.name, + roleDescription: existingRole.description || "", + keyIds: existingRole.keyIds || [], + permissionIds: existingRole.permissionIds, + }; + } + + return { + roleName: "", + roleDescription: "", + keyIds: [], + permissionIds: [], + }; +}; + +type UpsertRoleDialogProps = { + existingRole?: ExistingRole; + triggerButton?: boolean; + isOpen?: boolean; + onClose?: () => void; +}; + +export const UpsertRoleDialog = ({ + existingRole, + triggerButton, + isOpen: externalIsOpen, + onClose: externalOnClose, +}: UpsertRoleDialogProps) => { + const [internalIsOpen, setInternalIsOpen] = useState(false); + const isEditMode = Boolean(existingRole?.id); + + // Use external state if provided, otherwise use internal state + const isDialogOpen = externalIsOpen !== undefined ? externalIsOpen : internalIsOpen; + const setIsDialogOpen = + externalOnClose !== undefined + ? (open: boolean) => !open && externalOnClose() + : setInternalIsOpen; + + const storageKey = isEditMode ? `${FORM_STORAGE_KEY}_edit_${existingRole?.id}` : FORM_STORAGE_KEY; + + const methods = usePersistedForm( + storageKey, + { + resolver: zodResolver(rbacRoleSchema), + mode: "onChange", + shouldFocusError: true, + shouldUnregister: true, + }, + "memory", + ); + + const { + register, + formState: { errors, isValid }, + handleSubmit, + reset, + clearPersistedData, + saveCurrentValues, + loadSavedValues, + control, + } = methods; + + useEffect(() => { + if (!isDialogOpen) { + return; + } + + const loadData = async () => { + if (existingRole) { + // Edit mode + const hasSavedData = await loadSavedValues(); + if (!hasSavedData) { + const editValues = getDefaultValues(existingRole); + reset(editValues); + } + } else { + // Create mode + const hasSavedData = await loadSavedValues(); + if (!hasSavedData) { + reset(getDefaultValues()); + } + } + }; + + loadData(); + }, [existingRole, reset, loadSavedValues, isDialogOpen]); + + const upsertRoleMutation = useUpsertRole(() => { + clearPersistedData(); + reset(getDefaultValues()); + setIsDialogOpen(false); + }); + + const onSubmit = async (data: FormValues) => { + upsertRoleMutation.mutate(data); + }; + + const handleDialogToggle = (open: boolean) => { + if (!open) { + saveCurrentValues(); + } + setIsDialogOpen(open); + }; + + const dialogConfig = { + title: isEditMode ? "Edit role" : "Create new role", + subtitle: isEditMode + ? "Update role settings and permissions" + : "Define a role and assign permissions", + buttonText: isEditMode ? "Update role" : "Create new role", + footerText: isEditMode + ? "Changes will be applied immediately" + : "This role will be created immediately", + triggerTitle: isEditMode ? "Edit role" : "Create new role", + }; + + const defaultTrigger = ( + setIsDialogOpen(true)}> + {isEditMode ? : } + {dialogConfig.triggerTitle} + + ); + + return ( + <> + {triggerButton && {defaultTrigger}} + +
+ {/* Hidden input for roleId in edit mode */} + {isEditMode && } + + +
{dialogConfig.footerText}
+
+ } + > +
+ + + + + ( + + )} + /> + + ( + + )} + /> +
+ + + + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/upsert-role.schema.ts b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/upsert-role.schema.ts new file mode 100644 index 0000000000..34c0a30a2f --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/upsert-role.schema.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +export const roleNameSchema = z + .string() + .trim() + .min(2, { message: "Role name must be at least 2 characters long" }) + .max(60, { message: "Role name cannot exceed 60 characters" }) + .refine((name) => !name.match(/^\s|\s$/), { + message: "Role name cannot start or end with whitespace", + }) + .refine((name) => !name.match(/\s{2,}/), { + message: "Role name cannot contain consecutive spaces", + }); + +export const roleDescriptionSchema = z + .string() + .trim() + .max(30, { message: "Role description cannot exceed 30 characters" }) + .optional(); + +export const keyIdsSchema = z + .array(z.string()) + .default([]) + .transform((ids) => [...new Set(ids)]) + .optional(); + +export const permissionIdsSchema = z + .array(z.string()) + .default([]) + .transform((ids) => [...new Set(ids)]) + .optional(); + +export const rbacRoleSchema = z + .object({ + roleId: z.string().startsWith("role_").optional(), + roleName: roleNameSchema, + roleDescription: roleDescriptionSchema, + keyIds: keyIdsSchema, + permissionIds: permissionIdsSchema, + }) + .strict({ message: "Unknown fields are not allowed in role definition" }); + +export type FormValues = z.infer; diff --git a/apps/dashboard/app/(app)/authorization/roles/empty.tsx b/apps/dashboard/app/(app)/authorization/roles/empty.tsx deleted file mode 100644 index 2d1b9ed584..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/empty.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; -import { Empty } from "@unkey/ui"; -import { useState } from "react"; -import { RBACForm } from "../_components/rbac-form"; - -export const EmptyRoles = () => { - const [isRoleModalOpen, setIsRoleModalOpen] = useState(false); - return ( - - - No roles found - - Roles bundle permissions together to create reusable access profiles. Assign roles to API - keys instead of individual permissions for easier management. - - - - - - ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/filters.schema.ts b/apps/dashboard/app/(app)/authorization/roles/filters.schema.ts new file mode 100644 index 0000000000..4b9f84921a --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/filters.schema.ts @@ -0,0 +1,80 @@ +import type { FilterValue, StringConfig } from "@/components/logs/validation/filter.types"; +import { parseAsFilterValueArray } from "@/components/logs/validation/utils/nuqs-parsers"; +import { createFilterOutputSchema } from "@/components/logs/validation/utils/structured-output-schema-generator"; +import { z } from "zod"; + +const commonStringOperators = ["is", "contains", "startsWith", "endsWith"] as const; + +export const rolesFilterOperatorEnum = z.enum(commonStringOperators); +export type RolesFilterOperator = z.infer; + +export type FilterFieldConfigs = { + description: StringConfig; + name: StringConfig; + permissionSlug: StringConfig; + permissionName: StringConfig; + keyId: StringConfig; + keyName: StringConfig; +}; + +export const rolesFilterFieldConfig: FilterFieldConfigs = { + name: { + type: "string", + operators: [...commonStringOperators], + }, + description: { + type: "string", + operators: [...commonStringOperators], + }, + permissionSlug: { + type: "string", + operators: [...commonStringOperators], + }, + permissionName: { + type: "string", + operators: [...commonStringOperators], + }, + keyId: { + type: "string", + operators: [...commonStringOperators], + }, + keyName: { + type: "string", + operators: [...commonStringOperators], + }, +}; + +const allFilterFieldNames = Object.keys(rolesFilterFieldConfig) as (keyof FilterFieldConfigs)[]; + +if (allFilterFieldNames.length === 0) { + throw new Error("rolesFilterFieldConfig must contain at least one field definition."); +} + +const [firstFieldName, ...restFieldNames] = allFilterFieldNames; + +export const rolesFilterFieldEnum = z.enum([firstFieldName, ...restFieldNames]); + +export const rolesListFilterFieldNames = allFilterFieldNames; + +export type RolesFilterField = z.infer; + +export const filterOutputSchema = createFilterOutputSchema( + rolesFilterFieldEnum, + rolesFilterOperatorEnum, + rolesFilterFieldConfig, +); + +export type AllOperatorsUrlValue = { + value: string; + operator: RolesFilterOperator; +}; + +export type RolesFilterValue = FilterValue; + +export type RolesQuerySearchParams = { + [K in RolesFilterField]?: AllOperatorsUrlValue[] | null; +}; + +export const parseAsAllOperatorsFilterArray = parseAsFilterValueArray([ + ...commonStringOperators, +]); diff --git a/apps/dashboard/app/(app)/authorization/roles/hooks/use-filters.ts b/apps/dashboard/app/(app)/authorization/roles/hooks/use-filters.ts new file mode 100644 index 0000000000..afac7b8b57 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/hooks/use-filters.ts @@ -0,0 +1,106 @@ +import { useQueryStates } from "nuqs"; +import { useCallback, useMemo } from "react"; +import { + type AllOperatorsUrlValue, + type RolesFilterField, + type RolesFilterValue, + type RolesQuerySearchParams, + parseAsAllOperatorsFilterArray, + rolesFilterFieldConfig, + rolesListFilterFieldNames, +} from "../filters.schema"; + +export const queryParamsPayload = Object.fromEntries( + rolesListFilterFieldNames.map((field) => [field, parseAsAllOperatorsFilterArray]), +) as { [K in RolesFilterField]: typeof parseAsAllOperatorsFilterArray }; + +export const useFilters = () => { + const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload, { + history: "push", + }); + + const filters = useMemo(() => { + const activeFilters: RolesFilterValue[] = []; + + for (const field of rolesListFilterFieldNames) { + const value = searchParams[field]; + if (!Array.isArray(value)) { + continue; + } + + for (const filterItem of value) { + if (filterItem && typeof filterItem.value === "string" && filterItem.operator) { + const baseFilter: RolesFilterValue = { + id: crypto.randomUUID(), + field: field, + operator: filterItem.operator, + value: filterItem.value, + }; + activeFilters.push(baseFilter); + } + } + } + + return activeFilters; + }, [searchParams]); + + const updateFilters = useCallback( + (newFilters: RolesFilterValue[]) => { + const newParams: Partial = Object.fromEntries( + rolesListFilterFieldNames.map((field) => [field, null]), + ); + + const filtersByField = new Map(); + rolesListFilterFieldNames.forEach((field) => filtersByField.set(field, [])); + + newFilters.forEach((filter) => { + if (!rolesListFilterFieldNames.includes(filter.field)) { + throw new Error(`Invalid filter field: ${filter.field}`); + } + + const fieldConfig = rolesFilterFieldConfig[filter.field]; + if (!fieldConfig.operators.includes(filter.operator)) { + throw new Error(`Invalid operator '${filter.operator}' for field '${filter.field}'`); + } + + if (typeof filter.value !== "string") { + throw new Error(`Filter value must be a string for field '${filter.field}'`); + } + + const fieldFilters = filtersByField.get(filter.field); + if (!fieldFilters) { + throw new Error(`Failed to get filters for field '${filter.field}'`); + } + + fieldFilters.push({ + value: filter.value, + operator: filter.operator, + }); + }); + + // Set non-empty filter arrays in params + filtersByField.forEach((fieldFilters, field) => { + if (fieldFilters.length > 0) { + newParams[field] = fieldFilters; + } + }); + + setSearchParams(newParams); + }, + [setSearchParams], + ); + + const removeFilter = useCallback( + (id: string) => { + const newFilters = filters.filter((f) => f.id !== id); + updateFilters(newFilters); + }, + [filters, updateFilters], + ); + + return { + filters, + removeFilter, + updateFilters, + }; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/navigation.tsx b/apps/dashboard/app/(app)/authorization/roles/navigation.tsx index 738dada3fb..d6e34308e3 100644 --- a/apps/dashboard/app/(app)/authorization/roles/navigation.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/navigation.tsx @@ -1,61 +1,32 @@ "use client"; import { NavbarActionButton } from "@/components/navigation/action-button"; import { Navbar } from "@/components/navigation/navbar"; -import { formatNumber } from "@/lib/fmt"; -import { ShieldKey } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -import { useState } from "react"; -import { RBACForm } from "../_components/rbac-form"; +import { Plus, ShieldKey } from "@unkey/icons"; +import dynamic from "next/dynamic"; -interface NavigationProps { - roles: number; -} - -export function Navigation({ roles }: NavigationProps) { - const [isRoleModalOpen, setIsRoleModalOpen] = useState(false); +const UpsertRoleDialog = dynamic( + () => import("./components/upsert-role").then((mod) => mod.UpsertRoleDialog), + { + ssr: false, + loading: () => ( + + + Create new role + + ), + }, +); +export function Navigation() { return ( - <> - - }> - - Authorization - - - Roles - - - - - setIsRoleModalOpen(true)} - > - Create New Role - - - - - - + + } className="flex-1 w-full"> + Authorization + + Roles + + + + ); } diff --git a/apps/dashboard/app/(app)/authorization/roles/page.tsx b/apps/dashboard/app/(app)/authorization/roles/page.tsx index ce2cb64937..a0be3225ef 100644 --- a/apps/dashboard/app/(app)/authorization/roles/page.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/page.tsx @@ -1,119 +1,18 @@ -import { Navbar as SubMenu } from "@/components/dashboard/navbar"; -import { PageContent } from "@/components/page-content"; -import { Badge } from "@/components/ui/badge"; -import { getAuth } from "@/lib/auth"; -import { db } from "@/lib/db"; -import { formatNumber } from "@/lib/fmt"; -import { Button } from "@unkey/ui"; -import { ChevronRight } from "lucide-react"; -import Link from "next/link"; -import { redirect } from "next/navigation"; -import { navigation } from "../constants"; -import { EmptyRoles } from "./empty"; +"use client"; +import { RolesListControlCloud } from "./components/control-cloud"; +import { RoleListControls } from "./components/controls"; +import { RolesList } from "./components/table/roles-list"; import { Navigation } from "./navigation"; -export const revalidate = 0; - -export default async function RolesPage() { - const { orgId } = await getAuth(); - - // Get workspace with all permissions and roles with their permissions - const workspace = await db.query.workspaces.findFirst({ - where: (table, { and, eq, isNull }) => and(eq(table.orgId, orgId), isNull(table.deletedAtM)), - with: { - permissions: { - columns: { - id: true, - }, - }, - roles: { - columns: { - id: true, - name: true, - description: true, - }, - with: { - // Include all permissions for each role - permissions: { - with: { - permission: { - columns: { - id: true, - }, - }, - }, - }, - }, - }, - }, - }); - - if (!workspace) { - return redirect("/new"); - } - - const activePermissionIds = new Set(workspace.permissions.map((p) => p.id)); - - const enhancedRoles = workspace.roles.map((role) => { - const filteredPermissions = role.permissions.filter((rp) => - activePermissionIds.has(rp.permissionId), - ); - - return { - ...role, - // Replace the permissions array with filtered one - permissions: filteredPermissions, - // Add permission count for display - permissionCount: filteredPermissions.length, - }; - }); - - // Create the final workspace object with enhanced roles - const workspaceWithRoles = { - ...workspace, - roles: enhancedRoles, - }; - +export default function RolesPage() { return (
- - - -
-
- {workspaceWithRoles.roles.length === 0 ? ( - - ) : ( -
    - {workspaceWithRoles.roles.map((r) => ( - -
    -
    {r.name}
    - - {r.description} - -
    -
    - - {formatNumber(r.permissionCount)} Permissions - -
    -
    - -
    - - ))} -
- )} -
-
-
+ +
+ + + +
); } diff --git a/apps/dashboard/components/selected-item-list.tsx b/apps/dashboard/components/selected-item-list.tsx new file mode 100644 index 0000000000..82bdfe4570 --- /dev/null +++ b/apps/dashboard/components/selected-item-list.tsx @@ -0,0 +1,125 @@ +import { cn } from "@/lib/utils"; +import { XMark } from "@unkey/icons"; +import { AnimatePresence, motion } from "framer-motion"; + +interface BaseItem { + id: string; + name?: string; +} + +interface SelectedItemsListProps { + items: T[]; + disabled?: boolean; + onRemoveItem: (id: string) => void; + isItemRemovable?: (item: T) => boolean; + renderIcon: (item: T) => React.ReactNode; + renderPrimaryText: (item: T) => string; + renderSecondaryText: (item: T) => string; + renderBadge?: (item: T) => React.ReactNode; + className?: string; + gridCols?: 1 | 2 | 3 | 4; + itemHeight?: string; + enableTransitions?: boolean; +} + +const getGridColsClass = (cols: number) => { + const gridMap = { + 1: "grid-cols-1", + 2: "grid-cols-2", + 3: "grid-cols-3", + 4: "grid-cols-4", + } as const; + return gridMap[cols as keyof typeof gridMap] || "grid-cols-2"; +}; + +export function SelectedItemsList({ + items, + disabled = false, + onRemoveItem, + isItemRemovable, + renderIcon, + renderPrimaryText, + renderSecondaryText, + renderBadge, + className, + gridCols = 2, + itemHeight, + enableTransitions = true, +}: SelectedItemsListProps) { + if (items.length === 0) { + return null; + } + + const ItemComponent = enableTransitions ? motion.div : "div"; + + return ( +
+
+ + {items.map((item) => { + const canRemove = !disabled && (!isItemRemovable || isItemRemovable(item)); + + const itemProps = enableTransitions + ? { + layout: true, + initial: { opacity: 0, scale: 0.8, y: -10 }, + animate: { opacity: 1, scale: 1, y: 0 }, + exit: { opacity: 0, scale: 0.8, y: -10 }, + transition: { + type: "spring", + stiffness: 500, + damping: 30, + mass: 0.8, + }, + } + : {}; + + return ( + + {renderIcon(item)} +
+
+ + {renderPrimaryText(item)} + + {renderBadge?.(item)} +
+ + {renderSecondaryText(item)} + +
+ {canRemove ? ( + + ) : ( +
+ +
+ )} +
+ ); + })} +
+
+
+ ); +} diff --git a/apps/dashboard/components/virtual-table/index.tsx b/apps/dashboard/components/virtual-table/index.tsx index b29a1da94e..9ad586b6ea 100644 --- a/apps/dashboard/components/virtual-table/index.tsx +++ b/apps/dashboard/components/virtual-table/index.tsx @@ -189,12 +189,14 @@ export const VirtualTable = forwardRef>( height: `${virtualizer.getVirtualItems()[0]?.start || 0}px`, }} /> + {virtualizer.getVirtualItems().map((virtualRow) => { if (isLoading) { if (renderSkeletonRow) { return ( {renderSkeletonRow({ @@ -207,6 +209,7 @@ export const VirtualTable = forwardRef>( return ( {columns.map((column) => ( @@ -217,6 +220,7 @@ export const VirtualTable = forwardRef>( ); } + const item = tableData.getItemAt(virtualRow.index); if (!item) { return null; diff --git a/apps/dashboard/lib/trpc/routers/authorization/permissions/delete.ts b/apps/dashboard/lib/trpc/routers/authorization/permissions/delete.ts new file mode 100644 index 0000000000..ab30097d5c --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/permissions/delete.ts @@ -0,0 +1,126 @@ +import { insertAuditLogs } from "@/lib/audit"; +import { and, db, eq, inArray, schema } from "@/lib/db"; +import { requireUser, requireWorkspace, t } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +export const deletePermissionWithRelations = t.procedure + .use(requireUser) + .use(requireWorkspace) + .input( + z.object({ + permissionIds: z + .union([z.string(), z.array(z.string())]) + .transform((ids) => (Array.isArray(ids) ? ids : [ids])), + }), + ) + .mutation(async ({ input, ctx }) => { + if (input.permissionIds.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "At least one permission ID must be provided.", + }); + } + + await db.transaction(async (tx) => { + // Fetch all permissions to validate existence and get names for audit logs + const permissions = await tx.query.permissions.findMany({ + where: (table, { and, eq, inArray }) => + and(eq(table.workspaceId, ctx.workspace.id), inArray(table.id, input.permissionIds)), + }); + + if (permissions.length !== input.permissionIds.length) { + const foundIds = permissions.map((p) => p.id); + const missingIds = input.permissionIds.filter((id) => !foundIds.includes(id)); + throw new TRPCError({ + code: "NOT_FOUND", + message: `Permission(s) not found: ${missingIds.join( + ", ", + )}. Please try again or contact support@unkey.dev.`, + }); + } + + // Delete related records first to avoid foreign key constraints + // Delete roles_permissions relationships + await tx + .delete(schema.rolesPermissions) + .where( + and( + inArray(schema.rolesPermissions.permissionId, input.permissionIds), + eq(schema.rolesPermissions.workspaceId, ctx.workspace.id), + ), + ) + .catch((err) => { + console.error("Failed to delete role-permission relationships:", err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to delete the permissions. Please try again or contact support@unkey.dev", + }); + }); + + // Delete keys_permissions relationships + await tx + .delete(schema.keysPermissions) + .where( + and( + inArray(schema.keysPermissions.permissionId, input.permissionIds), + eq(schema.keysPermissions.workspaceId, ctx.workspace.id), + ), + ) + .catch((err) => { + console.error("Failed to delete key-permission relationships:", err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to delete the permissions. Please try again or contact support@unkey.dev", + }); + }); + + // Delete the permissions themselves + await tx + .delete(schema.permissions) + .where( + and( + inArray(schema.permissions.id, input.permissionIds), + eq(schema.permissions.workspaceId, ctx.workspace.id), + ), + ) + .catch((err) => { + console.error("Failed to delete permissions:", err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to delete the permissions. Please try again or contact support@unkey.dev", + }); + }); + + // Create single audit log for bulk delete + await insertAuditLogs(tx, { + workspaceId: ctx.workspace.id, + actor: { type: "user", id: ctx.user.id }, + event: "permission.delete", + description: `Deleted ${permissions.length} permission(s): ${permissions + .map((p) => p.name) + .join(", ")}`, + resources: permissions.map((permission) => ({ + type: "permission", + id: permission.id, + name: permission.name, + })), + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, + }).catch((err) => { + console.error("Failed to create audit log:", err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to delete the permissions. Please try again or contact support@unkey.dev.", + }); + }); + }); + + return { deletedCount: input.permissionIds.length }; + }); diff --git a/apps/dashboard/lib/trpc/routers/authorization/permissions/llm-search/index.ts b/apps/dashboard/lib/trpc/routers/authorization/permissions/llm-search/index.ts new file mode 100644 index 0000000000..4e18c17a94 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/permissions/llm-search/index.ts @@ -0,0 +1,20 @@ +import { env } from "@/lib/env"; +import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import OpenAI from "openai"; +import { z } from "zod"; +import { getStructuredSearchFromLLM } from "./utils"; + +const openai = env().OPENAI_API_KEY + ? new OpenAI({ + apiKey: env().OPENAI_API_KEY, + }) + : null; + +export const permissionsLlmSearch = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(z.object({ query: z.string() })) + .mutation(async ({ input }) => { + return await getStructuredSearchFromLLM(openai, input.query); + }); diff --git a/apps/dashboard/lib/trpc/routers/authorization/permissions/llm-search/utils.ts b/apps/dashboard/lib/trpc/routers/authorization/permissions/llm-search/utils.ts new file mode 100644 index 0000000000..72906deb4e --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/permissions/llm-search/utils.ts @@ -0,0 +1,390 @@ +import { + filterOutputSchema, + permissionsFilterFieldConfig, +} from "@/app/(app)/authorization/permissions/filters.schema"; +import { TRPCError } from "@trpc/server"; +import type OpenAI from "openai"; +import { zodResponseFormat } from "openai/helpers/zod.mjs"; + +export async function getStructuredSearchFromLLM(openai: OpenAI | null, userSearchMsg: string) { + try { + if (!openai) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "OpenAI isn't configured correctly, please check your API key", + }); + } + + const completion = await openai.beta.chat.completions.parse({ + // Don't change the model only a few models allow structured outputs + model: "gpt-4o-mini", + temperature: 0.2, // Range 0-2, lower = more focused/deterministic + top_p: 0.1, // Alternative to temperature, controls randomness + frequency_penalty: 0.5, // Range -2 to 2, higher = less repetition + presence_penalty: 0.5, // Range -2 to 2, higher = more topic diversity + n: 1, // Number of completions to generate + messages: [ + { + role: "system", + content: getSystemPrompt(), + }, + { + role: "user", + content: userSearchMsg, + }, + ], + response_format: zodResponseFormat(filterOutputSchema, "searchQuery"), + }); + + if (!completion.choices[0].message.parsed) { + throw new TRPCError({ + code: "UNPROCESSABLE_CONTENT", + message: + "Try using phrases like:\n" + + "• 'find roles with admin permissions'\n" + + "• 'show roles containing api.read'\n" + + "• 'find roles assigned to user keys'\n" + + "• 'show roles with database permissions'\n" + + "• 'find all admin and moderator roles'\n" + + "For additional help, contact support@unkey.dev", + }); + } + + return completion.choices[0].message.parsed; + } catch (error) { + console.error( + `Something went wrong when querying OpenAI. Input: ${JSON.stringify( + userSearchMsg, + )}\n Output ${(error as Error).message}}`, + ); + + if (error instanceof TRPCError) { + throw error; + } + + if ((error as { response: { status: number } }).response?.status === 429) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Search rate limit exceeded. Please try again in a few minutes.", + }); + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to process your search query. Please try again or contact support@unkey.dev if the issue persists.", + }); + } +} + +export const getSystemPrompt = () => { + const operatorsByField = Object.entries(permissionsFilterFieldConfig) + .map(([field, config]) => { + const operators = config.operators.join(", "); + return `- ${field} accepts ${operators} operator${config.operators.length > 1 ? "s" : ""}`; + }) + .join("\n"); + + return `You are an expert at converting natural language queries into permission filters, understanding context and inferring filter types from natural expressions. Handle complex, ambiguous queries by breaking them down into clear filters for permission management. + +Examples: + +# Permission Name Patterns +Query: "find admin and user permissions" +Result: [ + { + field: "name", + filters: [ + { operator: "contains", value: "admin" }, + { operator: "contains", value: "user" } + ] + } +] + +Query: "show permissions starting with api_" +Result: [ + { + field: "name", + filters: [ + { operator: "startsWith", value: "api_" } + ] + } +] + +# Permission Description Patterns +Query: "permissions for database access" +Result: [ + { + field: "description", + filters: [ + { operator: "contains", value: "database" } + ] + } +] + +Query: "find permissions with read access in description" +Result: [ + { + field: "description", + filters: [ + { operator: "contains", value: "read" } + ] + } +] + +# Permission Slug Searches +Query: "permissions with api.read and api.write slugs" +Result: [ + { + field: "slug", + filters: [ + { operator: "is", value: "api.read" }, + { operator: "is", value: "api.write" } + ] + } +] + +Query: "find permissions with slugs containing user" +Result: [ + { + field: "slug", + filters: [ + { operator: "contains", value: "user" } + ] + } +] + +Query: "show permissions ending with .create" +Result: [ + { + field: "slug", + filters: [ + { operator: "endsWith", value: ".create" } + ] + } +] + +# Role-based Permission Searches +Query: "permissions assigned to admin role" +Result: [ + { + field: "roleName", + filters: [ + { operator: "contains", value: "admin" } + ] + } +] + +Query: "find permissions for role_123" +Result: [ + { + field: "roleId", + filters: [ + { operator: "is", value: "role_123" } + ] + } +] + +Query: "permissions for moderator and editor roles" +Result: [ + { + field: "roleName", + filters: [ + { operator: "contains", value: "moderator" }, + { operator: "contains", value: "editor" } + ] + } +] + +# Complex Combinations +Query: "admin permissions with database access" +Result: [ + { + field: "name", + filters: [ + { operator: "contains", value: "admin" } + ] + }, + { + field: "description", + filters: [ + { operator: "contains", value: "database" } + ] + } +] + +Query: "find user.create or user.delete permissions assigned to admin roles" +Result: [ + { + field: "slug", + filters: [ + { operator: "is", value: "user.create" }, + { operator: "is", value: "user.delete" } + ] + }, + { + field: "roleName", + filters: [ + { operator: "contains", value: "admin" } + ] + } +] + +Query: "permissions named exactly 'super_admin' for role starting with admin_" +Result: [ + { + field: "name", + filters: [ + { operator: "is", value: "super_admin" } + ] + }, + { + field: "roleName", + filters: [ + { operator: "startsWith", value: "admin_" } + ] + } +] + +# Specific Permission Searches +Query: "permissions with api.read and api.write slugs" +Result: [ + { + field: "slug", + filters: [ + { operator: "is", value: "api.read" }, + { operator: "is", value: "api.write" } + ] + } +] + +Query: "find permissions ending with _manage" +Result: [ + { + field: "name", + filters: [ + { operator: "endsWith", value: "_manage" } + ] + } +] + +# Role ID Searches +Query: "permissions for roles starting with role_" +Result: [ + { + field: "roleId", + filters: [ + { operator: "startsWith", value: "role_" } + ] + } +] + +Query: "permissions assigned to multiple specific roles" +Result: [ + { + field: "roleId", + filters: [ + { operator: "is", value: "role_123" }, + { operator: "is", value: "role_456" } + ] + } +] + +Remember: +${operatorsByField} +- Use exact matches (is) for specific permission names, slugs, or role IDs +- Use contains for partial matches within names, descriptions, or role names +- Use startsWith/endsWith for prefix/suffix matching +- For permission searches, consider both name and description fields +- For slug searches, use exact matches for technical identifiers like "api.read" +- For role searches, distinguish between roleName (human-readable) and roleId (technical identifier) + +Special handling rules: +1. When terms could apply to multiple fields, prioritize: + - Exact technical terms (slugs, IDs) → use "is" operator + - Descriptive terms → use "contains" operator + - Permission hierarchies → check both name and description +2. Handle plurals and variations: + - "admins" → "admin" (normalize to singular) + - "APIs" → "api" (normalize case and plurals) + +Error Handling Rules: +1. Invalid operators: Default to "contains" for ambiguous searches +2. Empty values: Skip filters with empty or whitespace-only values +3. Conflicting constraints: Use the most specific constraint + +Ambiguity Resolution Priority: +1. Exact matches over partial (e.g., permission name "admin" vs description containing "admin") +2. Technical identifiers (slugs, role IDs) over human-readable names when context suggests precision +3. Permission-based searches over role names when permissions are explicitly mentioned +4. Multiple field searches when terms could apply to different contexts + +Output Validation: +1. Required fields must be present: field, filters +2. Filters must have: operator, value +3. Values must be non-empty strings +4. Operators must match field configuration +5. Field names must be valid: name, description, slug, roleId, roleName + +Additional Examples: + +# Error Handling Examples +Query: "show permissions with empty descriptions" +Result: [ + { + field: "description", + filters: [{ + operator: "is", + value: "" // Handles empty/null description searches + }] + } +] + +Query: "find read and write permissions" +Result: [ + { + field: "name", + filters: [ + { operator: "contains", value: "read" }, + { operator: "contains", value: "write" } + ] + } +] + +# Ambiguity Resolution Examples +Query: "api permissions" +Result: [ + { + field: "name", + filters: [{ + operator: "contains", + value: "api" + }] + }, + { + field: "description", + filters: [{ + operator: "contains", + value: "api" + }] + } +] + +Query: "user management permissions" +Result: [ + { + field: "name", + filters: [{ + operator: "contains", + value: "user" + }] + }, + { + field: "description", + filters: [{ + operator: "contains", + value: "management" + }] + } +]`; +}; diff --git a/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts b/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts new file mode 100644 index 0000000000..281712138b --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts @@ -0,0 +1,321 @@ +import { permissionsQueryPayload } from "@/app/(app)/authorization/permissions/components/table/query-logs.schema"; +import type { PermissionsFilterOperator } from "@/app/(app)/authorization/permissions/filters.schema"; +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({ + permissionId: z.string(), + name: z.string(), + description: z.string(), + slug: z.string(), + lastUpdated: z.number(), + totalConnectedKeys: z.number(), + assignedRoles: z.object({ + items: z.array(z.string()), + totalCount: z.number().optional(), + }), +}); + +export type Permission = z.infer; + +const permissionsResponse = z.object({ + permissions: z.array(permissions), + hasMore: z.boolean(), + total: z.number(), + nextCursor: z.number().int().nullish(), +}); + +export const queryPermissions = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(permissionsQueryPayload) + .output(permissionsResponse) + .query(async ({ ctx, input }) => { + const workspaceId = ctx.workspace.id; + const { cursor, name, description, slug, roleName, roleId } = input; + + // Build filter conditions + const nameFilter = buildFilterConditions(name, "name"); + const descriptionFilter = buildFilterConditions(description, "description"); + const slugFilter = buildFilterConditions(slug, "slug"); + const roleFilter = buildRoleFilter(roleName, roleId, workspaceId); + + // Build filter conditions for total count + const roleFilterForCount = buildRoleFilter(roleName, roleId, workspaceId); + + const result = await db.execute(sql` + SELECT + p.id, + p.name, + p.description, + 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) + FROM roles_permissions rp + WHERE rp.permission_id = p.id + AND rp.workspace_id = ${workspaceId} + ) as total_roles, + + -- Total connected keys through roles + ( + SELECT COUNT(DISTINCT kr.key_id) + FROM roles_permissions rp + INNER JOIN keys_roles kr ON kr.role_id = rp.role_id + WHERE rp.permission_id = p.id + AND rp.workspace_id = ${workspaceId} + ) as total_connected_keys, + + -- Total count of permissions (with filters applied) + ( + SELECT COUNT(*) + FROM permissions + WHERE workspace_id = ${workspaceId} + ${nameFilter} + ${descriptionFilter} + ${slugFilter} + ${roleFilterForCount} + ) as grand_total + + FROM ( + SELECT id, name, description, slug, updated_at_m + FROM permissions + WHERE workspace_id = ${workspaceId} + ${cursor ? sql`AND updated_at_m < ${cursor}` : sql``} + ${nameFilter} + ${descriptionFilter} + ${slugFilter} + ${roleFilter} + ORDER BY updated_at_m DESC + LIMIT ${DEFAULT_LIMIT + 1} + ) p + ORDER BY p.updated_at_m DESC +`); + + const rows = result.rows as { + id: string; + name: string; + description: string | null; + slug: string; + updated_at_m: number; + role_items: string | null; + total_roles: number; + total_connected_keys: number; + grand_total: number; + }[]; + + if (rows.length === 0) { + return { + permissions: [], + hasMore: false, + total: 0, + nextCursor: undefined, + }; + } + + const total = rows[0].grand_total; + const hasMore = rows.length > DEFAULT_LIMIT; + 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) }, + totalConnectedKeys: Number(row.total_connected_keys) || 0, + }; + }); + + return { + permissions: permissionsResponseData, + hasMore, + total: Number(total) || 0, + nextCursor: + hasMore && items.length > 0 + ? Number(items[items.length - 1].updated_at_m) || undefined + : undefined, + }; + }); + +function buildRoleFilter( + nameFilters: + | { + value: string; + operator: PermissionsFilterOperator; + }[] + | null + | undefined, + idFilters: + | { + value: string; + operator: PermissionsFilterOperator; + }[] + | null + | undefined, + workspaceId: string, +) { + const conditions = []; + + // Handle name filters + if (nameFilters && nameFilters.length > 0) { + const nameConditions = nameFilters.map((filter) => { + const value = filter.value; + switch (filter.operator) { + case "is": + return sql`id IN ( + SELECT DISTINCT rp.permission_id + FROM roles_permissions rp + JOIN roles r ON rp.role_id = r.id + WHERE rp.workspace_id = ${workspaceId} + AND r.name = ${value} + )`; + case "contains": + return sql`id IN ( + SELECT DISTINCT rp.permission_id + FROM roles_permissions rp + JOIN roles r ON rp.role_id = r.id + WHERE rp.workspace_id = ${workspaceId} + AND r.name LIKE ${`%${value}%`} + )`; + case "startsWith": + return sql`id IN ( + SELECT DISTINCT rp.permission_id + FROM roles_permissions rp + JOIN roles r ON rp.role_id = r.id + WHERE rp.workspace_id = ${workspaceId} + AND r.name LIKE ${`${value}%`} + )`; + case "endsWith": + return sql`id IN ( + SELECT DISTINCT rp.permission_id + FROM roles_permissions rp + JOIN roles r ON rp.role_id = r.id + WHERE rp.workspace_id = ${workspaceId} + AND r.name LIKE ${`%${value}`} + )`; + default: + throw new Error(`Invalid operator: ${filter.operator}`); + } + }); + 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; + switch (filter.operator) { + case "is": + return sql`id IN ( + SELECT DISTINCT rp.permission_id + FROM roles_permissions rp + JOIN roles r ON rp.role_id = r.id + WHERE rp.workspace_id = ${workspaceId} + AND r.id = ${value} + )`; + case "contains": + return sql`id IN ( + SELECT DISTINCT rp.permission_id + FROM roles_permissions rp + JOIN roles r ON rp.role_id = r.id + WHERE rp.workspace_id = ${workspaceId} + AND r.id LIKE ${`%${value}%`} + )`; + case "startsWith": + return sql`id IN ( + SELECT DISTINCT rp.permission_id + FROM roles_permissions rp + JOIN roles r ON rp.role_id = r.id + WHERE rp.workspace_id = ${workspaceId} + AND r.id LIKE ${`${value}%`} + )`; + case "endsWith": + return sql`id IN ( + SELECT DISTINCT rp.permission_id + FROM roles_permissions rp + JOIN roles r ON rp.role_id = r.id + WHERE rp.workspace_id = ${workspaceId} + AND r.id LIKE ${`%${value}`} + )`; + default: + throw new Error(`Invalid operator: ${filter.operator}`); + } + }); + conditions.push(sql`(${sql.join(idConditions, sql` OR `)})`); + } + + if (conditions.length === 0) { + return sql``; + } + + // Join name and ID conditions with AND + return sql`AND (${sql.join(conditions, sql` AND `)})`; +} + +function buildFilterConditions( + filters: + | { + value: string; + operator: PermissionsFilterOperator; + }[] + | null + | undefined, + columnName: string, +) { + if (!filters || filters.length === 0) { + return sql``; + } + + const conditions = filters.map((filter) => { + const value = filter.value; + switch (filter.operator) { + case "is": + return sql`${sql.identifier(columnName)} = ${value}`; + case "contains": + return sql`${sql.identifier(columnName)} LIKE ${`%${value}%`}`; + case "startsWith": + return sql`${sql.identifier(columnName)} LIKE ${`${value}%`}`; + case "endsWith": + return sql`${sql.identifier(columnName)} LIKE ${`%${value}`}`; + default: + throw new Error(`Invalid operator: ${filter.operator}`); + } + }); + + // Combine conditions with OR + return sql`AND (${sql.join(conditions, sql` OR `)})`; +} diff --git a/apps/dashboard/lib/trpc/routers/authorization/permissions/upsert.ts b/apps/dashboard/lib/trpc/routers/authorization/permissions/upsert.ts new file mode 100644 index 0000000000..1ea1b93071 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/permissions/upsert.ts @@ -0,0 +1,201 @@ +import { permissionSchema } from "@/app/(app)/authorization/permissions/components/upsert-permission/upsert-permission.schema"; +import { insertAuditLogs } from "@/lib/audit"; +import { and, db, eq, schema } from "@/lib/db"; +import { requireUser, requireWorkspace, t } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { newId } from "@unkey/id"; + +export const upsertPermission = t.procedure + .use(requireUser) + .use(requireWorkspace) + .input(permissionSchema) + .mutation(async ({ input, ctx }) => { + const isUpdate = Boolean(input.permissionId); + let permissionId = input.permissionId; + + if (!isUpdate) { + permissionId = newId("permission"); + if (!permissionId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to generate permission ID", + }); + } + } + + if (!permissionId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Invalid permission ID", + }); + } + + await db.transaction(async (tx) => { + if (isUpdate && input.permissionId) { + const updatePermissionId: string = input.permissionId; + + // Get existing permission + const existingPermission = await tx.query.permissions.findFirst({ + where: (table, { and, eq }) => + and(eq(table.id, updatePermissionId), eq(table.workspaceId, ctx.workspace.id)), + }); + + if (!existingPermission) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Permission not found or access denied", + }); + } + + // Check for name conflicts only if name is changing + if (existingPermission.name !== input.name) { + const nameConflict = await tx.query.permissions.findFirst({ + where: (table, { and, eq, ne }) => + and( + eq(table.workspaceId, ctx.workspace.id), + eq(table.name, input.name), + ne(table.id, updatePermissionId), + ), + }); + + if (nameConflict) { + throw new TRPCError({ + code: "CONFLICT", + message: `Permission with name '${input.name}' already exists`, + }); + } + } + + // Check for slug conflicts only if slug is changing + if (existingPermission.slug !== input.slug) { + const slugConflict = await tx.query.permissions.findFirst({ + where: (table, { and, eq, ne }) => + and( + eq(table.workspaceId, ctx.workspace.id), + eq(table.slug, input.slug), + ne(table.id, updatePermissionId), + ), + }); + + if (slugConflict) { + throw new TRPCError({ + code: "CONFLICT", + message: `Permission with slug '${input.slug}' already exists`, + }); + } + } + + // Update permission + await tx + .update(schema.permissions) + .set({ + name: input.name, + slug: input.slug, + description: input.description, + }) + .where( + and( + eq(schema.permissions.id, permissionId), + eq(schema.permissions.workspaceId, ctx.workspace.id), + ), + ) + .catch(() => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to update permission", + }); + }); + + await insertAuditLogs(tx, { + workspaceId: ctx.workspace.id, + event: "permission.update", + actor: { + type: "user", + id: ctx.user.id, + }, + description: `Updated permission ${permissionId}`, + resources: [ + { + type: "permission", + id: permissionId, + name: input.name, + }, + ], + context: { + userAgent: ctx.audit.userAgent, + location: ctx.audit.location, + }, + }); + } else { + // Create mode - check for both name and slug conflicts + const [nameConflict, slugConflict] = await Promise.all([ + tx.query.permissions.findFirst({ + where: (table, { and, eq }) => + and(eq(table.workspaceId, ctx.workspace.id), eq(table.name, input.name)), + }), + tx.query.permissions.findFirst({ + where: (table, { and, eq }) => + and(eq(table.workspaceId, ctx.workspace.id), eq(table.slug, input.slug)), + }), + ]); + + if (nameConflict) { + throw new TRPCError({ + code: "CONFLICT", + message: `Permission with name '${input.name}' already exists`, + }); + } + + if (slugConflict) { + throw new TRPCError({ + code: "CONFLICT", + message: `Permission with slug '${input.slug}' already exists`, + }); + } + + // Create new permission + await tx + .insert(schema.permissions) + .values({ + id: permissionId, + name: input.name, + slug: input.slug, + description: input.description, + workspaceId: ctx.workspace.id, + }) + .catch(() => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create permission", + }); + }); + + await insertAuditLogs(tx, { + workspaceId: ctx.workspace.id, + event: "permission.create", + actor: { + type: "user", + id: ctx.user.id, + }, + description: `Created permission ${permissionId}`, + resources: [ + { + type: "permission", + id: permissionId, + name: input.name, + }, + ], + context: { + userAgent: ctx.audit.userAgent, + location: ctx.audit.location, + }, + }); + } + }); + + return { + permissionId, + isUpdate, + message: isUpdate ? "Permission updated successfully" : "Permission created successfully", + }; + }); diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/connected-keys-and-perms.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/connected-keys-and-perms.ts new file mode 100644 index 0000000000..e81a1f5ebf --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/connected-keys-and-perms.ts @@ -0,0 +1,134 @@ +import { and, db, eq } from "@/lib/db"; +import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { keys, keysRoles, permissions, roles, rolesPermissions } from "@unkey/db/src/schema"; +import { z } from "zod"; + +const roleDetailsInput = z.object({ + roleId: z.string().min(1, "Role ID is required"), +}); + +const roleKey = z.object({ + id: z.string(), + name: z.string().nullable(), +}); +export type RoleKey = z.infer; + +const rolePermission = z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + description: z.string().nullable(), +}); +export type RolePermission = z.infer; + +const roleDetailsResponse = z.object({ + roleId: z.string(), + name: z.string(), + description: z.string().nullable(), + lastUpdated: z.number(), + keys: z.array(roleKey), + permissions: z.array(rolePermission), +}); + +export type RoleDetails = z.infer; + +export const getConnectedKeysAndPerms = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(roleDetailsInput) + .output(roleDetailsResponse) + .query(async ({ ctx, input }) => { + const { roleId } = input; + const workspaceId = ctx.workspace.id; + + try { + // First, verify the role exists in this workspace - security check + const roleResult = await db + .select({ + id: roles.id, + name: roles.name, + description: roles.description, + updated_at_m: roles.updatedAtM, + }) + .from(roles) + .where(and(eq(roles.id, roleId), eq(roles.workspaceId, workspaceId))) + .limit(1); + + if (roleResult.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Role not found or access denied", + }); + } + + const role = roleResult[0]; + const [keyResults, permissionResults] = await Promise.all([ + db + .selectDistinct({ + id: keys.id, + name: keys.name, + }) + .from(keysRoles) + .innerJoin(keys, eq(keysRoles.keyId, keys.id)) + .where(and(eq(keysRoles.roleId, roleId), eq(keysRoles.workspaceId, workspaceId))) + .orderBy(keys.name), + + db + .selectDistinct({ + id: permissions.id, + name: permissions.name, + slug: permissions.slug, + description: permissions.description, + }) + .from(rolesPermissions) + .innerJoin(permissions, eq(rolesPermissions.permissionId, permissions.id)) + .where( + and(eq(rolesPermissions.roleId, roleId), eq(rolesPermissions.workspaceId, workspaceId)), + ) + .orderBy(permissions.name), + ]); + + return { + roleId: role.id, + name: role.name, + description: role.description, + lastUpdated: Number(role.updated_at_m), + keys: keyResults.map((row) => { + return { + id: row.id, + name: row.name, + }; + }), + permissions: permissionResults.map((row) => { + return { + id: row.id, + name: row.name, + slug: row.slug, + description: row.description, + }; + }), + }; + } catch (error) { + // Re-throw TRPCErrors as-is + if (error instanceof TRPCError) { + throw error; + } + + // Handle database connection errors + if (error instanceof Error && error.message.includes("connection")) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Database connection failed", + }); + } + + // Handle all other errors + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch role details", + cause: error, + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/delete.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/delete.ts new file mode 100644 index 0000000000..71b79a7026 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/delete.ts @@ -0,0 +1,122 @@ +import { insertAuditLogs } from "@/lib/audit"; +import { and, db, eq, inArray, schema } from "@/lib/db"; +import { requireUser, requireWorkspace, t } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +export const deleteRoleWithRelations = t.procedure + .use(requireUser) + .use(requireWorkspace) + .input( + z.object({ + roleIds: z + .union([z.string(), z.array(z.string())]) + .transform((ids) => (Array.isArray(ids) ? ids : [ids])), + }), + ) + .mutation(async ({ input, ctx }) => { + if (input.roleIds.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "At least one role ID must be provided.", + }); + } + + await db.transaction(async (tx) => { + // Fetch all roles to validate existence and get names for audit logs + const roles = await tx.query.roles.findMany({ + where: (table, { and, eq, inArray }) => + and(eq(table.workspaceId, ctx.workspace.id), inArray(table.id, input.roleIds)), + }); + + if (roles.length !== input.roleIds.length) { + const foundIds = roles.map((r) => r.id); + const missingIds = input.roleIds.filter((id) => !foundIds.includes(id)); + throw new TRPCError({ + code: "NOT_FOUND", + message: `Role(s) not found: ${missingIds.join( + ", ", + )}. Please try again or contact support@unkey.dev.`, + }); + } + + // Delete related records first to avoid foreign key constraints + await tx + .delete(schema.rolesPermissions) + .where( + and( + inArray(schema.rolesPermissions.roleId, input.roleIds), + eq(schema.rolesPermissions.workspaceId, ctx.workspace.id), + ), + ) + .catch((err) => { + console.error("Failed to delete role permissions:", err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to delete the roles. Please try again or contact support@unkey.dev", + }); + }); + + await tx + .delete(schema.keysRoles) + .where( + and( + inArray(schema.keysRoles.roleId, input.roleIds), + eq(schema.keysRoles.workspaceId, ctx.workspace.id), + ), + ) + .catch((err) => { + console.error("Failed to delete key-role relationships:", err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to delete the roles. Please try again or contact support@unkey.dev", + }); + }); + + // Delete the roles themselves + await tx + .delete(schema.roles) + .where( + and( + inArray(schema.roles.id, input.roleIds), + eq(schema.roles.workspaceId, ctx.workspace.id), + ), + ) + .catch((err) => { + console.error("Failed to delete roles:", err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to delete the roles. Please try again or contact support@unkey.dev", + }); + }); + + // Create single audit log for bulk delete + await insertAuditLogs(tx, { + workspaceId: ctx.workspace.id, + actor: { type: "user", id: ctx.user.id }, + event: "role.delete", + description: `Deleted ${roles.length} role(s): ${roles.map((r) => r.name).join(", ")}`, + resources: roles.map((role) => ({ + type: "role", + id: role.id, + name: role.name, + })), + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, + }).catch((err) => { + console.error("Failed to create audit log:", err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to delete the roles. Please try again or contact support@unkey.dev.", + }); + }); + }); + + return { deletedCount: input.roleIds.length }; + }); diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/keys/query-keys.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/keys/query-keys.ts new file mode 100644 index 0000000000..58eaa7f241 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/keys/query-keys.ts @@ -0,0 +1,68 @@ +import { db } from "@/lib/db"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { KeysResponse, LIMIT, keysQueryPayload, transformKey } from "./schema-with-helpers"; + +export const queryKeys = t.procedure + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(keysQueryPayload) + .output(KeysResponse) + .query(async ({ ctx, input }) => { + const { cursor } = input; + const workspaceId = ctx.workspace.id; + + try { + const keysQuery = await db.query.keys.findMany({ + where: (key, { and, eq, lt, isNull }) => { + const conditions = [ + eq(key.workspaceId, workspaceId), + isNull(key.deletedAtM), // Only non-deleted keys + ]; + + if (cursor) { + conditions.push(lt(key.id, cursor)); + } + + return and(...conditions); + }, + limit: LIMIT + 1, // Fetch one extra to determine if there are more results + orderBy: (keys, { desc }) => desc(keys.id), + with: { + roles: { + with: { + role: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + }, + columns: { + id: true, + name: true, + }, + }); + + // Determine if there are more results + const hasMore = keysQuery.length > LIMIT; + + // Remove the extra item if it exists + const keys = hasMore ? keysQuery.slice(0, LIMIT) : keysQuery; + const nextCursor = hasMore && keys.length > 0 ? keys[keys.length - 1].id : undefined; + + return { + keys: keys.map(transformKey), + hasMore, + nextCursor, + }; + } catch (error) { + console.error("Error retrieving keys:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to retrieve keys. If this issue persists, please contact support.", + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/keys/schema-with-helpers.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/keys/schema-with-helpers.ts new file mode 100644 index 0000000000..4e1b78b677 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/keys/schema-with-helpers.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +export const LIMIT = 50; +export const keysQueryPayload = z.object({ + cursor: z.string().optional(), +}); + +export const RoleSchema = z.object({ + id: z.string(), + name: z.string(), +}); + +export const KeyResponseSchema = z.object({ + id: z.string(), + name: z.string().nullable(), + roles: z.array(RoleSchema), +}); + +export const KeysResponse = z.object({ + keys: z.array(KeyResponseSchema), + hasMore: z.boolean(), + nextCursor: z.string().nullish(), +}); + +export const keysSearchPayload = z.object({ + query: z.string().min(1, "Search query cannot be empty"), +}); + +export const KeysSearchResponse = z.object({ + keys: z.array(KeyResponseSchema), +}); + +type KeyWithRoles = { + id: string; + name: string | null; + roles: { + role: { id: string; name: string } | null; + }[]; +}; + +export const transformKey = (key: KeyWithRoles) => ({ + id: key.id, + name: key.name, + roles: key.roles + .filter((keyRole) => keyRole.role !== null) + .map((keyRole) => ({ + id: keyRole.role!.id, + name: keyRole.role!.name, + })), +}); diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/keys/search-key.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/keys/search-key.ts new file mode 100644 index 0000000000..da0762150f --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/keys/search-key.ts @@ -0,0 +1,66 @@ +import { db } from "@/lib/db"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { KeysSearchResponse, LIMIT, keysSearchPayload, transformKey } from "./schema-with-helpers"; + +export const searchKeys = t.procedure + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(keysSearchPayload) + .output(KeysSearchResponse) + .query(async ({ ctx, input }) => { + const { query } = input; + const workspaceId = ctx.workspace.id; + + if (!query.trim()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Search query cannot be empty", + }); + } + + try { + const searchTerm = `%${query.trim()}%`; + + const keysQuery = await db.query.keys.findMany({ + where: (key, { and, eq, or, like, isNull }) => { + return and( + eq(key.workspaceId, workspaceId), + isNull(key.deletedAtM), // Only non-deleted keys + or(like(key.id, searchTerm), like(key.name, searchTerm)), + ); + }, + limit: LIMIT, + orderBy: (keys, { asc }) => [ + asc(keys.name), // Name matches first + asc(keys.id), // Then by ID for consistency + ], + with: { + roles: { + with: { + role: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + }, + columns: { + id: true, + name: true, + }, + }); + + return { + keys: keysQuery.map(transformKey), + }; + } catch (error) { + console.error("Error searching keys:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to search keys. If this issue persists, please contact support.", + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/llm-search/index.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/llm-search/index.ts new file mode 100644 index 0000000000..e2041c192b --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/llm-search/index.ts @@ -0,0 +1,20 @@ +import { env } from "@/lib/env"; +import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import OpenAI from "openai"; +import { z } from "zod"; +import { getStructuredSearchFromLLM } from "./utils"; + +const openai = env().OPENAI_API_KEY + ? new OpenAI({ + apiKey: env().OPENAI_API_KEY, + }) + : null; + +export const rolesLlmSearch = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(z.object({ query: z.string() })) + .mutation(async ({ input }) => { + return await getStructuredSearchFromLLM(openai, input.query); + }); diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/llm-search/utils.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/llm-search/utils.ts new file mode 100644 index 0000000000..e188a3ac9a --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/llm-search/utils.ts @@ -0,0 +1,381 @@ +import { + filterOutputSchema, + rolesFilterFieldConfig, +} from "@/app/(app)/authorization/roles/filters.schema"; +import { TRPCError } from "@trpc/server"; +import type OpenAI from "openai"; +import { zodResponseFormat } from "openai/helpers/zod.mjs"; + +export async function getStructuredSearchFromLLM(openai: OpenAI | null, userSearchMsg: string) { + try { + if (!openai) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "OpenAI isn't configured correctly, please check your API key", + }); + } + + const completion = await openai.beta.chat.completions.parse({ + // Don't change the model only a few models allow structured outputs + model: "gpt-4o-mini", + temperature: 0.2, // Range 0-2, lower = more focused/deterministic + top_p: 0.1, // Alternative to temperature, controls randomness + frequency_penalty: 0.5, // Range -2 to 2, higher = less repetition + presence_penalty: 0.5, // Range -2 to 2, higher = more topic diversity + n: 1, // Number of completions to generate + messages: [ + { + role: "system", + content: getSystemPrompt(), + }, + { + role: "user", + content: userSearchMsg, + }, + ], + response_format: zodResponseFormat(filterOutputSchema, "searchQuery"), + }); + + if (!completion.choices[0].message.parsed) { + throw new TRPCError({ + code: "UNPROCESSABLE_CONTENT", + message: + "Try using phrases like:\n" + + "• 'find roles with admin permissions'\n" + + "• 'show roles containing api.read'\n" + + "• 'find roles assigned to user keys'\n" + + "• 'show roles with database permissions'\n" + + "• 'find all admin and moderator roles'\n" + + "For additional help, contact support@unkey.dev", + }); + } + + return completion.choices[0].message.parsed; + } catch (error) { + console.error( + `Something went wrong when querying OpenAI. Input: ${JSON.stringify( + userSearchMsg, + )}\n Output ${(error as Error).message}}`, + ); + + if (error instanceof TRPCError) { + throw error; + } + + if ((error as { response: { status: number } }).response?.status === 429) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Search rate limit exceeded. Please try again in a few minutes.", + }); + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to process your search query. Please try again or contact support@unkey.dev if the issue persists.", + }); + } +} + +export const getSystemPrompt = () => { + const operatorsByField = Object.entries(rolesFilterFieldConfig) + .map(([field, config]) => { + const operators = config.operators.join(", "); + return `- ${field} accepts ${operators} operator${config.operators.length > 1 ? "s" : ""}`; + }) + .join("\n"); + + return `You are an expert at converting natural language queries into role filters, understanding context and inferring filter types from natural expressions. Handle complex, ambiguous queries by breaking them down into clear filters for role management. + +Examples: + +# Role Name Patterns +Query: "find admin and moderator roles" +Result: [ + { + field: "name", + filters: [ + { operator: "contains", value: "admin" }, + { operator: "contains", value: "moderator" } + ] + } +] + +Query: "show roles starting with user_" +Result: [ + { + field: "name", + filters: [ + { operator: "startsWith", value: "user_" } + ] + } +] + +# Role Description Patterns +Query: "roles for database access" +Result: [ + { + field: "description", + filters: [ + { operator: "contains", value: "database" } + ] + } +] + +Query: "find roles with API permissions in description" +Result: [ + { + field: "description", + filters: [ + { operator: "contains", value: "API" } + ] + } +] + +# Permission-based Searches +Query: "roles with api.read and api.write permissions" +Result: [ + { + field: "permissionSlug", + filters: [ + { operator: "is", value: "api.read" }, + { operator: "is", value: "api.write" } + ] + } +] + +Query: "find roles containing database permissions" +Result: [ + { + field: "permissionName", + filters: [ + { operator: "contains", value: "database" } + ] + } +] + +Query: "show roles with admin permissions" +Result: [ + { + field: "permissionName", + filters: [ + { operator: "contains", value: "admin" } + ] + } +] + +# Key-based Searches +Query: "roles assigned to user keys" +Result: [ + { + field: "keyName", + filters: [ + { operator: "contains", value: "user" } + ] + } +] + +Query: "find roles with api_key_123" +Result: [ + { + field: "keyId", + filters: [ + { operator: "is", value: "api_key_123" } + ] + } +] + +Query: "roles for production keys" +Result: [ + { + field: "keyName", + filters: [ + { operator: "contains", value: "production" } + ] + } +] + +# Complex Combinations +Query: "admin roles with database permissions and user keys" +Result: [ + { + field: "name", + filters: [ + { operator: "contains", value: "admin" } + ] + }, + { + field: "permissionName", + filters: [ + { operator: "contains", value: "database" } + ] + }, + { + field: "keyName", + filters: [ + { operator: "contains", value: "user" } + ] + } +] + +Query: "find moderator or admin roles with api permissions" +Result: [ + { + field: "name", + filters: [ + { operator: "contains", value: "moderator" }, + { operator: "contains", value: "admin" } + ] + }, + { + field: "permissionName", + filters: [ + { operator: "contains", value: "api" } + ] + } +] + +Query: "roles named exactly 'super_admin' with write permissions" +Result: [ + { + field: "name", + filters: [ + { operator: "is", value: "super_admin" } + ] + }, + { + field: "permissionSlug", + filters: [ + { operator: "contains", value: "write" } + ] + } +] + +# Specific Permission Slug Searches +Query: "roles with user.create and user.delete permissions" +Result: [ + { + field: "permissionSlug", + filters: [ + { operator: "is", value: "user.create" }, + { operator: "is", value: "user.delete" } + ] + } +] + +Query: "find roles ending with _admin" +Result: [ + { + field: "name", + filters: [ + { operator: "endsWith", value: "_admin" } + ] + } +] + +# Key ID Searches +Query: "roles for key starting with key_" +Result: [ + { + field: "keyId", + filters: [ + { operator: "startsWith", value: "key_" } + ] + } +] + +Remember: +${operatorsByField} +- Use exact matches (is) for specific role names, permission slugs, or key IDs +- Use contains for partial matches within names, descriptions, or permissions +- Use startsWith/endsWith for prefix/suffix matching +- For role searches, consider both name and description fields +- For permission searches, distinguish between permissionName (human-readable) and permissionSlug (technical identifier) +- For key searches, distinguish between keyName (human-readable) and keyId (technical identifier) + +Special handling rules: +1. Map common terms to appropriate fields: + - "admin", "moderator", "user" → typically name or permissionName + - Permission patterns like "api.read", "user.create" → permissionSlug + - Technical IDs starting with prefixes → keyId or exact matches +2. When terms could apply to multiple fields, prioritize: + - Exact technical terms (slugs, IDs) → use "is" operator + - Descriptive terms → use "contains" operator + - Role hierarchies → check both name and permissions +3. Handle plurals and variations: + - "admins" → "admin" (normalize to singular) + - "APIs" → "api" (normalize case and plurals) + +Error Handling Rules: +1. Invalid operators: Default to "contains" for ambiguous searches +2. Empty values: Skip filters with empty or whitespace-only values +3. Conflicting constraints: Use the most specific constraint + +Ambiguity Resolution Priority: +1. Exact matches over partial (e.g., role name "admin" vs description containing "admin") +2. Technical identifiers over human-readable names when context suggests precision +3. Permission-based searches over role names when permissions are explicitly mentioned +4. Multiple field searches when terms could apply to different contexts + +Output Validation: +1. Required fields must be present: field, filters +2. Filters must have: operator, value +3. Values must be non-empty strings +4. Operators must match field configuration +5. Field names must be valid: name, description, permissionSlug, permissionName, keyId, keyName + +Additional Examples: + +# Error Handling Examples +Query: "show roles with empty permissions" +Result: [ + { + field: "permissionName", + filters: [{ + operator: "is", + value: "" // Handles empty/null permission searches + }] + } +] + +Query: "find development and staging roles" +Result: [ + { + field: "name", + filters: [ + { operator: "contains", value: "development" }, + { operator: "contains", value: "staging" } + ] + } +] + +# Ambiguity Resolution Examples +Query: "api roles" +Result: [ + { + field: "name", + filters: [{ + operator: "contains", + value: "api" + }] + }, + { + field: "permissionName", + filters: [{ + operator: "contains", + value: "api" + }] + } +] + +Query: "user management permissions" +Result: [ + { + field: "permissionName", + filters: [{ + operator: "contains", + value: "user" + }] + } +]`; +}; diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/query-permissions.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/query-permissions.ts new file mode 100644 index 0000000000..369c3f5b76 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/query-permissions.ts @@ -0,0 +1,68 @@ +import { db } from "@/lib/db"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { + LIMIT, + PermissionsQueryResponse, + permissionsQueryPayload, + transformPermission, +} from "./schema-with-helpers"; + +export const queryRolesPermissions = t.procedure + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(permissionsQueryPayload) + .output(PermissionsQueryResponse) + .query(async ({ ctx, input }) => { + const { cursor } = input; + const workspaceId = ctx.workspace.id; + + try { + const permissionsQuery = await db.query.permissions.findMany({ + where: (permission, { and, eq, lt }) => { + const conditions = [eq(permission.workspaceId, workspaceId)]; + if (cursor) { + conditions.push(lt(permission.id, cursor)); + } + return and(...conditions); + }, + limit: LIMIT + 1, + orderBy: (permissions, { desc }) => desc(permissions.id), + with: { + roles: { + with: { + role: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + }, + columns: { + id: true, + name: true, + description: true, + slug: true, + }, + }); + + const hasMore = permissionsQuery.length > LIMIT; + const permissions = hasMore ? permissionsQuery.slice(0, LIMIT) : permissionsQuery; + const nextCursor = + hasMore && permissions.length > 0 ? permissions[permissions.length - 1].id : undefined; + + return { + permissions: permissions.map(transformPermission), + hasMore, + nextCursor, + }; + } catch (error) { + console.error("Error retrieving permissions:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to retrieve permissions. If this issue persists, please contact support.", + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/schema-with-helpers.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/schema-with-helpers.ts new file mode 100644 index 0000000000..d9ef3eca17 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/schema-with-helpers.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +export const LIMIT = 50; + +const RoleSchema = z.object({ + id: z.string(), + name: z.string(), +}); + +const PermissionSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + slug: z.string(), + roles: z.array(RoleSchema), +}); + +export const permissionsSearchPayload = z.object({ + query: z.string().trim().min(1, "Search query cannot be empty"), +}); + +export const permissionsQueryPayload = z.object({ + cursor: z.string().optional(), +}); + +export const PermissionsSearchResponse = z.object({ + permissions: z.array(PermissionSchema), +}); + +export const PermissionsQueryResponse = z.object({ + permissions: z.array(PermissionSchema), + hasMore: z.boolean(), + nextCursor: z.string().nullish(), +}); + +type PermissionWithRoles = { + id: string; + name: string; + description: string | null; + slug: string; + roles: { + role: { id: string; name: string }; + }[]; +}; + +export const transformPermission = (permission: PermissionWithRoles) => ({ + id: permission.id, + name: permission.name, + description: permission.description, + slug: permission.slug, + roles: permission.roles + .filter((rolePermission) => rolePermission.role !== null) + .map((rolePermission) => ({ + id: rolePermission.role!.id, + name: rolePermission.role!.name, + })), +}); diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/search-permissions.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/search-permissions.ts new file mode 100644 index 0000000000..cceb9b35f4 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/search-permissions.ts @@ -0,0 +1,69 @@ +import { db } from "@/lib/db"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { + LIMIT, + PermissionsSearchResponse, + permissionsSearchPayload, + transformPermission, +} from "./schema-with-helpers"; + +export const searchRolesPermissions = t.procedure + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(permissionsSearchPayload) + .output(PermissionsSearchResponse) + .query(async ({ ctx, input }) => { + const { query } = input; + const workspaceId = ctx.workspace.id; + + try { + const permissionsQuery = await db.query.permissions.findMany({ + where: (permission, { and, eq, or, like }) => { + return and( + eq(permission.workspaceId, workspaceId), + or( + like(permission.id, query), + like(permission.slug, query), + like(permission.name, query), + like(permission.description, query), + ), + ); + }, + limit: LIMIT, + orderBy: (permissions, { asc }) => [ + asc(permissions.name), + asc(permissions.slug), + asc(permissions.id), + ], + with: { + roles: { + with: { + role: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + }, + columns: { + id: true, + name: true, + description: true, + slug: true, + }, + }); + + return { + permissions: permissionsQuery.map(transformPermission), + }; + } catch (error) { + console.error("Error searching permissions:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to search permissions. If this issue persists, please contact support.", + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/query.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/query.ts new file mode 100644 index 0000000000..bc02c2a87b --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/query.ts @@ -0,0 +1,472 @@ +import { rolesQueryPayload } from "@/app/(app)/authorization/roles/components/table/query-logs.schema"; +import type { RolesFilterOperator } from "@/app/(app)/authorization/roles/filters.schema"; +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({ + 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; + +const rolesResponse = z.object({ + roles: z.array(roles), + hasMore: z.boolean(), + total: z.number(), + nextCursor: z.number().int().nullish(), +}); + +export const queryRoles = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(rolesQueryPayload) + .output(rolesResponse) + .query(async ({ ctx, input }) => { + const workspaceId = ctx.workspace.id; + const { cursor, name, description, keyName, keyId, permissionSlug, permissionName } = input; + + // Build filter conditions + const nameFilter = buildFilterConditions(name, "name"); + const descriptionFilter = buildFilterConditions(description, "description"); + 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, + ); + + 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 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) { + return { + roles: [], + hasMore: false, + total: 0, + nextCursor: undefined, + }; + } + + const total = rows[0].grand_total; + 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), + }, + }; + }); + + return { + roles: rolesResponseData, + hasMore, + total: Number(total) || 0, + nextCursor: + hasMore && items.length > 0 + ? Number(items[items.length - 1].updated_at_m) || undefined + : undefined, + }; + }); + +function buildKeyFilter( + nameFilters: + | { + value: string; + operator: RolesFilterOperator; + }[] + | null + | undefined, + idFilters: + | { + value: string; + operator: RolesFilterOperator; + }[] + | null + | undefined, + workspaceId: string, +) { + const conditions = []; + + // Handle name filters + if (nameFilters && nameFilters.length > 0) { + const nameConditions = nameFilters.map((filter) => { + const value = filter.value; + switch (filter.operator) { + case "is": + return sql`id IN ( + SELECT DISTINCT kr.role_id + FROM keys_roles kr + JOIN \`keys\` k ON kr.key_id = k.id + WHERE kr.workspace_id = ${workspaceId} + AND k.name = ${value} + )`; + case "contains": + return sql`id IN ( + SELECT DISTINCT kr.role_id + FROM keys_roles kr + JOIN \`keys\` k ON kr.key_id = k.id + WHERE kr.workspace_id = ${workspaceId} + AND k.name LIKE ${`%${value}%`} + )`; + case "startsWith": + return sql`id IN ( + SELECT DISTINCT kr.role_id + FROM keys_roles kr + JOIN \`keys\` k ON kr.key_id = k.id + WHERE kr.workspace_id = ${workspaceId} + AND k.name LIKE ${`${value}%`} + )`; + case "endsWith": + return sql`id IN ( + SELECT DISTINCT kr.role_id + FROM keys_roles kr + JOIN \`keys\` k ON kr.key_id = k.id + WHERE kr.workspace_id = ${workspaceId} + AND k.name LIKE ${`%${value}`} + )`; + default: + throw new Error(`Invalid operator: ${filter.operator}`); + } + }); + 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; + switch (filter.operator) { + case "is": + return sql`id IN ( + SELECT DISTINCT kr.role_id + FROM keys_roles kr + JOIN \`keys\` k ON kr.key_id = k.id + WHERE kr.workspace_id = ${workspaceId} + AND k.id = ${value} + )`; + case "contains": + return sql`id IN ( + SELECT DISTINCT kr.role_id + FROM keys_roles kr + JOIN \`keys\` k ON kr.key_id = k.id + WHERE kr.workspace_id = ${workspaceId} + AND k.id LIKE ${`%${value}%`} + )`; + case "startsWith": + return sql`id IN ( + SELECT DISTINCT kr.role_id + FROM keys_roles kr + JOIN \`keys\` k ON kr.key_id = k.id + WHERE kr.workspace_id = ${workspaceId} + AND k.id LIKE ${`${value}%`} + )`; + case "endsWith": + return sql`id IN ( + SELECT DISTINCT kr.role_id + FROM keys_roles kr + JOIN \`keys\` k ON kr.key_id = k.id + WHERE kr.workspace_id = ${workspaceId} + AND k.id LIKE ${`%${value}`} + )`; + default: + throw new Error(`Invalid operator: ${filter.operator}`); + } + }); + conditions.push(sql`(${sql.join(idConditions, sql` OR `)})`); + } + + if (conditions.length === 0) { + return sql``; + } + + // Join name and ID conditions with AND + return sql`AND (${sql.join(conditions, sql` AND `)})`; +} + +function buildFilterConditions( + filters: + | { + value: string; + operator: RolesFilterOperator; + }[] + | null + | undefined, + columnName: string, +) { + if (!filters || filters.length === 0) { + return sql``; + } + + const conditions = filters.map((filter) => { + const value = filter.value; + switch (filter.operator) { + case "is": + return sql`${sql.identifier(columnName)} = ${value}`; + case "contains": + return sql`${sql.identifier(columnName)} LIKE ${`%${value}%`}`; + case "startsWith": + return sql`${sql.identifier(columnName)} LIKE ${`${value}%`}`; + case "endsWith": + return sql`${sql.identifier(columnName)} LIKE ${`%${value}`}`; + default: + throw new Error(`Invalid operator: ${filter.operator}`); + } + }); + + // Combine conditions with OR + return sql`AND (${sql.join(conditions, sql` OR `)})`; +} + +function buildPermissionFilter( + nameFilters: + | { + value: string; + operator: RolesFilterOperator; + }[] + | null + | undefined, + slugFilters: + | { + value: string; + operator: RolesFilterOperator; + }[] + | null + | undefined, + workspaceId: string, +) { + const conditions = []; + + // Handle name filters + if (nameFilters && nameFilters.length > 0) { + const nameConditions = nameFilters.map((filter) => { + const value = filter.value; + switch (filter.operator) { + case "is": + return sql`id IN ( + SELECT DISTINCT rp.role_id + FROM roles_permissions rp + JOIN permissions p ON rp.permission_id = p.id + WHERE rp.workspace_id = ${workspaceId} + AND p.name = ${value} + )`; + case "contains": + return sql`id IN ( + SELECT DISTINCT rp.role_id + FROM roles_permissions rp + JOIN permissions p ON rp.permission_id = p.id + WHERE rp.workspace_id = ${workspaceId} + AND p.name LIKE ${`%${value}%`} + )`; + case "startsWith": + return sql`id IN ( + SELECT DISTINCT rp.role_id + FROM roles_permissions rp + JOIN permissions p ON rp.permission_id = p.id + WHERE rp.workspace_id = ${workspaceId} + AND p.name LIKE ${`${value}%`} + )`; + case "endsWith": + return sql`id IN ( + SELECT DISTINCT rp.role_id + FROM roles_permissions rp + JOIN permissions p ON rp.permission_id = p.id + WHERE rp.workspace_id = ${workspaceId} + AND p.name LIKE ${`%${value}`} + )`; + default: + throw new Error(`Invalid operator: ${filter.operator}`); + } + }); + 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; + switch (filter.operator) { + case "is": + return sql`id IN ( + SELECT DISTINCT rp.role_id + FROM roles_permissions rp + JOIN permissions p ON rp.permission_id = p.id + WHERE rp.workspace_id = ${workspaceId} + AND p.slug = ${value} + )`; + case "contains": + return sql`id IN ( + SELECT DISTINCT rp.role_id + FROM roles_permissions rp + JOIN permissions p ON rp.permission_id = p.id + WHERE rp.workspace_id = ${workspaceId} + AND p.slug LIKE ${`%${value}%`} + )`; + case "startsWith": + return sql`id IN ( + SELECT DISTINCT rp.role_id + FROM roles_permissions rp + JOIN permissions p ON rp.permission_id = p.id + WHERE rp.workspace_id = ${workspaceId} + AND p.slug LIKE ${`${value}%`} + )`; + case "endsWith": + return sql`id IN ( + SELECT DISTINCT rp.role_id + FROM roles_permissions rp + JOIN permissions p ON rp.permission_id = p.id + WHERE rp.workspace_id = ${workspaceId} + AND p.slug LIKE ${`%${value}`} + )`; + default: + throw new Error(`Invalid operator: ${filter.operator}`); + } + }); + conditions.push(sql`(${sql.join(slugConditions, sql` OR `)})`); + } + + if (conditions.length === 0) { + 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 new file mode 100644 index 0000000000..47a898c783 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/upsert.ts @@ -0,0 +1,261 @@ +import { rbacRoleSchema } from "@/app/(app)/authorization/roles/components/upsert-role/upsert-role.schema"; +import { insertAuditLogs } from "@/lib/audit"; +import { and, db, eq, schema } from "@/lib/db"; +import { requireUser, requireWorkspace, t } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { newId } from "@unkey/id"; + +export const upsertRole = t.procedure + .use(requireUser) + .use(requireWorkspace) + .input(rbacRoleSchema) + .mutation(async ({ input, ctx }) => { + const isUpdate = Boolean(input.roleId); + let roleId = input.roleId; + + if (!isUpdate) { + roleId = newId("role"); + if (!roleId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to generate role ID", + }); + } + } + + if (!roleId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Invalid role ID", + }); + } + + 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 }) => + and(eq(table.id, updateRoleId), eq(table.workspaceId, ctx.workspace.id)), + }); + + if (!existingRole) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Role not found or access denied", + }); + } + + // Only check for name conflicts if the name is actually changing + if (existingRole.name !== input.roleName) { + const nameConflict = await tx.query.roles.findFirst({ + where: (table, { and, eq, ne }) => + and( + eq(table.workspaceId, ctx.workspace.id), + eq(table.name, input.roleName), + ne(table.id, updateRoleId), + ), + }); + + if (nameConflict) { + throw new TRPCError({ + code: "CONFLICT", + message: `Role with name '${input.roleName}' already exists`, + }); + } + } + + // Update role + await tx + .update(schema.roles) + .set({ + name: input.roleName, + description: input.roleDescription, + }) + .where(and(eq(schema.roles.id, roleId), eq(schema.roles.workspaceId, ctx.workspace.id))) + .catch(() => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to update role", + }); + }); + + // 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), + ), + ); + + await insertAuditLogs(tx, { + workspaceId: ctx.workspace.id, + event: "role.update", + actor: { + type: "user", + id: ctx.user.id, + }, + description: `Updated role ${roleId}`, + resources: [ + { + type: "role", + id: roleId, + name: input.roleName, + }, + ], + context: { + userAgent: ctx.audit.userAgent, + location: ctx.audit.location, + }, + }); + } else { + // Create mode - always check for name conflicts + const nameConflict = await tx.query.roles.findFirst({ + where: (table, { and, eq }) => + and(eq(table.workspaceId, ctx.workspace.id), eq(table.name, input.roleName)), + }); + + if (nameConflict) { + throw new TRPCError({ + code: "CONFLICT", + message: `Role with name '${input.roleName}' already exists`, + }); + } + + // Create new role + await tx + .insert(schema.roles) + .values({ + id: roleId, + name: input.roleName, // name maps to db.human_readable + description: input.roleDescription, + workspaceId: ctx.workspace.id, + }) + .catch(() => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create role", + }); + }); + + await insertAuditLogs(tx, { + workspaceId: ctx.workspace.id, + event: "role.create", + actor: { + type: "user", + id: ctx.user.id, + }, + description: `Created role ${roleId}`, + resources: [ + { + type: "role", + id: roleId, + name: input.roleName, + }, + ], + context: { + userAgent: ctx.audit.userAgent, + location: ctx.audit.location, + }, + }); + } + + // 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 { + roleId, + isUpdate, + message: isUpdate ? "Role updated successfully" : "Role created successfully", + }; + }); diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 0e44e2698a..46852c3016 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -20,6 +20,19 @@ import { updateApiIpWhitelist } from "./api/updateIpWhitelist"; import { updateApiName } from "./api/updateName"; import { fetchAuditLog } from "./audit/fetch"; import { auditLogsSearch } from "./audit/llm-search"; +import { deletePermissionWithRelations } from "./authorization/permissions/delete"; +import { permissionsLlmSearch } from "./authorization/permissions/llm-search"; +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 { queryKeys } from "./authorization/roles/keys/query-keys"; +import { searchKeys } from "./authorization/roles/keys/search-key"; +import { rolesLlmSearch } from "./authorization/roles/llm-search"; +import { queryRolesPermissions } from "./authorization/roles/permissions/query-permissions"; +import { searchRolesPermissions } from "./authorization/roles/permissions/search-permissions"; +import { queryRoles } from "./authorization/roles/query"; +import { upsertRole } from "./authorization/roles/upsert"; import { queryUsage } from "./billing/query-usage"; import { createIdentity } from "./identity/create"; import { queryIdentities } from "./identity/query"; @@ -30,6 +43,11 @@ import { deleteRootKeys } from "./key/deleteRootKey"; import { fetchKeyPermissions } from "./key/fetch-key-permissions"; import { queryKeyDetailsLogs } from "./key/query-logs"; import { keyDetailsVerificationsTimeseries } from "./key/query-timeseries"; +import { getConnectedRolesAndPerms } from "./key/rbac/connected-roles-and-perms"; +import { getPermissionSlugs } from "./key/rbac/get-permission-slugs"; +import { queryKeysRoles } from "./key/rbac/roles/query-keys-roles"; +import { searchKeysRoles } from "./key/rbac/roles/search-keys-roles"; +import { updateKeyRbac } from "./key/rbac/update-rbac"; import { updateKeysEnabled } from "./key/updateEnabled"; import { updateKeyExpiration } from "./key/updateExpiration"; import { updateKeyMetadata } from "./key/updateMetadata"; @@ -103,7 +121,20 @@ export const router = t.router({ ownerId: updateKeyOwner, ratelimit: updateKeyRatelimit, remaining: updateKeyRemaining, + rbac: t.router({ + update: updateKeyRbac, + roles: t.router({ + search: searchKeysRoles, + query: queryKeysRoles, + }), + permissions: t.router({ + search: searchRolesPermissions, + query: queryRolesPermissions, + }), + }), }), + queryPermissionSlugs: getPermissionSlugs, + connectedRolesAndPerms: getConnectedRolesAndPerms, }), rootKey: t.router({ create: createRootKey, @@ -152,6 +183,29 @@ export const router = t.router({ plain: t.router({ createIssue: createPlainIssue, }), + authorization: t.router({ + permissions: t.router({ + query: queryPermissions, + upsert: upsertPermission, + delete: deletePermissionWithRelations, + llmSearch: permissionsLlmSearch, + }), + roles: t.router({ + query: queryRoles, + keys: t.router({ + search: searchKeys, + query: queryKeys, + }), + permissions: t.router({ + search: searchRolesPermissions, + query: queryRolesPermissions, + }), + upsert: upsertRole, + delete: deleteRoleWithRelations, + llmSearch: rolesLlmSearch, + connectedKeysAndPerms: getConnectedKeysAndPerms, + }), + }), rbac: t.router({ addPermissionToRootKey: addPermissionToRootKey, connectPermissionToRole: connectPermissionToRole, diff --git a/apps/dashboard/lib/trpc/routers/key/rbac/connected-roles-and-perms.ts b/apps/dashboard/lib/trpc/routers/key/rbac/connected-roles-and-perms.ts new file mode 100644 index 0000000000..065b5190c4 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/key/rbac/connected-roles-and-perms.ts @@ -0,0 +1,183 @@ +import { and, db, eq } from "@/lib/db"; +import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { + keys, + keysPermissions, + keysRoles, + permissions, + roles, + rolesPermissions, +} from "@unkey/db/src/schema"; +import { z } from "zod"; + +const keyDetailsInput = z.object({ + keyId: z.string().min(1, "Key ID is required"), +}); + +const keyRole = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), +}); +export type KeyRole = z.infer; + +const keyPermission = z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + description: z.string().nullable(), + source: z.enum(["direct", "role"]).optional(), // Track if permission comes from direct assignment or role + roleId: z.string().optional(), +}); +export type KeyPermission = z.infer; + +const keyDetailsResponse = z.object({ + keyId: z.string(), + name: z.string().nullable(), + lastUpdated: z.number(), + roles: z.array(keyRole), + permissions: z.array(keyPermission), +}); +export type KeyRbacDetails = z.infer; + +export const getConnectedRolesAndPerms = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(keyDetailsInput) + .output(keyDetailsResponse) + .query(async ({ ctx, input }) => { + const { keyId } = input; + const workspaceId = ctx.workspace.id; + + try { + // First, verify the key exists in this workspace - security check + const keyResult = await db + .select({ + id: keys.id, + name: keys.name, + updated_at_m: keys.updatedAtM, + }) + .from(keys) + .where(and(eq(keys.id, keyId), eq(keys.workspaceId, workspaceId))) + .limit(1); + + if (keyResult.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Key not found or access denied", + }); + } + + const key = keyResult[0]; + + const [roleResults, directPermissionResults, rolePermissionResults] = await Promise.all([ + // Get roles directly assigned to the key + db + .selectDistinct({ + id: roles.id, + name: roles.name, + description: roles.description, + }) + .from(keysRoles) + .innerJoin(roles, eq(keysRoles.roleId, roles.id)) + .where(and(eq(keysRoles.keyId, keyId), eq(keysRoles.workspaceId, workspaceId))) + .orderBy(roles.name), + + // Get permissions directly assigned to the key + db + .selectDistinct({ + id: permissions.id, + name: permissions.name, + slug: permissions.slug, + description: permissions.description, + }) + .from(keysPermissions) + .innerJoin(permissions, eq(keysPermissions.permissionId, permissions.id)) + .where( + and(eq(keysPermissions.keyId, keyId), eq(keysPermissions.workspaceId, workspaceId)), + ) + .orderBy(permissions.name), + + // Get permissions inherited from roles + db + .selectDistinct({ + id: permissions.id, + name: permissions.name, + slug: permissions.slug, + description: permissions.description, + roleId: keysRoles.roleId, + }) + .from(keysRoles) + .innerJoin(rolesPermissions, eq(keysRoles.roleId, rolesPermissions.roleId)) + .innerJoin(permissions, eq(rolesPermissions.permissionId, permissions.id)) + .where(and(eq(keysRoles.keyId, keyId), eq(keysRoles.workspaceId, workspaceId))) + .orderBy(permissions.name), + ]); + + // Combine and dedup permissions + const allPermissions = new Map(); + + // Add direct permissions first + directPermissionResults.forEach((perm) => { + allPermissions.set(perm.id, { + id: perm.id, + name: perm.name, + slug: perm.slug, + description: perm.description, + source: "direct", + }); + }); + + // Add role permissions (if not already direct) + rolePermissionResults.forEach((perm) => { + if (!allPermissions.has(perm.id)) { + allPermissions.set(perm.id, { + id: perm.id, + name: perm.name, + slug: perm.slug, + description: perm.description, + roleId: perm.roleId, + source: "role", + }); + } + }); + + return { + keyId: key.id, + name: key.name, + lastUpdated: key.updated_at_m || Date.now(), + roles: roleResults + .map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + })) + .sort((a, b) => a.name.localeCompare(b.name)), + permissions: Array.from(allPermissions.values()).sort((a, b) => + a.name.localeCompare(b.name), + ), + }; + } catch (error) { + // Re-throw TRPCErrors as-is + if (error instanceof TRPCError) { + throw error; + } + + // Handle database connection errors + if (error instanceof Error && error.message.includes("connection")) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Database connection failed", + }); + } + + // Handle all other errors + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch key details", + cause: error, + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/key/rbac/get-permission-slugs.ts b/apps/dashboard/lib/trpc/routers/key/rbac/get-permission-slugs.ts new file mode 100644 index 0000000000..f1e08d5b71 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/key/rbac/get-permission-slugs.ts @@ -0,0 +1,131 @@ +import { and, db, eq, inArray } from "@/lib/db"; +import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { permissions, roles, rolesPermissions } from "@unkey/db/src/schema"; +import { z } from "zod"; + +const resolvePermissionSlugsInput = z.object({ + roleIds: z.array(z.string()).default([]), + permissionIds: z.array(z.string()).default([]), +}); + +const resolvePermissionSlugsResponse = z.object({ + slugs: z.array(z.string()), + totalCount: z.number(), + breakdown: z.object({ + fromRoles: z.number(), + fromDirectPermissions: z.number(), + }), +}); + +type PermissionSlug = { + slug: string; +}; + +export const getPermissionSlugs = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(resolvePermissionSlugsInput) + .output(resolvePermissionSlugsResponse) + .query(async ({ ctx, input }) => { + const { roleIds, permissionIds } = input; + const workspaceId = ctx.workspace.id; + + try { + // Role permissions query + let rolePermissionsPromise: Promise = Promise.resolve([]); + if (roleIds.length > 0) { + rolePermissionsPromise = db + .selectDistinct({ + slug: permissions.slug, + }) + .from(rolesPermissions) + .innerJoin(permissions, eq(rolesPermissions.permissionId, permissions.id)) + .innerJoin(roles, eq(rolesPermissions.roleId, roles.id)) + .where( + and( + inArray(rolesPermissions.roleId, roleIds), + eq(rolesPermissions.workspaceId, workspaceId), + eq(roles.workspaceId, workspaceId), + ), + ); + } + + // Direct permissions query + let directPermissionsPromise: Promise = Promise.resolve([]); + if (permissionIds.length > 0) { + directPermissionsPromise = db + .select({ + slug: permissions.slug, + }) + .from(permissions) + .where( + and(inArray(permissions.id, permissionIds), eq(permissions.workspaceId, workspaceId)), + ); + } + + const [rolePermissions, directPermissions] = await Promise.all([ + rolePermissionsPromise, + directPermissionsPromise, + ]); + + // Validate that all requested items were found + if (roleIds.length > 0) { + const roleCheck = await db + .select({ id: roles.id }) + .from(roles) + .where(and(inArray(roles.id, roleIds), eq(roles.workspaceId, workspaceId))); + + if (roleCheck.length !== roleIds.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "One or more roles not found or access denied", + }); + } + } + + if (permissionIds.length > 0 && directPermissions.length !== permissionIds.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "One or more permissions not found or access denied", + }); + } + + const slugsSet = new Set(); + + rolePermissions.forEach(({ slug }) => slugsSet.add(slug)); + directPermissions.forEach(({ slug }) => slugsSet.add(slug)); + + const allSlugs = Array.from(slugsSet).sort(); + + return { + slugs: allSlugs, + totalCount: allSlugs.length, + breakdown: { + fromRoles: rolePermissions.length, + fromDirectPermissions: directPermissions.length, + }, + }; + } catch (error) { + // Re-throw TRPCErrors as-is + if (error instanceof TRPCError) { + throw error; + } + + // Handle database connection errors + if (error instanceof Error && error.message.includes("connection")) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Database connection failed", + }); + } + + // Handle all other errors + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to resolve permission slugs", + cause: error, + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/key/rbac/roles/query-keys-roles.ts b/apps/dashboard/lib/trpc/routers/key/rbac/roles/query-keys-roles.ts new file mode 100644 index 0000000000..3b4f5fde4c --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/key/rbac/roles/query-keys-roles.ts @@ -0,0 +1,75 @@ +import { db } from "@/lib/db"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { LIMIT, RolesResponse, rolesQueryPayload, transformRole } from "./schema-with-helpers"; + +export const queryKeysRoles = t.procedure + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(rolesQueryPayload) + .output(RolesResponse) + .query(async ({ ctx, input }) => { + const { cursor } = input; + const workspaceId = ctx.workspace.id; + + try { + const rolesQuery = await db.query.roles.findMany({ + where: (role, { and, eq, lt }) => { + const conditions = [eq(role.workspaceId, workspaceId)]; + + if (cursor) { + conditions.push(lt(role.id, cursor)); + } + + return and(...conditions); + }, + limit: LIMIT + 1, // Fetch one extra to determine if there are more results + orderBy: (roles, { desc }) => desc(roles.id), + with: { + keys: { + with: { + key: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + permissions: { + with: { + permission: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + }, + columns: { + id: true, + name: true, + description: true, + }, + }); + + // Determine if there are more results + const hasMore = rolesQuery.length > LIMIT; + // Remove the extra item if it exists + const roles = hasMore ? rolesQuery.slice(0, LIMIT) : rolesQuery; + const nextCursor = hasMore && roles.length > 0 ? roles[roles.length - 1].id : undefined; + + return { + roles: roles.map(transformRole), + hasMore, + nextCursor, + }; + } catch (error) { + console.error("Error retrieving roles:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to retrieve roles. If this issue persists, please contact support.", + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/key/rbac/roles/schema-with-helpers.ts b/apps/dashboard/lib/trpc/routers/key/rbac/roles/schema-with-helpers.ts new file mode 100644 index 0000000000..924b52d6ef --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/key/rbac/roles/schema-with-helpers.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +export const LIMIT = 50; + +export const rolesQueryPayload = z.object({ + cursor: z.string().optional(), +}); + +export const KeySchema = z.object({ + id: z.string(), + name: z.string().nullable(), +}); + +export const PermissionSchema = z.object({ + id: z.string(), + name: z.string(), +}); + +export const RoleResponseSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + keys: z.array(KeySchema), + permissions: z.array(PermissionSchema), +}); + +export const RolesResponse = z.object({ + roles: z.array(RoleResponseSchema), + hasMore: z.boolean(), + nextCursor: z.string().nullish(), +}); + +export const rolesSearchPayload = z.object({ + query: z.string().min(1, "Search query cannot be empty"), +}); + +export const RolesSearchResponse = z.object({ + roles: z.array(RoleResponseSchema), +}); + +type RoleWithKeysAndPermissions = { + id: string; + name: string; + description: string | null; + keys: { + key: { id: string; name: string | null } | null; + }[]; + permissions: { + permission: { id: string; name: string } | null; + }[]; +}; + +export const transformRole = (role: RoleWithKeysAndPermissions) => ({ + id: role.id, + name: role.name, + description: role.description, + keys: role.keys + .filter((roleKey) => roleKey.key !== null) + .map((roleKey) => ({ + id: roleKey.key!.id, + name: roleKey.key!.name, + })), + permissions: role.permissions + .filter((rolePermission) => rolePermission.permission !== null) + .map((rolePermission) => ({ + id: rolePermission.permission!.id, + name: rolePermission.permission!.name, + })), +}); diff --git a/apps/dashboard/lib/trpc/routers/key/rbac/roles/search-keys-roles.ts b/apps/dashboard/lib/trpc/routers/key/rbac/roles/search-keys-roles.ts new file mode 100644 index 0000000000..e1c4a740fd --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/key/rbac/roles/search-keys-roles.ts @@ -0,0 +1,85 @@ +import { db } from "@/lib/db"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { + LIMIT, + RolesSearchResponse, + rolesSearchPayload, + transformRole, +} from "./schema-with-helpers"; + +export const searchKeysRoles = t.procedure + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(rolesSearchPayload) + .output(RolesSearchResponse) + .query(async ({ ctx, input }) => { + const { query } = input; + const workspaceId = ctx.workspace.id; + + if (!query.trim()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Search query cannot be empty", + }); + } + + try { + const searchTerm = `%${query.trim()}%`; + + const rolesQuery = await db.query.roles.findMany({ + where: (role, { and, eq, or, like }) => { + return and( + eq(role.workspaceId, workspaceId), + or( + like(role.id, searchTerm), + like(role.name, searchTerm), + like(role.description, searchTerm), + ), + ); + }, + limit: LIMIT, + orderBy: (roles, { asc }) => [ + asc(roles.name), // Name matches first + asc(roles.id), // Then by ID for consistency + ], + with: { + keys: { + with: { + key: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + permissions: { + with: { + permission: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + }, + columns: { + id: true, + name: true, + description: true, + }, + }); + + return { + roles: rolesQuery.map(transformRole), + }; + } catch (error) { + console.error("Error searching roles:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to search roles. If this issue persists, please contact support.", + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/key/rbac/update-rbac.ts b/apps/dashboard/lib/trpc/routers/key/rbac/update-rbac.ts new file mode 100644 index 0000000000..cb6b100f57 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/key/rbac/update-rbac.ts @@ -0,0 +1,228 @@ +import { updateKeyRbacSchema } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-rbac/update-key-rbac.schema"; +import { insertAuditLogs } from "@/lib/audit"; +import { and, db, eq, schema } from "@/lib/db"; +import { TRPCError } from "@trpc/server"; +import { requireUser, requireWorkspace, t } from "../../../trpc"; + +export const updateKeyRbac = t.procedure + .use(requireUser) + .use(requireWorkspace) + .input(updateKeyRbacSchema) + .mutation(async ({ input, ctx }) => { + const { keyId, roleIds, directPermissionIds } = input; + const workspaceId = ctx.workspace.id; + + // Verify key exists and belongs to workspace + const key = await db.query.keys + .findFirst({ + where: (table, { eq, isNull, and }) => + and(eq(table.workspaceId, workspaceId), eq(table.id, keyId), isNull(table.deletedAtM)), + }) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We were unable to update RBAC for this key. Please try again or contact support@unkey.dev", + }); + }); + + if (!key) { + throw new TRPCError({ + message: + "We are unable to find the correct key. Please try again or contact support@unkey.dev.", + code: "NOT_FOUND", + }); + } + + // Validate roles exist in workspace + if (roleIds.length > 0) { + const existingRoles = await db.query.roles + .findMany({ + where: (table, { eq, and, inArray }) => + and(eq(table.workspaceId, workspaceId), inArray(table.id, roleIds)), + columns: { id: true }, + }) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Unable to validate roles. Please try again or contact support@unkey.dev", + }); + }); + + if (existingRoles.length !== roleIds.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "One or more roles do not exist in this workspace", + }); + } + } + + // Validate direct permissions exist in workspace + if (directPermissionIds.length > 0) { + const existingPermissions = await db.query.permissions + .findMany({ + where: (table, { eq, and, inArray }) => + and(eq(table.workspaceId, workspaceId), inArray(table.id, directPermissionIds)), + columns: { id: true }, + }) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Unable to validate permissions. Please try again or contact support@unkey.dev", + }); + }); + + if (existingPermissions.length !== directPermissionIds.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "One or more permissions do not exist in this workspace", + }); + } + } + + // Calculate total effective permissions for response + let totalEffectivePermissions = directPermissionIds.length; + + await db + .transaction(async (tx) => { + // Get permissions that come from the requested roles for audit/response purposes + const rolePermissions = + roleIds.length > 0 + ? await tx.query.rolesPermissions + .findMany({ + where: (table, { inArray, eq, and }) => + and(inArray(table.roleId, roleIds), eq(table.workspaceId, workspaceId)), + columns: { permissionId: true }, + }) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Unable to resolve role permissions. Please try again or contact support@unkey.dev", + }); + }) + : []; + + // Calculate unique role permissions for total count + const uniqueRolePermissionIds = new Set(rolePermissions.map((rp) => rp.permissionId)); + + // Add role permissions to total, avoiding double-counting + directPermissionIds.forEach((id) => { + if (!uniqueRolePermissionIds.has(id)) { + uniqueRolePermissionIds.add(id); + } + }); + + totalEffectivePermissions = + uniqueRolePermissionIds.size + + directPermissionIds.filter((id) => !uniqueRolePermissionIds.has(id)).length; + + // Remove existing role assignments + await tx + .delete(schema.keysRoles) + .where( + and(eq(schema.keysRoles.keyId, keyId), eq(schema.keysRoles.workspaceId, workspaceId)), + ) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to remove existing role assignments", + }); + }); + + // Remove existing permission assignments + await tx + .delete(schema.keysPermissions) + .where( + and( + eq(schema.keysPermissions.keyId, keyId), + eq(schema.keysPermissions.workspaceId, workspaceId), + ), + ) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to remove existing permission assignments", + }); + }); + + // Insert new role assignments + if (roleIds.length > 0) { + await tx + .insert(schema.keysRoles) + .values( + roleIds.map((roleId) => ({ + keyId, + roleId, + workspaceId, + })), + ) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to assign new roles", + }); + }); + } + + // Insert direct permission assignments + if (directPermissionIds.length > 0) { + await tx + .insert(schema.keysPermissions) + .values( + directPermissionIds.map((permissionId) => ({ + keyId, + permissionId, + workspaceId, + })), + ) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to assign new permissions", + }); + }); + } + + // Audit log + await insertAuditLogs(tx, { + workspaceId, + actor: { + type: "user", + id: ctx.user.id, + }, + event: "key.update", + description: `Updated RBAC for key ${key.id}: ${roleIds.length} roles, ${directPermissionIds.length} direct permissions (${totalEffectivePermissions} total effective permissions)`, + resources: [ + { + type: "key", + id: key.id, + name: key.name || undefined, + }, + ], + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, + }); + }) + .catch((err) => { + if (err instanceof TRPCError) { + throw err; + } + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to update RBAC for this key. Please try again or contact support@unkey.dev", + }); + }); + + return { + keyId: key.id, + success: true, + rolesAssigned: roleIds.length, + directPermissionsAssigned: directPermissionIds.length, + totalEffectivePermissions, + }; + }); diff --git a/internal/db/src/schema/rbac.ts b/internal/db/src/schema/rbac.ts index 84932016d7..9a902fe812 100644 --- a/internal/db/src/schema/rbac.ts +++ b/internal/db/src/schema/rbac.ts @@ -97,7 +97,6 @@ export const roles = mysqlTable( workspaceId: varchar("workspace_id", { length: 256 }).notNull(), name: varchar("name", { length: 512 }).notNull(), description: varchar("description", { length: 512 }), - createdAtM: bigint("created_at_m", { mode: "number" }) .notNull() .default(0) diff --git a/internal/icons/src/icons/hand-holding-key.tsx b/internal/icons/src/icons/hand-holding-key.tsx new file mode 100644 index 0000000000..2046e36727 --- /dev/null +++ b/internal/icons/src/icons/hand-holding-key.tsx @@ -0,0 +1,97 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import { type IconProps, sizeMap } from "../props"; + +export const HandHoldingKey: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + + return ( + + + + + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/tag.tsx b/internal/icons/src/icons/tag.tsx new file mode 100644 index 0000000000..4c38e6e316 --- /dev/null +++ b/internal/icons/src/icons/tag.tsx @@ -0,0 +1,39 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import { type IconProps, sizeMap } from "../props"; + +export const Tag: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + + return ( + + + + + + + ); +}; diff --git a/internal/icons/src/index.ts b/internal/icons/src/index.ts index b236dd44aa..589f2cd66b 100644 --- a/internal/icons/src/index.ts +++ b/internal/icons/src/index.ts @@ -55,6 +55,7 @@ export * from "./icons/folder-cloud"; export * from "./icons/gauge"; export * from "./icons/gear"; export * from "./icons/grid"; +export * from "./icons/hand-holding-key"; export * from "./icons/input-password-edit"; export * from "./icons/input-password-settings"; export * from "./icons/input-search"; @@ -84,6 +85,7 @@ export * from "./icons/sliders"; export * from "./icons/sparkle-3"; export * from "./icons/storage"; export * from "./icons/sun"; +export * from "./icons/tag"; export * from "./icons/task-checked"; export * from "./icons/task-unchecked"; export * from "./icons/text-input"; diff --git a/internal/ui/src/components/dialog/dialog-container.tsx b/internal/ui/src/components/dialog/dialog-container.tsx index c70452c12b..04515c8627 100644 --- a/internal/ui/src/components/dialog/dialog-container.tsx +++ b/internal/ui/src/components/dialog/dialog-container.tsx @@ -40,6 +40,8 @@ export const DialogContainer = ({ "w-[90%] md:w-[70%] lg:w-[70%] xl:w-[50%] 2xl:w-[45%] max-w-[600px] max-h-[90vh] sm:max-h-[90vh] md:max-h-[70vh] lg:max-h-[90vh] xl:max-h-[80vh]", className, )} + // Otherwise our shortcuts hijacks dialog inputs + onKeyDown={(e) => e.stopPropagation()} onOpenAutoFocus={(e) => { if (preventAutoFocus) { e.preventDefault(); diff --git a/internal/ui/src/components/dialog/navigable-dialog.tsx b/internal/ui/src/components/dialog/navigable-dialog.tsx index 36548b9302..dec2cfe434 100644 --- a/internal/ui/src/components/dialog/navigable-dialog.tsx +++ b/internal/ui/src/components/dialog/navigable-dialog.tsx @@ -68,6 +68,8 @@ const NavigableDialogRoot = ({ e.stopPropagation()} className={cn( "drop-shadow-2xl border-grayA-4 overflow-hidden !rounded-2xl p-0 gap-0 flex flex-col max-h-[90vh]", dialogClassName,