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..adde229294 --- /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..9409a4fd06 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/delete-permission.tsx @@ -0,0 +1,154 @@ +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 (!open && !isConfirmPopoverOpen) { + 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..f8bed2ef48 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/permission-info.tsx @@ -0,0 +1,22 @@ +import type { Permission } from "@/lib/trpc/routers/authorization/permissions/query"; +import { HandHoldingKey } from "@unkey/icons"; + +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..a6fd24aa8c --- /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 permissions... + +
+ )} +
+ ); +}; 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..d8fc0a969f --- /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..c029f2c837 --- /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..52778c8686 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/index.tsx @@ -0,0 +1,219 @@ +"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 () => { + const hasSavedData = await loadSavedValues(); + if (!hasSavedData) { + reset(getDefaultValues(existingPermission)); + } + }; + + 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..eed174318d --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/upsert-permission.schema.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +export const permissionNameSchema = z + .string() + .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..af4651c677 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 PermissionsPage() { 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/components/table/roles-list.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/roles-list.tsx index bf00cf6d79..730acbbab0 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/roles-list.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/roles-list.tsx @@ -126,7 +126,7 @@ export const RolesList = () => { { key: "last_updated", header: "Last Updated", - width: "20%", + width: "12%", render: (role) => { return ( (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..6013f10321 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/permissions/llm-search/utils.ts @@ -0,0 +1,128 @@ +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}: ${operators}`; + }) + .join("\n"); + + return `Convert natural language queries into permission filters. Use context to infer the correct field and operator. + +FIELD OPERATORS: +${operatorsByField} + +OPERATOR RULES: +- "is": exact matches (IDs, specific slugs like "api.read") +- "contains": partial matches (names, descriptions, general terms) +- "startsWith/endsWith": prefix/suffix patterns + +EXAMPLES: + +Query: "admin permissions" +→ [{"field": "name", "filters": [{"operator": "contains", "value": "admin"}]}] + +Query: "api.read permission" +→ [{"field": "slug", "filters": [{"operator": "is", "value": "api.read"}]}] + +Query: "permissions for database access" +→ [{"field": "description", "filters": [{"operator": "contains", "value": "database"}]}] + +Query: "admin permissions with database access" +→ [ + {"field": "name", "filters": [{"operator": "contains", "value": "admin"}]}, + {"field": "description", "filters": [{"operator": "contains", "value": "database"}]} +] + +Query: "permissions assigned to admin role" +→ [{"field": "roleName", "filters": [{"operator": "contains", "value": "admin"}]}] + +Query: "permissions starting with api_" +→ [{"field": "name", "filters": [{"operator": "startsWith", "value": "api_"}]}] + +PRIORITY RULES: +1. Technical terms (api.read, role_123) → use "is" with slug/roleId +2. Descriptive terms (admin, database) → use "contains" with name/description +3. When ambiguous, search multiple relevant fields +4. Normalize plurals to singular, lowercase technical terms + +OUTPUT: Always return valid filters with field, operator, and non-empty value.`; +}; 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/permissions/schema-with-helpers.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/schema-with-helpers.ts index d9ef3eca17..01f79797e6 100644 --- 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 @@ -1,12 +1,8 @@ import { z } from "zod"; +import { RoleSchema } from "../keys/schema-with-helpers"; export const LIMIT = 50; -const RoleSchema = z.object({ - id: z.string(), - name: z.string(), -}); - const PermissionSchema = z.object({ id: z.string(), name: z.string(), @@ -49,9 +45,14 @@ export const transformPermission = (permission: PermissionWithRoles) => ({ description: permission.description, slug: permission.slug, roles: permission.roles - .filter((rolePermission) => rolePermission.role !== null) + .filter( + (rolePermission) => + rolePermission.role !== null && + rolePermission.role.id !== undefined && + rolePermission.role.name !== undefined, + ) .map((rolePermission) => ({ - id: rolePermission.role!.id, - name: rolePermission.role!.name, + id: rolePermission.role.id, + name: rolePermission.role.name, })), }); diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 8e8d24f43d..ea429cc89a 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -20,6 +20,10 @@ 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"; @@ -168,6 +172,12 @@ export const router = 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({ diff --git a/packages/hono/package.json b/packages/hono/package.json index 7e6fe8b406..eea5524bd3 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -8,19 +8,12 @@ "publishConfig": { "access": "public" }, - "keywords": [ - "unkey", - "client", - "api", - "hono" - ], + "keywords": ["unkey", "client", "api", "hono"], "bugs": { "url": "https://github.com/unkeyed/unkey/issues" }, "homepage": "https://github.com/unkeyed/unkey#readme", - "files": [ - "./dist/**" - ], + "files": ["./dist/**"], "author": "Andreas Thomas ", "scripts": { "build": "tsup",