From 1e9f5094dfaa769e0e79013ef21ad21427207c6c Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 2 Jun 2025 15:56:33 +0300 Subject: [PATCH 01/47] feat: add structured roles paginated result --- .../trpc/routers/authorization/roles/index.ts | 177 ++++++++++++++++++ apps/dashboard/lib/trpc/routers/index.ts | 4 + internal/db/src/schema/rbac.ts | 2 +- 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/lib/trpc/routers/authorization/roles/index.ts diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts new file mode 100644 index 0000000000..11429e5494 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts @@ -0,0 +1,177 @@ +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 = "|||"; +const DEFAULT_LIMIT = 50; +const MIN_LIMIT = 1; + +const rolesQueryInput = z.object({ + limit: z.number().int().min(MIN_LIMIT).max(DEFAULT_LIMIT).default(DEFAULT_LIMIT), + cursor: z.number().int().optional(), +}); + +export const roles = z.object({ + slug: 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(rolesQueryInput) + .output(rolesResponse) + .query(async ({ ctx, input }) => { + const workspaceId = ctx.workspace.id; + const { limit, cursor } = input; + + const result = await db.execute(sql` + SELECT + r.id, + r.name, + r.human_readable, + r.description, + r.updated_at_m, + + -- Keys data (only first ${MAX_ITEMS_TO_SHOW} names) + GROUP_CONCAT( + CASE + WHEN key_data.key_row_num <= ${MAX_ITEMS_TO_SHOW} + THEN key_data.key_name + END + ORDER BY key_data.key_name + SEPARATOR ${ITEM_SEPARATOR} + ) as key_items, + COALESCE(MAX(key_data.total_keys), 0) as total_keys, + + -- Permissions data (only first ${MAX_ITEMS_TO_SHOW} names) + GROUP_CONCAT( + CASE + WHEN perm_data.perm_row_num <= ${MAX_ITEMS_TO_SHOW} + THEN perm_data.permission_name + END + ORDER BY perm_data.permission_name + SEPARATOR ${ITEM_SEPARATOR} + ) as permission_items, + COALESCE(MAX(perm_data.total_permissions), 0) as total_permissions, + + -- Total count + (SELECT COUNT(*) FROM roles WHERE workspace_id = ${workspaceId}) as grand_total + + FROM ( + SELECT * + FROM roles + WHERE workspace_id = ${workspaceId} + ${cursor ? sql`AND updated_at_m < ${cursor}` : sql``} + ORDER BY updated_at_m DESC + LIMIT ${limit + 1} + ) r + LEFT JOIN ( + SELECT + kr.role_id, + k.name as key_name, + ROW_NUMBER() OVER (PARTITION BY kr.role_id ORDER BY k.name) as key_row_num, + COUNT(*) OVER (PARTITION BY kr.role_id) as total_keys + FROM keys_roles kr + JOIN \`keys\` k ON kr.key_id = k.id + WHERE kr.workspace_id = ${workspaceId} + ) key_data ON r.id = key_data.role_id AND key_data.key_row_num <= ${MAX_ITEMS_TO_SHOW} + LEFT JOIN ( + SELECT + rp.role_id, + p.name as permission_name, + ROW_NUMBER() OVER (PARTITION BY rp.role_id ORDER BY p.name) as perm_row_num, + COUNT(*) OVER (PARTITION BY rp.role_id) as total_permissions + FROM roles_permissions rp + JOIN permissions p ON rp.permission_id = p.id + WHERE rp.workspace_id = ${workspaceId} + ) perm_data ON r.id = perm_data.role_id AND perm_data.perm_row_num <= ${MAX_ITEMS_TO_SHOW} + GROUP BY r.id, r.name, r.human_readable, r.description, r.updated_at_m + ORDER BY r.updated_at_m DESC + `); + + const rows = result.rows as { + id: string; + name: string; + human_readable: string | null; + 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 > 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 { + slug: row.name, + name: row.human_readable || "", + 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, + }; + }); diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 0e44e2698a..98c98e618e 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -20,6 +20,7 @@ import { updateApiIpWhitelist } from "./api/updateIpWhitelist"; import { updateApiName } from "./api/updateName"; import { fetchAuditLog } from "./audit/fetch"; import { auditLogsSearch } from "./audit/llm-search"; +import { queryRoles } from "./authorization/roles"; import { queryUsage } from "./billing/query-usage"; import { createIdentity } from "./identity/create"; import { queryIdentities } from "./identity/query"; @@ -152,6 +153,9 @@ export const router = t.router({ plain: t.router({ createIssue: createPlainIssue, }), + authorization: t.router({ + roles: queryRoles, + }), rbac: t.router({ addPermissionToRootKey: addPermissionToRootKey, connectPermissionToRole: connectPermissionToRole, diff --git a/internal/db/src/schema/rbac.ts b/internal/db/src/schema/rbac.ts index bb0b4865b5..29ad08f573 100644 --- a/internal/db/src/schema/rbac.ts +++ b/internal/db/src/schema/rbac.ts @@ -92,8 +92,8 @@ export const roles = mysqlTable( id: varchar("id", { length: 256 }).primaryKey(), workspaceId: varchar("workspace_id", { length: 256 }).notNull(), name: varchar("name", { length: 512 }).notNull(), + human_readable: varchar("human_readable", { length: 512 }), description: varchar("description", { length: 512 }), - createdAtM: bigint("created_at_m", { mode: "number" }) .notNull() .default(0) From e784aef58d6dcbb65491303a6d4c443a976f0933 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 2 Jun 2025 16:51:06 +0300 Subject: [PATCH 02/47] feat: roles cleanup --- .../app/(app)/authorization/layout.tsx | 23 -- .../roles/[roleId]/delete-role.tsx | 124 ---------- .../roles/[roleId]/navigation.tsx | 58 ----- .../authorization/roles/[roleId]/page.tsx | 132 ----------- .../roles/[roleId]/permission-toggle.tsx | 83 ------- .../roles/[roleId]/settings-client.tsx | 216 ------------------ .../authorization/roles/[roleId]/tree.tsx | 160 ------------- .../app/(app)/authorization/roles/empty.tsx | 31 --- .../(app)/authorization/roles/navigation.tsx | 43 +--- .../app/(app)/authorization/roles/page.tsx | 116 +--------- 10 files changed, 5 insertions(+), 981 deletions(-) delete mode 100644 apps/dashboard/app/(app)/authorization/layout.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/[roleId]/delete-role.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/[roleId]/navigation.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/[roleId]/page.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/[roleId]/permission-toggle.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/[roleId]/settings-client.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/[roleId]/tree.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/empty.tsx 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/roles/[roleId]/delete-role.tsx b/apps/dashboard/app/(app)/authorization/roles/[roleId]/delete-role.tsx deleted file mode 100644 index 4711fa8417..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/[roleId]/delete-role.tsx +++ /dev/null @@ -1,124 +0,0 @@ -"use client"; -import { DialogContainer } from "@/components/dialog-container"; -import { toast } from "@/components/ui/toaster"; -import { trpc } from "@/lib/trpc/client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Button, 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 534a23031e..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/[roleId]/settings-client.tsx +++ /dev/null @@ -1,216 +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 { SettingCard } from "@unkey/ui"; -import { Button, Input } 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/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/navigation.tsx b/apps/dashboard/app/(app)/authorization/roles/navigation.tsx index 738dada3fb..8eaca543e2 100644 --- a/apps/dashboard/app/(app)/authorization/roles/navigation.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/navigation.tsx @@ -1,19 +1,8 @@ "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"; - -interface NavigationProps { - roles: number; -} - -export function Navigation({ roles }: NavigationProps) { - const [isRoleModalOpen, setIsRoleModalOpen] = useState(false); +export function Navigation() { return ( <> @@ -25,37 +14,7 @@ export function Navigation({ roles }: NavigationProps) { Roles - - - setIsRoleModalOpen(true)} - > - Create New Role - - - - ); } diff --git a/apps/dashboard/app/(app)/authorization/roles/page.tsx b/apps/dashboard/app/(app)/authorization/roles/page.tsx index ce2cb64937..cb458287ac 100644 --- a/apps/dashboard/app/(app)/authorization/roles/page.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/page.tsx @@ -1,119 +1,11 @@ -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 { 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 - -
    -
    - -
    - - ))} -
- )} -
-
-
+ +
hello
); } From 39beb8a884eb10e24c39b2b27be26b99418a1b7a Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 2 Jun 2025 17:16:44 +0300 Subject: [PATCH 03/47] feat: add initial table --- .../roles/components/control-cloud/index.tsx | 29 ++ .../components/logs-filters/index.tsx | 128 ++++++++ .../controls/components/logs-search/index.tsx | 63 ++++ .../roles/components/controls/index.tsx | 16 + .../actions/components/delete-key.tsx | 159 +++++++++ .../actions/components/disable-key.tsx | 165 ++++++++++ .../actions/components/edit-credits/index.tsx | 137 ++++++++ .../actions/components/edit-credits/utils.ts | 44 +++ .../components/edit-expiration/index.tsx | 113 +++++++ .../components/edit-expiration/utils.ts | 15 + .../components/edit-external-id/index.tsx | 135 ++++++++ .../actions/components/edit-key-name.tsx | 145 +++++++++ .../components/edit-metadata/index.tsx | 122 +++++++ .../actions/components/edit-metadata/utils.ts | 10 + .../components/edit-ratelimits/index.tsx | 114 +++++++ .../components/edit-ratelimits/utils.ts | 22 ++ .../components/hooks/use-delete-key.ts | 70 ++++ .../components/hooks/use-edit-credits.ts | 44 +++ .../components/hooks/use-edit-expiration.ts | 55 ++++ .../components/hooks/use-edit-external-id.ts | 114 +++++++ .../actions/components/hooks/use-edit-key.tsx | 56 ++++ .../components/hooks/use-edit-metadata.ts | 53 +++ .../components/hooks/use-edit-ratelimits.ts | 98 ++++++ .../hooks/use-update-key-status.tsx | 86 +++++ .../actions/components/key-info.tsx | 27 ++ .../keys-table-action.popover.constants.tsx | 97 ++++++ .../actions/keys-table-action.popover.tsx | 138 ++++++++ .../components/outcome-explainer.tsx | 153 +++++++++ .../table/components/bar-chart/index.tsx | 152 +++++++++ .../bar-chart/query-timeseries.schema.ts | 10 + .../bar-chart/use-fetch-timeseries.ts | 126 +++++++ .../table/components/hidden-value.tsx | 47 +++ .../components/table/components/last-used.tsx | 70 ++++ .../components/batch-edit-external-id.tsx | 169 ++++++++++ .../components/selection-controls/index.tsx | 235 +++++++++++++ .../components/table/components/skeletons.tsx | 82 +++++ .../status-cell/components/status-badge.tsx | 70 ++++ .../components/status-cell/constants.tsx | 79 +++++ .../table/components/status-cell/index.tsx | 141 ++++++++ .../status-cell/query-timeseries.schema.ts | 10 + .../components/status-cell/use-key-status.ts | 173 ++++++++++ .../table/hooks/use-roles-list-query.ts | 83 +++++ .../roles/components/table/keys-list.tsx | 308 ++++++++++++++++++ .../components/table/query-logs.schema.ts | 37 +++ .../components/table/utils/get-row-class.ts | 39 +++ .../authorization/roles/filters.schema.ts | 65 ++++ .../authorization/roles/hooks/use-filters.ts | 106 ++++++ .../(app)/authorization/roles/navigation.tsx | 20 +- .../app/(app)/authorization/roles/page.tsx | 5 +- .../trpc/routers/authorization/roles/index.ts | 4 +- 50 files changed, 4425 insertions(+), 14 deletions(-) create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/control-cloud/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/controls/components/logs-filters/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/controls/components/logs-search/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/controls/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-key.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/disable-key.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/utils.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/utils.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-external-id/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-key-name.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/utils.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/utils.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-delete-key.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-credits.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-expiration.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-external-id.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-key.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-metadata.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-ratelimits.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-update-key-status.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/key-info.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.constants.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/components/outcome-explainer.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/query-timeseries.schema.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/use-fetch-timeseries.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/hidden-value.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/last-used.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/selection-controls/components/batch-edit-external-id.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/selection-controls/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/skeletons.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/components/status-badge.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/constants.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/query-timeseries.schema.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/use-key-status.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/keys-list.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/query-logs.schema.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/utils/get-row-class.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/filters.schema.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/hooks/use-filters.ts 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..baaf8b3717 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/control-cloud/index.tsx @@ -0,0 +1,29 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import { ControlCloud } from "@/components/logs/control-cloud"; +import { useFilters } from "../../hooks/use-filters"; + +const formatFieldName = (field: string): string => { + switch (field) { + case "names": + return "Name"; + case "identities": + return "Identity"; + case "keyIds": + return "Key ID"; + default: + return field.charAt(0).toUpperCase() + field.slice(1); + } +}; + +export const KeysListControlCloud = () => { + 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..f495aa0aa4 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/controls/components/logs-filters/index.tsx @@ -0,0 +1,128 @@ +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 { keysListFilterFieldConfig } from "../../../../filters.schema"; +import { useFilters } from "../../../../hooks/use-filters"; + +export const LogsFilters = () => { + const { filters, updateFilters } = useFilters(); + + const options = keysListFilterFieldConfig.names.operators.map((op) => ({ + id: op, + label: op, + })); + const activeNameFilter = filters.find((f) => f.field === "names"); + const activeIdentityFilter = filters.find((f) => f.field === "identities"); + const activeKeyIdsFilter = filters.find((f) => f.field === "keyIds"); + const keyIdOptions = keysListFilterFieldConfig.names.operators.map((op) => ({ + id: op, + label: op, + })); + return ( + { + const activeFiltersWithoutNames = filters.filter((f) => f.field !== "names"); + updateFilters([ + ...activeFiltersWithoutNames, + { + field: "names", + id: crypto.randomUUID(), + operator: id, + value: text, + }, + ]); + }} + /> + ), + }, + { + id: "identities", + label: "Identity", + shortcut: "i", + component: ( + { + const activeFiltersWithoutNames = filters.filter((f) => f.field !== "identities"); + updateFilters([ + ...activeFiltersWithoutNames, + { + field: "identities", + id: crypto.randomUUID(), + operator: id, + value: text, + }, + ]); + }} + /> + ), + }, + { + id: "keyids", + label: "Key ID", + shortcut: "k", + component: ( + { + const activeFiltersWithoutKeyIds = filters.filter((f) => f.field !== "keyIds"); + updateFilters([ + ...activeFiltersWithoutKeyIds, + { + field: "keyIds", + id: crypto.randomUUID(), + operator: id, + value: text, + }, + ]); + }} + /> + ), + }, + ]} + activeFilters={filters} + > +
+ +
+
+ ); +}; 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..9a18da6f29 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/controls/components/logs-search/index.tsx @@ -0,0 +1,63 @@ +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 LogsSearch = ({ keyspaceId }: { keyspaceId: string }) => { + const { filters, updateFilters } = useFilters(); + const queryLLMForStructuredOutput = trpc.api.keys.listLlmSearch.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 as any); + }, + 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({ + keyspaceId, + 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..97e8c07861 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/controls/index.tsx @@ -0,0 +1,16 @@ +import { LogsDateTime } from "@/app/(app)/apis/_components/controls/components/logs-datetime"; +import { ControlsContainer, ControlsLeft } from "@/components/logs/controls-container"; +import { LogsFilters } from "./components/logs-filters"; +import { LogsSearch } from "./components/logs-search"; + +export function KeysListControls({ keyspaceId }: { keyspaceId: string }) { + return ( + + + + + + + + ); +} diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-key.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-key.tsx new file mode 100644 index 0000000000..510d055614 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-key.tsx @@ -0,0 +1,159 @@ +import { ConfirmPopover } from "@/components/confirmation-popover"; +import { DialogContainer } from "@/components/dialog-container"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { TriangleWarning2 } from "@unkey/icons"; +import { Button, FormCheckbox } from "@unkey/ui"; +import { useRef, useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { z } from "zod"; +import type { ActionComponentProps } from "../keys-table-action.popover"; +import { useDeleteKey } from "./hooks/use-delete-key"; +import { KeyInfo } from "./key-info"; + +const deleteKeyFormSchema = z.object({ + confirmDeletion: z.boolean().refine((val) => val === true, { + message: "Please confirm that you want to permanently delete this key", + }), +}); + +type DeleteKeyFormValues = z.infer; + +type DeleteKeyProps = { keyDetails: KeyDetails } & ActionComponentProps; + +export const DeleteKey = ({ keyDetails, isOpen, onClose }: DeleteKeyProps) => { + const [isConfirmPopoverOpen, setIsConfirmPopoverOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const deleteButtonRef = useRef(null); + + const methods = useForm({ + resolver: zodResolver(deleteKeyFormSchema), + mode: "onChange", + shouldFocusError: true, + shouldUnregister: true, + defaultValues: { + confirmDeletion: false, + }, + }); + + const { + formState: { errors }, + control, + watch, + } = methods; + + const confirmDeletion = watch("confirmDeletion"); + + const deleteKey = useDeleteKey(() => { + 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 performKeyDeletion = async () => { + try { + setIsLoading(true); + await deleteKey.mutateAsync({ + keyIds: [keyDetails.id], + }); + } catch { + // `useDeleteKey` already shows a toast, but we still need to + // prevent unhandled‐rejection noise in the console. + } finally { + setIsLoading(false); + } + }; + + return ( + <> + +
+ + +
+ This key will be permanently deleted immediately +
+ + } + > + +
+
+
+
+
+ +
+
+ Warning: deleting this key will remove all + associated data and metadata. This action cannot be undone. Any verification, + tracking, and historical usage tied to this key will be permanently lost. +
+
+ ( + + )} + /> + + + + + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/disable-key.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/disable-key.tsx new file mode 100644 index 0000000000..a64046630b --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/disable-key.tsx @@ -0,0 +1,165 @@ +import { revalidate } from "@/app/actions"; +import { ConfirmPopover } from "@/components/confirmation-popover"; +import { DialogContainer } from "@/components/dialog-container"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button, FormCheckbox } from "@unkey/ui"; +import { useRef, useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { z } from "zod"; +import type { ActionComponentProps } from "../keys-table-action.popover"; +import { useUpdateKeyStatus } from "./hooks/use-update-key-status"; +import { KeyInfo } from "./key-info"; + +const updateKeyStatusFormSchema = z.object({ + confirmStatusChange: z.boolean().refine((val) => val === true, { + message: "Please confirm that you want to change this key's status", + }), +}); + +type UpdateKeyStatusFormValues = z.infer; + +type UpdateKeyStatusProps = { keyDetails: KeyDetails } & ActionComponentProps; + +export const UpdateKeyStatus = ({ keyDetails, isOpen, onClose }: UpdateKeyStatusProps) => { + const isEnabling = !keyDetails.enabled; + const action = isEnabling ? "Enable" : "Disable"; + const [isConfirmPopoverOpen, setIsConfirmPopoverOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const actionButtonRef = useRef(null); + + const methods = useForm({ + resolver: zodResolver(updateKeyStatusFormSchema), + mode: "onChange", + shouldFocusError: true, + shouldUnregister: true, + defaultValues: { + confirmStatusChange: false, + }, + }); + + const { + formState: { errors }, + control, + watch, + } = methods; + + const confirmStatusChange = watch("confirmStatusChange"); + + const updateKeyStatus = useUpdateKeyStatus(() => { + onClose(); + revalidate(keyDetails.id); + }); + + 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 handleActionButtonClick = () => { + // Only show confirmation popover for disabling + if (isEnabling) { + performStatusUpdate(); + } else { + setIsConfirmPopoverOpen(true); + } + }; + + const performStatusUpdate = async () => { + try { + setIsLoading(true); + await updateKeyStatus.mutateAsync({ + keyIds: [keyDetails.id], + enabled: isEnabling, + }); + } catch { + // `useUpdateKeyStatus` already shows a toast, but we still need to + // prevent unhandled‐rejection noise in the console. + } finally { + setIsLoading(false); + } + }; + + return ( + <> + +
+ + +
Changes will be applied immediately
+
+ } + > + +
+
+
+ ( + + )} + /> + + + + {!isEnabling && ( + + )} + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/index.tsx new file mode 100644 index 0000000000..b3a8cc183f --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/index.tsx @@ -0,0 +1,137 @@ +import { UsageSetup } from "@/app/(app)/apis/[apiId]/_components/create-key/components/credits-setup"; +import { + type CreditsFormValues, + creditsSchema, +} from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; +import { DialogContainer } from "@/components/dialog-container"; +import { toast } from "@/components/ui/toaster"; +import { usePersistedForm } from "@/hooks/use-persisted-form"; +import { trpc } from "@/lib/trpc/client"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@unkey/ui"; +import { useEffect } from "react"; +import { FormProvider } from "react-hook-form"; +import type { ActionComponentProps } from "../../keys-table-action.popover"; +import { useEditCredits } from "../hooks/use-edit-credits"; +import { KeyInfo } from "../key-info"; +import { getKeyLimitDefaults } from "./utils"; + +const EDIT_CREDITS_FORM_STORAGE_KEY = "unkey_edit_credits_form_state"; + +type EditCreditsProps = { keyDetails: KeyDetails } & ActionComponentProps; + +export const EditCredits = ({ keyDetails, isOpen, onClose }: EditCreditsProps) => { + const trpcUtil = trpc.useUtils(); + const methods = usePersistedForm( + `${EDIT_CREDITS_FORM_STORAGE_KEY}_${keyDetails.id}`, + { + resolver: zodResolver(creditsSchema), + mode: "onChange", + shouldFocusError: true, + shouldUnregister: true, + defaultValues: getKeyLimitDefaults(keyDetails), + }, + "memory", + ); + + const { + handleSubmit, + formState: { isSubmitting, isValid }, + loadSavedValues, + saveCurrentValues, + clearPersistedData, + reset, + } = methods; + + // Load saved values when the dialog opens + useEffect(() => { + if (isOpen) { + loadSavedValues(); + } + }, [isOpen, loadSavedValues]); + + const key = useEditCredits(() => { + reset(getKeyLimitDefaults(keyDetails)); + clearPersistedData(); + trpcUtil.key.fetchPermissions.invalidate(); + onClose(); + }); + + const onSubmit = async (data: CreditsFormValues) => { + try { + if (data.limit) { + if (data.limit.enabled === true) { + if (data.limit.data) { + await key.mutateAsync({ + keyId: keyDetails.id, + limit: { + enabled: true, + data: data.limit.data, + }, + }); + } else { + // Shouldn't happen + toast.error("Failed to Update Key Limits", { + description: "An unexpected error occurred. Please try again later.", + action: { + label: "Contact Support", + onClick: () => window.open("https://support.unkey.dev", "_blank"), + }, + }); + } + } else { + await key.mutateAsync({ + keyId: keyDetails.id, + limit: { + enabled: false, + }, + }); + } + } + } catch { + // `useEditKeyRemainingUses` already shows a toast, but we still need to + // prevent unhandled‐rejection noise in the console. + } + }; + + return ( + +
+ { + saveCurrentValues(); + onClose(); + }} + title="Edit Credits" + footer={ +
+ +
Changes will be applied immediately
+
+ } + > + +
+
+
+
+ +
+ + + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/utils.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/utils.ts new file mode 100644 index 0000000000..7e39d40da2 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/utils.ts @@ -0,0 +1,44 @@ +import type { refillSchema } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; +import { getDefaultValues } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.utils"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import type { z } from "zod"; + +type Refill = z.infer; +export const getKeyLimitDefaults = (keyDetails: KeyDetails) => { + const defaultRemaining = + keyDetails.key.credits.remaining ?? getDefaultValues().limit?.data?.remaining ?? 100; + + let refill: Refill; + if (keyDetails.key.credits.refillDay) { + // Monthly refill + refill = { + interval: "monthly", + amount: keyDetails.key.credits.refillAmount ?? 100, + refillDay: keyDetails.key.credits.refillDay, + }; + } else if (keyDetails.key.credits.refillAmount) { + // Daily refill + refill = { + interval: "daily", + amount: keyDetails.key.credits.refillAmount, + refillDay: undefined, + }; + } else { + // No refill + refill = { + interval: "none", + amount: undefined, + refillDay: undefined, + }; + } + + return { + limit: { + enabled: keyDetails.key.credits.enabled, + data: { + remaining: defaultRemaining, + refill, + }, + }, + }; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/index.tsx new file mode 100644 index 0000000000..9f52765c28 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/index.tsx @@ -0,0 +1,113 @@ +import { ExpirationSetup } from "@/app/(app)/apis/[apiId]/_components/create-key/components/expiration-setup"; +import { + type ExpirationFormValues, + expirationSchema, +} from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; +import { DialogContainer } from "@/components/dialog-container"; +import { usePersistedForm } from "@/hooks/use-persisted-form"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@unkey/ui"; +import { useEffect } from "react"; +import { FormProvider } from "react-hook-form"; +import type { ActionComponentProps } from "../../keys-table-action.popover"; +import { useEditExpiration } from "../hooks/use-edit-expiration"; +import { KeyInfo } from "../key-info"; +import { getKeyExpirationDefaults } from "./utils"; + +const EDIT_EXPIRATION_FORM_STORAGE_KEY = "unkey_edit_expiration_form_state"; + +type EditExpirationProps = { + keyDetails: KeyDetails; +} & ActionComponentProps; + +export const EditExpiration = ({ keyDetails, isOpen, onClose }: EditExpirationProps) => { + const methods = usePersistedForm( + `${EDIT_EXPIRATION_FORM_STORAGE_KEY}_${keyDetails.id}`, + { + resolver: zodResolver(expirationSchema), + mode: "onChange", + shouldFocusError: true, + shouldUnregister: true, + defaultValues: getKeyExpirationDefaults(keyDetails), + }, + "memory", + ); + + const { + handleSubmit, + formState: { isSubmitting, isValid }, + loadSavedValues, + saveCurrentValues, + clearPersistedData, + reset, + } = methods; + + // Load saved values when the dialog opens + useEffect(() => { + if (isOpen) { + loadSavedValues(); + } + }, [isOpen, loadSavedValues]); + + const updateExpiration = useEditExpiration(() => { + reset(getKeyExpirationDefaults(keyDetails)); + clearPersistedData(); + onClose(); + }); + + const onSubmit = async (data: ExpirationFormValues) => { + try { + await updateExpiration.mutateAsync({ + keyId: keyDetails.id, + expiration: { + enabled: data.expiration.enabled, + data: data.expiration.enabled ? data.expiration.data : undefined, + }, + }); + } catch { + // `useEditExpiration` already shows a toast, but we still need to + // prevent unhandled rejection noise in the console. + } + }; + + return ( + +
+ { + saveCurrentValues(); + onClose(); + }} + title="Edit expiration" + footer={ +
+ +
Changes will be applied immediately
+
+ } + > + +
+
+
+
+ +
+ + + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/utils.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/utils.ts new file mode 100644 index 0000000000..267a265430 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/utils.ts @@ -0,0 +1,15 @@ +import { getDefaultValues } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.utils"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; + +export const getKeyExpirationDefaults = (keyDetails: KeyDetails) => { + const defaultExpiration = keyDetails.expires + ? new Date(keyDetails.expires) + : getDefaultValues().expiration?.data; + + return { + expiration: { + enabled: Boolean(keyDetails.expires), + data: defaultExpiration, + }, + }; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-external-id/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-external-id/index.tsx new file mode 100644 index 0000000000..991f9995ed --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-external-id/index.tsx @@ -0,0 +1,135 @@ +import { ExternalIdField } from "@/app/(app)/apis/[apiId]/_components/create-key/components/external-id-field"; +import { ConfirmPopover } from "@/components/confirmation-popover"; +import { DialogContainer } from "@/components/dialog-container"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { Button } from "@unkey/ui"; +import { useRef, useState } from "react"; +import type { ActionComponentProps } from "../../keys-table-action.popover"; +import { useEditExternalId } from "../hooks/use-edit-external-id"; +import { KeyInfo } from "../key-info"; + +type EditExternalIdProps = { + keyDetails: KeyDetails; +} & ActionComponentProps; + +export const EditExternalId = ({ + keyDetails, + isOpen, + onClose, +}: EditExternalIdProps): JSX.Element => { + const [originalIdentityId, setOriginalIdentityId] = useState( + keyDetails.identity_id || null, + ); + const [selectedIdentityId, setSelectedIdentityId] = useState( + keyDetails.identity_id || null, + ); + const [isConfirmPopoverOpen, setIsConfirmPopoverOpen] = useState(false); + const clearButtonRef = useRef(null); + + const updateKeyOwner = useEditExternalId(() => { + setOriginalIdentityId(selectedIdentityId); + onClose(); + }); + + const handleSubmit = () => { + updateKeyOwner.mutate({ + keyIds: keyDetails.id, + ownerType: "v2", + identity: { + id: selectedIdentityId, + }, + }); + }; + + const handleClearButtonClick = () => { + setIsConfirmPopoverOpen(true); + }; + + const handleDialogOpenChange = (open: boolean) => { + if (isConfirmPopoverOpen && !isOpen) { + // If confirm popover is active don't let this trigger outer popover + return; + } + + if (!isConfirmPopoverOpen && !open) { + onClose(); + } + }; + + const clearSelection = async () => { + setSelectedIdentityId(null); + await updateKeyOwner.mutateAsync({ + keyIds: keyDetails.id, + ownerType: "v2", + identity: { + id: null, + }, + }); + }; + + return ( + <> + +
+ {originalIdentityId !== null ? ( + + ) : ( + + )} +
+
Changes will be applied immediately
+
+ } + > + +
+
+
+ + + + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-key-name.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-key-name.tsx new file mode 100644 index 0000000000..50e124aa4c --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-key-name.tsx @@ -0,0 +1,145 @@ +import { nameSchema } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; +import { DialogContainer } from "@/components/dialog-container"; +import { usePersistedForm } from "@/hooks/use-persisted-form"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button, FormInput } from "@unkey/ui"; +import { useEffect } from "react"; +import { FormProvider } from "react-hook-form"; +import { z } from "zod"; +import type { ActionComponentProps } from "../keys-table-action.popover"; +import { useEditKeyName } from "./hooks/use-edit-key"; +import { KeyInfo } from "./key-info"; + +const editNameFormSchema = z + .object({ + name: nameSchema, + //Hidden field. Required for comparison + originalName: z.string().optional().default(""), + }) + .superRefine((data, ctx) => { + const normalizedNewName = (data.name || "").trim(); + const normalizedOriginalName = (data.originalName || "").trim(); + + if (normalizedNewName === normalizedOriginalName && normalizedNewName !== "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "New name must be different from the current name", + path: ["name"], + }); + } + }); + +const EDIT_NAME_FORM_STORAGE_KEY = "unkey_edit_name_form_state"; + +type EditNameFormValues = z.infer; +type EditKeyNameProps = { keyDetails: KeyDetails } & ActionComponentProps; + +export const EditKeyName = ({ keyDetails, isOpen, onClose }: EditKeyNameProps) => { + const methods = usePersistedForm( + `${EDIT_NAME_FORM_STORAGE_KEY}_${keyDetails.id}`, + { + resolver: zodResolver(editNameFormSchema), + mode: "onChange", + shouldFocusError: true, + shouldUnregister: true, + defaultValues: { + name: keyDetails.name || "", + originalName: keyDetails.name || "", + }, + }, + "memory", + ); + + const { + handleSubmit, + formState: { isSubmitting, errors, isValid }, + register, + loadSavedValues, + saveCurrentValues, + clearPersistedData, + reset, + } = methods; + + // Load saved values when the dialog opens + useEffect(() => { + if (isOpen) { + loadSavedValues(); + } + }, [isOpen, loadSavedValues]); + + const key = useEditKeyName(() => { + clearPersistedData(); + reset({ + name: keyDetails.name || "", + originalName: keyDetails.name || "", + }); + onClose(); + }); + + const onSubmit = async (data: EditNameFormValues) => { + try { + await key.mutateAsync({ ...data, keyId: keyDetails.id }); + } catch { + // `useEditKeyName` already shows a toast, but we still need to + // prevent unhandled‐rejection noise in the console. + } + }; + + return ( + +
+ { + saveCurrentValues(); + onClose(); + }} + title="Edit key name" + footer={ +
+ +
Changes will be applied immediately
+
+ } + > + +
+
+
+
+ +
+ + + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/index.tsx new file mode 100644 index 0000000000..8f9067b9f2 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/index.tsx @@ -0,0 +1,122 @@ +import { MetadataSetup } from "@/app/(app)/apis/[apiId]/_components/create-key/components/metadata-setup"; +import { + type MetadataFormValues, + metadataSchema, +} from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; +import { DialogContainer } from "@/components/dialog-container"; +import { usePersistedForm } from "@/hooks/use-persisted-form"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@unkey/ui"; +import { useEffect } from "react"; +import { FormProvider } from "react-hook-form"; +import type { ActionComponentProps } from "../../keys-table-action.popover"; +import { useEditMetadata } from "../hooks/use-edit-metadata"; +import { KeyInfo } from "../key-info"; +import { getKeyMetadataDefaults } from "./utils"; + +const EDIT_METADATA_FORM_STORAGE_KEY = "unkey_edit_metadata_form_state"; + +type EditMetadataProps = { + keyDetails: KeyDetails; +} & ActionComponentProps; + +export const EditMetadata = ({ keyDetails, isOpen, onClose }: EditMetadataProps) => { + const methods = usePersistedForm( + `${EDIT_METADATA_FORM_STORAGE_KEY}_${keyDetails.id}`, + { + resolver: zodResolver(metadataSchema), + mode: "onChange", + shouldFocusError: true, + shouldUnregister: true, + defaultValues: getKeyMetadataDefaults(keyDetails), + }, + "memory", + ); + + const { + handleSubmit, + formState: { isSubmitting, isValid }, + loadSavedValues, + saveCurrentValues, + clearPersistedData, + reset, + } = methods; + + // Load saved values when the dialog opens + useEffect(() => { + if (isOpen) { + loadSavedValues(); + } + }, [isOpen, loadSavedValues]); + + const updateMetadata = useEditMetadata(() => { + reset(getKeyMetadataDefaults(keyDetails)); + clearPersistedData(); + onClose(); + }); + + const onSubmit = async (data: MetadataFormValues) => { + try { + if (data.metadata.enabled && data.metadata.data) { + await updateMetadata.mutateAsync({ + keyId: keyDetails.id, + metadata: { + enabled: data.metadata.enabled, + data: data.metadata.data, + }, + }); + } else { + await updateMetadata.mutateAsync({ + keyId: keyDetails.id, + metadata: { + enabled: false, + }, + }); + } + } catch { + // useEditMetadata already shows a toast, but we still need to + // prevent unhandled rejection noise in the console. + } + }; + + return ( + +
+ { + saveCurrentValues(); + onClose(); + }} + title="Edit metadata" + footer={ +
+ +
Changes will be applied immediately
+
+ } + > + +
+
+
+
+ +
+ + + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/utils.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/utils.ts new file mode 100644 index 0000000000..015e2a834e --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/utils.ts @@ -0,0 +1,10 @@ +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; + +export const getKeyMetadataDefaults = (keyDetails: KeyDetails) => { + return { + metadata: { + enabled: Boolean(keyDetails.metadata), + data: JSON.stringify(JSON.parse(keyDetails.metadata || "{}"), null, 2) ?? undefined, + }, + }; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/index.tsx new file mode 100644 index 0000000000..da1456e56f --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/index.tsx @@ -0,0 +1,114 @@ +import { RatelimitSetup } from "@/app/(app)/apis/[apiId]/_components/create-key/components/ratelimit-setup"; +import { + type RatelimitFormValues, + ratelimitSchema, +} from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; +import { DialogContainer } from "@/components/dialog-container"; +import { usePersistedForm } from "@/hooks/use-persisted-form"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@unkey/ui"; +import { useEffect } from "react"; +import { FormProvider } from "react-hook-form"; +import type { ActionComponentProps } from "../../keys-table-action.popover"; +import { useEditRatelimits } from "../hooks/use-edit-ratelimits"; +import { KeyInfo } from "../key-info"; +import { getKeyRatelimitsDefaults } from "./utils"; + +const EDIT_RATELIMITS_FORM_STORAGE_KEY = "unkey_edit_ratelimits_form_state"; + +type EditRatelimitsProps = { + keyDetails: KeyDetails; +} & ActionComponentProps; + +export const EditRatelimits = ({ keyDetails, isOpen, onClose }: EditRatelimitsProps) => { + const methods = usePersistedForm( + `${EDIT_RATELIMITS_FORM_STORAGE_KEY}_${keyDetails.id}`, + { + resolver: zodResolver(ratelimitSchema), + mode: "onChange", + shouldFocusError: true, + shouldUnregister: true, + defaultValues: getKeyRatelimitsDefaults(keyDetails), + }, + "memory", + ); + + const { + handleSubmit, + formState: { isSubmitting, isValid }, + loadSavedValues, + saveCurrentValues, + clearPersistedData, + reset, + } = methods; + + // Load saved values when the dialog opens + useEffect(() => { + if (isOpen) { + loadSavedValues(); + } + }, [isOpen, loadSavedValues]); + + const key = useEditRatelimits(() => { + reset(getKeyRatelimitsDefaults(keyDetails)); + clearPersistedData(); + onClose(); + }); + + const onSubmit = async (data: RatelimitFormValues) => { + try { + await key.mutateAsync({ + keyId: keyDetails.id, + ratelimitType: "v2", + ratelimit: { + enabled: data.ratelimit.enabled, + data: data.ratelimit.data, + }, + }); + } catch { + // `useEditRatelimits` already shows a toast, but we still need to + // prevent unhandled rejection noise in the console. + } + }; + + return ( + +
+ { + saveCurrentValues(); + onClose(); + }} + title="Edit ratelimits" + footer={ +
+ +
Changes will be applied immediately
+
+ } + > + +
+
+
+
+ +
+ + + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/utils.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/utils.ts new file mode 100644 index 0000000000..769b833f26 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/utils.ts @@ -0,0 +1,22 @@ +import { getDefaultValues } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.utils"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; + +export const getKeyRatelimitsDefaults = (keyDetails: KeyDetails) => { + const defaultRatelimits = + keyDetails.key.ratelimits.items.length > 0 + ? keyDetails.key.ratelimits.items + : (getDefaultValues().ratelimit?.data ?? [ + { + name: "Default", + limit: 10, + refillInterval: 1000, + }, + ]); + + return { + ratelimit: { + enabled: keyDetails.key.ratelimits.enabled, + data: defaultRatelimits, + }, + }; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-delete-key.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-delete-key.ts new file mode 100644 index 0000000000..26eb25f6f2 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-delete-key.ts @@ -0,0 +1,70 @@ +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; + +export const useDeleteKey = (onSuccess?: () => void) => { + const trpcUtils = trpc.useUtils(); + const deleteKey = trpc.key.delete.useMutation({ + onSuccess(data, variable) { + const deletedCount = data.totalDeleted; + + if (deletedCount === 1) { + toast.success("Key Deleted", { + description: "Your key has been permanently deleted successfully", + duration: 5000, + }); + } else { + toast.success("Keys Deleted", { + description: `${deletedCount} keys have been permanently deleted successfully`, + duration: 5000, + }); + } + + // If some keys weren't found. Someone might've already deleted them when this is fired. + if (data.deletedKeyIds.length < variable.keyIds.length) { + const missingCount = variable.keyIds.length - data.deletedKeyIds.length; + toast.warning("Some Keys Not Found", { + description: `${missingCount} ${ + missingCount === 1 ? "key was" : "keys were" + } not found and could not be deleted.`, + duration: 7000, + }); + } + + trpcUtils.api.keys.list.invalidate(); + if (onSuccess) { + onSuccess(); + } + }, + onError(err, variable) { + const errorMessage = err.message || ""; + const isPlural = variable.keyIds.length > 1; + const keyText = isPlural ? "keys" : "key"; + + if (err.data?.code === "NOT_FOUND") { + toast.error("Key Deletion Failed", { + description: `Unable to find the ${keyText}. Please refresh and try again.`, + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: `We encountered an issue while deleting your ${keyText}. Please try again later or contact support at support.unkey.dev`, + }); + } else if (err.data?.code === "FORBIDDEN") { + toast.error("Permission Denied", { + description: `You don't have permission to delete ${ + isPlural ? "these keys" : "this key" + }.`, + }); + } else { + toast.error(`Failed to Delete ${isPlural ? "Keys" : "Key"}`, { + description: errorMessage || "An unexpected error occurred. Please try again later.", + action: { + label: "Contact Support", + onClick: () => window.open("https://support.unkey.dev", "_blank"), + }, + }); + } + }, + }); + + return deleteKey; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-credits.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-credits.ts new file mode 100644 index 0000000000..b17ef8a017 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-credits.ts @@ -0,0 +1,44 @@ +import { toast } from "@/components/ui/toaster"; + +import { trpc } from "@/lib/trpc/client"; + +export const useEditCredits = (onSuccess?: () => void) => { + const trpcUtils = trpc.useUtils(); + const updateKeyRemaining = trpc.key.update.remaining.useMutation({ + onSuccess(data, variables) { + const remainingChange = variables.limit?.enabled + ? `with ${variables.limit.data.remaining} uses remaining` + : "with limits disabled"; + + toast.success("Key Limits Updated", { + description: `Your key ${data.keyId} has been updated successfully ${remainingChange}`, + duration: 5000, + }); + trpcUtils.api.keys.list.invalidate(); + if (onSuccess) { + onSuccess(); + } + }, + onError(err) { + if (err.data?.code === "NOT_FOUND") { + toast.error("Key Update Failed", { + description: "Unable to find the key. Please refresh and try again.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We encountered an issue while updating your key. Please try again later or contact support at support.unkey.dev", + }); + } else { + toast.error("Failed to Update Key Limits", { + description: err.message || "An unexpected error occurred. Please try again later.", + action: { + label: "Contact Support", + onClick: () => window.open("https://support.unkey.dev", "_blank"), + }, + }); + } + }, + }); + return updateKeyRemaining; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-expiration.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-expiration.ts new file mode 100644 index 0000000000..aec5f35449 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-expiration.ts @@ -0,0 +1,55 @@ +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; + +export const useEditExpiration = (onSuccess?: () => void) => { + const trpcUtils = trpc.useUtils(); + const updateKeyExpiration = trpc.key.update.expiration.useMutation({ + onSuccess(_, variables) { + let description = ""; + if (variables.expiration?.enabled && variables.expiration.data) { + description = `Your key ${ + variables.keyId + } has been updated to expire on ${variables.expiration.data.toLocaleString()}`; + } else { + description = `Expiration has been disabled for key ${variables.keyId}`; + } + + toast.success("Key Expiration Updated", { + description, + duration: 5000, + }); + trpcUtils.api.keys.list.invalidate(); + if (onSuccess) { + onSuccess(); + } + }, + onError(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") { + toast.error("Invalid Request", { + description: err.message || "Please check your expiration settings and try again.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We were unable to update expiration on this key. Please try again or contact support@unkey.dev", + }); + } else { + toast.error("Failed to Update Key Expiration", { + 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"), + }, + }); + } + }, + }); + return updateKeyExpiration; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-external-id.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-external-id.ts new file mode 100644 index 0000000000..c6ebead907 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-external-id.ts @@ -0,0 +1,114 @@ +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import type { TRPCClientErrorLike } from "@trpc/client"; +import type { TRPCErrorShape } from "@trpc/server/rpc"; + +const handleKeyOwnerUpdateError = (err: TRPCClientErrorLike) => { + if (err.data?.code === "NOT_FOUND") { + toast.error("Key Update Failed", { + description: "Unable to find the key(s). Please refresh and try again.", + }); + } else if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid External ID Information", { + description: + err.message || "Please ensure your External ID information is valid and try again.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We are unable to update External ID information on this key. Please try again or contact support@unkey.dev", + }); + } else { + toast.error("Failed to Update Key External ID", { + 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"), + }, + }); + } +}; + +export const useEditExternalId = (onSuccess?: () => void) => { + const trpcUtils = trpc.useUtils(); + const updateKeyOwner = trpc.key.update.ownerId.useMutation({ + onSuccess(_, variables) { + let description = ""; + const keyId = Array.isArray(variables.keyIds) ? variables.keyIds[0] : variables.keyIds; + + if (variables.ownerType === "v2") { + if (variables.identity?.id) { + description = `Identity for key ${keyId} has been updated`; + } else { + description = `Identity has been removed from key ${keyId}`; + } + } + toast.success("Key External ID Updated", { + description, + duration: 5000, + }); + + trpcUtils.api.keys.list.invalidate(); + if (onSuccess) { + onSuccess(); + } + }, + onError(err) { + handleKeyOwnerUpdateError(err); + }, + }); + + return updateKeyOwner; +}; + +export const useBatchEditExternalId = (onSuccess?: () => void) => { + const trpcUtils = trpc.useUtils(); + const batchUpdateKeyOwner = trpc.key.update.ownerId.useMutation({ + onSuccess(data, variables) { + const updatedCount = data.updatedCount; + let description = ""; + + if (variables.ownerType === "v2") { + if (variables.identity?.id) { + description = `Identity has been updated for ${updatedCount} ${ + updatedCount === 1 ? "key" : "keys" + }`; + } else { + description = `Identity has been removed from ${updatedCount} ${ + updatedCount === 1 ? "key" : "keys" + }`; + } + } + toast.success("Key External ID Updated", { + description, + duration: 5000, + }); + + // Show warning if some keys were not found (if that info is available in the response) + const missingCount = Array.isArray(variables.keyIds) + ? variables.keyIds.length - updatedCount + : 0; + + if (missingCount > 0) { + toast.warning("Some Keys Not Found", { + description: `${missingCount} ${ + missingCount === 1 ? "key was" : "keys were" + } not found and could not be updated.`, + duration: 7000, + }); + } + + trpcUtils.api.keys.list.invalidate(); + if (onSuccess) { + onSuccess(); + } + }, + onError(err) { + handleKeyOwnerUpdateError(err); + }, + }); + + return batchUpdateKeyOwner; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-key.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-key.tsx new file mode 100644 index 0000000000..c970bafaa0 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-key.tsx @@ -0,0 +1,56 @@ +import { UNNAMED_KEY } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.constants"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; + +export const useEditKeyName = (onSuccess: () => void) => { + const trpcUtils = trpc.useUtils(); + + const key = trpc.key.update.name.useMutation({ + onSuccess(data) { + const nameChange = + data.previousName !== data.newName + ? `from "${data.previousName || UNNAMED_KEY}" to "${data.newName || UNNAMED_KEY}"` + : ""; + + toast.success("Key Name Updated", { + description: `Your key ${data.keyId} has been updated successfully ${nameChange}`, + duration: 5000, + }); + + trpcUtils.api.keys.list.invalidate(); + onSuccess(); + }, + onError(err) { + const errorMessage = err.message || ""; + + if (err.data?.code === "UNPROCESSABLE_CONTENT") { + toast.error("No Changes Detected", { + description: "The new name must be different from the current name.", + }); + } else if (err.data?.code === "NOT_FOUND") { + toast.error("Key Update Failed", { + description: "Unable to find the key. Please refresh and try again.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We encountered an issue while updating your key. Please try again later or contact support at support.unkey.dev", + }); + } else if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid Configuration", { + description: `Please check your key name. ${errorMessage}`, + }); + } else { + toast.error("Failed to Update Key", { + description: errorMessage || "An unexpected error occurred. Please try again later.", + action: { + label: "Contact Support", + onClick: () => window.open("https://support.unkey.dev", "_blank"), + }, + }); + } + }, + }); + + return key; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-metadata.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-metadata.ts new file mode 100644 index 0000000000..da6a7d5ccd --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-metadata.ts @@ -0,0 +1,53 @@ +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; + +export const useEditMetadata = (onSuccess?: () => void) => { + const trpcUtils = trpc.useUtils(); + const updateKeyMetadata = trpc.key.update.metadata.useMutation({ + onSuccess(_, variables) { + let description = ""; + if (variables.metadata?.enabled && variables.metadata.data) { + description = `Metadata for key ${variables.keyId} has been updated`; + } else { + description = `Metadata has been removed from key ${variables.keyId}`; + } + + toast.success("Key Metadata Updated", { + description, + duration: 5000, + }); + trpcUtils.api.keys.list.invalidate(); + if (onSuccess) { + onSuccess(); + } + }, + onError(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") { + toast.error("Invalid Metadata", { + description: err.message || "Please ensure your metadata is valid JSON and try again.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We are unable to update metadata on this key. Please try again or contact support@unkey.dev", + }); + } else { + toast.error("Failed to Update Key Metadata", { + 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"), + }, + }); + } + }, + }); + return updateKeyMetadata; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-ratelimits.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-ratelimits.ts new file mode 100644 index 0000000000..e2b5006c27 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-ratelimits.ts @@ -0,0 +1,98 @@ +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { formatDuration, intervalToDuration } from "date-fns"; + +export const useEditRatelimits = (onSuccess?: () => void) => { + const trpcUtils = trpc.useUtils(); + + const updateKeyRemaining = trpc.key.update.ratelimit.useMutation({ + onSuccess(data, variables) { + let description = ""; + + // Handle both V1 and V2 ratelimit types + if (variables.ratelimitType === "v2") { + if (variables.ratelimit?.enabled) { + const rulesCount = variables.ratelimit.data.length; + + if (rulesCount === 1) { + // If there's just one rule, show its limit directly + const rule = variables.ratelimit.data[0]; + description = `Your key ${data.keyId} has been updated with a limit of ${ + rule.limit + } requests per ${formatInterval(rule.refillInterval)}`; + } else { + // If there are multiple rules, show the count + description = `Your key ${data.keyId} has been updated with ${rulesCount} rate limit rules`; + } + } else { + description = `Your key ${data.keyId} has been updated with rate limits disabled`; + } + } else { + // V1 ratelimits + if (variables.enabled) { + description = `Your key ${data.keyId} has been updated with a limit of ${ + variables.ratelimitLimit + } requests per ${formatInterval(variables.ratelimitDuration || 0)}`; + } else { + description = `Your key ${data.keyId} has been updated with rate limits disabled`; + } + } + + toast.success("Key Ratelimits Updated", { + description, + duration: 5000, + }); + + trpcUtils.api.keys.list.invalidate(); + if (onSuccess) { + onSuccess(); + } + }, + onError(err) { + if (err.data?.code === "NOT_FOUND") { + toast.error("Key Update Failed", { + description: "Unable to find the key. Please refresh and try again.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We encountered an issue while updating your key. Please try again later or contact support at support.unkey.dev", + }); + } else { + toast.error("Failed to Update Key Limits", { + description: err.message || "An unexpected error occurred. Please try again later.", + action: { + label: "Contact Support", + onClick: () => window.open("https://support.unkey.dev", "_blank"), + }, + }); + } + }, + }); + + return updateKeyRemaining; +}; + +const formatInterval = (milliseconds: number): string => { + if (milliseconds < 1000) { + return `${milliseconds}ms`; + } + + const duration = intervalToDuration({ start: 0, end: milliseconds }); + + // Customize the format for different time ranges + if (milliseconds < 60000) { + // Less than a minute + return formatDuration(duration, { format: ["seconds"] }); + } + if (milliseconds < 3600000) { + // Less than an hour + return formatDuration(duration, { format: ["minutes", "seconds"] }); + } + if (milliseconds < 86400000) { + // Less than a day + return formatDuration(duration, { format: ["hours", "minutes"] }); + } + // Days or more + return formatDuration(duration, { format: ["days", "hours"] }); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-update-key-status.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-update-key-status.tsx new file mode 100644 index 0000000000..c15dc26d7a --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-update-key-status.tsx @@ -0,0 +1,86 @@ +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import type { TRPCClientErrorLike } from "@trpc/client"; +import type { TRPCErrorShape } from "@trpc/server/rpc"; + +const handleKeyUpdateError = (err: TRPCClientErrorLike) => { + const errorMessage = err.message || ""; + if (err.data?.code === "NOT_FOUND") { + toast.error("Key Update Failed", { + description: "Unable to find the key(s). Please refresh and try again.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We encountered an issue while updating your key(s). Please try again later or contact support at support.unkey.dev", + }); + } else { + toast.error("Failed to Update Key Status", { + description: errorMessage || "An unexpected error occurred. Please try again later.", + action: { + label: "Contact Support", + onClick: () => window.open("https://support.unkey.dev", "_blank"), + }, + }); + } +}; + +export const useUpdateKeyStatus = (onSuccess?: () => void) => { + const trpcUtils = trpc.useUtils(); + + const updateKeyEnabled = trpc.key.update.enabled.useMutation({ + onSuccess(data) { + toast.success(`Key ${data.enabled ? "Enabled" : "Disabled"}`, { + description: `Your key ${data.updatedKeyIds[0]} has been ${ + data.enabled ? "enabled" : "disabled" + } successfully`, + duration: 5000, + }); + trpcUtils.api.keys.list.invalidate(); + if (onSuccess) { + onSuccess(); + } + }, + onError(err) { + handleKeyUpdateError(err); + }, + }); + + return updateKeyEnabled; +}; + +export const useBatchUpdateKeyStatus = (onSuccess?: () => void) => { + const trpcUtils = trpc.useUtils(); + + const updateMultipleKeysEnabled = trpc.key.update.enabled.useMutation({ + onSuccess(data) { + const updatedCount = data.updatedKeyIds.length; + toast.success(`Keys ${data.enabled ? "Enabled" : "Disabled"}`, { + description: `${updatedCount} ${ + updatedCount === 1 ? "key has" : "keys have" + } been ${data.enabled ? "enabled" : "disabled"} successfully`, + duration: 5000, + }); + + // Show warning if some keys were not found + if (data.missingKeyIds && data.missingKeyIds.length > 0) { + toast.warning("Some Keys Not Found", { + description: `${data.missingKeyIds.length} ${ + data.missingKeyIds.length === 1 ? "key was" : "keys were" + } not found and could not be updated.`, + duration: 7000, + }); + } + + trpcUtils.api.keys.list.invalidate(); + if (onSuccess) { + onSuccess(); + } + }, + onError(err) { + handleKeyUpdateError(err); + }, + }); + + return updateMultipleKeysEnabled; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/key-info.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/key-info.tsx new file mode 100644 index 0000000000..73909967fb --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/key-info.tsx @@ -0,0 +1,27 @@ +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 }) => { + return ( +
+
+ +
+
+
{keyDetails.id}
+ +
+ {keyDetails.name ?? "Unnamed Key"} +
+
+
+
+ ); +}; 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..bd0c91206c --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.constants.tsx @@ -0,0 +1,97 @@ +import { toast } from "@/components/ui/toaster"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { + ArrowOppositeDirectionY, + Ban, + CalendarClock, + ChartPie, + Check, + Clone, + Code, + Gauge, + PenWriting3, + Trash, +} from "@unkey/icons"; +import { DeleteKey } from "./components/delete-key"; +import { UpdateKeyStatus } from "./components/disable-key"; +import { EditCredits } from "./components/edit-credits"; +import { EditExpiration } from "./components/edit-expiration"; +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"; + +export const getKeysTableActionItems = (key: KeyDetails): MenuItem[] => { + return [ + { + id: "override", + label: "Edit key name...", + icon: , + ActionComponent: (props) => , + }, + { + id: "copy", + label: "Copy key ID", + className: "mt-1", + icon: , + onClick: () => { + navigator.clipboard + .writeText(key.id) + .then(() => { + toast.success("Key ID copied to clipboard"); + }) + .catch((error) => { + console.error("Failed to copy to clipboard:", error); + toast.error("Failed to copy to clipboard"); + }); + }, + divider: true, + }, + { + id: "edit-external-id", + label: "Edit External ID...", + icon: , + ActionComponent: (props) => , + divider: true, + }, + { + id: key.enabled ? "disable-key" : "enable-key", + label: key.enabled ? "Disable Key..." : "Enable Key...", + icon: key.enabled ? : , + ActionComponent: (props) => , + divider: true, + }, + { + id: "edit-credits", + label: "Edit credits...", + icon: , + ActionComponent: (props) => , + }, + { + id: "edit-ratelimit", + label: "Edit ratelimit...", + icon: , + ActionComponent: (props) => , + }, + { + id: "edit-expiration", + label: "Edit expiration...", + icon: , + ActionComponent: (props) => , + }, + { + id: "edit-metadata", + label: "Edit metadata...", + icon: , + ActionComponent: (props) => , + divider: true, + }, + { + id: "delete-key", + label: "Delete key", + icon: , + ActionComponent: (props) => , + }, + ]; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.tsx new file mode 100644 index 0000000000..49862b19c9 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.tsx @@ -0,0 +1,138 @@ +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Dots } from "@unkey/icons"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { type FC, type PropsWithChildren, useEffect, useRef, useState } from "react"; + +export type ActionComponentProps = { + isOpen: boolean; + onClose: () => void; +}; + +export type MenuItem = { + id: string; + label: string; + icon: React.ReactNode; + onClick?: (e: React.MouseEvent | React.KeyboardEvent) => void; + className?: string; + disabled?: boolean; + divider?: boolean; + ActionComponent?: FC; +}; + +type BaseTableActionPopoverProps = PropsWithChildren<{ + items: MenuItem[]; + align?: "start" | "end"; +}>; + +export const KeysTableActionPopover = ({ + items, + align = "end", + children, +}: BaseTableActionPopoverProps) => { + const [enabledItem, setEnabledItem] = useState(); + const [open, setOpen] = useState(false); + const [focusIndex, setFocusIndex] = useState(0); + const menuItems = useRef([]); + + useEffect(() => { + if (open) { + const firstEnabledIndex = items.findIndex((item) => !item.disabled); + setFocusIndex(firstEnabledIndex >= 0 ? firstEnabledIndex : 0); + if (firstEnabledIndex >= 0) { + menuItems.current[firstEnabledIndex]?.focus(); + } + } + }, [open, items]); + + const handleActionSelection = (value: string) => { + setEnabledItem(value); + }; + + return ( + + e.stopPropagation()}> + {children ? ( + children + ) : ( + + )} + + { + e.preventDefault(); + const firstEnabledIndex = items.findIndex((item) => !item.disabled); + if (firstEnabledIndex >= 0) { + menuItems.current[firstEnabledIndex]?.focus(); + } + }} + onCloseAutoFocus={(e) => e.preventDefault()} + onEscapeKeyDown={(e) => { + e.preventDefault(); + setOpen(false); + }} + onInteractOutside={(e) => { + e.preventDefault(); + setOpen(false); + }} + > + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
e.stopPropagation()} className="py-2"> + {items.map((item, index) => ( +
+
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
{ + if (el) { + menuItems.current[index] = el; + } + }} + role="menuitem" + aria-disabled={item.disabled} + tabIndex={!item.disabled && focusIndex === index ? 0 : -1} + className={cn( + "flex w-full items-center px-2 py-1.5 gap-3 rounded-lg group", + !item.disabled && + "cursor-pointer hover:bg-gray-3 data-[state=open]:bg-gray-3 focus:outline-none focus:bg-gray-3", + item.disabled && "cursor-not-allowed opacity-50", + item.className, + )} + onClick={(e) => { + if (!item.disabled) { + item.onClick?.(e); + + if (!item.ActionComponent) { + setOpen(false); + } + + setEnabledItem(item.id); + } + }} + > +
+ {item.icon} +
+ {item.label} +
+
+ {item.divider &&
} + {item.ActionComponent && enabledItem === item.id && ( + handleActionSelection("none")} /> + )} +
+ ))} +
+ + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/components/outcome-explainer.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/components/outcome-explainer.tsx new file mode 100644 index 0000000000..3c3a947898 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/components/outcome-explainer.tsx @@ -0,0 +1,153 @@ +import { formatNumber } from "@/lib/fmt"; + +import { InfoTooltip } from "@unkey/ui"; +import { useMemo } from "react"; +import type { ProcessedTimeseriesDataPoint } from "../use-fetch-timeseries"; + +type OutcomeExplainerProps = { + children: React.ReactNode; + timeseries: ProcessedTimeseriesDataPoint[]; +}; + +type ErrorType = { + type: string; + value: string; + color: string; +}; + +export function OutcomeExplainer({ children, timeseries }: OutcomeExplainerProps): JSX.Element { + // Aggregate all timeseries data for the tooltip + const aggregatedData = useMemo(() => { + if (!timeseries || timeseries.length === 0) { + return { + valid: 0, + rate_limited: 0, + insufficient_permissions: 0, + forbidden: 0, + disabled: 0, + expired: 0, + usage_exceeded: 0, + total: 0, + }; + } + + return timeseries.reduce( + (acc, dataPoint) => { + acc.valid += dataPoint.valid || 0; + acc.rate_limited += dataPoint.rate_limited || 0; + acc.insufficient_permissions += dataPoint.insufficient_permissions || 0; + acc.forbidden += dataPoint.forbidden || 0; + acc.disabled += dataPoint.disabled || 0; + acc.expired += dataPoint.expired || 0; + acc.usage_exceeded += dataPoint.usage_exceeded || 0; + acc.total += dataPoint.total || 0; + return acc; + }, + { + valid: 0, + rate_limited: 0, + insufficient_permissions: 0, + forbidden: 0, + disabled: 0, + expired: 0, + usage_exceeded: 0, + total: 0, + }, + ); + }, [timeseries]); + + const errorTypes = useMemo(() => { + const potentialErrors = [ + { + type: "Insufficient Permissions", + rawValue: aggregatedData.insufficient_permissions, + color: "bg-error-9", + }, + { + type: "Rate Limited", + rawValue: aggregatedData.rate_limited, + color: "bg-error-9", + }, + { + type: "Forbidden", + rawValue: aggregatedData.forbidden, + color: "bg-error-9", + }, + { + type: "Disabled", + rawValue: aggregatedData.disabled, + color: "bg-error-9", + }, + { + type: "Expired", + rawValue: aggregatedData.expired, + color: "bg-error-9", + }, + { + type: "Usage Exceeded", + rawValue: aggregatedData.usage_exceeded, + color: "bg-error-9", + }, + ]; + + const filteredErrors = potentialErrors.filter((error) => error.rawValue > 0); + + return filteredErrors.map((error) => ({ + type: error.type, + value: formatNumber(error.rawValue), + color: error.color, + })) as ErrorType[]; + }, [aggregatedData]); + + return ( + +
API Key Activity
+
Last 36 hours
+ + {/* Valid count */} +
+
+
+
Valid
+
+
+ {formatNumber(aggregatedData.valid)} +
+
+ +
+ + {/* Error types */} +
+ {errorTypes.map((error, index) => ( +
+ key={index} + className="flex justify-between w-full items-center" + > +
+
+
{error.type}
+
+
{error.value}
+
+ ))} + + {errorTypes.length === 0 && aggregatedData.valid === 0 && ( +
No verification activity
+ )} +
+
+ } + > +
{children}
+ + ); +} diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/index.tsx new file mode 100644 index 0000000000..7eb03a22ad --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/index.tsx @@ -0,0 +1,152 @@ +import { cn } from "@/lib/utils"; +import { useMemo } from "react"; +import { UsageColumnSkeleton } from "../skeletons"; +import { OutcomeExplainer } from "./components/outcome-explainer"; +import { useFetchVerificationTimeseries } from "./use-fetch-timeseries"; + +type BarData = { + id: string | number; + topHeight: number; + bottomHeight: number; + totalHeight: number; +}; + +type VerificationBarChartProps = { + keyAuthId: string; + keyId: string; + maxBars?: number; + selected: boolean; +}; + +const MAX_HEIGHT_BUFFER_FACTOR = 1.3; +const MAX_BAR_HEIGHT = 28; + +export const VerificationBarChart = ({ + keyAuthId, + keyId, + selected, + maxBars = 30, +}: VerificationBarChartProps) => { + const { timeseries, isLoading, isError } = useFetchVerificationTimeseries(keyAuthId, keyId); + + const isEmpty = useMemo( + () => timeseries.reduce((acc, crr) => acc + crr.total, 0) === 0, + [timeseries], + ); + + const bars = useMemo((): BarData[] => { + if (isLoading || isError || timeseries.length === 0) { + // Return empty data if loading, error, or no data + return Array(maxBars).fill({ + id: 0, + topHeight: 0, + bottomHeight: 0, + totalHeight: 0, + }); + } + // Get the most recent data points (or all if less than maxBars) + const recentData = timeseries.slice(-maxBars); + // Calculate the maximum total value to normalize heights + const maxTotal = + Math.max(...recentData.map((item) => item.total), 1) * MAX_HEIGHT_BUFFER_FACTOR; + // Generate bars from the data + return recentData.map((item, index): BarData => { + // Scale to fit within max height of 28px + const totalHeight = Math.min( + Math.round((item.total / maxTotal) * MAX_BAR_HEIGHT), + MAX_BAR_HEIGHT, + ); + // Calculate heights proportionally + const topHeight = item.error + ? Math.max(Math.round((item.error / item.total) * totalHeight), 1) + : 0; + const bottomHeight = Math.max(totalHeight - topHeight, 0); + return { + id: index, + totalHeight, + topHeight, + bottomHeight, + }; + }); + }, [timeseries, isLoading, isError, maxBars]); + + // Pad with empty bars if we have fewer than maxBars data points + const displayBars = useMemo((): BarData[] => { + const result = [...bars]; + while (result.length < maxBars) { + result.unshift({ + id: `empty-${result.length}`, + topHeight: 0, + bottomHeight: 0, + totalHeight: 0, + }); + } + return result; + }, [bars, maxBars]); + + // Loading state - animated pulse effect for bars with grid layout + if (isLoading) { + return ; + } + + // Error state with grid layout + if (isError) { + return ( +
+
+ Error loading data +
+
+ ); + } + + // Empty state with grid layout + if (isEmpty) { + return ( +
+
+ No data available +
+
+ ); + } + + // Data display with grid layout + return ( + +
+ {displayBars.map((bar) => ( +
+
+
+
+ ))} +
+ + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/query-timeseries.schema.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/query-timeseries.schema.ts new file mode 100644 index 0000000000..51b95ba2df --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/query-timeseries.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const keysListQueryTimeseriesPayload = z.object({ + startTime: z.number().int(), + endTime: z.number().int(), + keyId: z.string(), + keyAuthId: z.string(), +}); + +export type KeysListQueryTimeseriesPayload = z.infer; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/use-fetch-timeseries.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/use-fetch-timeseries.ts new file mode 100644 index 0000000000..ab71bd7e37 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/use-fetch-timeseries.ts @@ -0,0 +1,126 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import { trpc } from "@/lib/trpc/client"; +import type { VerificationTimeseriesDataPoint } from "@unkey/clickhouse/src/verifications"; +import { useEffect, useMemo, useRef, useState } from "react"; + +export type ProcessedTimeseriesDataPoint = { + valid: number; + total: number; + success: number; + error: number; + rate_limited?: number; + insufficient_permissions?: number; + forbidden?: number; + disabled?: number; + expired?: number; + usage_exceeded?: number; +}; + +type CacheEntry = { + data: { timeseries: VerificationTimeseriesDataPoint[] }; + timestamp: number; +}; + +const timeseriesCache = new Map(); + +export const useFetchVerificationTimeseries = (keyAuthId: string, keyId: string) => { + // Use a ref for the initial timestamp to keep it stable + const initialTimeRef = useRef(Date.now()); + const cacheKey = `${keyAuthId}-${keyId}`; + + // Check if we have cached data + const cachedData = timeseriesCache.get(cacheKey); + + // State to force updates when cache changes + const [_, setCacheVersion] = useState(0); + + // Determine if we should run the query + const shouldFetch = !cachedData || Date.now() - cachedData.timestamp > 60000; + + // Set up query parameters - stable between renders + const queryParams = useMemo( + () => ({ + startTime: initialTimeRef.current - HISTORICAL_DATA_WINDOW * 3, + endTime: initialTimeRef.current, + keyAuthId, + keyId, + }), + [keyAuthId, keyId], + ); + + // Use TRPC's useQuery with critical settings + const { + data, + isLoading: trpcIsLoading, + isError, + } = trpc.api.keys.usageTimeseries.useQuery(queryParams, { + // CRITICAL: Only enable the query if we should fetch + enabled: shouldFetch, + // Prevent automatic refetching + refetchOnMount: false, + refetchOnWindowFocus: false, + staleTime: Number.POSITIVE_INFINITY, + refetchInterval: shouldFetch && queryParams.endTime >= Date.now() - 60_000 ? 10_000 : false, + }); + + // Process the timeseries data - using cached or fresh data + const effectiveData = data || (cachedData ? cachedData.data : undefined); + + // Process the timeseries from the effective data + const timeseries = useMemo(() => { + if (!effectiveData?.timeseries) { + return [] as ProcessedTimeseriesDataPoint[]; + } + + return effectiveData.timeseries.map((ts): ProcessedTimeseriesDataPoint => { + const result: ProcessedTimeseriesDataPoint = { + valid: ts.y.valid, + total: ts.y.total, + success: ts.y.valid, + error: ts.y.total - ts.y.valid, + }; + + // Add optional fields if they exist + if (ts.y.rate_limited_count !== undefined) { + result.rate_limited = ts.y.rate_limited_count; + } + if (ts.y.insufficient_permissions_count !== undefined) { + result.insufficient_permissions = ts.y.insufficient_permissions_count; + } + if (ts.y.forbidden_count !== undefined) { + result.forbidden = ts.y.forbidden_count; + } + if (ts.y.disabled_count !== undefined) { + result.disabled = ts.y.disabled_count; + } + if (ts.y.expired_count !== undefined) { + result.expired = ts.y.expired_count; + } + if (ts.y.usage_exceeded_count !== undefined) { + result.usage_exceeded = ts.y.usage_exceeded_count; + } + + return result; + }); + }, [effectiveData]); + + // Update cache when we get new data + useEffect(() => { + if (data) { + timeseriesCache.set(cacheKey, { + data, + timestamp: Date.now(), + }); + // Force a re-render to use cached data + setCacheVersion((prev) => prev + 1); + } + }, [data, cacheKey]); + + const isLoading = trpcIsLoading && !cachedData; + + return { + timeseries, + isLoading, + isError, + }; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/hidden-value.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/hidden-value.tsx new file mode 100644 index 0000000000..69bb2722fa --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/hidden-value.tsx @@ -0,0 +1,47 @@ +import { toast } from "@/components/ui/toaster"; +import { cn } from "@/lib/utils"; +import { CircleLock } from "@unkey/icons"; + +export const HiddenValueCell = ({ + value, + title = "Value", + selected, +}: { + value: string; + title: string; + selected: boolean; +}) => { + // Show only first 4 characters, then dots + const displayValue = value.padEnd(16, "•"); + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard + .writeText(value) + .then(() => { + toast.success(`${title} copied to clipboard`); + }) + .catch((error) => { + console.error("Failed to copy to clipboard:", error); + toast.error("Failed to copy to clipboard"); + }); + }; + + return ( + <> + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} +
handleClick(e)} + > +
+ +
+
{displayValue}
+
+ + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/last-used.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/last-used.tsx new file mode 100644 index 0000000000..f886ce85d2 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/last-used.tsx @@ -0,0 +1,70 @@ +import { Badge } from "@/components/ui/badge"; +import { trpc } from "@/lib/trpc/client"; +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 LastUsedCell = ({ + keyAuthId, + keyId, + isSelected, +}: { + keyAuthId: string; + keyId: string; + isSelected: boolean; +}) => { + const { data, isLoading, isError } = trpc.api.keys.latestVerification.useQuery({ + keyAuthId, + keyId, + }); + const badgeRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + + return ( + { + setShowTooltip(true); + }} + onMouseLeave={() => { + setShowTooltip(false); + }} + > +
+ +
+
+ {isLoading ? ( +
+
+
+
+
+ ) : isError ? ( + "Failed to load" + ) : data?.lastVerificationTime ? ( + + ) : ( + "Never used" + )} +
+ + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/selection-controls/components/batch-edit-external-id.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/selection-controls/components/batch-edit-external-id.tsx new file mode 100644 index 0000000000..181f1b534e --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/selection-controls/components/batch-edit-external-id.tsx @@ -0,0 +1,169 @@ +import { ExternalIdField } from "@/app/(app)/apis/[apiId]/_components/create-key/components/external-id-field"; +import { ConfirmPopover } from "@/components/confirmation-popover"; +import { DialogContainer } from "@/components/dialog-container"; +import { TriangleWarning2 } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { useRef, useState } from "react"; +import { useBatchEditExternalId } from "../../actions/components/hooks/use-edit-external-id"; + +type BatchEditExternalIdProps = { + selectedKeyIds: string[]; + keysWithExternalIds: number; // Count of keys that already have external IDs + isOpen: boolean; + onClose: () => void; +}; + +export const BatchEditExternalId = ({ + selectedKeyIds, + keysWithExternalIds, + isOpen, + onClose, +}: BatchEditExternalIdProps): JSX.Element => { + const [selectedIdentityId, setSelectedIdentityId] = useState(null); + const [isConfirmPopoverOpen, setIsConfirmPopoverOpen] = useState(false); + const clearButtonRef = useRef(null); + + const updateKeyOwner = useBatchEditExternalId(() => { + onClose(); + }); + + const handleSubmit = () => { + updateKeyOwner.mutate({ + keyIds: selectedKeyIds, + ownerType: "v2", + identity: { + id: selectedIdentityId, + }, + }); + }; + + const handleClearButtonClick = () => { + setIsConfirmPopoverOpen(true); + }; + + const handleDialogOpenChange = (open: boolean) => { + if (isConfirmPopoverOpen && !isOpen) { + // If confirm popover is active don't let this trigger outer popover + return; + } + + if (!isConfirmPopoverOpen && !open) { + onClose(); + } + }; + + const clearSelection = async () => { + await updateKeyOwner.mutateAsync({ + keyIds: selectedKeyIds, + ownerType: "v2", + identity: { + id: null, + }, + }); + }; + + const totalKeys = selectedKeyIds.length; + const hasKeysWithExternalIds = keysWithExternalIds > 0; + + // Determine what button to show based on whether a new external ID is selected + const showUpdateButton = selectedIdentityId !== null; + + return ( + <> + +
+ {showUpdateButton ? ( + + ) : ( + + )} +
+ {hasKeysWithExternalIds && ( +
+ Note: {keysWithExternalIds} out of {totalKeys} selected{" "} + {totalKeys === 1 ? "key" : "keys"} already{" "} + {keysWithExternalIds === 1 ? "has" : "have"} an External ID +
+ )} +
Changes will be applied immediately
+
+ } + > + {hasKeysWithExternalIds && ( +
+
+ +
+
+ Warning:{" "} + {keysWithExternalIds === totalKeys ? ( + <> + All selected keys already have External IDs. Setting a new ID will override the + existing ones. + + ) : ( + <> + Some selected keys already have External IDs. Setting a new ID will override the + existing ones. + + )} +
+
+ )} +
+ +
+ + 1 ? "IDs" : "ID"}`} + description={`This will remove the External ID association from ${keysWithExternalIds} ${ + keysWithExternalIds === 1 ? "key" : "keys" + }. Any tracking or analytics related to ${ + keysWithExternalIds === 1 ? "this ID" : "these IDs" + } will no longer be associated with ${ + keysWithExternalIds === 1 ? "this key" : "these keys" + }.`} + confirmButtonText={`Remove External ${keysWithExternalIds > 1 ? "IDs" : "ID"}`} + cancelButtonText="Cancel" + variant="danger" + /> + + ); +}; 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..732ae2e1fd --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/selection-controls/index.tsx @@ -0,0 +1,235 @@ +import { ConfirmPopover } from "@/components/confirmation-popover"; +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { ArrowOppositeDirectionY, Ban, CircleCheck, Trash, XMark } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { AnimatePresence, motion } from "framer-motion"; +import { useRef, useState } from "react"; +import { useDeleteKey } from "../actions/components/hooks/use-delete-key"; +import { useBatchUpdateKeyStatus } from "../actions/components/hooks/use-update-key-status"; +import { BatchEditExternalId } from "./components/batch-edit-external-id"; + +type SelectionControlsProps = { + selectedKeys: Set; + setSelectedKeys: (keys: Set) => void; + keys: KeyDetails[]; + getSelectedKeysState: () => "all-enabled" | "all-disabled" | "mixed" | null; +}; + +export const SelectionControls = ({ + selectedKeys, + keys, + setSelectedKeys, + getSelectedKeysState, +}: SelectionControlsProps) => { + const [isBatchEditExternalIdOpen, setIsBatchEditExternalIdOpen] = useState(false); + const [isDisableConfirmOpen, setIsDisableConfirmOpen] = useState(false); + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + const disableButtonRef = useRef(null); + const deleteButtonRef = useRef(null); + + const updateKeyStatus = useBatchUpdateKeyStatus(); + const deleteKey = useDeleteKey(() => { + setSelectedKeys(new Set()); + }); + + const handleDisableButtonClick = () => { + setIsDisableConfirmOpen(true); + }; + + const performDisableKeys = () => { + updateKeyStatus.mutate({ + enabled: false, + keyIds: Array.from(selectedKeys), + }); + }; + + const handleDeleteButtonClick = () => { + setIsDeleteConfirmOpen(true); + }; + + const performKeyDeletion = () => { + deleteKey.mutate({ + keyIds: Array.from(selectedKeys), + }); + }; + + const keysWithExternalIds = keys.filter( + (key) => selectedKeys.has(key.id) && key.identity_id, + ).length; + + return ( + <> + + {selectedKeys.size > 0 && ( + +
+
+ +
selected
+
+
+ + + + + +
+
+
+ )} +
+ + 1 ? "s" : "" + } and prevent any verification requests from being processed.`} + confirmButtonText="Disable keys" + cancelButtonText="Cancel" + variant="danger" + /> + + 1 ? "these keys" : "this key" + } will be permanently deleted.`} + confirmButtonText={`Delete key${selectedKeys.size > 1 ? "s" : ""}`} + cancelButtonText="Cancel" + variant="danger" + /> + + {isBatchEditExternalIdOpen && ( + setIsBatchEditExternalIdOpen(false)} + /> + )} + + ); +}; + +const AnimatedDigit = ({ digit, index }: { digit: string; index: number }) => { + return ( + + {digit} + + ); +}; + +const AnimatedCounter = ({ value }: { value: number }) => { + const digits = value.toString().split(""); + + return ( +
+ +
+ {digits.map((digit, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: + + ))} +
+
+
+ ); +}; 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..28fc0f7e6e --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/skeletons.tsx @@ -0,0 +1,82 @@ +import { Dots } from "@unkey/icons"; +import { cn } from "@unkey/ui/src/lib/utils"; + +export const KeyColumnSkeleton = () => ( +
+
+
+
+
+
+
+
+
+); + +export const ValueColumnSkeleton = () => ( +
+
+
+
+); + +export const UsageColumnSkeleton = ({ maxBars = 30 }: { maxBars?: number }) => ( +
+ {Array(maxBars) + .fill(0) + .map((_, index) => ( +
+ index + }`} + className="flex flex-col" + > +
+
+ ))} +
+); + +export const LastUsedColumnSkeleton = () => ( +
+
+
+
+
+); + +export const StatusColumnSkeleton = () => ( +
+
+
+
+); + +export const ActionColumnSkeleton = () => ( + +); diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/components/status-badge.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/components/status-badge.tsx new file mode 100644 index 0000000000..2f0e1d8279 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/components/status-badge.tsx @@ -0,0 +1,70 @@ +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { useRef } from "react"; +import { STATUS_STYLES } from "../../../utils/get-row-class"; + +type StatusBadgeProps = { + primary: { + label: string; + color: string; + icon: React.ReactNode; + }; + count: number; + isSelected?: boolean; +}; + +export const StatusBadge = ({ primary, count, isSelected = false }: StatusBadgeProps) => { + const badgeRef = useRef(null); + + const isDisabled = primary.label === "Disabled"; + + return ( +
+ {isDisabled ? ( + // Use Badge component only for "Disabled" label + + {primary.icon && {primary.icon}} + {primary.label} + + ) : ( +
0 ? "rounded-l-md" : "rounded-md", + )} + > + {primary.icon && {primary.icon}} + {primary.label} +
+ )} + + {count > 0 && + (isDisabled ? ( + + +{count} + + ) : ( +
+ +{count} +
+ ))} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/constants.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/constants.tsx new file mode 100644 index 0000000000..863aa2a44b --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/constants.tsx @@ -0,0 +1,79 @@ +import { + Ban, + CircleCaretRight, + CircleCheck, + CircleHalfDottedClock, + ShieldKey, + TriangleWarning2, +} from "@unkey/icons"; + +export type StatusType = + | "disabled" + | "low-credits" + | "expires-soon" + | "rate-limited" + | "validation-issues" + | "operational"; + +export interface StatusInfo { + type: StatusType; + label: string; + color: string; + icon: React.ReactNode; + tooltip: string; + priority: number; // Lower number = higher priority +} + +export const STATUS_DEFINITIONS: Record = { + "low-credits": { + type: "low-credits", + label: "Low Credits", + color: "bg-errorA-3 text-errorA-11", + icon: , + tooltip: "This key has a low credit balance. Top it off to prevent disruptions.", + priority: 1, + }, + "rate-limited": { + type: "rate-limited", + label: "Ratelimited", + color: "bg-errorA-3 text-errorA-11", + icon: , + tooltip: + "This key is getting ratelimited frequently. Check the configured ratelimits and reach out to your user about their usage.", + priority: 2, + }, + "expires-soon": { + type: "expires-soon", + label: "Expires soon", + color: "bg-orangeA-3 text-orangeA-11", + icon: , + tooltip: + "This key will expire in less than 24 hours. Rotate the key or extend its deadline to prevent disruptions.", + priority: 2, + }, + "validation-issues": { + type: "validation-issues", + label: "Potential issues", + color: "bg-warningA-3 text-warningA-11", + icon: , + tooltip: "This key has a high error rate. Please check its logs to debug potential issues.", + priority: 3, + }, + //TODO: Add a way to enable this through tooltip + disabled: { + type: "disabled", + label: "Disabled", + color: "bg-grayA-3 text-grayA-11", + icon: , + tooltip: "This key is currently disabled and cannot be used for verification.", + priority: 0, + }, + operational: { + type: "operational", + label: "Operational", + color: "bg-successA-3 text-successA-11", + icon: , + tooltip: "This key is operating normally.", + priority: 99, // Lowest priority + }, +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/index.tsx new file mode 100644 index 0000000000..e0892526f8 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/index.tsx @@ -0,0 +1,141 @@ +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 { cn } from "@/lib/utils"; +import { InfoTooltip } from "@unkey/ui"; +import { StatusBadge } from "./components/status-badge"; +import { useKeyStatus } from "./use-key-status"; + +type StatusDisplayProps = { + keyData: KeyDetails; + keyAuthId: string; + isSelected: boolean; +}; + +export const StatusDisplay = ({ keyAuthId, keyData, isSelected }: StatusDisplayProps) => { + const { primary, count, isLoading, statuses, isError } = useKeyStatus(keyAuthId, keyData); + const utils = trpc.useUtils(); + + const enableKeyMutation = trpc.api.keys.enableKey.useMutation({ + onSuccess: async () => { + toast.success("Key enabled successfully!"); + await utils.api.keys.list.invalidate({ keyAuthId }); + }, + onError: (error) => { + toast.error("Failed to enable key", { + description: error.message || "An unknown error occurred.", + }); + }, + }); + + if (isLoading) { + return ( +
+
+
+
+ ); + } + + if (isError) { + return ( +
+ Failed to load +
+ ); + } + + return ( + + {statuses && statuses.length > 1 && ( +
+
+
Key status overview
+
+ This key has{" "} + {statuses.length} active + flags{" "} +
+
+
+ )} + + {statuses?.map((status, i) => ( +
+
+
+ +
+ +
+ {status.type === "disabled" ? ( +
+ + This key has been manually disabled and cannot be used for any requests. + + {" "} +
+ ) : ( + status.tooltip + )} +
+
+
+ ))} +
+ } + > + + + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/query-timeseries.schema.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/query-timeseries.schema.ts new file mode 100644 index 0000000000..142868060e --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/query-timeseries.schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const keyOutcomesQueryPayload = z.object({ + startTime: z.number().int(), + endTime: z.number().int(), + keyId: z.string(), + keyAuthId: z.string(), +}); + +export type KeyOutcomesQueryPayload = z.infer; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/use-key-status.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/use-key-status.ts new file mode 100644 index 0000000000..16ac9cb98f --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/use-key-status.ts @@ -0,0 +1,173 @@ +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import { useMemo } from "react"; +import { + type ProcessedTimeseriesDataPoint, + useFetchVerificationTimeseries, +} from "../bar-chart/use-fetch-timeseries"; +import { STATUS_DEFINITIONS, type StatusInfo } from "./constants"; + +const RATE_LIMIT_THRESHOLD_PERCENT = 0.1; // 10% +const VALIDATION_ISSUE_THRESHOLD_PERCENT = 0.1; // 10% +const LOW_CREDITS_THRESHOLD_ABSOLUTE = 0; +const LOW_CREDITS_THRESHOLD_REFILL_PERCENT = 0.1; // 10% +const EXPIRY_THRESHOLD_HOURS = 24; + +type AggregatedData = { + total: number; + error: number; + rate_limited: number; +}; + +const aggregateTimeseries = (timeseries: ProcessedTimeseriesDataPoint[]): AggregatedData => { + return timeseries.reduce( + (acc, point) => { + acc.total += point.total; + acc.error += point.error; + acc.rate_limited += point.rate_limited ?? 0; + return acc; + }, + { total: 0, error: 0, rate_limited: 0 }, + ); +}; + +type UseKeyStatusResult = { + primary: { + label: string; + color: string; + icon: React.ReactNode; + }; + count: number; + statuses: StatusInfo[]; + isLoading: boolean; + isError: boolean; +}; + +const LOADING_PRIMARY = { + label: "Loading", + color: "bg-grayA-3", + icon: null, +}; + +export const useKeyStatus = (keyAuthId: string, keyData: KeyDetails): UseKeyStatusResult => { + const { timeseries, isError, isLoading } = useFetchVerificationTimeseries(keyAuthId, keyData.id); + + const statusResult = useMemo(() => { + // Handle case where keyData might not be loaded yet + if (!keyData) { + return { + primary: LOADING_PRIMARY, + count: 0, + statuses: [], + }; + } + + if (isLoading && timeseries.length === 0) { + return { + primary: LOADING_PRIMARY, + count: 0, + statuses: [], + }; + } + + if (isError) { + const fallbackStatus = keyData.enabled + ? STATUS_DEFINITIONS.operational + : STATUS_DEFINITIONS.disabled; + return { + primary: { + label: fallbackStatus.label, + color: fallbackStatus.color, + icon: fallbackStatus.icon, + }, + count: 0, + statuses: [fallbackStatus], + }; + } + + if (!keyData.enabled) { + const disabledStatus = STATUS_DEFINITIONS.disabled; + return { + primary: { + label: disabledStatus.label, + color: disabledStatus.color, + icon: disabledStatus.icon, + }, + count: 0, + statuses: [disabledStatus], + }; + } + + const applicableStatuses: StatusInfo[] = []; + const aggregatedData = aggregateTimeseries(timeseries); + const totalVerifications = aggregatedData.total; + + if ( + totalVerifications > 0 && + aggregatedData.rate_limited / totalVerifications > RATE_LIMIT_THRESHOLD_PERCENT + ) { + applicableStatuses.push(STATUS_DEFINITIONS["rate-limited"]); + } + + if ( + totalVerifications > 0 && + aggregatedData.error / totalVerifications > VALIDATION_ISSUE_THRESHOLD_PERCENT + ) { + applicableStatuses.push(STATUS_DEFINITIONS["validation-issues"]); + } + + const remaining = keyData.key.credits.remaining; + const refillAmount = keyData.key.credits.refillAmount; + const isLowOnCredits = + (remaining != null && remaining === LOW_CREDITS_THRESHOLD_ABSOLUTE) || + (refillAmount && + remaining != null && + refillAmount > 0 && + remaining < refillAmount * LOW_CREDITS_THRESHOLD_REFILL_PERCENT); + + if (isLowOnCredits) { + applicableStatuses.push(STATUS_DEFINITIONS["low-credits"]); + } + + // Check Expiry + if (keyData.expires) { + const hoursToExpiry = (keyData.expires * 1000 - Date.now()) / (1000 * 60 * 60); + // Ensure current time used is consistent if needed, Date.now() is fine here + if (hoursToExpiry > 0 && hoursToExpiry <= EXPIRY_THRESHOLD_HOURS) { + applicableStatuses.push(STATUS_DEFINITIONS["expires-soon"]); + } + } + + // Handle Operational state (if no issues found) + if (applicableStatuses.length === 0) { + const operationalStatus = STATUS_DEFINITIONS.operational; + return { + primary: { + label: operationalStatus.label, + color: operationalStatus.color, + icon: operationalStatus.icon, + }, + count: 0, + statuses: [operationalStatus], // Return array with the single operational status + }; + } + + applicableStatuses.sort((a, b) => a.priority - b.priority); // Sort by priority + + const primaryStatus = applicableStatuses[0]; // Highest priority is the first element + return { + primary: { + label: primaryStatus.label, + color: primaryStatus.color, + icon: primaryStatus.icon, + }, + count: applicableStatuses.length - 1, // Count of *other* statuses besides primary + statuses: applicableStatuses, // Return the full sorted array of applicable statuses + }; + }, [keyData, timeseries, isLoading, isError]); + + return { + ...statusResult, + isLoading: isLoading || !keyData, + isError, + }; +}; 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..bfd01d775e --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts @@ -0,0 +1,83 @@ +import { trpc } from "@/lib/trpc/client"; +import type { Roles } from "@/lib/trpc/routers/authorization/roles"; +import { useEffect, useMemo, useState } from "react"; +import { keysListFilterFieldNames, rolesFilterFieldConfig } from "../../../filters.schema"; +import { useFilters } from "../../../hooks/use-filters"; + +type RolesQueryPayload = { + limit?: number; + cursor?: number; +}; + +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]); + + // For now, roles endpoint doesn't support filtering, so we prepare for future enhancement + const queryParams = useMemo((): RolesQueryPayload => { + // Currently the roles endpoint only supports limit and cursor + // Filter validation is kept for future compatibility + filters.forEach((filter) => { + if (!keysListFilterFieldNames.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}'`); + } + }); + + return { + limit: 50, // Using default from your endpoint + }; + }, [filters]); + + const { + data: rolesData, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + isLoading: isLoadingInitial, + } = trpc.authorization.roles.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.slug, 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/keys-list.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/keys-list.tsx new file mode 100644 index 0000000000..875d35f739 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/keys-list.tsx @@ -0,0 +1,308 @@ +"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 { Roles } from "@/lib/trpc/routers/authorization/roles"; +import { BookBookmark, CircleInfoSparkle, Shield } from "@unkey/icons"; +import { AnimatedLoadingSpinner, Button, Checkbox, Empty } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import Link from "next/link"; +import { useCallback, useMemo, useState } from "react"; +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 [navigatingRoleSlug, setNavigatingRoleSlug] = useState(null); + const [selectedRoles, setSelectedRoles] = useState>(new Set()); + const [hoveredRoleSlug, setHoveredRoleSlug] = useState(null); + + const handleLinkClick = useCallback((roleSlug: string) => { + setNavigatingRoleSlug(roleSlug); + setSelectedRole(null); + }, []); + + const toggleSelection = useCallback((roleSlug: string) => { + setSelectedRoles((prevSelected) => { + const newSelected = new Set(prevSelected); + if (newSelected.has(roleSlug)) { + newSelected.delete(roleSlug); + } else { + newSelected.add(roleSlug); + } + return newSelected; + }); + }, []); + + const columns: Column[] = useMemo( + () => [ + { + key: "role", + header: "Role", + width: "25%", + headerClassName: "pl-[18px]", + render: (role) => { + const isNavigating = role.slug === navigatingRoleSlug; + const isSelected = selectedRoles.has(role.slug); + const isHovered = hoveredRoleSlug === role.slug; + + const iconContainer = ( +
setHoveredRoleSlug(role.slug)} + onMouseLeave={() => setHoveredRoleSlug(null)} + > + {isNavigating ? ( +
+ +
+ ) : ( + <> + {!isSelected && !isHovered && ( + + )} + + {(isSelected || isHovered) && ( + toggleSelection(role.slug)} + /> + )} + + )} +
+ ); + + return ( +
+
+ {iconContainer} +
+ { + handleLinkClick(role.slug); + }} + > +
{role.slug}
+ + {role.name && ( + + {role.name} + + )} +
+
+
+ ); + }, + }, + { + key: "description", + header: "Description", + width: "25%", + render: (role) => ( +
+ {role.description || No description} +
+ ), + }, + { + key: "assignedKeys", + header: "Assigned Keys", + width: "25%", + render: (role) => ( + + ), + }, + { + key: "permissions", + header: "Permissions", + width: "25%", + render: (role) => ( + + ), + }, + ], + [navigatingRoleSlug, handleLinkClick, selectedRoles, toggleSelection, hoveredRoleSlug], + ); + + return ( + <> + role.slug} + rowClassName={(role) => getRowClassName(role, selectedRole)} + loadMoreFooterProps={{ + hide: isLoading, + buttonText: "Load more roles", + hasMore, + 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, idx) => ( + + {column.key === "role" && } + {column.key === "description" && } + {(column.key === "assignedKeys" || column.key === "permissions") && ( + + )} + {!["role", "description", "assignedKeys", "permissions"].includes(column.key) && ( +
+ )} + + )) + } + /> + + ); +}; + +const AssignedItemsCell = ({ + items, + totalCount, + type, +}: { + items: string[]; + totalCount?: number; + type: "keys" | "permissions"; +}) => { + const hasMore = totalCount && totalCount > items.length; + const icon = + type === "keys" ? : ; + + if (items.length === 0) { + return ( +
+ {icon} + None assigned +
+ ); + } + + return ( +
+
+ {icon} + + {totalCount ? totalCount : items.length} {type} + +
+
+ {items.map((item, idx) => ( + + key={idx} + variant="secondary" + className="text-xs py-0.5 px-1.5" + > + {item} + + ))} + {hasMore && ( + + +{(totalCount || 0) - items.length} more + + )} +
+
+ ); +}; + +const RoleColumnSkeleton = () => ( +
+
+
+
+
+
+
+); + +const DescriptionColumnSkeleton = () => ( +
+); + +const AssignedItemsColumnSkeleton = () => ( +
+
+
+
+
+
+
+
+
+
+); 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..d9eaad8cbc --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/query-logs.schema.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { rolesFilterOperatorEnum } from "../../filters.schema"; + +export const queryRolesPayload = z.object({ + limit: z.number().int(), + slug: z + .object({ + filters: z.array( + z.object({ + operator: rolesFilterOperatorEnum, + value: z.string(), + }), + ), + }) + .nullable(), + description: z + .object({ + filters: z.array( + z.object({ + operator: rolesFilterOperatorEnum, + value: z.string(), + }), + ), + }) + .nullable(), + name: z + .object({ + filters: z.array( + z.object({ + operator: rolesFilterOperatorEnum, + value: z.string(), + }), + ), + }) + .nullable(), + cursor: z.number().nullable().optional().nullable(), +}); 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..09a011a087 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/utils/get-row-class.ts @@ -0,0 +1,39 @@ +import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; +import type { Roles } from "@/lib/trpc/routers/authorization/roles"; +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: KeyDetails, selectedLog: Roles | null) => { + const style = STATUS_STYLES; + const isSelected = log.id === 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/filters.schema.ts b/apps/dashboard/app/(app)/authorization/roles/filters.schema.ts new file mode 100644 index 0000000000..ba020466d1 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/filters.schema.ts @@ -0,0 +1,65 @@ +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; + slug: StringConfig; +}; + +export const rolesFilterFieldConfig: FilterFieldConfigs = { + name: { + type: "string", + operators: [...commonStringOperators], + }, + slug: { + type: "string", + operators: [...commonStringOperators], + }, + description: { + 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 keysListFilterFieldNames = 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..912e797a24 --- /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, + keysListFilterFieldNames, + parseAsAllOperatorsFilterArray, + rolesFilterFieldConfig, +} from "../filters.schema"; + +export const queryParamsPayload = Object.fromEntries( + keysListFilterFieldNames.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 keysListFilterFieldNames) { + 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( + keysListFilterFieldNames.map((field) => [field, null]), + ); + + const filtersByField = new Map(); + keysListFilterFieldNames.forEach((field) => filtersByField.set(field, [])); + + newFilters.forEach((filter) => { + if (!keysListFilterFieldNames.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 8eaca543e2..ff2d76b12d 100644 --- a/apps/dashboard/app/(app)/authorization/roles/navigation.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/navigation.tsx @@ -4,17 +4,13 @@ import { ShieldKey } from "@unkey/icons"; export function Navigation() { return ( - <> - - }> - - Authorization - - - Roles - - - - + + }> + Authorization + + Roles + + + ); } diff --git a/apps/dashboard/app/(app)/authorization/roles/page.tsx b/apps/dashboard/app/(app)/authorization/roles/page.tsx index cb458287ac..5d50a68ed2 100644 --- a/apps/dashboard/app/(app)/authorization/roles/page.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/page.tsx @@ -1,11 +1,14 @@ "use client"; +import { RolesList } from "./components/table/keys-list"; import { Navigation } from "./navigation"; export default function RolesPage() { return (
-
hello
+
+ +
); } diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts index 11429e5494..75a740442b 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts @@ -4,7 +4,7 @@ import { z } from "zod"; const MAX_ITEMS_TO_SHOW = 3; const ITEM_SEPARATOR = "|||"; -const DEFAULT_LIMIT = 50; +export const DEFAULT_LIMIT = 50; const MIN_LIMIT = 1; const rolesQueryInput = z.object({ @@ -13,6 +13,7 @@ const rolesQueryInput = z.object({ }); export const roles = z.object({ + roleId: z.string(), slug: z.string(), name: z.string(), description: z.string(), @@ -147,6 +148,7 @@ export const queryRoles = t.procedure : []; return { + roleId: row.id, slug: row.name, name: row.human_readable || "", description: row.description || "", From 2bcf464e9499f9a972bc97854f8fa0ff10611d7e Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Mon, 2 Jun 2025 21:54:18 +0300 Subject: [PATCH 04/47] feat: iterate on roles table --- .../table/{keys-list.tsx => roles-list.tsx} | 136 ++++++++--------- .../app/(app)/authorization/roles/page.tsx | 2 +- .../trpc/routers/authorization/roles/index.ts | 137 ++++++++++-------- internal/db/src/schema/rbac.ts | 2 +- internal/icons/src/icons/asterisk.tsx | 63 ++++++++ internal/icons/src/icons/tag.tsx | 39 +++++ internal/icons/src/index.ts | 2 + 7 files changed, 237 insertions(+), 144 deletions(-) rename apps/dashboard/app/(app)/authorization/roles/components/table/{keys-list.tsx => roles-list.tsx} (69%) create mode 100644 internal/icons/src/icons/asterisk.tsx create mode 100644 internal/icons/src/icons/tag.tsx diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/keys-list.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/roles-list.tsx similarity index 69% rename from apps/dashboard/app/(app)/authorization/roles/components/table/keys-list.tsx rename to apps/dashboard/app/(app)/authorization/roles/components/table/roles-list.tsx index 875d35f739..1a6fc1405b 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/keys-list.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/roles-list.tsx @@ -1,12 +1,10 @@ "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 { Roles } from "@/lib/trpc/routers/authorization/roles"; -import { BookBookmark, CircleInfoSparkle, Shield } from "@unkey/icons"; -import { AnimatedLoadingSpinner, Button, Checkbox, Empty } from "@unkey/ui"; +import { Asterisk, BookBookmark, Key2, Tag } from "@unkey/icons"; +import { Button, Checkbox, Empty } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; -import Link from "next/link"; import { useCallback, useMemo, useState } from "react"; import { useRolesListQuery } from "./hooks/use-roles-list-query"; import { getRowClassName } from "./utils/get-row-class"; @@ -14,15 +12,9 @@ import { getRowClassName } from "./utils/get-row-class"; export const RolesList = () => { const { roles, isLoading, isLoadingMore, loadMore, totalCount, hasMore } = useRolesListQuery(); const [selectedRole, setSelectedRole] = useState(null); - const [navigatingRoleSlug, setNavigatingRoleSlug] = useState(null); const [selectedRoles, setSelectedRoles] = useState>(new Set()); const [hoveredRoleSlug, setHoveredRoleSlug] = useState(null); - const handleLinkClick = useCallback((roleSlug: string) => { - setNavigatingRoleSlug(roleSlug); - setSelectedRole(null); - }, []); - const toggleSelection = useCallback((roleSlug: string) => { setSelectedRoles((prevSelected) => { const newSelected = new Set(prevSelected); @@ -43,38 +35,26 @@ export const RolesList = () => { width: "25%", headerClassName: "pl-[18px]", render: (role) => { - const isNavigating = role.slug === navigatingRoleSlug; const isSelected = selectedRoles.has(role.slug); const isHovered = hoveredRoleSlug === role.slug; const iconContainer = (
setHoveredRoleSlug(role.slug)} onMouseLeave={() => setHoveredRoleSlug(null)} > - {isNavigating ? ( -
- -
- ) : ( - <> - {!isSelected && !isHovered && ( - - )} - - {(isSelected || isHovered) && ( - toggleSelection(role.slug)} - /> - )} - + {!isSelected && !isHovered && } + {(isSelected || isHovered) && ( + toggleSelection(role.slug)} + /> )}
); @@ -84,23 +64,19 @@ export const RolesList = () => {
{iconContainer}
- { - handleLinkClick(role.slug); - }} - > -
{role.slug}
- - {role.name && ( +
+ {role.name} +
+ {role.description ? ( - {role.name} + {role.description} + + ) : ( + + No description )}
@@ -110,12 +86,15 @@ export const RolesList = () => { }, }, { - key: "description", - header: "Description", + key: "slug", + header: "Slug", width: "25%", render: (role) => ( -
- {role.description || No description} +
+ {role.slug}
), }, @@ -144,7 +123,7 @@ export const RolesList = () => { ), }, ], - [navigatingRoleSlug, handleLinkClick, selectedRoles, toggleSelection, hoveredRoleSlug], + [selectedRoles, toggleSelection, hoveredRoleSlug], ); return ( @@ -240,42 +219,41 @@ const AssignedItemsCell = ({ }) => { const hasMore = totalCount && totalCount > items.length; const icon = - type === "keys" ? : ; + type === "keys" ? ( + + ) : ( + + ); if (items.length === 0) { return ( -
- {icon} - None assigned +
+
+ {icon} + None assigned +
); } return ( -
-
- {icon} - - {totalCount ? totalCount : items.length} {type} - -
-
- {items.map((item, idx) => ( - - key={idx} - variant="secondary" - className="text-xs py-0.5 px-1.5" - > - {item} - - ))} - {hasMore && ( - - +{(totalCount || 0) - items.length} more - - )} -
+
+ {items.map((item) => ( +
+ {icon} + {item} +
+ ))} + {hasMore && ( +
+ + more {totalCount - 3} keys... + +
+ )}
); }; diff --git a/apps/dashboard/app/(app)/authorization/roles/page.tsx b/apps/dashboard/app/(app)/authorization/roles/page.tsx index 5d50a68ed2..a54e5e4da9 100644 --- a/apps/dashboard/app/(app)/authorization/roles/page.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { RolesList } from "./components/table/keys-list"; +import { RolesList } from "./components/table/roles-list"; import { Navigation } from "./navigation"; export default function RolesPage() { diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts index 75a740442b..2259c5e3af 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts @@ -48,69 +48,80 @@ export const queryRoles = t.procedure const { limit, cursor } = input; const result = await db.execute(sql` - SELECT - r.id, - r.name, - r.human_readable, - r.description, - r.updated_at_m, - - -- Keys data (only first ${MAX_ITEMS_TO_SHOW} names) - GROUP_CONCAT( - CASE - WHEN key_data.key_row_num <= ${MAX_ITEMS_TO_SHOW} - THEN key_data.key_name - END - ORDER BY key_data.key_name - SEPARATOR ${ITEM_SEPARATOR} - ) as key_items, - COALESCE(MAX(key_data.total_keys), 0) as total_keys, - - -- Permissions data (only first ${MAX_ITEMS_TO_SHOW} names) - GROUP_CONCAT( - CASE - WHEN perm_data.perm_row_num <= ${MAX_ITEMS_TO_SHOW} - THEN perm_data.permission_name - END - ORDER BY perm_data.permission_name - SEPARATOR ${ITEM_SEPARATOR} - ) as permission_items, - COALESCE(MAX(perm_data.total_permissions), 0) as total_permissions, - - -- Total count - (SELECT COUNT(*) FROM roles WHERE workspace_id = ${workspaceId}) as grand_total - - FROM ( - SELECT * - FROM roles - WHERE workspace_id = ${workspaceId} - ${cursor ? sql`AND updated_at_m < ${cursor}` : sql``} - ORDER BY updated_at_m DESC - LIMIT ${limit + 1} - ) r - LEFT JOIN ( - SELECT - kr.role_id, - k.name as key_name, - ROW_NUMBER() OVER (PARTITION BY kr.role_id ORDER BY k.name) as key_row_num, - COUNT(*) OVER (PARTITION BY kr.role_id) as total_keys - FROM keys_roles kr - JOIN \`keys\` k ON kr.key_id = k.id - WHERE kr.workspace_id = ${workspaceId} - ) key_data ON r.id = key_data.role_id AND key_data.key_row_num <= ${MAX_ITEMS_TO_SHOW} - LEFT JOIN ( - SELECT - rp.role_id, - p.name as permission_name, - ROW_NUMBER() OVER (PARTITION BY rp.role_id ORDER BY p.name) as perm_row_num, - COUNT(*) OVER (PARTITION BY rp.role_id) as total_permissions - FROM roles_permissions rp - JOIN permissions p ON rp.permission_id = p.id - WHERE rp.workspace_id = ${workspaceId} - ) perm_data ON r.id = perm_data.role_id AND perm_data.perm_row_num <= ${MAX_ITEMS_TO_SHOW} - GROUP BY r.id, r.name, r.human_readable, r.description, r.updated_at_m - ORDER BY r.updated_at_m DESC - `); + SELECT + r.id, + r.name, + r.human_readable, + 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 + (SELECT COUNT(*) FROM roles WHERE workspace_id = ${workspaceId}) as grand_total + + FROM ( + SELECT * + FROM roles + WHERE workspace_id = ${workspaceId} + ${cursor ? sql`AND updated_at_m < ${cursor}` : sql``} + ORDER BY updated_at_m DESC + LIMIT ${limit + 1} + ) r + ORDER BY r.updated_at_m DESC +`); const rows = result.rows as { id: string; diff --git a/internal/db/src/schema/rbac.ts b/internal/db/src/schema/rbac.ts index 29ad08f573..d17fc4e105 100644 --- a/internal/db/src/schema/rbac.ts +++ b/internal/db/src/schema/rbac.ts @@ -18,7 +18,7 @@ export const permissions = mysqlTable( workspaceId: varchar("workspace_id", { length: 256 }).notNull(), name: varchar("name", { length: 512 }).notNull(), description: varchar("description", { length: 512 }), - + human_readable: varchar("human_readable", { length: 512 }), createdAtM: bigint("created_at_m", { mode: "number" }) .notNull() .default(0) diff --git a/internal/icons/src/icons/asterisk.tsx b/internal/icons/src/icons/asterisk.tsx new file mode 100644 index 0000000000..d2b5832de5 --- /dev/null +++ b/internal/icons/src/icons/asterisk.tsx @@ -0,0 +1,63 @@ +/** + * 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 Asterisk: 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..29d7ed94a4 100644 --- a/internal/icons/src/index.ts +++ b/internal/icons/src/index.ts @@ -4,6 +4,7 @@ export * from "./icons/arrow-dotted-rotate-anticlockwise"; export * from "./icons/arrow-opposite-direction-y"; export * from "./icons/arrow-right"; export * from "./icons/arrow-up-right"; +export * from "./icons/asterisk"; export * from "./icons/ban"; export * from "./icons/bars-filter"; export * from "./icons/bolt"; @@ -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"; From a51786dc8e3161bca1a1f53ebead54477e28bf45 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 3 Jun 2025 12:24:29 +0300 Subject: [PATCH 05/47] feat: add filters --- .../roles/components/control-cloud/index.tsx | 12 ++-- .../components/logs-filters/index.tsx | 57 +++++++++---------- .../roles/components/controls/index.tsx | 6 +- .../app/(app)/authorization/roles/page.tsx | 4 ++ 4 files changed, 40 insertions(+), 39 deletions(-) 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 index baaf8b3717..0a80312a52 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/control-cloud/index.tsx @@ -4,18 +4,18 @@ import { useFilters } from "../../hooks/use-filters"; const formatFieldName = (field: string): string => { switch (field) { - case "names": + case "name": return "Name"; - case "identities": - return "Identity"; - case "keyIds": - return "Key ID"; + case "slug": + return "Slug"; + case "description": + return "Description"; default: return field.charAt(0).toUpperCase() + field.slice(1); } }; -export const KeysListControlCloud = () => { +export const RolesListControlCloud = () => { const { filters, updateFilters, removeFilter } = useFilters(); return ( { const { filters, updateFilters } = useFilters(); - const options = keysListFilterFieldConfig.names.operators.map((op) => ({ - id: op, - label: op, - })); - const activeNameFilter = filters.find((f) => f.field === "names"); - const activeIdentityFilter = filters.find((f) => f.field === "identities"); - const activeKeyIdsFilter = filters.find((f) => f.field === "keyIds"); - const keyIdOptions = keysListFilterFieldConfig.names.operators.map((op) => ({ + const options = rolesFilterFieldConfig.name.operators.map((op) => ({ id: op, label: op, })); + const activeNameFilter = filters.find((f) => f.field === "name"); + const activeSlugFilter = filters.find((f) => f.field === "slug"); + const activeDescriptionFilter = filters.find((f) => f.field === "description"); + return ( { defaultOption={activeNameFilter?.operator} defaultText={activeNameFilter?.value as string} onApply={(id, text) => { - const activeFiltersWithoutNames = filters.filter((f) => f.field !== "names"); + const activeFiltersWithoutNames = filters.filter((f) => f.field !== "name"); updateFilters([ ...activeFiltersWithoutNames, { - field: "names", + field: "name", id: crypto.randomUUID(), operator: id, value: text, @@ -50,21 +47,21 @@ export const LogsFilters = () => { ), }, { - id: "identities", - label: "Identity", - shortcut: "i", + id: "slug", + label: "Slug", + shortcut: "s", component: ( { - const activeFiltersWithoutNames = filters.filter((f) => f.field !== "identities"); + const activeFiltersWithoutNames = filters.filter((f) => f.field !== "slug"); updateFilters([ ...activeFiltersWithoutNames, { - field: "identities", + field: "slug", id: crypto.randomUUID(), operator: id, value: text, @@ -75,21 +72,23 @@ export const LogsFilters = () => { ), }, { - id: "keyids", - label: "Key ID", - shortcut: "k", + id: "description", + label: "Description", + shortcut: "d", component: ( { - const activeFiltersWithoutKeyIds = filters.filter((f) => f.field !== "keyIds"); + const activeFiltersWithoutDescriptions = filters.filter( + (f) => f.field !== "description", + ); updateFilters([ - ...activeFiltersWithoutKeyIds, + ...activeFiltersWithoutDescriptions, { - field: "keyIds", + field: "description", id: crypto.randomUUID(), operator: id, value: text, diff --git a/apps/dashboard/app/(app)/authorization/roles/components/controls/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/controls/index.tsx index 97e8c07861..3fdbdb45f2 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/controls/index.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/controls/index.tsx @@ -1,15 +1,13 @@ -import { LogsDateTime } from "@/app/(app)/apis/_components/controls/components/logs-datetime"; import { ControlsContainer, ControlsLeft } from "@/components/logs/controls-container"; import { LogsFilters } from "./components/logs-filters"; import { LogsSearch } from "./components/logs-search"; -export function KeysListControls({ keyspaceId }: { keyspaceId: string }) { +export function RoleListControls() { return ( - + - ); diff --git a/apps/dashboard/app/(app)/authorization/roles/page.tsx b/apps/dashboard/app/(app)/authorization/roles/page.tsx index a54e5e4da9..a0be3225ef 100644 --- a/apps/dashboard/app/(app)/authorization/roles/page.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/page.tsx @@ -1,4 +1,6 @@ "use client"; +import { RolesListControlCloud } from "./components/control-cloud"; +import { RoleListControls } from "./components/controls"; import { RolesList } from "./components/table/roles-list"; import { Navigation } from "./navigation"; @@ -7,6 +9,8 @@ export default function RolesPage() {
+ +
From fdb36441887dadcd43734dd81b8fd99d9fad40a5 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 3 Jun 2025 12:35:36 +0300 Subject: [PATCH 06/47] feat: add proper filter schema --- .../components/outcome-explainer.tsx | 153 ---------------- .../table/components/bar-chart/index.tsx | 152 --------------- .../bar-chart/query-timeseries.schema.ts | 10 - .../bar-chart/use-fetch-timeseries.ts | 126 ------------- .../table/components/hidden-value.tsx | 47 ----- .../status-cell/components/status-badge.tsx | 70 ------- .../components/status-cell/constants.tsx | 79 -------- .../table/components/status-cell/index.tsx | 141 -------------- .../status-cell/query-timeseries.schema.ts | 10 - .../components/status-cell/use-key-status.ts | 173 ------------------ .../table/hooks/use-roles-list-query.ts | 37 ++-- .../components/table/query-logs.schema.ts | 50 ++--- .../authorization/roles/filters.schema.ts | 2 +- .../authorization/roles/hooks/use-filters.ts | 12 +- .../trpc/routers/authorization/roles/index.ts | 15 +- 15 files changed, 47 insertions(+), 1030 deletions(-) delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/components/outcome-explainer.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/index.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/query-timeseries.schema.ts delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/use-fetch-timeseries.ts delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/hidden-value.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/components/status-badge.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/constants.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/index.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/query-timeseries.schema.ts delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/use-key-status.ts diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/components/outcome-explainer.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/components/outcome-explainer.tsx deleted file mode 100644 index 3c3a947898..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/components/outcome-explainer.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { formatNumber } from "@/lib/fmt"; - -import { InfoTooltip } from "@unkey/ui"; -import { useMemo } from "react"; -import type { ProcessedTimeseriesDataPoint } from "../use-fetch-timeseries"; - -type OutcomeExplainerProps = { - children: React.ReactNode; - timeseries: ProcessedTimeseriesDataPoint[]; -}; - -type ErrorType = { - type: string; - value: string; - color: string; -}; - -export function OutcomeExplainer({ children, timeseries }: OutcomeExplainerProps): JSX.Element { - // Aggregate all timeseries data for the tooltip - const aggregatedData = useMemo(() => { - if (!timeseries || timeseries.length === 0) { - return { - valid: 0, - rate_limited: 0, - insufficient_permissions: 0, - forbidden: 0, - disabled: 0, - expired: 0, - usage_exceeded: 0, - total: 0, - }; - } - - return timeseries.reduce( - (acc, dataPoint) => { - acc.valid += dataPoint.valid || 0; - acc.rate_limited += dataPoint.rate_limited || 0; - acc.insufficient_permissions += dataPoint.insufficient_permissions || 0; - acc.forbidden += dataPoint.forbidden || 0; - acc.disabled += dataPoint.disabled || 0; - acc.expired += dataPoint.expired || 0; - acc.usage_exceeded += dataPoint.usage_exceeded || 0; - acc.total += dataPoint.total || 0; - return acc; - }, - { - valid: 0, - rate_limited: 0, - insufficient_permissions: 0, - forbidden: 0, - disabled: 0, - expired: 0, - usage_exceeded: 0, - total: 0, - }, - ); - }, [timeseries]); - - const errorTypes = useMemo(() => { - const potentialErrors = [ - { - type: "Insufficient Permissions", - rawValue: aggregatedData.insufficient_permissions, - color: "bg-error-9", - }, - { - type: "Rate Limited", - rawValue: aggregatedData.rate_limited, - color: "bg-error-9", - }, - { - type: "Forbidden", - rawValue: aggregatedData.forbidden, - color: "bg-error-9", - }, - { - type: "Disabled", - rawValue: aggregatedData.disabled, - color: "bg-error-9", - }, - { - type: "Expired", - rawValue: aggregatedData.expired, - color: "bg-error-9", - }, - { - type: "Usage Exceeded", - rawValue: aggregatedData.usage_exceeded, - color: "bg-error-9", - }, - ]; - - const filteredErrors = potentialErrors.filter((error) => error.rawValue > 0); - - return filteredErrors.map((error) => ({ - type: error.type, - value: formatNumber(error.rawValue), - color: error.color, - })) as ErrorType[]; - }, [aggregatedData]); - - return ( - -
API Key Activity
-
Last 36 hours
- - {/* Valid count */} -
-
-
-
Valid
-
-
- {formatNumber(aggregatedData.valid)} -
-
- -
- - {/* Error types */} -
- {errorTypes.map((error, index) => ( -
- key={index} - className="flex justify-between w-full items-center" - > -
-
-
{error.type}
-
-
{error.value}
-
- ))} - - {errorTypes.length === 0 && aggregatedData.valid === 0 && ( -
No verification activity
- )} -
-
- } - > -
{children}
- - ); -} diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/index.tsx deleted file mode 100644 index 7eb03a22ad..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/index.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { cn } from "@/lib/utils"; -import { useMemo } from "react"; -import { UsageColumnSkeleton } from "../skeletons"; -import { OutcomeExplainer } from "./components/outcome-explainer"; -import { useFetchVerificationTimeseries } from "./use-fetch-timeseries"; - -type BarData = { - id: string | number; - topHeight: number; - bottomHeight: number; - totalHeight: number; -}; - -type VerificationBarChartProps = { - keyAuthId: string; - keyId: string; - maxBars?: number; - selected: boolean; -}; - -const MAX_HEIGHT_BUFFER_FACTOR = 1.3; -const MAX_BAR_HEIGHT = 28; - -export const VerificationBarChart = ({ - keyAuthId, - keyId, - selected, - maxBars = 30, -}: VerificationBarChartProps) => { - const { timeseries, isLoading, isError } = useFetchVerificationTimeseries(keyAuthId, keyId); - - const isEmpty = useMemo( - () => timeseries.reduce((acc, crr) => acc + crr.total, 0) === 0, - [timeseries], - ); - - const bars = useMemo((): BarData[] => { - if (isLoading || isError || timeseries.length === 0) { - // Return empty data if loading, error, or no data - return Array(maxBars).fill({ - id: 0, - topHeight: 0, - bottomHeight: 0, - totalHeight: 0, - }); - } - // Get the most recent data points (or all if less than maxBars) - const recentData = timeseries.slice(-maxBars); - // Calculate the maximum total value to normalize heights - const maxTotal = - Math.max(...recentData.map((item) => item.total), 1) * MAX_HEIGHT_BUFFER_FACTOR; - // Generate bars from the data - return recentData.map((item, index): BarData => { - // Scale to fit within max height of 28px - const totalHeight = Math.min( - Math.round((item.total / maxTotal) * MAX_BAR_HEIGHT), - MAX_BAR_HEIGHT, - ); - // Calculate heights proportionally - const topHeight = item.error - ? Math.max(Math.round((item.error / item.total) * totalHeight), 1) - : 0; - const bottomHeight = Math.max(totalHeight - topHeight, 0); - return { - id: index, - totalHeight, - topHeight, - bottomHeight, - }; - }); - }, [timeseries, isLoading, isError, maxBars]); - - // Pad with empty bars if we have fewer than maxBars data points - const displayBars = useMemo((): BarData[] => { - const result = [...bars]; - while (result.length < maxBars) { - result.unshift({ - id: `empty-${result.length}`, - topHeight: 0, - bottomHeight: 0, - totalHeight: 0, - }); - } - return result; - }, [bars, maxBars]); - - // Loading state - animated pulse effect for bars with grid layout - if (isLoading) { - return ; - } - - // Error state with grid layout - if (isError) { - return ( -
-
- Error loading data -
-
- ); - } - - // Empty state with grid layout - if (isEmpty) { - return ( -
-
- No data available -
-
- ); - } - - // Data display with grid layout - return ( - -
- {displayBars.map((bar) => ( -
-
-
-
- ))} -
- - ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/query-timeseries.schema.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/query-timeseries.schema.ts deleted file mode 100644 index 51b95ba2df..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/query-timeseries.schema.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { z } from "zod"; - -export const keysListQueryTimeseriesPayload = z.object({ - startTime: z.number().int(), - endTime: z.number().int(), - keyId: z.string(), - keyAuthId: z.string(), -}); - -export type KeysListQueryTimeseriesPayload = z.infer; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/use-fetch-timeseries.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/use-fetch-timeseries.ts deleted file mode 100644 index ab71bd7e37..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/bar-chart/use-fetch-timeseries.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; -import { trpc } from "@/lib/trpc/client"; -import type { VerificationTimeseriesDataPoint } from "@unkey/clickhouse/src/verifications"; -import { useEffect, useMemo, useRef, useState } from "react"; - -export type ProcessedTimeseriesDataPoint = { - valid: number; - total: number; - success: number; - error: number; - rate_limited?: number; - insufficient_permissions?: number; - forbidden?: number; - disabled?: number; - expired?: number; - usage_exceeded?: number; -}; - -type CacheEntry = { - data: { timeseries: VerificationTimeseriesDataPoint[] }; - timestamp: number; -}; - -const timeseriesCache = new Map(); - -export const useFetchVerificationTimeseries = (keyAuthId: string, keyId: string) => { - // Use a ref for the initial timestamp to keep it stable - const initialTimeRef = useRef(Date.now()); - const cacheKey = `${keyAuthId}-${keyId}`; - - // Check if we have cached data - const cachedData = timeseriesCache.get(cacheKey); - - // State to force updates when cache changes - const [_, setCacheVersion] = useState(0); - - // Determine if we should run the query - const shouldFetch = !cachedData || Date.now() - cachedData.timestamp > 60000; - - // Set up query parameters - stable between renders - const queryParams = useMemo( - () => ({ - startTime: initialTimeRef.current - HISTORICAL_DATA_WINDOW * 3, - endTime: initialTimeRef.current, - keyAuthId, - keyId, - }), - [keyAuthId, keyId], - ); - - // Use TRPC's useQuery with critical settings - const { - data, - isLoading: trpcIsLoading, - isError, - } = trpc.api.keys.usageTimeseries.useQuery(queryParams, { - // CRITICAL: Only enable the query if we should fetch - enabled: shouldFetch, - // Prevent automatic refetching - refetchOnMount: false, - refetchOnWindowFocus: false, - staleTime: Number.POSITIVE_INFINITY, - refetchInterval: shouldFetch && queryParams.endTime >= Date.now() - 60_000 ? 10_000 : false, - }); - - // Process the timeseries data - using cached or fresh data - const effectiveData = data || (cachedData ? cachedData.data : undefined); - - // Process the timeseries from the effective data - const timeseries = useMemo(() => { - if (!effectiveData?.timeseries) { - return [] as ProcessedTimeseriesDataPoint[]; - } - - return effectiveData.timeseries.map((ts): ProcessedTimeseriesDataPoint => { - const result: ProcessedTimeseriesDataPoint = { - valid: ts.y.valid, - total: ts.y.total, - success: ts.y.valid, - error: ts.y.total - ts.y.valid, - }; - - // Add optional fields if they exist - if (ts.y.rate_limited_count !== undefined) { - result.rate_limited = ts.y.rate_limited_count; - } - if (ts.y.insufficient_permissions_count !== undefined) { - result.insufficient_permissions = ts.y.insufficient_permissions_count; - } - if (ts.y.forbidden_count !== undefined) { - result.forbidden = ts.y.forbidden_count; - } - if (ts.y.disabled_count !== undefined) { - result.disabled = ts.y.disabled_count; - } - if (ts.y.expired_count !== undefined) { - result.expired = ts.y.expired_count; - } - if (ts.y.usage_exceeded_count !== undefined) { - result.usage_exceeded = ts.y.usage_exceeded_count; - } - - return result; - }); - }, [effectiveData]); - - // Update cache when we get new data - useEffect(() => { - if (data) { - timeseriesCache.set(cacheKey, { - data, - timestamp: Date.now(), - }); - // Force a re-render to use cached data - setCacheVersion((prev) => prev + 1); - } - }, [data, cacheKey]); - - const isLoading = trpcIsLoading && !cachedData; - - return { - timeseries, - isLoading, - isError, - }; -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/hidden-value.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/hidden-value.tsx deleted file mode 100644 index 69bb2722fa..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/hidden-value.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { toast } from "@/components/ui/toaster"; -import { cn } from "@/lib/utils"; -import { CircleLock } from "@unkey/icons"; - -export const HiddenValueCell = ({ - value, - title = "Value", - selected, -}: { - value: string; - title: string; - selected: boolean; -}) => { - // Show only first 4 characters, then dots - const displayValue = value.padEnd(16, "•"); - - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - navigator.clipboard - .writeText(value) - .then(() => { - toast.success(`${title} copied to clipboard`); - }) - .catch((error) => { - console.error("Failed to copy to clipboard:", error); - toast.error("Failed to copy to clipboard"); - }); - }; - - return ( - <> - {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} -
handleClick(e)} - > -
- -
-
{displayValue}
-
- - ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/components/status-badge.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/components/status-badge.tsx deleted file mode 100644 index 2f0e1d8279..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/components/status-badge.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Badge } from "@/components/ui/badge"; -import { cn } from "@/lib/utils"; -import { useRef } from "react"; -import { STATUS_STYLES } from "../../../utils/get-row-class"; - -type StatusBadgeProps = { - primary: { - label: string; - color: string; - icon: React.ReactNode; - }; - count: number; - isSelected?: boolean; -}; - -export const StatusBadge = ({ primary, count, isSelected = false }: StatusBadgeProps) => { - const badgeRef = useRef(null); - - const isDisabled = primary.label === "Disabled"; - - return ( -
- {isDisabled ? ( - // Use Badge component only for "Disabled" label - - {primary.icon && {primary.icon}} - {primary.label} - - ) : ( -
0 ? "rounded-l-md" : "rounded-md", - )} - > - {primary.icon && {primary.icon}} - {primary.label} -
- )} - - {count > 0 && - (isDisabled ? ( - - +{count} - - ) : ( -
- +{count} -
- ))} -
- ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/constants.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/constants.tsx deleted file mode 100644 index 863aa2a44b..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/constants.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { - Ban, - CircleCaretRight, - CircleCheck, - CircleHalfDottedClock, - ShieldKey, - TriangleWarning2, -} from "@unkey/icons"; - -export type StatusType = - | "disabled" - | "low-credits" - | "expires-soon" - | "rate-limited" - | "validation-issues" - | "operational"; - -export interface StatusInfo { - type: StatusType; - label: string; - color: string; - icon: React.ReactNode; - tooltip: string; - priority: number; // Lower number = higher priority -} - -export const STATUS_DEFINITIONS: Record = { - "low-credits": { - type: "low-credits", - label: "Low Credits", - color: "bg-errorA-3 text-errorA-11", - icon: , - tooltip: "This key has a low credit balance. Top it off to prevent disruptions.", - priority: 1, - }, - "rate-limited": { - type: "rate-limited", - label: "Ratelimited", - color: "bg-errorA-3 text-errorA-11", - icon: , - tooltip: - "This key is getting ratelimited frequently. Check the configured ratelimits and reach out to your user about their usage.", - priority: 2, - }, - "expires-soon": { - type: "expires-soon", - label: "Expires soon", - color: "bg-orangeA-3 text-orangeA-11", - icon: , - tooltip: - "This key will expire in less than 24 hours. Rotate the key or extend its deadline to prevent disruptions.", - priority: 2, - }, - "validation-issues": { - type: "validation-issues", - label: "Potential issues", - color: "bg-warningA-3 text-warningA-11", - icon: , - tooltip: "This key has a high error rate. Please check its logs to debug potential issues.", - priority: 3, - }, - //TODO: Add a way to enable this through tooltip - disabled: { - type: "disabled", - label: "Disabled", - color: "bg-grayA-3 text-grayA-11", - icon: , - tooltip: "This key is currently disabled and cannot be used for verification.", - priority: 0, - }, - operational: { - type: "operational", - label: "Operational", - color: "bg-successA-3 text-successA-11", - icon: , - tooltip: "This key is operating normally.", - priority: 99, // Lowest priority - }, -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/index.tsx deleted file mode 100644 index e0892526f8..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/index.tsx +++ /dev/null @@ -1,141 +0,0 @@ -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 { cn } from "@/lib/utils"; -import { InfoTooltip } from "@unkey/ui"; -import { StatusBadge } from "./components/status-badge"; -import { useKeyStatus } from "./use-key-status"; - -type StatusDisplayProps = { - keyData: KeyDetails; - keyAuthId: string; - isSelected: boolean; -}; - -export const StatusDisplay = ({ keyAuthId, keyData, isSelected }: StatusDisplayProps) => { - const { primary, count, isLoading, statuses, isError } = useKeyStatus(keyAuthId, keyData); - const utils = trpc.useUtils(); - - const enableKeyMutation = trpc.api.keys.enableKey.useMutation({ - onSuccess: async () => { - toast.success("Key enabled successfully!"); - await utils.api.keys.list.invalidate({ keyAuthId }); - }, - onError: (error) => { - toast.error("Failed to enable key", { - description: error.message || "An unknown error occurred.", - }); - }, - }); - - if (isLoading) { - return ( -
-
-
-
- ); - } - - if (isError) { - return ( -
- Failed to load -
- ); - } - - return ( - - {statuses && statuses.length > 1 && ( -
-
-
Key status overview
-
- This key has{" "} - {statuses.length} active - flags{" "} -
-
-
- )} - - {statuses?.map((status, i) => ( -
-
-
- -
- -
- {status.type === "disabled" ? ( -
- - This key has been manually disabled and cannot be used for any requests. - - {" "} -
- ) : ( - status.tooltip - )} -
-
-
- ))} -
- } - > - - - ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/query-timeseries.schema.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/query-timeseries.schema.ts deleted file mode 100644 index 142868060e..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/query-timeseries.schema.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { z } from "zod"; - -export const keyOutcomesQueryPayload = z.object({ - startTime: z.number().int(), - endTime: z.number().int(), - keyId: z.string(), - keyAuthId: z.string(), -}); - -export type KeyOutcomesQueryPayload = z.infer; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/use-key-status.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/use-key-status.ts deleted file mode 100644 index 16ac9cb98f..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/status-cell/use-key-status.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; -import { useMemo } from "react"; -import { - type ProcessedTimeseriesDataPoint, - useFetchVerificationTimeseries, -} from "../bar-chart/use-fetch-timeseries"; -import { STATUS_DEFINITIONS, type StatusInfo } from "./constants"; - -const RATE_LIMIT_THRESHOLD_PERCENT = 0.1; // 10% -const VALIDATION_ISSUE_THRESHOLD_PERCENT = 0.1; // 10% -const LOW_CREDITS_THRESHOLD_ABSOLUTE = 0; -const LOW_CREDITS_THRESHOLD_REFILL_PERCENT = 0.1; // 10% -const EXPIRY_THRESHOLD_HOURS = 24; - -type AggregatedData = { - total: number; - error: number; - rate_limited: number; -}; - -const aggregateTimeseries = (timeseries: ProcessedTimeseriesDataPoint[]): AggregatedData => { - return timeseries.reduce( - (acc, point) => { - acc.total += point.total; - acc.error += point.error; - acc.rate_limited += point.rate_limited ?? 0; - return acc; - }, - { total: 0, error: 0, rate_limited: 0 }, - ); -}; - -type UseKeyStatusResult = { - primary: { - label: string; - color: string; - icon: React.ReactNode; - }; - count: number; - statuses: StatusInfo[]; - isLoading: boolean; - isError: boolean; -}; - -const LOADING_PRIMARY = { - label: "Loading", - color: "bg-grayA-3", - icon: null, -}; - -export const useKeyStatus = (keyAuthId: string, keyData: KeyDetails): UseKeyStatusResult => { - const { timeseries, isError, isLoading } = useFetchVerificationTimeseries(keyAuthId, keyData.id); - - const statusResult = useMemo(() => { - // Handle case where keyData might not be loaded yet - if (!keyData) { - return { - primary: LOADING_PRIMARY, - count: 0, - statuses: [], - }; - } - - if (isLoading && timeseries.length === 0) { - return { - primary: LOADING_PRIMARY, - count: 0, - statuses: [], - }; - } - - if (isError) { - const fallbackStatus = keyData.enabled - ? STATUS_DEFINITIONS.operational - : STATUS_DEFINITIONS.disabled; - return { - primary: { - label: fallbackStatus.label, - color: fallbackStatus.color, - icon: fallbackStatus.icon, - }, - count: 0, - statuses: [fallbackStatus], - }; - } - - if (!keyData.enabled) { - const disabledStatus = STATUS_DEFINITIONS.disabled; - return { - primary: { - label: disabledStatus.label, - color: disabledStatus.color, - icon: disabledStatus.icon, - }, - count: 0, - statuses: [disabledStatus], - }; - } - - const applicableStatuses: StatusInfo[] = []; - const aggregatedData = aggregateTimeseries(timeseries); - const totalVerifications = aggregatedData.total; - - if ( - totalVerifications > 0 && - aggregatedData.rate_limited / totalVerifications > RATE_LIMIT_THRESHOLD_PERCENT - ) { - applicableStatuses.push(STATUS_DEFINITIONS["rate-limited"]); - } - - if ( - totalVerifications > 0 && - aggregatedData.error / totalVerifications > VALIDATION_ISSUE_THRESHOLD_PERCENT - ) { - applicableStatuses.push(STATUS_DEFINITIONS["validation-issues"]); - } - - const remaining = keyData.key.credits.remaining; - const refillAmount = keyData.key.credits.refillAmount; - const isLowOnCredits = - (remaining != null && remaining === LOW_CREDITS_THRESHOLD_ABSOLUTE) || - (refillAmount && - remaining != null && - refillAmount > 0 && - remaining < refillAmount * LOW_CREDITS_THRESHOLD_REFILL_PERCENT); - - if (isLowOnCredits) { - applicableStatuses.push(STATUS_DEFINITIONS["low-credits"]); - } - - // Check Expiry - if (keyData.expires) { - const hoursToExpiry = (keyData.expires * 1000 - Date.now()) / (1000 * 60 * 60); - // Ensure current time used is consistent if needed, Date.now() is fine here - if (hoursToExpiry > 0 && hoursToExpiry <= EXPIRY_THRESHOLD_HOURS) { - applicableStatuses.push(STATUS_DEFINITIONS["expires-soon"]); - } - } - - // Handle Operational state (if no issues found) - if (applicableStatuses.length === 0) { - const operationalStatus = STATUS_DEFINITIONS.operational; - return { - primary: { - label: operationalStatus.label, - color: operationalStatus.color, - icon: operationalStatus.icon, - }, - count: 0, - statuses: [operationalStatus], // Return array with the single operational status - }; - } - - applicableStatuses.sort((a, b) => a.priority - b.priority); // Sort by priority - - const primaryStatus = applicableStatuses[0]; // Highest priority is the first element - return { - primary: { - label: primaryStatus.label, - color: primaryStatus.color, - icon: primaryStatus.icon, - }, - count: applicableStatuses.length - 1, // Count of *other* statuses besides primary - statuses: applicableStatuses, // Return the full sorted array of applicable statuses - }; - }, [keyData, timeseries, isLoading, isError]); - - return { - ...statusResult, - isLoading: isLoading || !keyData, - isError, - }; -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts index bfd01d775e..1b772c78db 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts @@ -1,13 +1,9 @@ import { trpc } from "@/lib/trpc/client"; import type { Roles } from "@/lib/trpc/routers/authorization/roles"; import { useEffect, useMemo, useState } from "react"; -import { keysListFilterFieldNames, rolesFilterFieldConfig } from "../../../filters.schema"; +import { rolesFilterFieldConfig, rolesListFilterFieldNames } from "../../../filters.schema"; import { useFilters } from "../../../hooks/use-filters"; - -type RolesQueryPayload = { - limit?: number; - cursor?: number; -}; +import type { RolesQueryPayload } from "../query-logs.schema"; export function useRolesListQuery() { const [totalCount, setTotalCount] = useState(0); @@ -16,28 +12,31 @@ export function useRolesListQuery() { const rolesList = useMemo(() => Array.from(rolesMap.values()), [rolesMap]); - // For now, roles endpoint doesn't support filtering, so we prepare for future enhancement - const queryParams = useMemo((): RolesQueryPayload => { - // Currently the roles endpoint only supports limit and cursor - // Filter validation is kept for future compatibility + const queryParams = useMemo(() => { + const params: RolesQueryPayload = { + ...Object.fromEntries(rolesListFilterFieldNames.map((field) => [field, []])), + }; + filters.forEach((filter) => { - if (!keysListFilterFieldNames.includes(filter.field)) { - throw new Error(`Invalid filter field: ${filter.field}`); + if (!rolesListFilterFieldNames.includes(filter.field) || !params[filter.field]) { + return; } const fieldConfig = rolesFilterFieldConfig[filter.field]; - if (!fieldConfig.operators.includes(filter.operator)) { - throw new Error(`Invalid operator '${filter.operator}' for field '${filter.field}'`); + const validOperators = fieldConfig.operators; + if (!validOperators.includes(filter.operator)) { + throw new Error("Invalid operator"); } - if (typeof filter.value !== "string") { - throw new Error(`Filter value must be a string for field '${filter.field}'`); + if (typeof filter.value === "string") { + params[filter.field]?.push({ + operator: filter.operator, + value: filter.value, + }); } }); - return { - limit: 50, // Using default from your endpoint - }; + return params; }, [filters]); const { 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 index d9eaad8cbc..34b20d5159 100644 --- 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 @@ -1,37 +1,21 @@ import { z } from "zod"; import { rolesFilterOperatorEnum } from "../../filters.schema"; -export const queryRolesPayload = z.object({ - limit: z.number().int(), - slug: z - .object({ - filters: z.array( - z.object({ - operator: rolesFilterOperatorEnum, - value: z.string(), - }), - ), - }) - .nullable(), - description: z - .object({ - filters: z.array( - z.object({ - operator: rolesFilterOperatorEnum, - value: z.string(), - }), - ), - }) - .nullable(), - name: z - .object({ - filters: z.array( - z.object({ - operator: rolesFilterOperatorEnum, - value: z.string(), - }), - ), - }) - .nullable(), - cursor: z.number().nullable().optional().nullable(), +const filterItemSchema = z.object({ + operator: rolesFilterOperatorEnum, + value: z.string(), }); + +const baseFilterArraySchema = z.array(filterItemSchema).nullish(); + +const baseRolesSchema = z.object({ + slug: baseFilterArraySchema, + description: baseFilterArraySchema, + name: baseFilterArraySchema, +}); + +export const rolesQueryPayload = baseRolesSchema.extend({ + cursor: z.number().nullish(), +}); + +export type RolesQueryPayload = z.infer; diff --git a/apps/dashboard/app/(app)/authorization/roles/filters.schema.ts b/apps/dashboard/app/(app)/authorization/roles/filters.schema.ts index ba020466d1..31fdc23626 100644 --- a/apps/dashboard/app/(app)/authorization/roles/filters.schema.ts +++ b/apps/dashboard/app/(app)/authorization/roles/filters.schema.ts @@ -39,7 +39,7 @@ const [firstFieldName, ...restFieldNames] = allFilterFieldNames; export const rolesFilterFieldEnum = z.enum([firstFieldName, ...restFieldNames]); -export const keysListFilterFieldNames = allFilterFieldNames; +export const rolesListFilterFieldNames = allFilterFieldNames; export type RolesFilterField = z.infer; diff --git a/apps/dashboard/app/(app)/authorization/roles/hooks/use-filters.ts b/apps/dashboard/app/(app)/authorization/roles/hooks/use-filters.ts index 912e797a24..afac7b8b57 100644 --- a/apps/dashboard/app/(app)/authorization/roles/hooks/use-filters.ts +++ b/apps/dashboard/app/(app)/authorization/roles/hooks/use-filters.ts @@ -5,13 +5,13 @@ import { type RolesFilterField, type RolesFilterValue, type RolesQuerySearchParams, - keysListFilterFieldNames, parseAsAllOperatorsFilterArray, rolesFilterFieldConfig, + rolesListFilterFieldNames, } from "../filters.schema"; export const queryParamsPayload = Object.fromEntries( - keysListFilterFieldNames.map((field) => [field, parseAsAllOperatorsFilterArray]), + rolesListFilterFieldNames.map((field) => [field, parseAsAllOperatorsFilterArray]), ) as { [K in RolesFilterField]: typeof parseAsAllOperatorsFilterArray }; export const useFilters = () => { @@ -22,7 +22,7 @@ export const useFilters = () => { const filters = useMemo(() => { const activeFilters: RolesFilterValue[] = []; - for (const field of keysListFilterFieldNames) { + for (const field of rolesListFilterFieldNames) { const value = searchParams[field]; if (!Array.isArray(value)) { continue; @@ -47,14 +47,14 @@ export const useFilters = () => { const updateFilters = useCallback( (newFilters: RolesFilterValue[]) => { const newParams: Partial = Object.fromEntries( - keysListFilterFieldNames.map((field) => [field, null]), + rolesListFilterFieldNames.map((field) => [field, null]), ); const filtersByField = new Map(); - keysListFilterFieldNames.forEach((field) => filtersByField.set(field, [])); + rolesListFilterFieldNames.forEach((field) => filtersByField.set(field, [])); newFilters.forEach((filter) => { - if (!keysListFilterFieldNames.includes(filter.field)) { + if (!rolesListFilterFieldNames.includes(filter.field)) { throw new Error(`Invalid filter field: ${filter.field}`); } diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts index 2259c5e3af..34940bbdbd 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts @@ -1,3 +1,4 @@ +import { rolesQueryPayload } from "@/app/(app)/authorization/roles/components/table/query-logs.schema"; import { db, sql } from "@/lib/db"; import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; import { z } from "zod"; @@ -5,12 +6,6 @@ import { z } from "zod"; const MAX_ITEMS_TO_SHOW = 3; const ITEM_SEPARATOR = "|||"; export const DEFAULT_LIMIT = 50; -const MIN_LIMIT = 1; - -const rolesQueryInput = z.object({ - limit: z.number().int().min(MIN_LIMIT).max(DEFAULT_LIMIT).default(DEFAULT_LIMIT), - cursor: z.number().int().optional(), -}); export const roles = z.object({ roleId: z.string(), @@ -41,11 +36,11 @@ export const queryRoles = t.procedure .use(requireUser) .use(requireWorkspace) .use(withRatelimit(ratelimit.read)) - .input(rolesQueryInput) + .input(rolesQueryPayload) .output(rolesResponse) .query(async ({ ctx, input }) => { const workspaceId = ctx.workspace.id; - const { limit, cursor } = input; + const { cursor } = input; const result = await db.execute(sql` SELECT @@ -118,7 +113,7 @@ export const queryRoles = t.procedure WHERE workspace_id = ${workspaceId} ${cursor ? sql`AND updated_at_m < ${cursor}` : sql``} ORDER BY updated_at_m DESC - LIMIT ${limit + 1} + LIMIT ${DEFAULT_LIMIT + 1} ) r ORDER BY r.updated_at_m DESC `); @@ -146,7 +141,7 @@ export const queryRoles = t.procedure } const total = rows[0].grand_total; - const hasMore = rows.length > limit; + const hasMore = rows.length > DEFAULT_LIMIT; const items = hasMore ? rows.slice(0, -1) : rows; const rolesResponseData: Roles[] = items.map((row) => { From bc1380d0705cdf72041309c47dff5e4a994c004b Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 3 Jun 2025 13:06:51 +0300 Subject: [PATCH 07/47] fix: colors --- .../table/components/last-updated.tsx | 47 +++++++++++ .../components/table/components/last-used.tsx | 70 ----------------- .../roles/components/table/roles-list.tsx | 77 +++++++++++++------ .../components/table/utils/get-row-class.ts | 5 +- .../trpc/routers/authorization/roles/index.ts | 56 +++++++++++++- 5 files changed, 156 insertions(+), 99 deletions(-) create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/last-updated.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/last-used.tsx 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/last-used.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/last-used.tsx deleted file mode 100644 index f886ce85d2..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/last-used.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Badge } from "@/components/ui/badge"; -import { trpc } from "@/lib/trpc/client"; -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 LastUsedCell = ({ - keyAuthId, - keyId, - isSelected, -}: { - keyAuthId: string; - keyId: string; - isSelected: boolean; -}) => { - const { data, isLoading, isError } = trpc.api.keys.latestVerification.useQuery({ - keyAuthId, - keyId, - }); - const badgeRef = useRef(null); - const [showTooltip, setShowTooltip] = useState(false); - - return ( - { - setShowTooltip(true); - }} - onMouseLeave={() => { - setShowTooltip(false); - }} - > -
- -
-
- {isLoading ? ( -
-
-
-
-
- ) : isError ? ( - "Failed to load" - ) : data?.lastVerificationTime ? ( - - ) : ( - "Never used" - )} -
- - ); -}; 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 1a6fc1405b..6f4526f592 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 @@ -6,6 +6,7 @@ import { Asterisk, BookBookmark, Key2, 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 { LastUpdated } from "./components/last-updated"; import { useRolesListQuery } from "./hooks/use-roles-list-query"; import { getRowClassName } from "./utils/get-row-class"; @@ -32,7 +33,7 @@ export const RolesList = () => { { key: "role", header: "Role", - width: "25%", + width: "20%", headerClassName: "pl-[18px]", render: (role) => { const isSelected = selectedRoles.has(role.slug); @@ -41,9 +42,9 @@ export const RolesList = () => { const iconContainer = (
setHoveredRoleSlug(role.slug)} onMouseLeave={() => setHoveredRoleSlug(null)} @@ -88,24 +89,31 @@ export const RolesList = () => { { key: "slug", header: "Slug", - width: "25%", - render: (role) => ( -
- {role.slug} -
- ), + width: "20%", + render: (role) => { + const isRowSelected = role.roleId === selectedRole?.roleId; + return ( +
+ {role.slug} +
+ ); + }, }, { key: "assignedKeys", header: "Assigned Keys", - width: "25%", + width: "20%", render: (role) => ( ), @@ -113,17 +121,31 @@ export const RolesList = () => { { key: "permissions", header: "Permissions", - width: "25%", + width: "20%", render: (role) => ( ), }, + { + key: "last_updated", + header: "Last Updated", + width: "20%", + render: (role) => { + return ( + + ); + }, + }, ], - [selectedRoles, toggleSelection, hoveredRoleSlug], + [selectedRoles, toggleSelection, hoveredRoleSlug, selectedRole?.roleId], ); return ( @@ -212,10 +234,12 @@ const AssignedItemsCell = ({ items, totalCount, type, + isSelected = false, }: { items: string[]; totalCount?: number; type: "keys" | "permissions"; + isSelected?: boolean; }) => { const hasMore = totalCount && totalCount > items.length; const icon = @@ -225,10 +249,20 @@ const AssignedItemsCell = ({ ); + 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
@@ -237,20 +271,17 @@ const AssignedItemsCell = ({ } return ( -
+
{items.map((item) => ( -
+
{icon} {item}
))} {hasMore && ( -
+
- more {totalCount - 3} keys... + more {totalCount - items.length} {type}...
)} diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/utils/get-row-class.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/utils/get-row-class.ts index 09a011a087..0195caf35b 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/utils/get-row-class.ts +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/utils/get-row-class.ts @@ -1,4 +1,3 @@ -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; import type { Roles } from "@/lib/trpc/routers/authorization/roles"; import { cn } from "@/lib/utils"; @@ -24,9 +23,9 @@ export const STATUS_STYLES = { focusRing: "focus:ring-accent-7", }; -export const getRowClassName = (log: KeyDetails, selectedLog: Roles | null) => { +export const getRowClassName = (log: Roles, selectedLog: Roles | null) => { const style = STATUS_STYLES; - const isSelected = log.id === selectedLog?.roleId; + const isSelected = log.roleId === selectedLog?.roleId; return cn( style.base, diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts index 34940bbdbd..65a5786d38 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts @@ -1,4 +1,5 @@ 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"; @@ -32,6 +33,40 @@ const rolesResponse = z.object({ nextCursor: z.number().int().nullish(), }); +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 `)})`; +} + export const queryRoles = t.procedure .use(requireUser) .use(requireWorkspace) @@ -40,7 +75,12 @@ export const queryRoles = t.procedure .output(rolesResponse) .query(async ({ ctx, input }) => { const workspaceId = ctx.workspace.id; - const { cursor } = input; + const { cursor, slug, name, description } = input; + + // Build filter conditions + const slugFilter = buildFilterConditions(slug, "name"); + const nameFilter = buildFilterConditions(name, "human_readable"); + const descriptionFilter = buildFilterConditions(description, "description"); const result = await db.execute(sql` SELECT @@ -104,14 +144,24 @@ export const queryRoles = t.procedure AND p.name IS NOT NULL ) as total_permissions, - -- Total count of roles - (SELECT COUNT(*) FROM roles WHERE workspace_id = ${workspaceId}) as grand_total + -- Total count of roles (with filters applied) + ( + SELECT COUNT(*) + FROM roles + WHERE workspace_id = ${workspaceId} + ${slugFilter} + ${nameFilter} + ${descriptionFilter} + ) as grand_total FROM ( SELECT * FROM roles WHERE workspace_id = ${workspaceId} ${cursor ? sql`AND updated_at_m < ${cursor}` : sql``} + ${slugFilter} + ${nameFilter} + ${descriptionFilter} ORDER BY updated_at_m DESC LIMIT ${DEFAULT_LIMIT + 1} ) r From 8675df80185f9b10ca20535968513c84a5a3edba Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 3 Jun 2025 13:29:23 +0300 Subject: [PATCH 08/47] fix: skeletons --- .../components/table/components/skeletons.tsx | 96 ++++----- .../roles/components/table/roles-list.tsx | 182 ++++++++---------- .../components/virtual-table/index.tsx | 4 + 3 files changed, 115 insertions(+), 167 deletions(-) diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/skeletons.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/skeletons.tsx index 28fc0f7e6e..adb83ce9b6 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/skeletons.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/skeletons.tsx @@ -1,82 +1,52 @@ -import { Dots } from "@unkey/icons"; -import { cn } from "@unkey/ui/src/lib/utils"; +import { Asterisk, ChartActivity2, Key2, Tag } from "@unkey/icons"; -export const KeyColumnSkeleton = () => ( -
+export const RoleColumnSkeleton = () => ( +
-
+
+ +
-
-
+
+
); -export const ValueColumnSkeleton = () => ( -
-
-
-
+export const SlugColumnSkeleton = () => ( +
); -export const UsageColumnSkeleton = ({ maxBars = 30 }: { maxBars?: number }) => ( -
- {Array(maxBars) - .fill(0) - .map((_, index) => ( -
- index - }`} - className="flex flex-col" - > -
-
- ))} +export const AssignedKeysColumnSkeleton = () => ( +
+
+ +
+
+
+ +
+
); -export const LastUsedColumnSkeleton = () => ( -
-
-
-
+export const PermissionsColumnSkeleton = () => ( +
+
+ +
+
+
+ +
+
); -export const StatusColumnSkeleton = () => ( -
-
+export const LastUpdatedColumnSkeleton = () => ( +
+
); - -export const ActionColumnSkeleton = () => ( - -); 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 6f4526f592..3da61296e7 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 @@ -7,6 +7,13 @@ import { Button, Checkbox, Empty } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; import { useCallback, useMemo, useState } from "react"; import { LastUpdated } from "./components/last-updated"; +import { + AssignedKeysColumnSkeleton, + LastUpdatedColumnSkeleton, + PermissionsColumnSkeleton, + RoleColumnSkeleton, + SlugColumnSkeleton, +} from "./components/skeletons"; import { useRolesListQuery } from "./hooks/use-roles-list-query"; import { getRowClassName } from "./utils/get-row-class"; @@ -149,84 +156,78 @@ export const RolesList = () => { ); return ( - <> - role.slug} - rowClassName={(role) => getRowClassName(role, selectedRole)} - loadMoreFooterProps={{ - hide: isLoading, - buttonText: "Load more roles", - hasMore, - 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. - - - - - - - + role.slug} + rowClassName={(role) => getRowClassName(role, selectedRole)} + loadMoreFooterProps={{ + hide: isLoading, + buttonText: "Load more roles", + hasMore, + countInfoText: ( +
+ Showing {roles.length} + of + {totalCount} + roles
- } - config={{ - rowHeight: 52, - layoutMode: "grid", - rowBorders: true, - containerPadding: "px-0", - }} - renderSkeletonRow={({ columns, rowHeight }) => - columns.map((column, idx) => ( - - {column.key === "role" && } - {column.key === "description" && } - {(column.key === "assignedKeys" || column.key === "permissions") && ( - - )} - {!["role", "description", "assignedKeys", "permissions"].includes(column.key) && ( -
- )} - - )) - } - /> - + ), + }} + 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" && } + + )) + } + /> ); }; @@ -288,30 +289,3 @@ const AssignedItemsCell = ({
); }; - -const RoleColumnSkeleton = () => ( -
-
-
-
-
-
-
-); - -const DescriptionColumnSkeleton = () => ( -
-); - -const AssignedItemsColumnSkeleton = () => ( -
-
-
-
-
-
-
-
-
-
-); diff --git a/apps/dashboard/components/virtual-table/index.tsx b/apps/dashboard/components/virtual-table/index.tsx index afbf8d485a..a7b09d1cad 100644 --- a/apps/dashboard/components/virtual-table/index.tsx +++ b/apps/dashboard/components/virtual-table/index.tsx @@ -188,12 +188,14 @@ export const VirtualTable = forwardRef>( height: `${virtualizer.getVirtualItems()[0]?.start || 0}px`, }} /> + {virtualizer.getVirtualItems().map((virtualRow) => { if (isLoading) { if (renderSkeletonRow) { return ( {renderSkeletonRow({ @@ -206,6 +208,7 @@ export const VirtualTable = forwardRef>( return ( {columns.map((column) => ( @@ -216,6 +219,7 @@ export const VirtualTable = forwardRef>( ); } + const item = tableData.getItemAt(virtualRow.index); if (!item) { return null; From 576e27fdfbcbd80b7941c6022d9c55aeccad7d01 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 3 Jun 2025 14:33:18 +0300 Subject: [PATCH 09/47] feta: add upsert --- .../table/hooks/use-roles-list-query.ts | 2 +- .../roles/components/table/roles-list.tsx | 2 +- .../components/table/utils/get-row-class.ts | 2 +- .../upsert-role/hooks/use-upsert-role.ts | 60 +++++ .../roles/components/upsert-role/index.tsx | 125 +++++++++ .../upsert-role/upsert-role.schema.ts | 62 +++++ .../roles/{index.ts => query.ts} | 0 .../routers/authorization/roles/upsert.ts | 247 ++++++++++++++++++ apps/dashboard/lib/trpc/routers/index.ts | 4 +- 9 files changed, 500 insertions(+), 4 deletions(-) create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/upsert-role/hooks/use-upsert-role.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/upsert-role/upsert-role.schema.ts rename apps/dashboard/lib/trpc/routers/authorization/roles/{index.ts => query.ts} (100%) create mode 100644 apps/dashboard/lib/trpc/routers/authorization/roles/upsert.ts diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts index 1b772c78db..4255b42dc3 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts @@ -1,5 +1,5 @@ import { trpc } from "@/lib/trpc/client"; -import type { Roles } from "@/lib/trpc/routers/authorization/roles"; +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"; 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 3da61296e7..7cabc56b72 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/roles-list.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/roles-list.tsx @@ -1,7 +1,7 @@ "use client"; import { VirtualTable } from "@/components/virtual-table/index"; import type { Column } from "@/components/virtual-table/types"; -import type { Roles } from "@/lib/trpc/routers/authorization/roles"; +import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; import { Asterisk, BookBookmark, Key2, Tag } from "@unkey/icons"; import { Button, Checkbox, Empty } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/utils/get-row-class.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/utils/get-row-class.ts index 0195caf35b..ffe7759263 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/utils/get-row-class.ts +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/utils/get-row-class.ts @@ -1,4 +1,4 @@ -import type { Roles } from "@/lib/trpc/routers/authorization/roles"; +import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; import { cn } from "@/lib/utils"; export type StatusStyle = { 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..8ea54a90dd --- /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.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..2d8e3f2361 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx @@ -0,0 +1,125 @@ +"use client"; +import { DialogContainer } from "@/components/dialog-container"; +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 { Plus } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { useState } from "react"; +import { FormProvider } from "react-hook-form"; +import { type FormValues, rbacRoleSchema } from "./upsert-role.schema"; + +// Storage key for saving form state +const FORM_STORAGE_KEY = "unkey_upsert_role_form_state"; + +export const UpsertRoleDialog = () => { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const methods = usePersistedForm( + FORM_STORAGE_KEY, + { + resolver: zodResolver(rbacRoleSchema), + mode: "onChange", + shouldFocusError: true, + shouldUnregister: true, + }, + "memory", + ); + + // const { + // handleSubmit, + // formState, + // getValues, + // reset, + // trigger, + // clearPersistedData, + // loadSavedValues, + // saveCurrentValues, + // } = methods; + // + // // Update form defaults when keyspace defaults change after revalidation + // useEffect(() => { + // const newDefaults = getDefaultValues(keyspaceDefaults); + // clearPersistedData(); + // reset(newDefaults); + // }, [keyspaceDefaults, reset, clearPersistedData]); + // + // const key = useCreateKey((data) => { + // if (data?.key && data?.keyId) { + // setCreatedKeyData({ + // key: data.key, + // id: data.keyId, + // name: data.name, + // }); + // setSuccessDialogOpen(true); + // } + // + // // Clean up form state + // clearPersistedData(); + // reset(getDefaultValues()); + // setIsSettingsOpen(false); + // resetValidSteps(); + // }); + + // + // const onSubmit = async (data: FormValues) => { + // if (!keyspaceId) { + // toast.error("Failed to Create Key", { + // description: "An unexpected error occurred. Please try again later.", + // action: { + // label: "Contact Support", + // onClick: () => window.open("https://support.unkey.dev", "_blank"), + // }, + // }); + // return; + // } + // const finalData = formValuesToApiInput(data, keyspaceId); + // + // try { + // await key.mutateAsync(finalData); + // } catch { + // // `useCreateKey` already shows a toast, but we still need to + // // prevent unhandled‐rejection noise in the console. + // } + // }; + // + + return ( + <> + + setIsDialogOpen(true)}> + + Create new key + + + +
+ + +
+ Namespaces can be used to separate different rate limiting concerns +
+
+ } + /> + + + + ); +}; 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..d8354da2d7 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/upsert-role.schema.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; + +export const roleNameSchema = z + .string() + .trim() + .min(2, { message: "Role name must be at least 2 characters long" }) + .max(64, { message: "Role name cannot exceed 64 characters" }) + .regex(/^[a-zA-Z][a-zA-Z0-9\s\-_]*$/, { + message: + "Role name must start with a letter and contain only letters, numbers, spaces, hyphens, and underscores", + }) + .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" }); + +export const roleSlugSchema = z + .string() + .trim() + .min(2, { message: "Role slug must be at least 2 characters long" }) + .max(30, { message: "Role slug cannot exceed 32 characters" }); + +export const keyIdsSchema = z + .array( + z.string().refine((id) => id.startsWith("key_"), { + message: "Each key ID must start with 'key_'", + }), + ) + .default([]) + .transform((ids) => [...new Set(ids)]); // Remove duplicates + +export const permissionIdsSchema = z + .array( + z.string().refine((id) => id.startsWith("perm_"), { + message: "Each permission ID must start with 'perm_'", + }), + ) + .min(1, { message: "Role must have at least one permission assigned" }) + .transform((ids) => [...new Set(ids)]) // Remove duplicates + .refine((ids) => ids.length >= 1, { + message: "After removing duplicates, role must still have at least one permission", + }); + +export const rbacRoleSchema = z + .object({ + roleId: z.string().startsWith("role_").optional(), // If provided, it's an update + roleName: roleNameSchema, + roleDescription: roleDescriptionSchema, + roleSlug: roleSlugSchema, + keyIds: keyIdsSchema, + permissionIds: permissionIdsSchema, + }) + .strict({ message: "Unknown fields are not allowed in role definition" }); + +export type FormValues = z.infer; diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/index.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/query.ts similarity index 100% rename from apps/dashboard/lib/trpc/routers/authorization/roles/index.ts rename to apps/dashboard/lib/trpc/routers/authorization/roles/query.ts 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..4f4573b787 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/upsert.ts @@ -0,0 +1,247 @@ +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", + }); + } + + await db.transaction(async (tx) => { + // Check for name conflicts (excluding current role if updating) + const nameConflict = await tx.query.roles.findFirst({ + where: (table, { and, eq, ne }) => { + const conditions = [ + eq(table.workspaceId, ctx.workspace.id), + eq(table.name, input.roleSlug), // slug maps to db.name + ]; + + if (isUpdate && input.roleId) { + conditions.push(ne(table.id, input.roleId)); + } + + return and(...conditions); + }, + }); + + if (nameConflict) { + throw new TRPCError({ + code: "CONFLICT", + message: `Role with slug '${input.roleSlug}' already exists`, + }); + } + + if (isUpdate) { + // Verify role exists and belongs to workspace + const existingRole = await tx.query.roles.findFirst({ + where: (table, { and, eq }) => + and(eq(table.id, roleId!), eq(table.workspaceId, ctx.workspace.id)), + }); + + if (!existingRole) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Role not found or access denied", + }); + } + + // Update role + await tx + .update(schema.roles) + .set({ + name: input.roleSlug, // slug maps to db.name + human_readable: input.roleName, // name maps to db.human_readable + 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 new role + await tx + .insert(schema.roles) + .values({ + id: roleId, + name: input.roleSlug, // slug maps to db.name + human_readable: 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.length > 0) { + await tx + .insert(schema.rolesPermissions) + .values( + input.permissionIds.map((permissionId) => ({ + permissionId, + roleId: 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.length > 0) { + await tx + .insert(schema.keysRoles) + .values( + input.keyIds.map((keyId) => ({ + keyId, + roleId: 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 98c98e618e..99a7c0fef2 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -20,7 +20,8 @@ import { updateApiIpWhitelist } from "./api/updateIpWhitelist"; import { updateApiName } from "./api/updateName"; import { fetchAuditLog } from "./audit/fetch"; import { auditLogsSearch } from "./audit/llm-search"; -import { queryRoles } from "./authorization/roles"; +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"; @@ -155,6 +156,7 @@ export const router = t.router({ }), authorization: t.router({ roles: queryRoles, + upsert: upsertRole, }), rbac: t.router({ addPermissionToRootKey: addPermissionToRootKey, From 423b38f105185f79762f9e886a86ea3f70a32549 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 3 Jun 2025 15:06:38 +0300 Subject: [PATCH 10/47] feat: add initial form elements --- .../roles/components/upsert-role/index.tsx | 247 ++++++++++++------ .../upsert-role/upsert-role.schema.ts | 3 +- .../(app)/authorization/roles/navigation.tsx | 22 +- 3 files changed, 188 insertions(+), 84 deletions(-) diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx index 2d8e3f2361..837b27c7dc 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx @@ -4,120 +4,207 @@ 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 { Plus } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -import { useState } from "react"; +import { PenWriting3, Plus } from "@unkey/icons"; +import { Button, FormInput, FormTextarea } from "@unkey/ui"; +import { useEffect, useState } from "react"; import { FormProvider } from "react-hook-form"; import { type FormValues, rbacRoleSchema } from "./upsert-role.schema"; -// Storage key for saving form state const FORM_STORAGE_KEY = "unkey_upsert_role_form_state"; -export const UpsertRoleDialog = () => { +const getDefaultValues = (): Partial => ({ + roleName: "", + roleDescription: "", + roleSlug: "", + keyIds: [], + permissionIds: [], +}); + +interface UpsertRoleDialogProps { + roleId?: string; + existingRole?: { + name: string; + slug: string; + description?: string; + keyIds?: string[]; + permissionIds?: string[]; + }; + triggerButton?: React.ReactNode; +} + +export const UpsertRoleDialog = ({ + roleId, + existingRole, + triggerButton, +}: UpsertRoleDialogProps) => { const [isDialogOpen, setIsDialogOpen] = useState(false); + const isEditMode = Boolean(roleId); + + // Use different storage keys for create vs edit to avoid conflicts + const storageKey = isEditMode ? `${FORM_STORAGE_KEY}_edit_${roleId}` : FORM_STORAGE_KEY; const methods = usePersistedForm( - FORM_STORAGE_KEY, + storageKey, { resolver: zodResolver(rbacRoleSchema), mode: "onChange", shouldFocusError: true, shouldUnregister: true, + defaultValues: getDefaultValues(), }, "memory", ); - // const { - // handleSubmit, - // formState, - // getValues, - // reset, - // trigger, - // clearPersistedData, - // loadSavedValues, - // saveCurrentValues, - // } = methods; - // - // // Update form defaults when keyspace defaults change after revalidation - // useEffect(() => { - // const newDefaults = getDefaultValues(keyspaceDefaults); - // clearPersistedData(); - // reset(newDefaults); - // }, [keyspaceDefaults, reset, clearPersistedData]); - // - // const key = useCreateKey((data) => { - // if (data?.key && data?.keyId) { - // setCreatedKeyData({ - // key: data.key, - // id: data.keyId, - // name: data.name, - // }); - // setSuccessDialogOpen(true); - // } - // - // // Clean up form state - // clearPersistedData(); - // reset(getDefaultValues()); - // setIsSettingsOpen(false); - // resetValidSteps(); - // }); - - // - // const onSubmit = async (data: FormValues) => { - // if (!keyspaceId) { - // toast.error("Failed to Create Key", { - // description: "An unexpected error occurred. Please try again later.", - // action: { - // label: "Contact Support", - // onClick: () => window.open("https://support.unkey.dev", "_blank"), - // }, - // }); - // return; - // } - // const finalData = formValuesToApiInput(data, keyspaceId); - // - // try { - // await key.mutateAsync(finalData); - // } catch { - // // `useCreateKey` already shows a toast, but we still need to - // // prevent unhandled‐rejection noise in the console. - // } - // }; - // + const { + register, + formState: { errors, isValid }, + handleSubmit, + reset, + clearPersistedData, + saveCurrentValues, + } = methods; + + // Load existing role data when in edit mode + useEffect(() => { + if (isEditMode && existingRole) { + const editValues: Partial = { + roleName: existingRole.name, + roleSlug: existingRole.slug, + roleDescription: existingRole.description || "", + keyIds: existingRole.keyIds || [], + permissionIds: existingRole.permissionIds || [], + }; + reset(editValues); + } + }, [isEditMode, existingRole, reset]); + + // Add invisible roleId field for updates + const hiddenRoleIdRegister = isEditMode ? register("roleId") : null; + + const onSubmit = async (data: FormValues) => { + try { + if (isEditMode) { + console.log("Updating role:", { ...data, roleId }); + // TODO: await updateRole(roleId, data); + } else { + console.log("Creating role:", data); + // TODO: await createRole(data); + } + + clearPersistedData(); + reset(getDefaultValues()); + setIsDialogOpen(false); + } catch (error) { + console.error(`Failed to ${isEditMode ? "update" : "create"} role:`, error); + // TODO: Show error toast + } + }; + + const handleDialogClose = (open: boolean) => { + if (!open) { + saveCurrentValues(); + if (!isEditMode) { + reset(getDefaultValues()); + } + } + 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 ( <> - - setIsDialogOpen(true)}> - - Create new key - - + {!triggerButton && {defaultTrigger}} + + {triggerButton && ( + // biome-ignore lint/a11y/useKeyWithClickEvents: +
setIsDialogOpen(true)}>{triggerButton}
+ )} + -
+ + {/* Hidden field for role ID in edit mode */} + {isEditMode && hiddenRoleIdRegister && ( + + )} + -
- Namespaces can be used to separate different rate limiting concerns -
+
{dialogConfig.footerText}
} - /> + > +
+ {/* Role Name - Required */} + + + {/* Role Slug - Auto-generated but editable */} + + + {/* Role Description - Optional */} + +
+ 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 index d8354da2d7..ad65afc3e3 100644 --- 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 @@ -19,7 +19,8 @@ export const roleNameSchema = z export const roleDescriptionSchema = z .string() .trim() - .max(30, { message: "Role description cannot exceed 30 characters" }); + .max(30, { message: "Role description cannot exceed 30 characters" }) + .optional(); export const roleSlugSchema = z .string() diff --git a/apps/dashboard/app/(app)/authorization/roles/navigation.tsx b/apps/dashboard/app/(app)/authorization/roles/navigation.tsx index ff2d76b12d..bdccebec83 100644 --- a/apps/dashboard/app/(app)/authorization/roles/navigation.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/navigation.tsx @@ -1,16 +1,32 @@ "use client"; +import { NavbarActionButton } from "@/components/navigation/action-button"; import { Navbar } from "@/components/navigation/navbar"; -import { ShieldKey } from "@unkey/icons"; +import { Plus, ShieldKey } from "@unkey/icons"; +import dynamic from "next/dynamic"; + +const UpsertRoleDialog = dynamic( + () => import("./components/upsert-role").then((mod) => mod.UpsertRoleDialog), + { + ssr: false, + loading: () => ( + + + Create new role + + ), + }, +); export function Navigation() { return ( - - }> + + } className="flex-1 w-full"> Authorization Roles + ); } From 54f9f1a7c4def54a22c572b1c6e4c131b3123290 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 3 Jun 2025 17:42:34 +0300 Subject: [PATCH 11/47] feat: add searcahable combobox for keys --- .../assign-key/hooks/use-fetch-keys.ts | 61 +++++++ .../assign-key/hooks/use-search-keys.ts | 22 +++ .../components/assign-key/key-field.tsx | 163 ++++++++++++++++++ .../components/assign-key/utils.tsx | 140 +++++++++++++++ .../roles/components/upsert-role/index.tsx | 25 ++- .../routers/authorization/roles/query-keys.ts | 101 +++++++++++ .../routers/authorization/roles/search-key.ts | 95 ++++++++++ apps/dashboard/lib/trpc/routers/index.ts | 4 + 8 files changed, 606 insertions(+), 5 deletions(-) create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/hooks/use-fetch-keys.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/hooks/use-search-keys.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/key-field.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/utils.tsx create mode 100644 apps/dashboard/lib/trpc/routers/authorization/roles/query-keys.ts create mode 100644 apps/dashboard/lib/trpc/routers/authorization/roles/search-key.ts 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..bfee1fb19e --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/hooks/use-fetch-keys.ts @@ -0,0 +1,61 @@ +// useFetchKeys.ts +"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.queryKeys.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..29a71a4ebd --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/hooks/use-search-keys.ts @@ -0,0 +1,22 @@ +import { trpc } from "@/lib/trpc/client"; +import { useMemo } from "react"; + +export const useSearchKeys = (query: string) => { + const { data, isLoading, error } = trpc.authorization.searchKey.useQuery( + { query }, + { + enabled: query.trim().length > 0, // Only search when there's a query + staleTime: 10_000, + }, + ); + + const searchResults = useMemo(() => { + return data?.keys || []; + }, [data?.keys]); + + return { + searchResults, + isSearching: isLoading, + 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..394648cce1 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/key-field.tsx @@ -0,0 +1,163 @@ +import { FormCombobox } from "@/components/ui/form-combobox"; +import { Key2, XMark } from "@unkey/icons"; +import { useMemo, useState } from "react"; +import { useFetchKeys } from "./hooks/use-fetch-keys"; +import { useSearchKeys } from "./hooks/use-search-keys"; +import { createKeyOptions } from "./utils"; + +type KeyFieldProps = { + value: string[]; + onChange: (ids: string[]) => void; + error?: string; + disabled?: boolean; + roleId?: string; +}; + +export const KeyField = ({ value, onChange, error, disabled = false, roleId }: 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]); + + // Get selected key details for display + const selectedKeys = useMemo(() => { + return value + .map((keyId) => allKeys.find((k) => k.id === keyId)) + .filter((key): key is NonNullable => key !== undefined); + }, [value, allKeys]); + + 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-key/utils.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/utils.tsx new file mode 100644 index 0000000000..1a6d707767 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/utils.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.id.length > 15 ? `${key.id.slice(0, 8)}...${key.id.slice(-4)}` : key.id} + +
+ {key.roles.find((item) => item.id === roleId) && ( + } + /> + )} +
+
+ +
+ {/* 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/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx index 837b27c7dc..dd06170125 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx @@ -7,7 +7,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { PenWriting3, Plus } from "@unkey/icons"; import { Button, FormInput, FormTextarea } from "@unkey/ui"; import { useEffect, useState } from "react"; -import { FormProvider } from "react-hook-form"; +import { Controller, FormProvider } from "react-hook-form"; +import { KeyField } from "./components/assign-key/key-field"; import { type FormValues, rbacRoleSchema } from "./upsert-role.schema"; const FORM_STORAGE_KEY = "unkey_upsert_role_form_state"; @@ -26,7 +27,7 @@ interface UpsertRoleDialogProps { name: string; slug: string; description?: string; - keyIds?: string[]; + keyIds: string[]; permissionIds?: string[]; }; triggerButton?: React.ReactNode; @@ -62,6 +63,7 @@ export const UpsertRoleDialog = ({ reset, clearPersistedData, saveCurrentValues, + control, } = methods; // Load existing role data when in edit mode @@ -71,7 +73,7 @@ export const UpsertRoleDialog = ({ roleName: existingRole.name, roleSlug: existingRole.slug, roleDescription: existingRole.description || "", - keyIds: existingRole.keyIds || [], + keyIds: existingRole.keyIds || null, // Changed to single key permissionIds: existingRole.permissionIds || [], }; reset(editValues); @@ -171,7 +173,7 @@ export const UpsertRoleDialog = ({ + + {/* Key Selection */} + ( + + )} + />
diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/query-keys.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/query-keys.ts new file mode 100644 index 0000000000..71d7f3cf5a --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/query-keys.ts @@ -0,0 +1,101 @@ +import { db } from "@/lib/db"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +const LIMIT = 50; +const keysQueryPayload = z.object({ + cursor: z.string().optional(), +}); + +const RoleSchema = z.object({ + id: z.string(), + name: z.string(), +}); + +const KeyResponseSchema = z.object({ + id: z.string(), + name: z.string().nullable(), + roles: z.array(RoleSchema), +}); + +const KeysResponse = z.object({ + keys: z.array(KeyResponseSchema), + hasMore: z.boolean(), + nextCursor: z.string().nullish(), +}); + +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 transformedKeys = keys.map((key) => ({ + id: key.id, + name: key.name, + roles: key.roles + .filter((keyRole) => keyRole.role !== null) + .map((keyRole) => ({ + id: keyRole.role.id, + name: keyRole.role.name, + })), + })); + const nextCursor = hasMore && keys.length > 0 ? keys[keys.length - 1].id : undefined; + + return { + keys: transformedKeys, + 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/search-key.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/search-key.ts new file mode 100644 index 0000000000..a8a519b16d --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/search-key.ts @@ -0,0 +1,95 @@ +import { db } from "@/lib/db"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +const LIMIT = 50; +const keysSearchPayload = z.object({ + query: z.string().min(1, "Search query cannot be empty"), +}); + +const RoleSchema = z.object({ + id: z.string(), + name: z.string(), +}); + +const KeySearchResponseSchema = z.object({ + id: z.string(), + name: z.string().nullable(), + roles: z.array(RoleSchema), +}); + +const KeysSearchResponse = z.object({ + keys: z.array(KeySearchResponseSchema), +}); + +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, + }, + }); + + const transformedKeys = keysQuery.map((key) => ({ + id: key.id, + name: key.name, + roles: key.roles.map((keyRole) => ({ + id: keyRole.role.id, + name: keyRole.role.name, + })), + })); + + return { + keys: transformedKeys, + }; + } 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/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 99a7c0fef2..949483f2c9 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -21,6 +21,8 @@ import { updateApiName } from "./api/updateName"; import { fetchAuditLog } from "./audit/fetch"; import { auditLogsSearch } from "./audit/llm-search"; import { queryRoles } from "./authorization/roles/query"; +import { queryKeys } from "./authorization/roles/query-keys"; +import { searchKeys } from "./authorization/roles/search-key"; import { upsertRole } from "./authorization/roles/upsert"; import { queryUsage } from "./billing/query-usage"; import { createIdentity } from "./identity/create"; @@ -157,6 +159,8 @@ export const router = t.router({ authorization: t.router({ roles: queryRoles, upsert: upsertRole, + searchKey: searchKeys, + queryKeys: queryKeys, }), rbac: t.router({ addPermissionToRootKey: addPermissionToRootKey, From 88d98ae1aa4f90e8d1599757b29ee41a6efb99b0 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 4 Jun 2025 12:45:10 +0300 Subject: [PATCH 12/47] feat: add permissions field --- .../{utils.tsx => create-key-options.tsx} | 14 +- .../assign-key/hooks/use-fetch-keys.ts | 3 +- .../assign-key/hooks/use-search-keys.ts | 2 +- .../components/assign-key/key-field.tsx | 2 +- .../create-permission-options.tsx | 147 +++++++++++++++ .../hooks/use-fetch-permissions.ts | 60 ++++++ .../hooks/use-search-permissions.ts | 22 +++ .../assign-permission/permissions-field.tsx | 175 ++++++++++++++++++ .../roles/components/upsert-role/index.tsx | 16 +- .../upsert-role/upsert-role.schema.ts | 2 +- .../authorization/roles/query-permissions.ts | 101 ++++++++++ .../authorization/roles/search-permissions.ts | 104 +++++++++++ apps/dashboard/lib/trpc/routers/index.ts | 12 +- 13 files changed, 645 insertions(+), 15 deletions(-) rename apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/{utils.tsx => create-key-options.tsx} (94%) create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/create-permission-options.tsx create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/hooks/use-fetch-permissions.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/hooks/use-search-permissions.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/permissions-field.tsx create mode 100644 apps/dashboard/lib/trpc/routers/authorization/roles/query-permissions.ts create mode 100644 apps/dashboard/lib/trpc/routers/authorization/roles/search-permissions.ts diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/utils.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/create-key-options.tsx similarity index 94% rename from apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/utils.tsx rename to apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/create-key-options.tsx index 1a6d707767..8b052496c8 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/utils.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/create-key-options.tsx @@ -41,18 +41,18 @@ export function createKeyOptions({ {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}
- {key.roles.find((item) => item.id === roleId) && ( - } - /> - )}
{ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = - trpc.authorization.queryKeys.useInfiniteQuery( + trpc.authorization.keys.query.useInfiniteQuery( { limit, }, 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 index 29a71a4ebd..7e2f4847ab 100644 --- 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 @@ -2,7 +2,7 @@ import { trpc } from "@/lib/trpc/client"; import { useMemo } from "react"; export const useSearchKeys = (query: string) => { - const { data, isLoading, error } = trpc.authorization.searchKey.useQuery( + const { data, isLoading, error } = trpc.authorization.keys.search.useQuery( { query }, { enabled: query.trim().length > 0, // Only search when there's a query diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/key-field.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/key-field.tsx index 394648cce1..07edfdd0fb 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/key-field.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/key-field.tsx @@ -1,9 +1,9 @@ import { FormCombobox } from "@/components/ui/form-combobox"; 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"; -import { createKeyOptions } from "./utils"; type KeyFieldProps = { value: string[]; 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..747b6ba413 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/create-permission-options.tsx @@ -0,0 +1,147 @@ +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 Permission = { + id: string; + name: string; + description: string | null; + 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.id.length > 15 + ? `${permission.id.slice(0, 8)}...${permission.id.slice(-4)}` + : permission.id} + +
+
+
+ +
+ {/* Header */} +
+ Permission Details +
+ {/* Content */} +
+
+
Permission ID
+
{permission.id}
+
+
+
Name
+
{permission.name}
+
+ {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.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..d6724e2cec --- /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.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..38efa0f433 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/hooks/use-search-permissions.ts @@ -0,0 +1,22 @@ +import { trpc } from "@/lib/trpc/client"; +import { useMemo } from "react"; + +export const useSearchPermissions = (query: string) => { + const { data, isLoading, error } = trpc.authorization.permissions.search.useQuery( + { query }, + { + enabled: query.trim().length > 0, // Only search when there's a query + staleTime: 10_000, + }, + ); + + const searchResults = useMemo(() => { + return data?.permissions || []; + }, [data?.permissions]); + + return { + searchResults, + isSearching: isLoading, + 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..acceda7f13 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/permissions-field.tsx @@ -0,0 +1,175 @@ +import { FormCombobox } from "@/components/ui/form-combobox"; +import { Key2, 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; +}; + +export const PermissionField = ({ + value, + onChange, + error, + disabled = false, + roleId, +}: 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.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]); + + // Get selected permission details for display + const selectedPermissions = useMemo(() => { + return value + .map((permissionId) => allPermissions.find((p) => p.id === permissionId)) + .filter( + (permission): permission is NonNullable => permission !== undefined, + ); + }, [value, allPermissions]); + + 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, or description..." + emptyMessage={ + isSearching ? ( +
Searching...
+ ) : ( +
No permissions found
+ ) + } + variant="default" + error={error} + disabled={disabled} + /> + + {/* Selected Permissions Display */} + {selectedPermissions.length > 0 && ( +
+
+ {selectedPermissions.map((permission) => ( +
+
+ +
+
+ + {permission.id.length > 15 + ? `${permission.id.slice(0, 8)}...${permission.id.slice(-4)}` + : permission.id} + + + {permission.name} + +
+ {!disabled && ( + + )} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx index dd06170125..3209df35ae 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx @@ -9,6 +9,7 @@ import { Button, 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 { type FormValues, rbacRoleSchema } from "./upsert-role.schema"; const FORM_STORAGE_KEY = "unkey_upsert_role_form_state"; @@ -174,7 +175,7 @@ export const UpsertRoleDialog = ({ className="[&_input:first-of-type]:h-[36px]" placeholder="Domain manager" label="Name" - maxLength={64} + maxLength={60} description="A descriptive name for this role (2-64 characters, must start with a letter)" error={errors.roleName?.message} variant="default" @@ -218,6 +219,19 @@ export const UpsertRoleDialog = ({ /> )} /> + + {/* Permission Selection */} + ( + + )} + />
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 index ad65afc3e3..9abfa9b3bd 100644 --- 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 @@ -4,7 +4,7 @@ export const roleNameSchema = z .string() .trim() .min(2, { message: "Role name must be at least 2 characters long" }) - .max(64, { message: "Role name cannot exceed 64 characters" }) + .max(60, { message: "Role name cannot exceed 64 characters" }) .regex(/^[a-zA-Z][a-zA-Z0-9\s\-_]*$/, { message: "Role name must start with a letter and contain only letters, numbers, spaces, hyphens, and underscores", diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/query-permissions.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/query-permissions.ts new file mode 100644 index 0000000000..3128aab5bf --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/query-permissions.ts @@ -0,0 +1,101 @@ +import { db } from "@/lib/db"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +const LIMIT = 50; + +const permissionsQueryPayload = z.object({ + cursor: z.string().optional(), +}); + +const RoleSchema = z.object({ + id: z.string(), + name: z.string(), +}); + +const PermissionResponseSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + roles: z.array(RoleSchema), +}); + +const PermissionsResponse = z.object({ + permissions: z.array(PermissionResponseSchema), + hasMore: z.boolean(), + nextCursor: z.string().nullish(), +}); + +export const queryRolesPermissions = t.procedure + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(permissionsQueryPayload) + .output(PermissionsResponse) + .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, // Fetch one extra to determine if there are more results + orderBy: (permissions, { desc }) => desc(permissions.id), + with: { + roles: { + with: { + role: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + }, + columns: { + id: true, + name: true, + description: true, + }, + }); + + // Determine if there are more results + const hasMore = permissionsQuery.length > LIMIT; + // Remove the extra item if it exists + const permissions = hasMore ? permissionsQuery.slice(0, LIMIT) : permissionsQuery; + + const transformedPermissions = permissions.map((permission) => ({ + id: permission.id, + name: permission.name, + description: permission.description, + roles: permission.roles + .filter((rolePermission) => rolePermission.role !== null) + .map((rolePermission) => ({ + id: rolePermission.role.id, + name: rolePermission.role.name, + })), + })); + + const nextCursor = + hasMore && permissions.length > 0 ? permissions[permissions.length - 1].id : undefined; + + return { + permissions: transformedPermissions, + 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/search-permissions.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/search-permissions.ts new file mode 100644 index 0000000000..fcd2ef2017 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/search-permissions.ts @@ -0,0 +1,104 @@ +import { db } from "@/lib/db"; +import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +const LIMIT = 50; + +const permissionsSearchPayload = z.object({ + query: z.string().min(1, "Search query cannot be empty"), +}); + +const RoleSchema = z.object({ + id: z.string(), + name: z.string(), +}); + +const PermissionSearchResponseSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + roles: z.array(RoleSchema), +}); + +const PermissionsSearchResponse = z.object({ + permissions: z.array(PermissionSearchResponseSchema), +}); + +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; + + if (!query.trim()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Search query cannot be empty", + }); + } + + try { + const searchTerm = `%${query.trim()}%`; + + const permissionsQuery = await db.query.permissions.findMany({ + where: (permission, { and, eq, or, like }) => { + return and( + eq(permission.workspaceId, workspaceId), + or( + like(permission.id, searchTerm), + like(permission.name, searchTerm), + like(permission.description, searchTerm), + ), + ); + }, + limit: LIMIT, + orderBy: (permissions, { asc }) => [ + asc(permissions.name), // Name matches first + asc(permissions.id), // Then by ID for consistency + ], + with: { + roles: { + with: { + role: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + }, + columns: { + id: true, + name: true, + description: true, + }, + }); + + const transformedPermissions = permissionsQuery.map((permission) => ({ + id: permission.id, + name: permission.name, + description: permission.description, + roles: permission.roles + .filter((rolePermission) => rolePermission.role !== null) + .map((rolePermission) => ({ + id: rolePermission.role.id, + name: rolePermission.role.name, + })), + })); + + return { + permissions: transformedPermissions, + }; + } 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/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 949483f2c9..2717ef28e1 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -22,7 +22,9 @@ import { fetchAuditLog } from "./audit/fetch"; import { auditLogsSearch } from "./audit/llm-search"; import { queryRoles } from "./authorization/roles/query"; import { queryKeys } from "./authorization/roles/query-keys"; +import { queryRolesPermissions } from "./authorization/roles/query-permissions"; import { searchKeys } from "./authorization/roles/search-key"; +import { searchRolesPermissions } from "./authorization/roles/search-permissions"; import { upsertRole } from "./authorization/roles/upsert"; import { queryUsage } from "./billing/query-usage"; import { createIdentity } from "./identity/create"; @@ -159,8 +161,14 @@ export const router = t.router({ authorization: t.router({ roles: queryRoles, upsert: upsertRole, - searchKey: searchKeys, - queryKeys: queryKeys, + keys: t.router({ + search: searchKeys, + query: queryKeys, + }), + permissions: t.router({ + search: searchRolesPermissions, + query: queryRolesPermissions, + }), }), rbac: t.router({ addPermissionToRootKey: addPermissionToRootKey, From 452fe9098495bd02ad170b7bc623b6e69aafeae6 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 4 Jun 2025 13:08:58 +0300 Subject: [PATCH 13/47] chore: get rid of human_readable --- .../components/logs-filters/index.tsx | 26 ------------ .../table/hooks/use-roles-list-query.ts | 2 +- .../components/table/query-logs.schema.ts | 1 - .../roles/components/table/roles-list.tsx | 41 +++++-------------- .../create-permission-options.tsx | 9 +++- .../assign-permission/permissions-field.tsx | 11 +++-- .../roles/components/upsert-role/index.tsx | 14 ------- .../upsert-role/upsert-role.schema.ts | 7 ---- .../authorization/roles/filters.schema.ts | 5 --- .../authorization/roles/query-permissions.ts | 3 ++ .../trpc/routers/authorization/roles/query.ts | 13 ++---- .../authorization/roles/search-permissions.ts | 3 ++ .../routers/authorization/roles/upsert.ts | 10 ++--- internal/db/src/schema/rbac.ts | 1 - 14 files changed, 38 insertions(+), 108 deletions(-) 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 index 9d39fe100b..a238b1c536 100644 --- 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 @@ -15,7 +15,6 @@ export const LogsFilters = () => { label: op, })); const activeNameFilter = filters.find((f) => f.field === "name"); - const activeSlugFilter = filters.find((f) => f.field === "slug"); const activeDescriptionFilter = filters.find((f) => f.field === "description"); return ( @@ -46,31 +45,6 @@ export const LogsFilters = () => { /> ), }, - { - id: "slug", - label: "Slug", - shortcut: "s", - component: ( - { - const activeFiltersWithoutNames = filters.filter((f) => f.field !== "slug"); - updateFilters([ - ...activeFiltersWithoutNames, - { - field: "slug", - id: crypto.randomUUID(), - operator: id, - value: text, - }, - ]); - }} - /> - ), - }, { id: "description", label: "Description", diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts index 4255b42dc3..a286d17311 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts @@ -59,7 +59,7 @@ export function useRolesListQuery() { rolesData.pages.forEach((page) => { page.roles.forEach((role) => { // Use slug as the unique identifier - newMap.set(role.slug, role); + newMap.set(role.roleId, role); }); }); 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 index 34b20d5159..4fed765ec1 100644 --- 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 @@ -9,7 +9,6 @@ const filterItemSchema = z.object({ const baseFilterArraySchema = z.array(filterItemSchema).nullish(); const baseRolesSchema = z.object({ - slug: baseFilterArraySchema, description: baseFilterArraySchema, name: baseFilterArraySchema, }); 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 7cabc56b72..8799e2d1da 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 @@ -21,15 +21,15 @@ export const RolesList = () => { const { roles, isLoading, isLoadingMore, loadMore, totalCount, hasMore } = useRolesListQuery(); const [selectedRole, setSelectedRole] = useState(null); const [selectedRoles, setSelectedRoles] = useState>(new Set()); - const [hoveredRoleSlug, setHoveredRoleSlug] = useState(null); + const [hoveredRoleName, setHoveredRoleName] = useState(null); - const toggleSelection = useCallback((roleSlug: string) => { + const toggleSelection = useCallback((roleName: string) => { setSelectedRoles((prevSelected) => { const newSelected = new Set(prevSelected); - if (newSelected.has(roleSlug)) { - newSelected.delete(roleSlug); + if (newSelected.has(roleName)) { + newSelected.delete(roleName); } else { - newSelected.add(roleSlug); + newSelected.add(roleName); } return newSelected; }); @@ -43,8 +43,8 @@ export const RolesList = () => { width: "20%", headerClassName: "pl-[18px]", render: (role) => { - const isSelected = selectedRoles.has(role.slug); - const isHovered = hoveredRoleSlug === role.slug; + const isSelected = selectedRoles.has(role.name); + const isHovered = hoveredRoleName === role.name; const iconContainer = (
{ "bg-grayA-3", isSelected && "bg-grayA-5", )} - onMouseEnter={() => setHoveredRoleSlug(role.slug)} - onMouseLeave={() => setHoveredRoleSlug(null)} + onMouseEnter={() => setHoveredRoleName(role.name)} + onMouseLeave={() => setHoveredRoleName(null)} > {!isSelected && !isHovered && } {(isSelected || isHovered) && ( toggleSelection(role.slug)} + onCheckedChange={() => toggleSelection(role.name)} /> )}
@@ -93,25 +93,6 @@ export const RolesList = () => { ); }, }, - { - key: "slug", - header: "Slug", - width: "20%", - render: (role) => { - const isRowSelected = role.roleId === selectedRole?.roleId; - return ( -
- {role.slug} -
- ); - }, - }, { key: "assignedKeys", header: "Assigned Keys", @@ -152,7 +133,7 @@ export const RolesList = () => { }, }, ], - [selectedRoles, toggleSelection, hoveredRoleSlug, selectedRole?.roleId], + [selectedRoles, toggleSelection, hoveredRoleName, selectedRole?.roleId], ); return ( 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 index 747b6ba413..2a1a95d8e1 100644 --- 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 @@ -7,6 +7,7 @@ type Permission = { id: string; name: string; description: string | null; + slug: string; roles: { id: string; name: string; @@ -77,6 +78,10 @@ export function createPermissionOptions({
Name
{permission.name}
+
+
Slug
+
{permission.slug}
+
{permission.description && (
Description
@@ -117,7 +122,9 @@ export function createPermissionOptions({
), value: permission.id, - searchValue: `${permission.id} ${permission.name} ${permission.description || ""}`.trim(), + searchValue: `${permission.id} ${permission.name} ${permission.slug} ${ + permission.description || "" + }`.trim(), })); if (hasNextPage) { diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/permissions-field.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/permissions-field.tsx index acceda7f13..7d8c0736b6 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/permissions-field.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-permission/permissions-field.tsx @@ -37,6 +37,7 @@ export const PermissionField = ({ (permission) => permission.id.toLowerCase().includes(searchTerm) || permission.name.toLowerCase().includes(searchTerm) || + permission.slug.toLowerCase().includes(searchTerm) || permission.description?.toLowerCase().includes(searchTerm), ); } @@ -120,7 +121,7 @@ export const PermissionField = ({ Select permissions
} - searchPlaceholder="Search permissions by name, ID, or description..." + searchPlaceholder="Search permissions by name, ID, slug, or description..." emptyMessage={ isSearching ? (
Searching...
@@ -147,12 +148,10 @@ export const PermissionField = ({
- {permission.id.length > 15 - ? `${permission.id.slice(0, 8)}...${permission.id.slice(-4)}` - : permission.id} + {permission.name} - {permission.name} + {permission.slug}
{!disabled && ( @@ -160,7 +159,7 @@ export const PermissionField = ({ type="button" onClick={() => handleRemovePermission(permission.id)} className="ml-1 p-0.5 hover:bg-grayA-4 rounded text-grayA-11 hover:text-accent-12 transition-colors flex-shrink-0" - aria-label={`Remove ${permission.name || permission.id}`} + aria-label={`Remove ${permission.name}`} > diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx index 3209df35ae..25c225f704 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx @@ -17,7 +17,6 @@ const FORM_STORAGE_KEY = "unkey_upsert_role_form_state"; const getDefaultValues = (): Partial => ({ roleName: "", roleDescription: "", - roleSlug: "", keyIds: [], permissionIds: [], }); @@ -72,7 +71,6 @@ export const UpsertRoleDialog = ({ if (isEditMode && existingRole) { const editValues: Partial = { roleName: existingRole.name, - roleSlug: existingRole.slug, roleDescription: existingRole.description || "", keyIds: existingRole.keyIds || null, // Changed to single key permissionIds: existingRole.permissionIds || [], @@ -183,18 +181,6 @@ export const UpsertRoleDialog = ({ {...register("roleName")} /> - {/* Role Slug - Auto-generated but editable */} - - {/* Role Description - Optional */} id.startsWith("key_"), { @@ -54,7 +48,6 @@ export const rbacRoleSchema = z roleId: z.string().startsWith("role_").optional(), // If provided, it's an update roleName: roleNameSchema, roleDescription: roleDescriptionSchema, - roleSlug: roleSlugSchema, keyIds: keyIdsSchema, permissionIds: permissionIdsSchema, }) diff --git a/apps/dashboard/app/(app)/authorization/roles/filters.schema.ts b/apps/dashboard/app/(app)/authorization/roles/filters.schema.ts index 31fdc23626..1c3cf1e366 100644 --- a/apps/dashboard/app/(app)/authorization/roles/filters.schema.ts +++ b/apps/dashboard/app/(app)/authorization/roles/filters.schema.ts @@ -11,7 +11,6 @@ export type RolesFilterOperator = z.infer; export type FilterFieldConfigs = { description: StringConfig; name: StringConfig; - slug: StringConfig; }; export const rolesFilterFieldConfig: FilterFieldConfigs = { @@ -19,10 +18,6 @@ export const rolesFilterFieldConfig: FilterFieldConfigs = { type: "string", operators: [...commonStringOperators], }, - slug: { - type: "string", - operators: [...commonStringOperators], - }, description: { type: "string", operators: [...commonStringOperators], diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/query-permissions.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/query-permissions.ts index 3128aab5bf..d16918d7ca 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/roles/query-permissions.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/query-permissions.ts @@ -18,6 +18,7 @@ const PermissionResponseSchema = z.object({ id: z.string(), name: z.string(), description: z.string().nullable(), + slug: z.string(), roles: z.array(RoleSchema), }); @@ -63,6 +64,7 @@ export const queryRolesPermissions = t.procedure id: true, name: true, description: true, + slug: true, }, }); @@ -75,6 +77,7 @@ export const queryRolesPermissions = t.procedure id: permission.id, name: permission.name, description: permission.description, + slug: permission.slug, roles: permission.roles .filter((rolePermission) => rolePermission.role !== null) .map((rolePermission) => ({ diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/query.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/query.ts index 65a5786d38..9d3f54c275 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/roles/query.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/query.ts @@ -10,7 +10,6 @@ export const DEFAULT_LIMIT = 50; export const roles = z.object({ roleId: z.string(), - slug: z.string(), name: z.string(), description: z.string(), lastUpdated: z.number(), @@ -75,18 +74,16 @@ export const queryRoles = t.procedure .output(rolesResponse) .query(async ({ ctx, input }) => { const workspaceId = ctx.workspace.id; - const { cursor, slug, name, description } = input; + const { cursor, name, description } = input; // Build filter conditions - const slugFilter = buildFilterConditions(slug, "name"); - const nameFilter = buildFilterConditions(name, "human_readable"); + const nameFilter = buildFilterConditions(name, "name"); const descriptionFilter = buildFilterConditions(description, "description"); const result = await db.execute(sql` SELECT r.id, r.name, - r.human_readable, r.description, r.updated_at_m, @@ -149,7 +146,6 @@ export const queryRoles = t.procedure SELECT COUNT(*) FROM roles WHERE workspace_id = ${workspaceId} - ${slugFilter} ${nameFilter} ${descriptionFilter} ) as grand_total @@ -159,7 +155,6 @@ export const queryRoles = t.procedure FROM roles WHERE workspace_id = ${workspaceId} ${cursor ? sql`AND updated_at_m < ${cursor}` : sql``} - ${slugFilter} ${nameFilter} ${descriptionFilter} ORDER BY updated_at_m DESC @@ -171,7 +166,6 @@ export const queryRoles = t.procedure const rows = result.rows as { id: string; name: string; - human_readable: string | null; description: string | null; updated_at_m: number; key_items: string | null; @@ -205,8 +199,7 @@ export const queryRoles = t.procedure return { roleId: row.id, - slug: row.name, - name: row.human_readable || "", + name: row.name || "", description: row.description || "", lastUpdated: Number(row.updated_at_m) || 0, assignedKeys: diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/search-permissions.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/search-permissions.ts index fcd2ef2017..77474330e6 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/roles/search-permissions.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/search-permissions.ts @@ -18,6 +18,7 @@ const PermissionSearchResponseSchema = z.object({ id: z.string(), name: z.string(), description: z.string().nullable(), + slug: z.string(), roles: z.array(RoleSchema), }); @@ -76,6 +77,7 @@ export const searchRolesPermissions = t.procedure id: true, name: true, description: true, + slug: true, }, }); @@ -83,6 +85,7 @@ export const searchRolesPermissions = t.procedure id: permission.id, name: permission.name, description: permission.description, + slug: permission.slug, roles: permission.roles .filter((rolePermission) => rolePermission.role !== null) .map((rolePermission) => ({ diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/upsert.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/upsert.ts index 4f4573b787..ee22da2d53 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/roles/upsert.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/upsert.ts @@ -30,7 +30,7 @@ export const upsertRole = t.procedure where: (table, { and, eq, ne }) => { const conditions = [ eq(table.workspaceId, ctx.workspace.id), - eq(table.name, input.roleSlug), // slug maps to db.name + eq(table.name, input.roleName), // slug maps to db.name ]; if (isUpdate && input.roleId) { @@ -44,7 +44,7 @@ export const upsertRole = t.procedure if (nameConflict) { throw new TRPCError({ code: "CONFLICT", - message: `Role with slug '${input.roleSlug}' already exists`, + message: `Role with name '${input.roleName}' already exists`, }); } @@ -66,8 +66,7 @@ export const upsertRole = t.procedure await tx .update(schema.roles) .set({ - name: input.roleSlug, // slug maps to db.name - human_readable: input.roleName, // name maps to db.human_readable + name: input.roleName, description: input.roleDescription, }) .where(and(eq(schema.roles.id, roleId), eq(schema.roles.workspaceId, ctx.workspace.id))) @@ -124,8 +123,7 @@ export const upsertRole = t.procedure .insert(schema.roles) .values({ id: roleId, - name: input.roleSlug, // slug maps to db.name - human_readable: input.roleName, // name maps to db.human_readable + name: input.roleName, // name maps to db.human_readable description: input.roleDescription, workspaceId: ctx.workspace.id, }) diff --git a/internal/db/src/schema/rbac.ts b/internal/db/src/schema/rbac.ts index 20160089cc..9a902fe812 100644 --- a/internal/db/src/schema/rbac.ts +++ b/internal/db/src/schema/rbac.ts @@ -96,7 +96,6 @@ export const roles = mysqlTable( id: varchar("id", { length: 256 }).primaryKey(), workspaceId: varchar("workspace_id", { length: 256 }).notNull(), name: varchar("name", { length: 512 }).notNull(), - human_readable: varchar("human_readable", { length: 512 }), description: varchar("description", { length: 512 }), createdAtM: bigint("created_at_m", { mode: "number" }) .notNull() From a74cc38dc170e303cfc6d96a349b7e5f38a748e9 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 4 Jun 2025 13:31:33 +0300 Subject: [PATCH 14/47] chore: rename --- .../roles/{ => keys}/query-keys.ts | 0 .../roles/{ => keys}/search-key.ts | 0 .../{ => permissions}/query-permissions.ts | 54 +++--------------- .../roles/permissions/schema-with-helpers.ts | 57 +++++++++++++++++++ .../{ => permissions}/search-permissions.ts | 52 ++++------------- apps/dashboard/lib/trpc/routers/index.ts | 8 +-- 6 files changed, 81 insertions(+), 90 deletions(-) rename apps/dashboard/lib/trpc/routers/authorization/roles/{ => keys}/query-keys.ts (100%) rename apps/dashboard/lib/trpc/routers/authorization/roles/{ => keys}/search-key.ts (100%) rename apps/dashboard/lib/trpc/routers/authorization/roles/{ => permissions}/query-permissions.ts (59%) create mode 100644 apps/dashboard/lib/trpc/routers/authorization/roles/permissions/schema-with-helpers.ts rename apps/dashboard/lib/trpc/routers/authorization/roles/{ => permissions}/search-permissions.ts (61%) diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/query-keys.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/keys/query-keys.ts similarity index 100% rename from apps/dashboard/lib/trpc/routers/authorization/roles/query-keys.ts rename to apps/dashboard/lib/trpc/routers/authorization/roles/keys/query-keys.ts diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/search-key.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/keys/search-key.ts similarity index 100% rename from apps/dashboard/lib/trpc/routers/authorization/roles/search-key.ts rename to apps/dashboard/lib/trpc/routers/authorization/roles/keys/search-key.ts diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/query-permissions.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/query-permissions.ts similarity index 59% rename from apps/dashboard/lib/trpc/routers/authorization/roles/query-permissions.ts rename to apps/dashboard/lib/trpc/routers/authorization/roles/permissions/query-permissions.ts index d16918d7ca..369c3f5b76 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/roles/query-permissions.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/query-permissions.ts @@ -1,38 +1,18 @@ import { db } from "@/lib/db"; import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; import { TRPCError } from "@trpc/server"; -import { z } from "zod"; - -const LIMIT = 50; - -const permissionsQueryPayload = z.object({ - cursor: z.string().optional(), -}); - -const RoleSchema = z.object({ - id: z.string(), - name: z.string(), -}); - -const PermissionResponseSchema = z.object({ - id: z.string(), - name: z.string(), - description: z.string().nullable(), - slug: z.string(), - roles: z.array(RoleSchema), -}); - -const PermissionsResponse = z.object({ - permissions: z.array(PermissionResponseSchema), - hasMore: z.boolean(), - nextCursor: z.string().nullish(), -}); +import { + LIMIT, + PermissionsQueryResponse, + permissionsQueryPayload, + transformPermission, +} from "./schema-with-helpers"; export const queryRolesPermissions = t.procedure .use(requireWorkspace) .use(withRatelimit(ratelimit.read)) .input(permissionsQueryPayload) - .output(PermissionsResponse) + .output(PermissionsQueryResponse) .query(async ({ ctx, input }) => { const { cursor } = input; const workspaceId = ctx.workspace.id; @@ -46,7 +26,7 @@ export const queryRolesPermissions = t.procedure } return and(...conditions); }, - limit: LIMIT + 1, // Fetch one extra to determine if there are more results + limit: LIMIT + 1, orderBy: (permissions, { desc }) => desc(permissions.id), with: { roles: { @@ -68,29 +48,13 @@ export const queryRolesPermissions = t.procedure }, }); - // Determine if there are more results const hasMore = permissionsQuery.length > LIMIT; - // Remove the extra item if it exists const permissions = hasMore ? permissionsQuery.slice(0, LIMIT) : permissionsQuery; - - const transformedPermissions = permissions.map((permission) => ({ - 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, - })), - })); - const nextCursor = hasMore && permissions.length > 0 ? permissions[permissions.length - 1].id : undefined; return { - permissions: transformedPermissions, + permissions: permissions.map(transformPermission), hasMore, nextCursor, }; 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..332a659c51 --- /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().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/search-permissions.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/search-permissions.ts similarity index 61% rename from apps/dashboard/lib/trpc/routers/authorization/roles/search-permissions.ts rename to apps/dashboard/lib/trpc/routers/authorization/roles/permissions/search-permissions.ts index 77474330e6..71739727dd 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/roles/search-permissions.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/permissions/search-permissions.ts @@ -1,30 +1,12 @@ import { db } from "@/lib/db"; import { ratelimit, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; import { TRPCError } from "@trpc/server"; -import { z } from "zod"; - -const LIMIT = 50; - -const permissionsSearchPayload = z.object({ - query: z.string().min(1, "Search query cannot be empty"), -}); - -const RoleSchema = z.object({ - id: z.string(), - name: z.string(), -}); - -const PermissionSearchResponseSchema = z.object({ - id: z.string(), - name: z.string(), - description: z.string().nullable(), - slug: z.string(), - roles: z.array(RoleSchema), -}); - -const PermissionsSearchResponse = z.object({ - permissions: z.array(PermissionSearchResponseSchema), -}); +import { + LIMIT, + PermissionsSearchResponse, + permissionsSearchPayload, + transformPermission, +} from "./schema-with-helpers"; export const searchRolesPermissions = t.procedure .use(requireWorkspace) @@ -44,13 +26,13 @@ export const searchRolesPermissions = t.procedure try { const searchTerm = `%${query.trim()}%`; - const permissionsQuery = await db.query.permissions.findMany({ where: (permission, { and, eq, or, like }) => { return and( eq(permission.workspaceId, workspaceId), or( like(permission.id, searchTerm), + like(permission.slug, searchTerm), like(permission.name, searchTerm), like(permission.description, searchTerm), ), @@ -58,8 +40,9 @@ export const searchRolesPermissions = t.procedure }, limit: LIMIT, orderBy: (permissions, { asc }) => [ - asc(permissions.name), // Name matches first - asc(permissions.id), // Then by ID for consistency + asc(permissions.name), + asc(permissions.slug), + asc(permissions.id), ], with: { roles: { @@ -81,21 +64,8 @@ export const searchRolesPermissions = t.procedure }, }); - const transformedPermissions = permissionsQuery.map((permission) => ({ - 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, - })), - })); - return { - permissions: transformedPermissions, + permissions: permissionsQuery.map(transformPermission), }; } catch (error) { console.error("Error searching permissions:", error); diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 2717ef28e1..30a38ecf2c 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -20,11 +20,11 @@ import { updateApiIpWhitelist } from "./api/updateIpWhitelist"; import { updateApiName } from "./api/updateName"; import { fetchAuditLog } from "./audit/fetch"; import { auditLogsSearch } from "./audit/llm-search"; +import { queryKeys } from "./authorization/roles/keys/query-keys"; +import { searchKeys } from "./authorization/roles/keys/search-key"; +import { queryRolesPermissions } from "./authorization/roles/permissions/query-permissions"; +import { searchRolesPermissions } from "./authorization/roles/permissions/search-permissions"; import { queryRoles } from "./authorization/roles/query"; -import { queryKeys } from "./authorization/roles/query-keys"; -import { queryRolesPermissions } from "./authorization/roles/query-permissions"; -import { searchKeys } from "./authorization/roles/search-key"; -import { searchRolesPermissions } from "./authorization/roles/search-permissions"; import { upsertRole } from "./authorization/roles/upsert"; import { queryUsage } from "./billing/query-usage"; import { createIdentity } from "./identity/create"; From 8c6497cea322e53fda60b0fb85c5c47b042bc217 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 4 Jun 2025 13:50:37 +0300 Subject: [PATCH 15/47] feat: add submit --- .../controls/components/logs-search/index.tsx | 2 +- .../create-permission-options.tsx | 39 +++++++++++------- .../roles/components/upsert-role/index.tsx | 40 +++++++++---------- .../upsert-role/upsert-role.schema.ts | 11 ++--- 4 files changed, 47 insertions(+), 45 deletions(-) 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 index 9a18da6f29..0583b98e28 100644 --- 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 @@ -23,7 +23,7 @@ export const LogsSearch = ({ keyspaceId }: { keyspaceId: string }) => { return; } const transformedFilters = transformStructuredOutputToFilters(data, filters); - updateFilters(transformedFilters as any); + updateFilters(transformedFilters); }, onError(error) { const errorMessage = `Unable to process your search request${ 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 index 2a1a95d8e1..126b182982 100644 --- 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 @@ -38,22 +38,31 @@ export function createPermissionOptions({
-
-
- {permission.name} - {permission.roles.find((item) => item.id === roleId) && ( - } - /> - )} +
+
+
+
+ + {permission.name} + + {permission.roles.find((item) => item.id === roleId) && ( + } + /> + )} +
+ + {permission.slug} + +
- - {permission.id.length > 15 - ? `${permission.id.slice(0, 8)}...${permission.id.slice(-4)}` - : permission.id} - + {permission.description && ( + + {permission.description} + + )}
diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx index 25c225f704..2608000bff 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/index.tsx @@ -10,6 +10,7 @@ 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"; @@ -66,13 +67,19 @@ export const UpsertRoleDialog = ({ control, } = methods; + const upsertRoleMutation = useUpsertRole(() => { + clearPersistedData(); + reset(getDefaultValues()); + setIsDialogOpen(false); + }); + // Load existing role data when in edit mode useEffect(() => { if (isEditMode && existingRole) { const editValues: Partial = { roleName: existingRole.name, roleDescription: existingRole.description || "", - keyIds: existingRole.keyIds || null, // Changed to single key + keyIds: existingRole.keyIds || null, permissionIds: existingRole.permissionIds || [], }; reset(editValues); @@ -83,22 +90,12 @@ export const UpsertRoleDialog = ({ const hiddenRoleIdRegister = isEditMode ? register("roleId") : null; const onSubmit = async (data: FormValues) => { - try { - if (isEditMode) { - console.log("Updating role:", { ...data, roleId }); - // TODO: await updateRole(roleId, data); - } else { - console.log("Creating role:", data); - // TODO: await createRole(data); - } + const mutationData = { + ...data, + ...(isEditMode && { roleId }), // Include roleId only for updates + }; - clearPersistedData(); - reset(getDefaultValues()); - setIsDialogOpen(false); - } catch (error) { - console.error(`Failed to ${isEditMode ? "update" : "create"} role:`, error); - // TODO: Show error toast - } + upsertRoleMutation.mutate(mutationData); }; const handleDialogClose = (open: boolean) => { @@ -159,7 +156,8 @@ export const UpsertRoleDialog = ({ variant="primary" size="xlg" className="w-full rounded-lg" - disabled={!isValid} + disabled={!isValid || upsertRoleMutation.isLoading} + loading={upsertRoleMutation.isLoading} > {dialogConfig.buttonText} @@ -171,10 +169,10 @@ export const UpsertRoleDialog = ({ {/* Role Name - Required */} ( @@ -212,7 +210,7 @@ export const UpsertRoleDialog = ({ control={control} render={({ field, fieldState }) => ( 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 index e8807e0f59..36b85ad704 100644 --- 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 @@ -5,10 +5,6 @@ export const roleNameSchema = z .trim() .min(2, { message: "Role name must be at least 2 characters long" }) .max(60, { message: "Role name cannot exceed 64 characters" }) - .regex(/^[a-zA-Z][a-zA-Z0-9\s\-_]*$/, { - message: - "Role name must start with a letter and contain only letters, numbers, spaces, hyphens, and underscores", - }) .refine((name) => !name.match(/^\s|\s$/), { message: "Role name cannot start or end with whitespace", }) @@ -29,7 +25,8 @@ export const keyIdsSchema = z }), ) .default([]) - .transform((ids) => [...new Set(ids)]); // Remove duplicates + .transform((ids) => [...new Set(ids)]) // Remove duplicates + .optional(); export const permissionIdsSchema = z .array( @@ -39,9 +36,7 @@ export const permissionIdsSchema = z ) .min(1, { message: "Role must have at least one permission assigned" }) .transform((ids) => [...new Set(ids)]) // Remove duplicates - .refine((ids) => ids.length >= 1, { - message: "After removing duplicates, role must still have at least one permission", - }); + .optional(); export const rbacRoleSchema = z .object({ From d355bf279627330c1da44b6be47941d72ce7b5af Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 4 Jun 2025 13:56:19 +0300 Subject: [PATCH 16/47] chore: fix scoping of trpc --- .../table/hooks/use-roles-list-query.ts | 2 +- .../assign-key/hooks/use-fetch-keys.ts | 2 +- .../assign-key/hooks/use-search-keys.ts | 2 +- .../hooks/use-fetch-permissions.ts | 2 +- .../hooks/use-search-permissions.ts | 2 +- .../upsert-role/hooks/use-upsert-role.ts | 2 +- .../routers/authorization/roles/upsert.ts | 4 ++-- apps/dashboard/lib/trpc/routers/index.ts | 20 ++++++++++--------- 8 files changed, 19 insertions(+), 17 deletions(-) diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts index a286d17311..7d89d995c9 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/hooks/use-roles-list-query.ts @@ -45,7 +45,7 @@ export function useRolesListQuery() { fetchNextPage, isFetchingNextPage, isLoading: isLoadingInitial, - } = trpc.authorization.roles.useInfiniteQuery(queryParams, { + } = trpc.authorization.roles.query.useInfiniteQuery(queryParams, { getNextPageParam: (lastPage) => lastPage.nextCursor, staleTime: Number.POSITIVE_INFINITY, refetchOnMount: false, 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 index 1911e0dff0..2186740c88 100644 --- 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 @@ -5,7 +5,7 @@ import { useMemo } from "react"; export const useFetchKeys = (limit = 50) => { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = - trpc.authorization.keys.query.useInfiniteQuery( + trpc.authorization.roles.keys.query.useInfiniteQuery( { limit, }, 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 index 7e2f4847ab..f9fb4cfe4d 100644 --- 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 @@ -2,7 +2,7 @@ import { trpc } from "@/lib/trpc/client"; import { useMemo } from "react"; export const useSearchKeys = (query: string) => { - const { data, isLoading, error } = trpc.authorization.keys.search.useQuery( + const { data, isLoading, error } = trpc.authorization.roles.keys.search.useQuery( { query }, { enabled: query.trim().length > 0, // Only search when there's a query 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 index d6724e2cec..4aaffe4a4b 100644 --- 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 @@ -5,7 +5,7 @@ import { useMemo } from "react"; export const useFetchPermissions = (limit = 50) => { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = - trpc.authorization.permissions.query.useInfiniteQuery( + trpc.authorization.roles.permissions.query.useInfiniteQuery( { limit, }, 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 index 38efa0f433..2b7c9a93a2 100644 --- 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 @@ -2,7 +2,7 @@ import { trpc } from "@/lib/trpc/client"; import { useMemo } from "react"; export const useSearchPermissions = (query: string) => { - const { data, isLoading, error } = trpc.authorization.permissions.search.useQuery( + const { data, isLoading, error } = trpc.authorization.roles.permissions.search.useQuery( { query }, { enabled: query.trim().length > 0, // Only search when there's a query diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/hooks/use-upsert-role.ts b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/hooks/use-upsert-role.ts index 8ea54a90dd..cb6727ac62 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/hooks/use-upsert-role.ts +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/hooks/use-upsert-role.ts @@ -10,7 +10,7 @@ export const useUpsertRole = ( ) => { const trpcUtils = trpc.useUtils(); - const role = trpc.authorization.upsert.useMutation({ + const role = trpc.authorization.roles.upsert.useMutation({ onSuccess(data) { trpcUtils.authorization.roles.invalidate(); diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/upsert.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/upsert.ts index ee22da2d53..906cfc47a6 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/roles/upsert.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/upsert.ts @@ -157,7 +157,7 @@ export const upsertRole = t.procedure } // Add role-permission relationships - if (input.permissionIds.length > 0) { + if (input.permissionIds && input.permissionIds.length > 0) { await tx .insert(schema.rolesPermissions) .values( @@ -197,7 +197,7 @@ export const upsertRole = t.procedure } // Add key-role relationships - if (input.keyIds.length > 0) { + if (input.keyIds && input.keyIds.length > 0) { await tx .insert(schema.keysRoles) .values( diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 30a38ecf2c..f5f44ddded 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -159,15 +159,17 @@ export const router = t.router({ createIssue: createPlainIssue, }), authorization: t.router({ - roles: queryRoles, - upsert: upsertRole, - keys: t.router({ - search: searchKeys, - query: queryKeys, - }), - permissions: t.router({ - search: searchRolesPermissions, - query: queryRolesPermissions, + roles: t.router({ + query: queryRoles, + keys: t.router({ + search: searchKeys, + query: queryKeys, + }), + permissions: t.router({ + search: searchRolesPermissions, + query: queryRolesPermissions, + }), + upsert: upsertRole, }), }), rbac: t.router({ From 34c0e8b3eaf62ad576219ecdd90bc35d08e99b4a Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 4 Jun 2025 14:59:54 +0300 Subject: [PATCH 17/47] feat: add initial edit role --- .../actions/components/delete-key.tsx | 4 +- .../actions/components/disable-key.tsx | 165 ------------------ .../actions/components/edit-credits/index.tsx | 137 --------------- .../actions/components/edit-credits/utils.ts | 44 ----- .../components/edit-expiration/index.tsx | 113 ------------ .../components/edit-expiration/utils.ts | 15 -- .../components/edit-external-id/index.tsx | 135 -------------- .../actions/components/edit-key-name.tsx | 145 --------------- .../components/edit-metadata/index.tsx | 122 ------------- .../actions/components/edit-metadata/utils.ts | 10 -- .../components/edit-ratelimits/index.tsx | 114 ------------ .../components/edit-ratelimits/utils.ts | 22 --- .../use-fetch-connected-keys-and-perms.ts | 22 +++ .../actions/components/key-info.tsx | 27 --- .../keys-table-action.popover.constants.tsx | 140 +++++++-------- .../actions/keys-table-action.popover.tsx | 138 --------------- .../roles/components/table/roles-list.tsx | 10 ++ .../components/assign-key/key-field.tsx | 41 ++++- .../assign-permission/permissions-field.tsx | 38 +++- .../roles/components/upsert-role/index.tsx | 29 ++- .../upsert-role/upsert-role.schema.ts | 2 +- .../roles/connected-keys-and-perms.ts | 162 +++++++++++++++++ apps/dashboard/lib/trpc/routers/index.ts | 2 + 23 files changed, 362 insertions(+), 1275 deletions(-) delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/disable-key.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/index.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/utils.ts delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/index.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/utils.ts delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-external-id/index.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-key-name.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/index.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/utils.ts delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/index.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/utils.ts create mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-fetch-connected-keys-and-perms.ts delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/key-info.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.tsx create mode 100644 apps/dashboard/lib/trpc/routers/authorization/roles/connected-keys-and-perms.ts diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-key.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-key.tsx index 510d055614..0d8b977666 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-key.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-key.tsx @@ -1,3 +1,5 @@ +import { KeyInfo } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/key-info"; +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 { DialogContainer } from "@/components/dialog-container"; import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; @@ -7,9 +9,7 @@ import { Button, FormCheckbox } from "@unkey/ui"; import { useRef, useState } from "react"; import { Controller, FormProvider, useForm } from "react-hook-form"; import { z } from "zod"; -import type { ActionComponentProps } from "../keys-table-action.popover"; import { useDeleteKey } from "./hooks/use-delete-key"; -import { KeyInfo } from "./key-info"; const deleteKeyFormSchema = z.object({ confirmDeletion: z.boolean().refine((val) => val === true, { diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/disable-key.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/disable-key.tsx deleted file mode 100644 index a64046630b..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/disable-key.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { revalidate } from "@/app/actions"; -import { ConfirmPopover } from "@/components/confirmation-popover"; -import { DialogContainer } from "@/components/dialog-container"; -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Button, FormCheckbox } from "@unkey/ui"; -import { useRef, useState } from "react"; -import { Controller, FormProvider, useForm } from "react-hook-form"; -import { z } from "zod"; -import type { ActionComponentProps } from "../keys-table-action.popover"; -import { useUpdateKeyStatus } from "./hooks/use-update-key-status"; -import { KeyInfo } from "./key-info"; - -const updateKeyStatusFormSchema = z.object({ - confirmStatusChange: z.boolean().refine((val) => val === true, { - message: "Please confirm that you want to change this key's status", - }), -}); - -type UpdateKeyStatusFormValues = z.infer; - -type UpdateKeyStatusProps = { keyDetails: KeyDetails } & ActionComponentProps; - -export const UpdateKeyStatus = ({ keyDetails, isOpen, onClose }: UpdateKeyStatusProps) => { - const isEnabling = !keyDetails.enabled; - const action = isEnabling ? "Enable" : "Disable"; - const [isConfirmPopoverOpen, setIsConfirmPopoverOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const actionButtonRef = useRef(null); - - const methods = useForm({ - resolver: zodResolver(updateKeyStatusFormSchema), - mode: "onChange", - shouldFocusError: true, - shouldUnregister: true, - defaultValues: { - confirmStatusChange: false, - }, - }); - - const { - formState: { errors }, - control, - watch, - } = methods; - - const confirmStatusChange = watch("confirmStatusChange"); - - const updateKeyStatus = useUpdateKeyStatus(() => { - onClose(); - revalidate(keyDetails.id); - }); - - 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 handleActionButtonClick = () => { - // Only show confirmation popover for disabling - if (isEnabling) { - performStatusUpdate(); - } else { - setIsConfirmPopoverOpen(true); - } - }; - - const performStatusUpdate = async () => { - try { - setIsLoading(true); - await updateKeyStatus.mutateAsync({ - keyIds: [keyDetails.id], - enabled: isEnabling, - }); - } catch { - // `useUpdateKeyStatus` already shows a toast, but we still need to - // prevent unhandled‐rejection noise in the console. - } finally { - setIsLoading(false); - } - }; - - return ( - <> - -
- - -
Changes will be applied immediately
-
- } - > - -
-
-
- ( - - )} - /> - - - - {!isEnabling && ( - - )} - - ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/index.tsx deleted file mode 100644 index b3a8cc183f..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/index.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { UsageSetup } from "@/app/(app)/apis/[apiId]/_components/create-key/components/credits-setup"; -import { - type CreditsFormValues, - creditsSchema, -} from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; -import { DialogContainer } from "@/components/dialog-container"; -import { toast } from "@/components/ui/toaster"; -import { usePersistedForm } from "@/hooks/use-persisted-form"; -import { trpc } from "@/lib/trpc/client"; -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Button } from "@unkey/ui"; -import { useEffect } from "react"; -import { FormProvider } from "react-hook-form"; -import type { ActionComponentProps } from "../../keys-table-action.popover"; -import { useEditCredits } from "../hooks/use-edit-credits"; -import { KeyInfo } from "../key-info"; -import { getKeyLimitDefaults } from "./utils"; - -const EDIT_CREDITS_FORM_STORAGE_KEY = "unkey_edit_credits_form_state"; - -type EditCreditsProps = { keyDetails: KeyDetails } & ActionComponentProps; - -export const EditCredits = ({ keyDetails, isOpen, onClose }: EditCreditsProps) => { - const trpcUtil = trpc.useUtils(); - const methods = usePersistedForm( - `${EDIT_CREDITS_FORM_STORAGE_KEY}_${keyDetails.id}`, - { - resolver: zodResolver(creditsSchema), - mode: "onChange", - shouldFocusError: true, - shouldUnregister: true, - defaultValues: getKeyLimitDefaults(keyDetails), - }, - "memory", - ); - - const { - handleSubmit, - formState: { isSubmitting, isValid }, - loadSavedValues, - saveCurrentValues, - clearPersistedData, - reset, - } = methods; - - // Load saved values when the dialog opens - useEffect(() => { - if (isOpen) { - loadSavedValues(); - } - }, [isOpen, loadSavedValues]); - - const key = useEditCredits(() => { - reset(getKeyLimitDefaults(keyDetails)); - clearPersistedData(); - trpcUtil.key.fetchPermissions.invalidate(); - onClose(); - }); - - const onSubmit = async (data: CreditsFormValues) => { - try { - if (data.limit) { - if (data.limit.enabled === true) { - if (data.limit.data) { - await key.mutateAsync({ - keyId: keyDetails.id, - limit: { - enabled: true, - data: data.limit.data, - }, - }); - } else { - // Shouldn't happen - toast.error("Failed to Update Key Limits", { - description: "An unexpected error occurred. Please try again later.", - action: { - label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), - }, - }); - } - } else { - await key.mutateAsync({ - keyId: keyDetails.id, - limit: { - enabled: false, - }, - }); - } - } - } catch { - // `useEditKeyRemainingUses` already shows a toast, but we still need to - // prevent unhandled‐rejection noise in the console. - } - }; - - return ( - -
- { - saveCurrentValues(); - onClose(); - }} - title="Edit Credits" - footer={ -
- -
Changes will be applied immediately
-
- } - > - -
-
-
-
- -
- - - - ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/utils.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/utils.ts deleted file mode 100644 index 7e39d40da2..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-credits/utils.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { refillSchema } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; -import { getDefaultValues } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.utils"; -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; -import type { z } from "zod"; - -type Refill = z.infer; -export const getKeyLimitDefaults = (keyDetails: KeyDetails) => { - const defaultRemaining = - keyDetails.key.credits.remaining ?? getDefaultValues().limit?.data?.remaining ?? 100; - - let refill: Refill; - if (keyDetails.key.credits.refillDay) { - // Monthly refill - refill = { - interval: "monthly", - amount: keyDetails.key.credits.refillAmount ?? 100, - refillDay: keyDetails.key.credits.refillDay, - }; - } else if (keyDetails.key.credits.refillAmount) { - // Daily refill - refill = { - interval: "daily", - amount: keyDetails.key.credits.refillAmount, - refillDay: undefined, - }; - } else { - // No refill - refill = { - interval: "none", - amount: undefined, - refillDay: undefined, - }; - } - - return { - limit: { - enabled: keyDetails.key.credits.enabled, - data: { - remaining: defaultRemaining, - refill, - }, - }, - }; -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/index.tsx deleted file mode 100644 index 9f52765c28..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/index.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { ExpirationSetup } from "@/app/(app)/apis/[apiId]/_components/create-key/components/expiration-setup"; -import { - type ExpirationFormValues, - expirationSchema, -} from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; -import { DialogContainer } from "@/components/dialog-container"; -import { usePersistedForm } from "@/hooks/use-persisted-form"; -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Button } from "@unkey/ui"; -import { useEffect } from "react"; -import { FormProvider } from "react-hook-form"; -import type { ActionComponentProps } from "../../keys-table-action.popover"; -import { useEditExpiration } from "../hooks/use-edit-expiration"; -import { KeyInfo } from "../key-info"; -import { getKeyExpirationDefaults } from "./utils"; - -const EDIT_EXPIRATION_FORM_STORAGE_KEY = "unkey_edit_expiration_form_state"; - -type EditExpirationProps = { - keyDetails: KeyDetails; -} & ActionComponentProps; - -export const EditExpiration = ({ keyDetails, isOpen, onClose }: EditExpirationProps) => { - const methods = usePersistedForm( - `${EDIT_EXPIRATION_FORM_STORAGE_KEY}_${keyDetails.id}`, - { - resolver: zodResolver(expirationSchema), - mode: "onChange", - shouldFocusError: true, - shouldUnregister: true, - defaultValues: getKeyExpirationDefaults(keyDetails), - }, - "memory", - ); - - const { - handleSubmit, - formState: { isSubmitting, isValid }, - loadSavedValues, - saveCurrentValues, - clearPersistedData, - reset, - } = methods; - - // Load saved values when the dialog opens - useEffect(() => { - if (isOpen) { - loadSavedValues(); - } - }, [isOpen, loadSavedValues]); - - const updateExpiration = useEditExpiration(() => { - reset(getKeyExpirationDefaults(keyDetails)); - clearPersistedData(); - onClose(); - }); - - const onSubmit = async (data: ExpirationFormValues) => { - try { - await updateExpiration.mutateAsync({ - keyId: keyDetails.id, - expiration: { - enabled: data.expiration.enabled, - data: data.expiration.enabled ? data.expiration.data : undefined, - }, - }); - } catch { - // `useEditExpiration` already shows a toast, but we still need to - // prevent unhandled rejection noise in the console. - } - }; - - return ( - -
- { - saveCurrentValues(); - onClose(); - }} - title="Edit expiration" - footer={ -
- -
Changes will be applied immediately
-
- } - > - -
-
-
-
- -
- - - - ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/utils.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/utils.ts deleted file mode 100644 index 267a265430..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-expiration/utils.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { getDefaultValues } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.utils"; -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; - -export const getKeyExpirationDefaults = (keyDetails: KeyDetails) => { - const defaultExpiration = keyDetails.expires - ? new Date(keyDetails.expires) - : getDefaultValues().expiration?.data; - - return { - expiration: { - enabled: Boolean(keyDetails.expires), - data: defaultExpiration, - }, - }; -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-external-id/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-external-id/index.tsx deleted file mode 100644 index 991f9995ed..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-external-id/index.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { ExternalIdField } from "@/app/(app)/apis/[apiId]/_components/create-key/components/external-id-field"; -import { ConfirmPopover } from "@/components/confirmation-popover"; -import { DialogContainer } from "@/components/dialog-container"; -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; -import { Button } from "@unkey/ui"; -import { useRef, useState } from "react"; -import type { ActionComponentProps } from "../../keys-table-action.popover"; -import { useEditExternalId } from "../hooks/use-edit-external-id"; -import { KeyInfo } from "../key-info"; - -type EditExternalIdProps = { - keyDetails: KeyDetails; -} & ActionComponentProps; - -export const EditExternalId = ({ - keyDetails, - isOpen, - onClose, -}: EditExternalIdProps): JSX.Element => { - const [originalIdentityId, setOriginalIdentityId] = useState( - keyDetails.identity_id || null, - ); - const [selectedIdentityId, setSelectedIdentityId] = useState( - keyDetails.identity_id || null, - ); - const [isConfirmPopoverOpen, setIsConfirmPopoverOpen] = useState(false); - const clearButtonRef = useRef(null); - - const updateKeyOwner = useEditExternalId(() => { - setOriginalIdentityId(selectedIdentityId); - onClose(); - }); - - const handleSubmit = () => { - updateKeyOwner.mutate({ - keyIds: keyDetails.id, - ownerType: "v2", - identity: { - id: selectedIdentityId, - }, - }); - }; - - const handleClearButtonClick = () => { - setIsConfirmPopoverOpen(true); - }; - - const handleDialogOpenChange = (open: boolean) => { - if (isConfirmPopoverOpen && !isOpen) { - // If confirm popover is active don't let this trigger outer popover - return; - } - - if (!isConfirmPopoverOpen && !open) { - onClose(); - } - }; - - const clearSelection = async () => { - setSelectedIdentityId(null); - await updateKeyOwner.mutateAsync({ - keyIds: keyDetails.id, - ownerType: "v2", - identity: { - id: null, - }, - }); - }; - - return ( - <> - -
- {originalIdentityId !== null ? ( - - ) : ( - - )} -
-
Changes will be applied immediately
-
- } - > - -
-
-
- - - - - ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-key-name.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-key-name.tsx deleted file mode 100644 index 50e124aa4c..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-key-name.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { nameSchema } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; -import { DialogContainer } from "@/components/dialog-container"; -import { usePersistedForm } from "@/hooks/use-persisted-form"; -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Button, FormInput } from "@unkey/ui"; -import { useEffect } from "react"; -import { FormProvider } from "react-hook-form"; -import { z } from "zod"; -import type { ActionComponentProps } from "../keys-table-action.popover"; -import { useEditKeyName } from "./hooks/use-edit-key"; -import { KeyInfo } from "./key-info"; - -const editNameFormSchema = z - .object({ - name: nameSchema, - //Hidden field. Required for comparison - originalName: z.string().optional().default(""), - }) - .superRefine((data, ctx) => { - const normalizedNewName = (data.name || "").trim(); - const normalizedOriginalName = (data.originalName || "").trim(); - - if (normalizedNewName === normalizedOriginalName && normalizedNewName !== "") { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "New name must be different from the current name", - path: ["name"], - }); - } - }); - -const EDIT_NAME_FORM_STORAGE_KEY = "unkey_edit_name_form_state"; - -type EditNameFormValues = z.infer; -type EditKeyNameProps = { keyDetails: KeyDetails } & ActionComponentProps; - -export const EditKeyName = ({ keyDetails, isOpen, onClose }: EditKeyNameProps) => { - const methods = usePersistedForm( - `${EDIT_NAME_FORM_STORAGE_KEY}_${keyDetails.id}`, - { - resolver: zodResolver(editNameFormSchema), - mode: "onChange", - shouldFocusError: true, - shouldUnregister: true, - defaultValues: { - name: keyDetails.name || "", - originalName: keyDetails.name || "", - }, - }, - "memory", - ); - - const { - handleSubmit, - formState: { isSubmitting, errors, isValid }, - register, - loadSavedValues, - saveCurrentValues, - clearPersistedData, - reset, - } = methods; - - // Load saved values when the dialog opens - useEffect(() => { - if (isOpen) { - loadSavedValues(); - } - }, [isOpen, loadSavedValues]); - - const key = useEditKeyName(() => { - clearPersistedData(); - reset({ - name: keyDetails.name || "", - originalName: keyDetails.name || "", - }); - onClose(); - }); - - const onSubmit = async (data: EditNameFormValues) => { - try { - await key.mutateAsync({ ...data, keyId: keyDetails.id }); - } catch { - // `useEditKeyName` already shows a toast, but we still need to - // prevent unhandled‐rejection noise in the console. - } - }; - - return ( - -
- { - saveCurrentValues(); - onClose(); - }} - title="Edit key name" - footer={ -
- -
Changes will be applied immediately
-
- } - > - -
-
-
-
- -
- - - - ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/index.tsx deleted file mode 100644 index 8f9067b9f2..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/index.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { MetadataSetup } from "@/app/(app)/apis/[apiId]/_components/create-key/components/metadata-setup"; -import { - type MetadataFormValues, - metadataSchema, -} from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; -import { DialogContainer } from "@/components/dialog-container"; -import { usePersistedForm } from "@/hooks/use-persisted-form"; -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Button } from "@unkey/ui"; -import { useEffect } from "react"; -import { FormProvider } from "react-hook-form"; -import type { ActionComponentProps } from "../../keys-table-action.popover"; -import { useEditMetadata } from "../hooks/use-edit-metadata"; -import { KeyInfo } from "../key-info"; -import { getKeyMetadataDefaults } from "./utils"; - -const EDIT_METADATA_FORM_STORAGE_KEY = "unkey_edit_metadata_form_state"; - -type EditMetadataProps = { - keyDetails: KeyDetails; -} & ActionComponentProps; - -export const EditMetadata = ({ keyDetails, isOpen, onClose }: EditMetadataProps) => { - const methods = usePersistedForm( - `${EDIT_METADATA_FORM_STORAGE_KEY}_${keyDetails.id}`, - { - resolver: zodResolver(metadataSchema), - mode: "onChange", - shouldFocusError: true, - shouldUnregister: true, - defaultValues: getKeyMetadataDefaults(keyDetails), - }, - "memory", - ); - - const { - handleSubmit, - formState: { isSubmitting, isValid }, - loadSavedValues, - saveCurrentValues, - clearPersistedData, - reset, - } = methods; - - // Load saved values when the dialog opens - useEffect(() => { - if (isOpen) { - loadSavedValues(); - } - }, [isOpen, loadSavedValues]); - - const updateMetadata = useEditMetadata(() => { - reset(getKeyMetadataDefaults(keyDetails)); - clearPersistedData(); - onClose(); - }); - - const onSubmit = async (data: MetadataFormValues) => { - try { - if (data.metadata.enabled && data.metadata.data) { - await updateMetadata.mutateAsync({ - keyId: keyDetails.id, - metadata: { - enabled: data.metadata.enabled, - data: data.metadata.data, - }, - }); - } else { - await updateMetadata.mutateAsync({ - keyId: keyDetails.id, - metadata: { - enabled: false, - }, - }); - } - } catch { - // useEditMetadata already shows a toast, but we still need to - // prevent unhandled rejection noise in the console. - } - }; - - return ( - -
- { - saveCurrentValues(); - onClose(); - }} - title="Edit metadata" - footer={ -
- -
Changes will be applied immediately
-
- } - > - -
-
-
-
- -
- - - - ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/utils.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/utils.ts deleted file mode 100644 index 015e2a834e..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-metadata/utils.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; - -export const getKeyMetadataDefaults = (keyDetails: KeyDetails) => { - return { - metadata: { - enabled: Boolean(keyDetails.metadata), - data: JSON.stringify(JSON.parse(keyDetails.metadata || "{}"), null, 2) ?? undefined, - }, - }; -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/index.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/index.tsx deleted file mode 100644 index da1456e56f..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/index.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { RatelimitSetup } from "@/app/(app)/apis/[apiId]/_components/create-key/components/ratelimit-setup"; -import { - type RatelimitFormValues, - ratelimitSchema, -} from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; -import { DialogContainer } from "@/components/dialog-container"; -import { usePersistedForm } from "@/hooks/use-persisted-form"; -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Button } from "@unkey/ui"; -import { useEffect } from "react"; -import { FormProvider } from "react-hook-form"; -import type { ActionComponentProps } from "../../keys-table-action.popover"; -import { useEditRatelimits } from "../hooks/use-edit-ratelimits"; -import { KeyInfo } from "../key-info"; -import { getKeyRatelimitsDefaults } from "./utils"; - -const EDIT_RATELIMITS_FORM_STORAGE_KEY = "unkey_edit_ratelimits_form_state"; - -type EditRatelimitsProps = { - keyDetails: KeyDetails; -} & ActionComponentProps; - -export const EditRatelimits = ({ keyDetails, isOpen, onClose }: EditRatelimitsProps) => { - const methods = usePersistedForm( - `${EDIT_RATELIMITS_FORM_STORAGE_KEY}_${keyDetails.id}`, - { - resolver: zodResolver(ratelimitSchema), - mode: "onChange", - shouldFocusError: true, - shouldUnregister: true, - defaultValues: getKeyRatelimitsDefaults(keyDetails), - }, - "memory", - ); - - const { - handleSubmit, - formState: { isSubmitting, isValid }, - loadSavedValues, - saveCurrentValues, - clearPersistedData, - reset, - } = methods; - - // Load saved values when the dialog opens - useEffect(() => { - if (isOpen) { - loadSavedValues(); - } - }, [isOpen, loadSavedValues]); - - const key = useEditRatelimits(() => { - reset(getKeyRatelimitsDefaults(keyDetails)); - clearPersistedData(); - onClose(); - }); - - const onSubmit = async (data: RatelimitFormValues) => { - try { - await key.mutateAsync({ - keyId: keyDetails.id, - ratelimitType: "v2", - ratelimit: { - enabled: data.ratelimit.enabled, - data: data.ratelimit.data, - }, - }); - } catch { - // `useEditRatelimits` already shows a toast, but we still need to - // prevent unhandled rejection noise in the console. - } - }; - - return ( - -
- { - saveCurrentValues(); - onClose(); - }} - title="Edit ratelimits" - footer={ -
- -
Changes will be applied immediately
-
- } - > - -
-
-
-
- -
- - - - ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/utils.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/utils.ts deleted file mode 100644 index 769b833f26..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/edit-ratelimits/utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getDefaultValues } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.utils"; -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; - -export const getKeyRatelimitsDefaults = (keyDetails: KeyDetails) => { - const defaultRatelimits = - keyDetails.key.ratelimits.items.length > 0 - ? keyDetails.key.ratelimits.items - : (getDefaultValues().ratelimit?.data ?? [ - { - name: "Default", - limit: 10, - refillInterval: 1000, - }, - ]); - - return { - ratelimit: { - enabled: keyDetails.key.ratelimits.enabled, - data: defaultRatelimits, - }, - }; -}; 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/key-info.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/key-info.tsx deleted file mode 100644 index 73909967fb..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/key-info.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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 }) => { - return ( -
-
- -
-
-
{keyDetails.id}
- -
- {keyDetails.name ?? "Unnamed Key"} -
-
-
-
- ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.constants.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.constants.tsx index bd0c91206c..0def51d67b 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.constants.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.constants.tsx @@ -1,43 +1,28 @@ +import type { MenuItem } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover"; import { toast } from "@/components/ui/toaster"; -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; -import { - ArrowOppositeDirectionY, - Ban, - CalendarClock, - ChartPie, - Check, - Clone, - Code, - Gauge, - PenWriting3, - Trash, -} from "@unkey/icons"; +import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; +import { Clone, PenWriting3, Trash } from "@unkey/icons"; +import { useEffect } from "react"; +import { UpsertRoleDialog } from "../../../upsert-role"; import { DeleteKey } from "./components/delete-key"; -import { UpdateKeyStatus } from "./components/disable-key"; -import { EditCredits } from "./components/edit-credits"; -import { EditExpiration } from "./components/edit-expiration"; -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 { useFetchConnectedKeysAndPerms } from "./components/hooks/use-fetch-connected-keys-and-perms"; -export const getKeysTableActionItems = (key: KeyDetails): MenuItem[] => { +export const getRolesTableActionItems = (role: Roles): MenuItem[] => { return [ { - id: "override", - label: "Edit key name...", + id: "edit-role", + label: "Edit role...", icon: , - ActionComponent: (props) => , + ActionComponent: (props) => , }, { id: "copy", - label: "Copy key ID", + label: "Copy role", className: "mt-1", icon: , onClick: () => { navigator.clipboard - .writeText(key.id) + .writeText(JSON.stringify(role)) .then(() => { toast.success("Key ID copied to clipboard"); }) @@ -49,49 +34,66 @@ export const getKeysTableActionItems = (key: KeyDetails): MenuItem[] => { divider: true, }, { - id: "edit-external-id", - label: "Edit External ID...", - icon: , - ActionComponent: (props) => , - divider: true, - }, - { - id: key.enabled ? "disable-key" : "enable-key", - label: key.enabled ? "Disable Key..." : "Enable Key...", - icon: key.enabled ? : , - ActionComponent: (props) => , - divider: true, - }, - { - id: "edit-credits", - label: "Edit credits...", - icon: , - ActionComponent: (props) => , - }, - { - id: "edit-ratelimit", - label: "Edit ratelimit...", - icon: , - ActionComponent: (props) => , - }, - { - id: "edit-expiration", - label: "Edit expiration...", - icon: , - ActionComponent: (props) => , - }, - { - id: "edit-metadata", - label: "Edit metadata...", - icon: , - ActionComponent: (props) => , - divider: true, - }, - { - id: "delete-key", - label: "Delete key", + id: "delete-role", + label: "Delete role", icon: , - ActionComponent: (props) => , + ActionComponent: (props) => , }, ]; }; + +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.keyId), + permissionIds: permissions.map((permission) => permission.id), + name: role.name, + description: role.description, + }} + isOpen={isOpen} + onClose={onClose} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.tsx deleted file mode 100644 index 49862b19c9..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Dots } from "@unkey/icons"; -import { cn } from "@unkey/ui/src/lib/utils"; -import { type FC, type PropsWithChildren, useEffect, useRef, useState } from "react"; - -export type ActionComponentProps = { - isOpen: boolean; - onClose: () => void; -}; - -export type MenuItem = { - id: string; - label: string; - icon: React.ReactNode; - onClick?: (e: React.MouseEvent | React.KeyboardEvent) => void; - className?: string; - disabled?: boolean; - divider?: boolean; - ActionComponent?: FC; -}; - -type BaseTableActionPopoverProps = PropsWithChildren<{ - items: MenuItem[]; - align?: "start" | "end"; -}>; - -export const KeysTableActionPopover = ({ - items, - align = "end", - children, -}: BaseTableActionPopoverProps) => { - const [enabledItem, setEnabledItem] = useState(); - const [open, setOpen] = useState(false); - const [focusIndex, setFocusIndex] = useState(0); - const menuItems = useRef([]); - - useEffect(() => { - if (open) { - const firstEnabledIndex = items.findIndex((item) => !item.disabled); - setFocusIndex(firstEnabledIndex >= 0 ? firstEnabledIndex : 0); - if (firstEnabledIndex >= 0) { - menuItems.current[firstEnabledIndex]?.focus(); - } - } - }, [open, items]); - - const handleActionSelection = (value: string) => { - setEnabledItem(value); - }; - - return ( - - e.stopPropagation()}> - {children ? ( - children - ) : ( - - )} - - { - e.preventDefault(); - const firstEnabledIndex = items.findIndex((item) => !item.disabled); - if (firstEnabledIndex >= 0) { - menuItems.current[firstEnabledIndex]?.focus(); - } - }} - onCloseAutoFocus={(e) => e.preventDefault()} - onEscapeKeyDown={(e) => { - e.preventDefault(); - setOpen(false); - }} - onInteractOutside={(e) => { - e.preventDefault(); - setOpen(false); - }} - > - {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} -
e.stopPropagation()} className="py-2"> - {items.map((item, index) => ( -
-
- {/* biome-ignore lint/a11y/useKeyWithClickEvents: */} -
{ - if (el) { - menuItems.current[index] = el; - } - }} - role="menuitem" - aria-disabled={item.disabled} - tabIndex={!item.disabled && focusIndex === index ? 0 : -1} - className={cn( - "flex w-full items-center px-2 py-1.5 gap-3 rounded-lg group", - !item.disabled && - "cursor-pointer hover:bg-gray-3 data-[state=open]:bg-gray-3 focus:outline-none focus:bg-gray-3", - item.disabled && "cursor-not-allowed opacity-50", - item.className, - )} - onClick={(e) => { - if (!item.disabled) { - item.onClick?.(e); - - if (!item.ActionComponent) { - setOpen(false); - } - - setEnabledItem(item.id); - } - }} - > -
- {item.icon} -
- {item.label} -
-
- {item.divider &&
} - {item.ActionComponent && enabledItem === item.id && ( - handleActionSelection("none")} /> - )} -
- ))} -
- - - ); -}; 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 8799e2d1da..cf02c2b60e 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/roles-list.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/roles-list.tsx @@ -1,4 +1,5 @@ "use client"; +import { KeysTableActionPopover } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover"; 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"; @@ -6,6 +7,7 @@ import { Asterisk, BookBookmark, Key2, 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 { getRolesTableActionItems } from "./components/actions/keys-table-action.popover.constants"; import { LastUpdated } from "./components/last-updated"; import { AssignedKeysColumnSkeleton, @@ -132,6 +134,14 @@ export const RolesList = () => { ); }, }, + { + key: "action", + header: "", + width: "15%", + render: (key) => { + return ; + }, + }, ], [selectedRoles, toggleSelection, hoveredRoleName, selectedRole?.roleId], ); diff --git a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/key-field.tsx b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/key-field.tsx index 07edfdd0fb..ed80eddc5a 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/key-field.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/upsert-role/components/assign-key/key-field.tsx @@ -11,9 +11,17 @@ type KeyFieldProps = { error?: string; disabled?: boolean; roleId?: string; + selectedKeysData?: { keyId: string; keyName: string | null }[]; }; -export const KeyField = ({ value, onChange, error, disabled = false, roleId }: KeyFieldProps) => { +export const KeyField = ({ + value, + onChange, + error, + disabled = false, + roleId, + selectedKeysData = [], +}: KeyFieldProps) => { const [searchValue, setSearchValue] = useState(""); const { keys, isFetchingNextPage, hasNextPage, loadMore } = useFetchKeys(); const { searchResults, isSearching } = useSearchKeys(searchValue); @@ -74,12 +82,35 @@ export const KeyField = ({ value, onChange, error, disabled = false, roleId }: K }); }, [baseOptions, allKeys, roleId, value]); - // Get selected key details for display const selectedKeys = useMemo(() => { return value - .map((keyId) => allKeys.find((k) => k.id === keyId)) + .map((keyId) => { + // First: check selectedKeysData (for pre-loaded edit data) + const preLoadedKey = selectedKeysData.find((k) => k.keyId === keyId); + if (preLoadedKey) { + return { + id: preLoadedKey.keyId, + name: preLoadedKey.keyName, + }; + } + + // 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]); + }, [value, allKeys, selectedKeysData]); const handleRemoveKey = (keyId: string) => { onChange(value.filter((id) => id !== keyId)); @@ -147,7 +178,7 @@ export const KeyField = ({ value, onChange, error, disabled = false, roleId }: K
- This key will be permanently deleted immediately + Changes may take up to 60s to propagate globally
} > - +
@@ -119,9 +119,10 @@ export const DeleteKey = ({ keyDetails, isOpen, onClose }: DeleteKeyProps) => {
- Warning: deleting this key will remove all - associated data and metadata. This action cannot be undone. Any verification, - tracking, and historical usage tied to this key will be permanently lost. + 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.
{ size="md" checked={field.value} onCheckedChange={field.onChange} - label="I understand this will permanently delete the key and all its associated data" + label="I understand this will permanently delete the role and detach it from all assigned keys and permissions" error={errors.confirmDeletion?.message} /> )} @@ -146,11 +147,11 @@ export const DeleteKey = ({ keyDetails, isOpen, onClose }: DeleteKeyProps) => { 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-key.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-delete-key.ts deleted file mode 100644 index 26eb25f6f2..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-delete-key.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { toast } from "@/components/ui/toaster"; -import { trpc } from "@/lib/trpc/client"; - -export const useDeleteKey = (onSuccess?: () => void) => { - const trpcUtils = trpc.useUtils(); - const deleteKey = trpc.key.delete.useMutation({ - onSuccess(data, variable) { - const deletedCount = data.totalDeleted; - - if (deletedCount === 1) { - toast.success("Key Deleted", { - description: "Your key has been permanently deleted successfully", - duration: 5000, - }); - } else { - toast.success("Keys Deleted", { - description: `${deletedCount} keys have been permanently deleted successfully`, - duration: 5000, - }); - } - - // If some keys weren't found. Someone might've already deleted them when this is fired. - if (data.deletedKeyIds.length < variable.keyIds.length) { - const missingCount = variable.keyIds.length - data.deletedKeyIds.length; - toast.warning("Some Keys Not Found", { - description: `${missingCount} ${ - missingCount === 1 ? "key was" : "keys were" - } not found and could not be deleted.`, - duration: 7000, - }); - } - - trpcUtils.api.keys.list.invalidate(); - if (onSuccess) { - onSuccess(); - } - }, - onError(err, variable) { - const errorMessage = err.message || ""; - const isPlural = variable.keyIds.length > 1; - const keyText = isPlural ? "keys" : "key"; - - if (err.data?.code === "NOT_FOUND") { - toast.error("Key Deletion Failed", { - description: `Unable to find the ${keyText}. Please refresh and try again.`, - }); - } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { - toast.error("Server Error", { - description: `We encountered an issue while deleting your ${keyText}. Please try again later or contact support at support.unkey.dev`, - }); - } else if (err.data?.code === "FORBIDDEN") { - toast.error("Permission Denied", { - description: `You don't have permission to delete ${ - isPlural ? "these keys" : "this key" - }.`, - }); - } else { - toast.error(`Failed to Delete ${isPlural ? "Keys" : "Key"}`, { - description: errorMessage || "An unexpected error occurred. Please try again later.", - action: { - label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), - }, - }); - } - }, - }); - - return deleteKey; -}; 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..24140cfff0 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-delete-role.ts @@ -0,0 +1,48 @@ +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; + +export const useDeleteRole = (onSuccess: (data: { roleId: string; message: string }) => void) => { + const trpcUtils = trpc.useUtils(); + + const deleteRole = trpc.authorization.roles.delete.useMutation({ + onSuccess(_, variables) { + trpcUtils.authorization.roles.invalidate(); + + toast.success("Role Deleted", { + description: "The role has been successfully removed from your workspace.", + }); + + onSuccess({ + roleId: variables.roleId, + message: "Role deleted successfully", + }); + }, + onError(err) { + if (err.data?.code === "NOT_FOUND") { + toast.error("Role Not Found", { + description: + "The role you're trying to delete no longer exists or you don't have access to it.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We encountered an issue while deleting 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 Delete 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 deleteRole; +}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-credits.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-credits.ts deleted file mode 100644 index b17ef8a017..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-credits.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { toast } from "@/components/ui/toaster"; - -import { trpc } from "@/lib/trpc/client"; - -export const useEditCredits = (onSuccess?: () => void) => { - const trpcUtils = trpc.useUtils(); - const updateKeyRemaining = trpc.key.update.remaining.useMutation({ - onSuccess(data, variables) { - const remainingChange = variables.limit?.enabled - ? `with ${variables.limit.data.remaining} uses remaining` - : "with limits disabled"; - - toast.success("Key Limits Updated", { - description: `Your key ${data.keyId} has been updated successfully ${remainingChange}`, - duration: 5000, - }); - trpcUtils.api.keys.list.invalidate(); - if (onSuccess) { - onSuccess(); - } - }, - onError(err) { - if (err.data?.code === "NOT_FOUND") { - toast.error("Key Update Failed", { - description: "Unable to find the key. Please refresh and try again.", - }); - } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { - toast.error("Server Error", { - description: - "We encountered an issue while updating your key. Please try again later or contact support at support.unkey.dev", - }); - } else { - toast.error("Failed to Update Key Limits", { - description: err.message || "An unexpected error occurred. Please try again later.", - action: { - label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), - }, - }); - } - }, - }); - return updateKeyRemaining; -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-expiration.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-expiration.ts deleted file mode 100644 index aec5f35449..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-expiration.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { toast } from "@/components/ui/toaster"; -import { trpc } from "@/lib/trpc/client"; - -export const useEditExpiration = (onSuccess?: () => void) => { - const trpcUtils = trpc.useUtils(); - const updateKeyExpiration = trpc.key.update.expiration.useMutation({ - onSuccess(_, variables) { - let description = ""; - if (variables.expiration?.enabled && variables.expiration.data) { - description = `Your key ${ - variables.keyId - } has been updated to expire on ${variables.expiration.data.toLocaleString()}`; - } else { - description = `Expiration has been disabled for key ${variables.keyId}`; - } - - toast.success("Key Expiration Updated", { - description, - duration: 5000, - }); - trpcUtils.api.keys.list.invalidate(); - if (onSuccess) { - onSuccess(); - } - }, - onError(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") { - toast.error("Invalid Request", { - description: err.message || "Please check your expiration settings and try again.", - }); - } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { - toast.error("Server Error", { - description: - "We were unable to update expiration on this key. Please try again or contact support@unkey.dev", - }); - } else { - toast.error("Failed to Update Key Expiration", { - 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"), - }, - }); - } - }, - }); - return updateKeyExpiration; -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-external-id.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-external-id.ts deleted file mode 100644 index c6ebead907..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-external-id.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { toast } from "@/components/ui/toaster"; -import { trpc } from "@/lib/trpc/client"; -import type { TRPCClientErrorLike } from "@trpc/client"; -import type { TRPCErrorShape } from "@trpc/server/rpc"; - -const handleKeyOwnerUpdateError = (err: TRPCClientErrorLike) => { - if (err.data?.code === "NOT_FOUND") { - toast.error("Key Update Failed", { - description: "Unable to find the key(s). Please refresh and try again.", - }); - } else if (err.data?.code === "BAD_REQUEST") { - toast.error("Invalid External ID Information", { - description: - err.message || "Please ensure your External ID information is valid and try again.", - }); - } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { - toast.error("Server Error", { - description: - "We are unable to update External ID information on this key. Please try again or contact support@unkey.dev", - }); - } else { - toast.error("Failed to Update Key External ID", { - 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"), - }, - }); - } -}; - -export const useEditExternalId = (onSuccess?: () => void) => { - const trpcUtils = trpc.useUtils(); - const updateKeyOwner = trpc.key.update.ownerId.useMutation({ - onSuccess(_, variables) { - let description = ""; - const keyId = Array.isArray(variables.keyIds) ? variables.keyIds[0] : variables.keyIds; - - if (variables.ownerType === "v2") { - if (variables.identity?.id) { - description = `Identity for key ${keyId} has been updated`; - } else { - description = `Identity has been removed from key ${keyId}`; - } - } - toast.success("Key External ID Updated", { - description, - duration: 5000, - }); - - trpcUtils.api.keys.list.invalidate(); - if (onSuccess) { - onSuccess(); - } - }, - onError(err) { - handleKeyOwnerUpdateError(err); - }, - }); - - return updateKeyOwner; -}; - -export const useBatchEditExternalId = (onSuccess?: () => void) => { - const trpcUtils = trpc.useUtils(); - const batchUpdateKeyOwner = trpc.key.update.ownerId.useMutation({ - onSuccess(data, variables) { - const updatedCount = data.updatedCount; - let description = ""; - - if (variables.ownerType === "v2") { - if (variables.identity?.id) { - description = `Identity has been updated for ${updatedCount} ${ - updatedCount === 1 ? "key" : "keys" - }`; - } else { - description = `Identity has been removed from ${updatedCount} ${ - updatedCount === 1 ? "key" : "keys" - }`; - } - } - toast.success("Key External ID Updated", { - description, - duration: 5000, - }); - - // Show warning if some keys were not found (if that info is available in the response) - const missingCount = Array.isArray(variables.keyIds) - ? variables.keyIds.length - updatedCount - : 0; - - if (missingCount > 0) { - toast.warning("Some Keys Not Found", { - description: `${missingCount} ${ - missingCount === 1 ? "key was" : "keys were" - } not found and could not be updated.`, - duration: 7000, - }); - } - - trpcUtils.api.keys.list.invalidate(); - if (onSuccess) { - onSuccess(); - } - }, - onError(err) { - handleKeyOwnerUpdateError(err); - }, - }); - - return batchUpdateKeyOwner; -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-key.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-key.tsx deleted file mode 100644 index c970bafaa0..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-key.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { UNNAMED_KEY } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.constants"; -import { toast } from "@/components/ui/toaster"; -import { trpc } from "@/lib/trpc/client"; - -export const useEditKeyName = (onSuccess: () => void) => { - const trpcUtils = trpc.useUtils(); - - const key = trpc.key.update.name.useMutation({ - onSuccess(data) { - const nameChange = - data.previousName !== data.newName - ? `from "${data.previousName || UNNAMED_KEY}" to "${data.newName || UNNAMED_KEY}"` - : ""; - - toast.success("Key Name Updated", { - description: `Your key ${data.keyId} has been updated successfully ${nameChange}`, - duration: 5000, - }); - - trpcUtils.api.keys.list.invalidate(); - onSuccess(); - }, - onError(err) { - const errorMessage = err.message || ""; - - if (err.data?.code === "UNPROCESSABLE_CONTENT") { - toast.error("No Changes Detected", { - description: "The new name must be different from the current name.", - }); - } else if (err.data?.code === "NOT_FOUND") { - toast.error("Key Update Failed", { - description: "Unable to find the key. Please refresh and try again.", - }); - } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { - toast.error("Server Error", { - description: - "We encountered an issue while updating your key. Please try again later or contact support at support.unkey.dev", - }); - } else if (err.data?.code === "BAD_REQUEST") { - toast.error("Invalid Configuration", { - description: `Please check your key name. ${errorMessage}`, - }); - } else { - toast.error("Failed to Update Key", { - description: errorMessage || "An unexpected error occurred. Please try again later.", - action: { - label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), - }, - }); - } - }, - }); - - return key; -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-metadata.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-metadata.ts deleted file mode 100644 index da6a7d5ccd..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-metadata.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { toast } from "@/components/ui/toaster"; -import { trpc } from "@/lib/trpc/client"; - -export const useEditMetadata = (onSuccess?: () => void) => { - const trpcUtils = trpc.useUtils(); - const updateKeyMetadata = trpc.key.update.metadata.useMutation({ - onSuccess(_, variables) { - let description = ""; - if (variables.metadata?.enabled && variables.metadata.data) { - description = `Metadata for key ${variables.keyId} has been updated`; - } else { - description = `Metadata has been removed from key ${variables.keyId}`; - } - - toast.success("Key Metadata Updated", { - description, - duration: 5000, - }); - trpcUtils.api.keys.list.invalidate(); - if (onSuccess) { - onSuccess(); - } - }, - onError(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") { - toast.error("Invalid Metadata", { - description: err.message || "Please ensure your metadata is valid JSON and try again.", - }); - } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { - toast.error("Server Error", { - description: - "We are unable to update metadata on this key. Please try again or contact support@unkey.dev", - }); - } else { - toast.error("Failed to Update Key Metadata", { - 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"), - }, - }); - } - }, - }); - return updateKeyMetadata; -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-ratelimits.ts b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-ratelimits.ts deleted file mode 100644 index e2b5006c27..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-edit-ratelimits.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { toast } from "@/components/ui/toaster"; -import { trpc } from "@/lib/trpc/client"; -import { formatDuration, intervalToDuration } from "date-fns"; - -export const useEditRatelimits = (onSuccess?: () => void) => { - const trpcUtils = trpc.useUtils(); - - const updateKeyRemaining = trpc.key.update.ratelimit.useMutation({ - onSuccess(data, variables) { - let description = ""; - - // Handle both V1 and V2 ratelimit types - if (variables.ratelimitType === "v2") { - if (variables.ratelimit?.enabled) { - const rulesCount = variables.ratelimit.data.length; - - if (rulesCount === 1) { - // If there's just one rule, show its limit directly - const rule = variables.ratelimit.data[0]; - description = `Your key ${data.keyId} has been updated with a limit of ${ - rule.limit - } requests per ${formatInterval(rule.refillInterval)}`; - } else { - // If there are multiple rules, show the count - description = `Your key ${data.keyId} has been updated with ${rulesCount} rate limit rules`; - } - } else { - description = `Your key ${data.keyId} has been updated with rate limits disabled`; - } - } else { - // V1 ratelimits - if (variables.enabled) { - description = `Your key ${data.keyId} has been updated with a limit of ${ - variables.ratelimitLimit - } requests per ${formatInterval(variables.ratelimitDuration || 0)}`; - } else { - description = `Your key ${data.keyId} has been updated with rate limits disabled`; - } - } - - toast.success("Key Ratelimits Updated", { - description, - duration: 5000, - }); - - trpcUtils.api.keys.list.invalidate(); - if (onSuccess) { - onSuccess(); - } - }, - onError(err) { - if (err.data?.code === "NOT_FOUND") { - toast.error("Key Update Failed", { - description: "Unable to find the key. Please refresh and try again.", - }); - } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { - toast.error("Server Error", { - description: - "We encountered an issue while updating your key. Please try again later or contact support at support.unkey.dev", - }); - } else { - toast.error("Failed to Update Key Limits", { - description: err.message || "An unexpected error occurred. Please try again later.", - action: { - label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), - }, - }); - } - }, - }); - - return updateKeyRemaining; -}; - -const formatInterval = (milliseconds: number): string => { - if (milliseconds < 1000) { - return `${milliseconds}ms`; - } - - const duration = intervalToDuration({ start: 0, end: milliseconds }); - - // Customize the format for different time ranges - if (milliseconds < 60000) { - // Less than a minute - return formatDuration(duration, { format: ["seconds"] }); - } - if (milliseconds < 3600000) { - // Less than an hour - return formatDuration(duration, { format: ["minutes", "seconds"] }); - } - if (milliseconds < 86400000) { - // Less than a day - return formatDuration(duration, { format: ["hours", "minutes"] }); - } - // Days or more - return formatDuration(duration, { format: ["days", "hours"] }); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-update-key-status.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-update-key-status.tsx deleted file mode 100644 index c15dc26d7a..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/hooks/use-update-key-status.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { toast } from "@/components/ui/toaster"; -import { trpc } from "@/lib/trpc/client"; -import type { TRPCClientErrorLike } from "@trpc/client"; -import type { TRPCErrorShape } from "@trpc/server/rpc"; - -const handleKeyUpdateError = (err: TRPCClientErrorLike) => { - const errorMessage = err.message || ""; - if (err.data?.code === "NOT_FOUND") { - toast.error("Key Update Failed", { - description: "Unable to find the key(s). Please refresh and try again.", - }); - } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { - toast.error("Server Error", { - description: - "We encountered an issue while updating your key(s). Please try again later or contact support at support.unkey.dev", - }); - } else { - toast.error("Failed to Update Key Status", { - description: errorMessage || "An unexpected error occurred. Please try again later.", - action: { - label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), - }, - }); - } -}; - -export const useUpdateKeyStatus = (onSuccess?: () => void) => { - const trpcUtils = trpc.useUtils(); - - const updateKeyEnabled = trpc.key.update.enabled.useMutation({ - onSuccess(data) { - toast.success(`Key ${data.enabled ? "Enabled" : "Disabled"}`, { - description: `Your key ${data.updatedKeyIds[0]} has been ${ - data.enabled ? "enabled" : "disabled" - } successfully`, - duration: 5000, - }); - trpcUtils.api.keys.list.invalidate(); - if (onSuccess) { - onSuccess(); - } - }, - onError(err) { - handleKeyUpdateError(err); - }, - }); - - return updateKeyEnabled; -}; - -export const useBatchUpdateKeyStatus = (onSuccess?: () => void) => { - const trpcUtils = trpc.useUtils(); - - const updateMultipleKeysEnabled = trpc.key.update.enabled.useMutation({ - onSuccess(data) { - const updatedCount = data.updatedKeyIds.length; - toast.success(`Keys ${data.enabled ? "Enabled" : "Disabled"}`, { - description: `${updatedCount} ${ - updatedCount === 1 ? "key has" : "keys have" - } been ${data.enabled ? "enabled" : "disabled"} successfully`, - duration: 5000, - }); - - // Show warning if some keys were not found - if (data.missingKeyIds && data.missingKeyIds.length > 0) { - toast.warning("Some Keys Not Found", { - description: `${data.missingKeyIds.length} ${ - data.missingKeyIds.length === 1 ? "key was" : "keys were" - } not found and could not be updated.`, - duration: 7000, - }); - } - - trpcUtils.api.keys.list.invalidate(); - if (onSuccess) { - onSuccess(); - } - }, - onError(err) { - handleKeyUpdateError(err); - }, - }); - - return updateMultipleKeysEnabled; -}; 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 index 0edbdb6d0e..204dd2a855 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.constants.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/keys-table-action.popover.constants.tsx @@ -2,10 +2,8 @@ import type { MenuItem } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_compon import { toast } from "@/components/ui/toaster"; import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; import { Clone, PenWriting3, Trash } from "@unkey/icons"; -import { useEffect } from "react"; -import { UpsertRoleDialog } from "../../../upsert-role"; -import { DeleteKey } from "./components/delete-key"; -import { useFetchConnectedKeysAndPerms } from "./components/hooks/use-fetch-connected-keys-and-perms"; +import { DeleteRole } from "./components/delete-role"; +import { EditRole } from "./components/edit-role"; export const getRolesTableActionItems = (role: Roles): MenuItem[] => { return [ @@ -24,7 +22,7 @@ export const getRolesTableActionItems = (role: Roles): MenuItem[] => { navigator.clipboard .writeText(JSON.stringify(role)) .then(() => { - toast.success("Key ID copied to clipboard"); + toast.success("Role data copied to clipboard"); }) .catch((error) => { console.error("Failed to copy to clipboard:", error); @@ -37,63 +35,7 @@ export const getRolesTableActionItems = (role: Roles): MenuItem[] => { id: "delete-role", label: "Delete role", icon: , - ActionComponent: (props) => , + ActionComponent: (props) => , }, ]; }; - -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/lib/trpc/routers/authorization/roles/delete.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/delete.ts new file mode 100644 index 0000000000..af21bf7336 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/delete.ts @@ -0,0 +1,67 @@ +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 { z } from "zod"; + +export const deleteRole = t.procedure + .use(requireUser) + .use(requireWorkspace) + .input( + z.object({ + roleId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + await db.transaction(async (tx) => { + const role = await tx.query.roles.findFirst({ + where: (table, { and, eq }) => + and(eq(table.workspaceId, ctx.workspace.id), eq(table.id, input.roleId)), + }); + + if (!role) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "We are unable to find the correct role. Please try again or contact support@unkey.dev.", + }); + } + await tx + .delete(schema.roles) + .where( + and(eq(schema.roles.id, input.roleId), eq(schema.roles.workspaceId, ctx.workspace.id)), + ) + .catch((err) => { + console.error("Failed to delete role:", err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to delete the role. Please try again or contact support@unkey.dev", + }); + }); + await insertAuditLogs(tx, { + workspaceId: ctx.workspace.id, + actor: { type: "user", id: ctx.user.id }, + event: "role.delete", + description: `Deleted role ${input.roleId}`, + resources: [ + { + type: "role", + id: input.roleId, + name: role.name, + }, + ], + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, + }).catch((err) => { + console.error(err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to delete the role. Please try again or contact support@unkey.dev.", + }); + }); + }); + }); diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 2790c19194..e059cc8130 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -171,6 +171,7 @@ export const router = t.router({ query: queryRolesPermissions, }), upsert: upsertRole, + delete: deleteRole, connectedKeysAndPerms: getConnectedKeysAndPerms, }), }), From 2137d9f9dfd724effeebad32da0a1931cce66b24 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 4 Jun 2025 17:50:51 +0300 Subject: [PATCH 21/47] feat: add role deletion --- .../components/selection-controls/index.tsx | 2 +- .../actions/components/delete-role.tsx | 2 +- .../components/hooks/use-delete-role.ts | 30 ++- .../components/batch-edit-external-id.tsx | 169 ----------------- .../components/selection-controls/index.tsx | 171 ++---------------- .../components/table/components/skeletons.tsx | 15 +- .../roles/components/table/roles-list.tsx | 6 + .../routers/authorization/roles/delete.ts | 99 +++++++--- apps/dashboard/lib/trpc/routers/index.ts | 3 +- 9 files changed, 141 insertions(+), 356 deletions(-) delete mode 100644 apps/dashboard/app/(app)/authorization/roles/components/table/components/selection-controls/components/batch-edit-external-id.tsx 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)/authorization/roles/components/table/components/actions/components/delete-role.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-role.tsx index 46793d7053..690b2a2374 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-role.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-role.tsx @@ -69,7 +69,7 @@ export const DeleteRole = ({ roleDetails, isOpen, onClose }: DeleteRoleProps) => try { setIsLoading(true); await deleteRole.mutateAsync({ - roleId: roleDetails.roleId, + roleIds: roleDetails.roleId, }); } catch { // `useDeleteRole` already shows a toast, but we still need to 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 index 24140cfff0..ec9e18b04a 100644 --- 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 @@ -1,39 +1,49 @@ import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; -export const useDeleteRole = (onSuccess: (data: { roleId: string; message: string }) => void) => { +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(); - toast.success("Role Deleted", { - description: "The role has been successfully removed from your workspace.", + 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({ - roleId: variables.roleId, - message: "Role deleted successfully", + roleIds: variables.roleIds, + message: isPlural ? `${roleCount} roles deleted successfully` : "Role deleted successfully", }); }, onError(err) { if (err.data?.code === "NOT_FOUND") { - toast.error("Role Not Found", { + toast.error("Role(s) Not Found", { description: - "The role you're trying to delete no longer exists or you don't have access to it.", + "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 role. Please try again later or contact support.", + "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", { + toast.error("Failed to Delete Role(s)", { description: err.message || "An unexpected error occurred. Please try again later.", action: { label: "Contact Support", diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/selection-controls/components/batch-edit-external-id.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/selection-controls/components/batch-edit-external-id.tsx deleted file mode 100644 index 181f1b534e..0000000000 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/selection-controls/components/batch-edit-external-id.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { ExternalIdField } from "@/app/(app)/apis/[apiId]/_components/create-key/components/external-id-field"; -import { ConfirmPopover } from "@/components/confirmation-popover"; -import { DialogContainer } from "@/components/dialog-container"; -import { TriangleWarning2 } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -import { useRef, useState } from "react"; -import { useBatchEditExternalId } from "../../actions/components/hooks/use-edit-external-id"; - -type BatchEditExternalIdProps = { - selectedKeyIds: string[]; - keysWithExternalIds: number; // Count of keys that already have external IDs - isOpen: boolean; - onClose: () => void; -}; - -export const BatchEditExternalId = ({ - selectedKeyIds, - keysWithExternalIds, - isOpen, - onClose, -}: BatchEditExternalIdProps): JSX.Element => { - const [selectedIdentityId, setSelectedIdentityId] = useState(null); - const [isConfirmPopoverOpen, setIsConfirmPopoverOpen] = useState(false); - const clearButtonRef = useRef(null); - - const updateKeyOwner = useBatchEditExternalId(() => { - onClose(); - }); - - const handleSubmit = () => { - updateKeyOwner.mutate({ - keyIds: selectedKeyIds, - ownerType: "v2", - identity: { - id: selectedIdentityId, - }, - }); - }; - - const handleClearButtonClick = () => { - setIsConfirmPopoverOpen(true); - }; - - const handleDialogOpenChange = (open: boolean) => { - if (isConfirmPopoverOpen && !isOpen) { - // If confirm popover is active don't let this trigger outer popover - return; - } - - if (!isConfirmPopoverOpen && !open) { - onClose(); - } - }; - - const clearSelection = async () => { - await updateKeyOwner.mutateAsync({ - keyIds: selectedKeyIds, - ownerType: "v2", - identity: { - id: null, - }, - }); - }; - - const totalKeys = selectedKeyIds.length; - const hasKeysWithExternalIds = keysWithExternalIds > 0; - - // Determine what button to show based on whether a new external ID is selected - const showUpdateButton = selectedIdentityId !== null; - - return ( - <> - -
- {showUpdateButton ? ( - - ) : ( - - )} -
- {hasKeysWithExternalIds && ( -
- Note: {keysWithExternalIds} out of {totalKeys} selected{" "} - {totalKeys === 1 ? "key" : "keys"} already{" "} - {keysWithExternalIds === 1 ? "has" : "have"} an External ID -
- )} -
Changes will be applied immediately
-
- } - > - {hasKeysWithExternalIds && ( -
-
- -
-
- Warning:{" "} - {keysWithExternalIds === totalKeys ? ( - <> - All selected keys already have External IDs. Setting a new ID will override the - existing ones. - - ) : ( - <> - Some selected keys already have External IDs. Setting a new ID will override the - existing ones. - - )} -
-
- )} -
- -
-
- 1 ? "IDs" : "ID"}`} - description={`This will remove the External ID association from ${keysWithExternalIds} ${ - keysWithExternalIds === 1 ? "key" : "keys" - }. Any tracking or analytics related to ${ - keysWithExternalIds === 1 ? "this ID" : "these IDs" - } will no longer be associated with ${ - keysWithExternalIds === 1 ? "this key" : "these keys" - }.`} - confirmButtonText={`Remove External ${keysWithExternalIds > 1 ? "IDs" : "ID"}`} - cancelButtonText="Cancel" - variant="danger" - /> - - ); -}; 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 index 732ae2e1fd..bf1959fd96 100644 --- 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 @@ -1,66 +1,38 @@ +import { AnimatedCounter } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/selection-controls"; import { ConfirmPopover } from "@/components/confirmation-popover"; -import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; -import { ArrowOppositeDirectionY, Ban, CircleCheck, Trash, XMark } from "@unkey/icons"; +import { Trash, XMark } from "@unkey/icons"; import { Button } from "@unkey/ui"; import { AnimatePresence, motion } from "framer-motion"; import { useRef, useState } from "react"; -import { useDeleteKey } from "../actions/components/hooks/use-delete-key"; -import { useBatchUpdateKeyStatus } from "../actions/components/hooks/use-update-key-status"; -import { BatchEditExternalId } from "./components/batch-edit-external-id"; +import { useDeleteRole } from "../actions/components/hooks/use-delete-role"; type SelectionControlsProps = { - selectedKeys: Set; - setSelectedKeys: (keys: Set) => void; - keys: KeyDetails[]; - getSelectedKeysState: () => "all-enabled" | "all-disabled" | "mixed" | null; + selectedRoles: Set; + setSelectedRoles: (keys: Set) => void; }; -export const SelectionControls = ({ - selectedKeys, - keys, - setSelectedKeys, - getSelectedKeysState, -}: SelectionControlsProps) => { - const [isBatchEditExternalIdOpen, setIsBatchEditExternalIdOpen] = useState(false); - const [isDisableConfirmOpen, setIsDisableConfirmOpen] = useState(false); +export const SelectionControls = ({ selectedRoles, setSelectedRoles }: SelectionControlsProps) => { const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); - const disableButtonRef = useRef(null); const deleteButtonRef = useRef(null); - const updateKeyStatus = useBatchUpdateKeyStatus(); - const deleteKey = useDeleteKey(() => { - setSelectedKeys(new Set()); + const deleteRole = useDeleteRole(() => { + setSelectedRoles(new Set()); }); - const handleDisableButtonClick = () => { - setIsDisableConfirmOpen(true); - }; - - const performDisableKeys = () => { - updateKeyStatus.mutate({ - enabled: false, - keyIds: Array.from(selectedKeys), - }); - }; - const handleDeleteButtonClick = () => { setIsDeleteConfirmOpen(true); }; - const performKeyDeletion = () => { - deleteKey.mutate({ - keyIds: Array.from(selectedKeys), + const performRoleDeletion = () => { + deleteRole.mutate({ + roleIds: Array.from(selectedRoles), }); }; - const keysWithExternalIds = keys.filter( - (key) => selectedKeys.has(key.id) && key.identity_id, - ).length; - return ( <> - {selectedKeys.size > 0 && ( + {selectedRoles.size > 0 && (
- +
selected
@@ -92,55 +64,19 @@ export const SelectionControls = ({ variant="outline" size="sm" className="text-gray-12 font-medium text-[13px]" - onClick={() => setIsBatchEditExternalIdOpen(true)} - > - Change External ID - - - - @@ -150,86 +86,19 @@ export const SelectionControls = ({ )} - 1 ? "s" : "" - } and prevent any verification requests from being processed.`} - confirmButtonText="Disable keys" - cancelButtonText="Cancel" - variant="danger" - /> - 1 ? "these keys" : "this key" + selectedRoles.size > 1 ? "these roles" : "this role" } will be permanently deleted.`} - confirmButtonText={`Delete key${selectedKeys.size > 1 ? "s" : ""}`} + confirmButtonText={`Delete role${selectedRoles.size > 1 ? "s" : ""}`} cancelButtonText="Cancel" variant="danger" /> - - {isBatchEditExternalIdOpen && ( - setIsBatchEditExternalIdOpen(false)} - /> - )} ); }; - -const AnimatedDigit = ({ digit, index }: { digit: string; index: number }) => { - return ( - - {digit} - - ); -}; - -const AnimatedCounter = ({ value }: { value: number }) => { - const digits = value.toString().split(""); - - return ( -
- -
- {digits.map((digit, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: - - ))} -
-
-
- ); -}; diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/skeletons.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/skeletons.tsx index adb83ce9b6..502ce24e58 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/skeletons.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/skeletons.tsx @@ -1,4 +1,5 @@ -import { Asterisk, ChartActivity2, Key2, Tag } from "@unkey/icons"; +import { cn } from "@/lib/utils"; +import { Asterisk, ChartActivity2, Dots, Key2, Tag } from "@unkey/icons"; export const RoleColumnSkeleton = () => (
@@ -50,3 +51,15 @@ export const LastUpdatedColumnSkeleton = () => (
); + +export const ActionColumnSkeleton = () => ( + +); 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 cf02c2b60e..015bf55e0d 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 @@ -9,7 +9,9 @@ import { cn } from "@unkey/ui/src/lib/utils"; import { useCallback, useMemo, useState } from "react"; import { getRolesTableActionItems } from "./components/actions/keys-table-action.popover.constants"; import { LastUpdated } from "./components/last-updated"; +import { SelectionControls } from "./components/selection-controls"; import { + ActionColumnSkeleton, AssignedKeysColumnSkeleton, LastUpdatedColumnSkeleton, PermissionsColumnSkeleton, @@ -161,6 +163,9 @@ export const RolesList = () => { hide: isLoading, buttonText: "Load more roles", hasMore, + headerContent: ( + + ), countInfoText: (
Showing {roles.length} @@ -215,6 +220,7 @@ export const RolesList = () => { {column.key === "assignedKeys" && } {column.key === "permissions" && } {column.key === "last_updated" && } + {column.key === "action" && } )) } diff --git a/apps/dashboard/lib/trpc/routers/authorization/roles/delete.ts b/apps/dashboard/lib/trpc/routers/authorization/roles/delete.ts index af21bf7336..71b79a7026 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/roles/delete.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/roles/delete.ts @@ -1,67 +1,122 @@ import { insertAuditLogs } from "@/lib/audit"; -import { and, db, eq, schema } from "@/lib/db"; +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 deleteRole = t.procedure +export const deleteRoleWithRelations = t.procedure .use(requireUser) .use(requireWorkspace) .input( z.object({ - roleId: z.string(), + 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) => { - const role = await tx.query.roles.findFirst({ - where: (table, { and, eq }) => - and(eq(table.workspaceId, ctx.workspace.id), eq(table.id, input.roleId)), + // 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 (!role) { + 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: - "We are unable to find the correct role. Please try again or contact support@unkey.dev.", + 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(eq(schema.roles.id, input.roleId), eq(schema.roles.workspaceId, ctx.workspace.id)), + and( + inArray(schema.roles.id, input.roleIds), + eq(schema.roles.workspaceId, ctx.workspace.id), + ), ) .catch((err) => { - console.error("Failed to delete role:", err); + console.error("Failed to delete roles:", err); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: - "We are unable to delete the role. Please try again or contact support@unkey.dev", + "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 role ${input.roleId}`, - resources: [ - { - type: "role", - id: input.roleId, - name: role.name, - }, - ], + 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(err); + console.error("Failed to create audit log:", err); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: - "We are unable to delete the role. Please try again or contact support@unkey.dev.", + "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/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index e059cc8130..84d52434ad 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -21,6 +21,7 @@ import { updateApiName } from "./api/updateName"; import { fetchAuditLog } from "./audit/fetch"; import { auditLogsSearch } from "./audit/llm-search"; 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 { queryRolesPermissions } from "./authorization/roles/permissions/query-permissions"; @@ -171,7 +172,7 @@ export const router = t.router({ query: queryRolesPermissions, }), upsert: upsertRole, - delete: deleteRole, + delete: deleteRoleWithRelations, connectedKeysAndPerms: getConnectedKeysAndPerms, }), }), From 43b82c979ac90acd3e92c6b68fa4471ae1d249d7 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 4 Jun 2025 18:49:01 +0300 Subject: [PATCH 22/47] feat: add llm search --- .../roles/components/control-cloud/index.tsx | 8 + .../components/logs-filters/index.tsx | 125 +++--- .../controls/components/logs-search/index.tsx | 17 +- .../roles/components/controls/index.tsx | 4 +- .../components/table/query-logs.schema.ts | 15 +- .../authorization/roles/filters.schema.ts | 20 + .../authorization/roles/llm-search/index.ts | 20 + .../authorization/roles/llm-search/utils.ts | 381 ++++++++++++++++++ .../trpc/routers/authorization/roles/query.ts | 316 +++++++++++++-- apps/dashboard/lib/trpc/routers/index.ts | 2 + 10 files changed, 792 insertions(+), 116 deletions(-) create mode 100644 apps/dashboard/lib/trpc/routers/authorization/roles/llm-search/index.ts create mode 100644 apps/dashboard/lib/trpc/routers/authorization/roles/llm-search/utils.ts 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 index 0a80312a52..e73830f412 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/control-cloud/index.tsx @@ -10,6 +10,14 @@ const formatFieldName = (field: string): string => { return "Slug"; case "description": return "Description"; + case "keyName": + return "Key name"; + case "keyId": + return "Key ID"; + case "permissionSlug": + return "Permission slug"; + case "permissionName": + return "Permission name"; default: return field.charAt(0).toUpperCase() + field.slice(1); } 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 index a238b1c536..ddbefe1791 100644 --- 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 @@ -1,80 +1,75 @@ 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 { rolesFilterFieldConfig } from "../../../../filters.schema"; +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(); - const options = rolesFilterFieldConfig.name.operators.map((op) => ({ - id: op, - label: op, - })); - const activeNameFilter = filters.find((f) => f.field === "name"); - const activeDescriptionFilter = filters.find((f) => f.field === "description"); + // 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 ( - { - const activeFiltersWithoutNames = filters.filter((f) => f.field !== "name"); - updateFilters([ - ...activeFiltersWithoutNames, - { - field: "name", - id: crypto.randomUUID(), - operator: id, - value: text, - }, - ]); - }} - /> - ), - }, - { - id: "description", - label: "Description", - shortcut: "d", - component: ( - { - const activeFiltersWithoutDescriptions = filters.filter( - (f) => f.field !== "description", - ); - updateFilters([ - ...activeFiltersWithoutDescriptions, - { - field: "description", - id: crypto.randomUUID(), - operator: id, - value: text, - }, - ]); - }} - /> - ), - }, - ]} - activeFilters={filters} - > +
-
- 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/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/navigation.tsx b/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx deleted file mode 100644 index 3f5837df6b..0000000000 --- a/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"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"; - -export function Navigation({ - numberOfPermissions, -}: { - numberOfPermissions: number; -}) { - const [open, setOpen] = useState(false); - return ( - <> - - }> - - Authorization - - - Permissions - - - - - setOpen(true)} - > - Create New Permission - - - - - - ); -} diff --git a/apps/dashboard/app/(app)/authorization/permissions/page.tsx b/apps/dashboard/app/(app)/authorization/permissions/page.tsx deleted file mode 100644 index 2601f8f713..0000000000 --- a/apps/dashboard/app/(app)/authorization/permissions/page.tsx +++ /dev/null @@ -1,119 +0,0 @@ -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"; -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; - }); - - 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" : ""} - -
    -
    - -
    - - ))} -
- )} -
-
-
-
- ); -} From cc2d04b3415eb45bf151e7f632402383422900fe Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 10 Jun 2025 14:13:25 +0300 Subject: [PATCH 32/47] feat: add initial structrue --- .../permissions/filters.schema.ts | 93 +++++ .../authorization/permissions/navigation.tsx | 18 + .../(app)/authorization/permissions/page.tsx | 15 + .../authorization/permissions/query.ts | 317 ++++++++++++++++++ 4 files changed, 443 insertions(+) create mode 100644 apps/dashboard/app/(app)/authorization/permissions/filters.schema.ts create mode 100644 apps/dashboard/app/(app)/authorization/permissions/navigation.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/page.tsx create mode 100644 apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts 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..a3a8b08caa --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/filters.schema.ts @@ -0,0 +1,93 @@ +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< + typeof permissionsFilterOperatorEnum +>; + +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< + PermissionsFilterField, + PermissionsFilterOperator +>; + +export type PermissionsQuerySearchParams = { + [K in PermissionsFilterField]?: AllOperatorsUrlValue[] | null; +}; + +export const parseAsAllOperatorsFilterArray = + parseAsFilterValueArray([ + ...commonStringOperators, + ]); diff --git a/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx b/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx new file mode 100644 index 0000000000..35b2f6c98b --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx @@ -0,0 +1,18 @@ +"use client"; +import { Navbar } from "@/components/navigation/navbar"; +import { ShieldKey } from "@unkey/icons"; + +export function Navigation() { + return ( + + } 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 new file mode 100644 index 0000000000..11daa8cd69 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/page.tsx @@ -0,0 +1,15 @@ +"use client"; +import { Navigation } from "./navigation"; + +export default function RolesPage() { + return ( +
+ + {/*
*/} + {/* */} + {/* */} + {/* */} + {/*
*/} +
+ ); +} 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..663a35c097 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts @@ -0,0 +1,317 @@ +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(), + assignedRoles: z.object({ + items: z.array(z.string()), + totalCount: z.number().optional(), + }), +}); + +export type Permissions = 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 + 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 + 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 + ) as total_roles, + + -- 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; + 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: Permissions[] = 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) }, + }; + }); + + 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 `)})`; +} From 283f1de317caaaade21f22ff67a866a3a6c424d7 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 10 Jun 2025 14:15:29 +0300 Subject: [PATCH 33/47] chore: run fmt --- .../authorization/_components/rbac-form.tsx | 266 ------------------ .../permissions/filters.schema.ts | 41 +-- .../authorization/permissions/navigation.tsx | 4 +- .../actions/components/delete-role.tsx | 3 +- .../authorization/permissions/query.ts | 16 +- 5 files changed, 17 insertions(+), 313 deletions(-) delete mode 100644 apps/dashboard/app/(app)/authorization/_components/rbac-form.tsx diff --git a/apps/dashboard/app/(app)/authorization/_components/rbac-form.tsx b/apps/dashboard/app/(app)/authorization/_components/rbac-form.tsx deleted file mode 100644 index 10e11bda4a..0000000000 --- a/apps/dashboard/app/(app)/authorization/_components/rbac-form.tsx +++ /dev/null @@ -1,266 +0,0 @@ -"use client"; -import { revalidateTag } from "@/app/actions"; -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, DialogContainer, FormInput, FormTextarea } from "@unkey/ui"; -import { validation } from "@unkey/validation"; -import { useRouter } from "next/navigation"; -import type { PropsWithChildren, ReactNode } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -const formSchema = z.object({ - name: validation.name, - description: validation.description.optional(), -}); - -type FormValues = z.infer; - -type BaseProps = { - isModalOpen: boolean; - onOpenChange: (value: boolean) => void; - title: string; - description?: ReactNode; - buttonText: string; - footerText?: string; - nameDescription?: ReactNode; - descriptionHelp?: string; - namePlaceholder?: string; - descriptionPlaceholder?: string; - formId: string; -}; - -type CreateProps = BaseProps & { - type: "create"; - itemType: "permission" | "role"; - additionalParams?: Record; -}; - -type UpdateProps = BaseProps & { - type: "update"; - itemType: "permission" | "role"; - item: { - id: string; - name: string; - description?: string | null; - }; -}; - -type Props = PropsWithChildren; - -export const RBACForm = (props: Props) => { - const { - isModalOpen, - onOpenChange, - title, - buttonText, - footerText, - nameDescription, - descriptionHelp, - namePlaceholder, - descriptionPlaceholder, - formId, - type, - itemType, - children, - } = props; - - const router = useRouter(); - const { rbac } = trpc.useUtils(); - - // Set default values based on form type - const defaultValues = { - name: props.type === "update" ? props.item.name : "", - description: props.type === "update" ? (props.item.description ?? "") : "", - }; - - const { - register, - handleSubmit, - reset, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(formSchema), - defaultValues, - }); - - // Get the appropriate mutation based on form type and item type - const createPermission = trpc.rbac.createPermission.useMutation({ - onSuccess() { - toast.success("Permission created", { - description: "Your new permission has been successfully created", - }); - handleSuccess(); - }, - onError: handleError, - }); - - const createRole = trpc.rbac.createRole.useMutation({ - onSuccess({ roleId }) { - toast.success("Role created", { - description: "Your new role has been successfully created", - }); - handleSuccess(); - // Special case: navigate to the new role page - router.push(`/authorization/roles/${roleId}`); - }, - onError: handleError, - }); - - const updatePermission = trpc.rbac.updatePermission.useMutation({ - onSuccess() { - toast.success("Permission updated", { - description: "Permission has been successfully updated", - }); - if (props.type === "update") { - revalidateTag(tags.permission(props.item.id)); - } - handleSuccess(); - }, - onError: handleError, - }); - - const updateRole = trpc.rbac.updateRole.useMutation({ - onSuccess() { - toast.success("Role updated", { - description: "Role has been successfully updated", - }); - if (props.type === "update") { - revalidateTag(tags.role(props.item.id)); - } - handleSuccess(); - }, - onError: handleError, - }); - - // Helper functions for success and error handling - function handleSuccess() { - reset(); - onOpenChange(false); - rbac.invalidate(); - router.refresh(); - } - - function handleError(err: { message: string }) { - toast.error(`Failed to ${type} ${itemType}`, { - description: err.message, - }); - } - - // Determine loading state based on active mutation - const isLoading = - (itemType === "permission" && type === "create" && createPermission.isLoading) || - (itemType === "role" && type === "create" && createRole.isLoading) || - (itemType === "permission" && type === "update" && updatePermission.isLoading) || - (itemType === "role" && type === "update" && updateRole.isLoading) || - isSubmitting; - - // Form submission handler - const onSubmitForm = async (values: FormValues) => { - try { - // Carefully handle the description field according to each mutation's requirements - if (type === "create" && itemType === "permission") { - await createPermission.mutateAsync({ - name: values.name, - description: values.description || undefined, - }); - } else if (type === "create" && itemType === "role") { - await createRole.mutateAsync({ - name: values.name, - description: values.description || undefined, - permissionIds: ((props as CreateProps).additionalParams?.permissionIds || []) as string[], - }); - } else if (type === "update" && itemType === "permission") { - await updatePermission.mutateAsync({ - id: (props as UpdateProps).item.id, - name: values.name, - description: values.description || null, - }); - } else if (type === "update" && itemType === "role") { - await updateRole.mutateAsync({ - id: (props as UpdateProps).item.id, - name: values.name, - description: values.description || null, - }); - } - } catch (error) { - console.error("Form submission error:", error); - } - }; - - // Default descriptions based on item type - const defaultNameDescription = - itemType === "permission" ? ( -
- A unique key to identify your permission. We suggest using{" "} - . (dot) separated names, to structure your - hierarchy. For example we use api.create_key{" "} - or api.update_api in our own permissions. -
- ) : ( -
- A unique name for your role. You will use this when managing roles through the API. These - are not customer facing. -
- ); - - const defaultDescriptionHelp = - itemType === "permission" - ? "Add a description to help others understand what this permission allows." - : "Add a description to help others understand what this role represents."; - - const defaultNamePlaceholder = itemType === "permission" ? "domain.create" : "domain.manager"; - const defaultDescriptionPlaceholder = - itemType === "permission" - ? "Create a new domain in this account." - : "Manage domains and DNS records"; - - return ( - <> - {children} - - - {footerText &&
{footerText}
} -
- } - > -
- - - - - - ); -}; diff --git a/apps/dashboard/app/(app)/authorization/permissions/filters.schema.ts b/apps/dashboard/app/(app)/authorization/permissions/filters.schema.ts index a3a8b08caa..5846b0e19b 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/filters.schema.ts +++ b/apps/dashboard/app/(app)/authorization/permissions/filters.schema.ts @@ -1,21 +1,11 @@ -import type { - FilterValue, - StringConfig, -} from "@/components/logs/validation/filter.types"; +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; +const commonStringOperators = ["is", "contains", "startsWith", "endsWith"] as const; export const permissionsFilterOperatorEnum = z.enum(commonStringOperators); -export type PermissionsFilterOperator = z.infer< - typeof permissionsFilterOperatorEnum ->; +export type PermissionsFilterOperator = z.infer; export type FilterFieldConfigs = { description: StringConfig; @@ -49,28 +39,23 @@ export const permissionsFilterFieldConfig: FilterFieldConfigs = { }; const allFilterFieldNames = Object.keys( - permissionsFilterFieldConfig + permissionsFilterFieldConfig, ) as (keyof FilterFieldConfigs)[]; if (allFilterFieldNames.length === 0) { - throw new Error( - "permissionsFilterFieldConfig must contain at least one field definition." - ); + throw new Error("permissionsFilterFieldConfig must contain at least one field definition."); } const [firstFieldName, ...restFieldNames] = allFilterFieldNames; -export const permissionsFilterFieldEnum = z.enum([ - firstFieldName, - ...restFieldNames, -]); +export const permissionsFilterFieldEnum = z.enum([firstFieldName, ...restFieldNames]); export const permissionsListFilterFieldNames = allFilterFieldNames; export type PermissionsFilterField = z.infer; export const filterOutputSchema = createFilterOutputSchema( permissionsFilterFieldEnum, permissionsFilterOperatorEnum, - permissionsFilterFieldConfig + permissionsFilterFieldConfig, ); export type AllOperatorsUrlValue = { @@ -78,16 +63,12 @@ export type AllOperatorsUrlValue = { operator: PermissionsFilterOperator; }; -export type PermissionsFilterValue = FilterValue< - PermissionsFilterField, - PermissionsFilterOperator ->; +export type PermissionsFilterValue = FilterValue; export type PermissionsQuerySearchParams = { [K in PermissionsFilterField]?: AllOperatorsUrlValue[] | null; }; -export const parseAsAllOperatorsFilterArray = - parseAsFilterValueArray([ - ...commonStringOperators, - ]); +export const parseAsAllOperatorsFilterArray = parseAsFilterValueArray([ + ...commonStringOperators, +]); diff --git a/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx b/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx index 35b2f6c98b..330bd59624 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx @@ -6,9 +6,7 @@ export function Navigation() { return ( } className="flex-1 w-full"> - - Authorization - + Authorization Permissions diff --git a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-role.tsx b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-role.tsx index 690b2a2374..65684abf89 100644 --- a/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-role.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/components/table/components/actions/components/delete-role.tsx @@ -1,10 +1,9 @@ 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 { DialogContainer } from "@/components/dialog-container"; import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; import { zodResolver } from "@hookform/resolvers/zod"; import { TriangleWarning2 } from "@unkey/icons"; -import { Button, FormCheckbox } from "@unkey/ui"; +import { Button, DialogContainer, FormCheckbox } from "@unkey/ui"; import { useRef, useState } from "react"; import { Controller, FormProvider, useForm } from "react-hook-form"; import { z } from "zod"; diff --git a/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts b/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts index 663a35c097..91fd4a8e05 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts @@ -1,11 +1,5 @@ import { db, sql } from "@/lib/db"; -import { - ratelimit, - requireUser, - requireWorkspace, - t, - withRatelimit, -} from "@/lib/trpc/trpc"; +import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; import { z } from "zod"; const MAX_ITEMS_TO_SHOW = 3; @@ -138,9 +132,7 @@ export const queryPermissions = t.procedure const permissionsResponseData: Permissions[] = items.map((row) => { // Parse concatenated strings back to arrays const roleItems = row.role_items - ? row.role_items - .split(ITEM_SEPARATOR) - .filter((item) => item.trim() !== "") + ? row.role_items.split(ITEM_SEPARATOR).filter((item) => item.trim() !== "") : []; return { @@ -182,7 +174,7 @@ function buildRoleFilter( }[] | null | undefined, - workspaceId: string + workspaceId: string, ) { const conditions = []; @@ -290,7 +282,7 @@ function buildFilterConditions( }[] | null | undefined, - columnName: string + columnName: string, ) { if (!filters || filters.length === 0) { return sql``; From f79ff470f0f0a72670d3ebfa569f7f818eecd2b9 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 10 Jun 2025 15:13:03 +0300 Subject: [PATCH 34/47] feat: add columns and new sql query --- .../components/control-cloud/index.tsx | 35 +++ .../components/logs-filters/index.tsx | 96 +++++++ .../controls/components/logs-search/index.tsx | 64 +++++ .../permissions/components/controls/index.tsx | 14 + .../actions/components/delete-role.tsx | 159 +++++++++++ .../actions/components/edit-role.tsx | 61 ++++ .../components/hooks/use-delete-role.ts | 58 ++++ .../use-fetch-connected-keys-and-perms.ts | 22 ++ .../actions/components/role-info.tsx | 29 ++ .../keys-table-action.popover.constants.tsx | 61 ++++ .../table/components/assigned-items-cell.tsx | 63 +++++ .../table/components/last-updated.tsx | 47 ++++ .../components/selection-controls/index.tsx | 104 +++++++ .../components/table/components/skeletons.tsx | 65 +++++ .../table/hooks/use-permissions-list-query.ts | 86 ++++++ .../components/table/permissions-list.tsx | 265 ++++++++++++++++++ .../components/table/query-logs.schema.ts | 27 ++ .../components/table/utils/get-row-class.ts | 38 +++ .../permissions/hooks/use-filters.ts | 106 +++++++ .../(app)/authorization/permissions/page.tsx | 11 +- .../(app)/authorization/roles/navigation.tsx | 2 +- .../authorization/permissions/query.ts | 128 +++++---- apps/dashboard/lib/trpc/routers/index.ts | 4 + 23 files changed, 1481 insertions(+), 64 deletions(-) create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/control-cloud/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/controls/components/logs-filters/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/controls/components/logs-search/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/controls/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/delete-role.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/edit-role.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/hooks/use-delete-role.ts create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/hooks/use-fetch-connected-keys-and-perms.ts create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/role-info.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/keys-table-action.popover.constants.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/components/assigned-items-cell.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/components/last-updated.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/components/selection-controls/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/components/skeletons.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/hooks/use-permissions-list-query.ts create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/query-logs.schema.ts create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/utils/get-row-class.ts create mode 100644 apps/dashboard/app/(app)/authorization/permissions/hooks/use-filters.ts 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..45df55b6f4 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/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/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..ddbefe1791 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/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/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..03280904ae --- /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 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/permissions/components/controls/index.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/controls/index.tsx new file mode 100644 index 0000000000..8a6471f97a --- /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 { RolesSearch } from "./components/logs-search"; + +export function RoleListControls() { + return ( + + + + + + + ); +} diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/delete-role.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/delete-role.tsx new file mode 100644 index 0000000000..65684abf89 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/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/permissions/components/table/components/actions/components/edit-role.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/edit-role.tsx new file mode 100644 index 0000000000..adfc3cca12 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/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/permissions/components/table/components/actions/components/hooks/use-delete-role.ts b/apps/dashboard/app/(app)/authorization/permissions/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/permissions/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/permissions/components/table/components/actions/components/hooks/use-fetch-connected-keys-and-perms.ts b/apps/dashboard/app/(app)/authorization/permissions/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/permissions/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/permissions/components/table/components/actions/components/role-info.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/role-info.tsx new file mode 100644 index 0000000000..96571fef32 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/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/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..8fb80b183a --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/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/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..068625af5d --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/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/permissions/components/table/components/skeletons.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/skeletons.tsx new file mode 100644 index 0000000000..69ef3ac60b --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/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/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..ffd8388279 --- /dev/null +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx @@ -0,0 +1,265 @@ +"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 { 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 { 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: "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: "20%", + render: (permission) => { + return ( + + ); + }, + }, + // { + // key: "action", + // header: "", + // width: "15%", + // render: (role) => { + // 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 roles 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 === "assignedKeys" && } + {column.key === "permissions" && } + {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/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/page.tsx b/apps/dashboard/app/(app)/authorization/permissions/page.tsx index 11daa8cd69..06e47cc292 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/page.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/page.tsx @@ -1,15 +1,16 @@ "use client"; +import { PermissionsList } from "./components/table/permissions-list"; import { Navigation } from "./navigation"; export default function RolesPage() { return (
- {/*
*/} - {/* */} - {/* */} - {/* */} - {/*
*/} +
+ {/* */} + {/* */} + +
); } diff --git a/apps/dashboard/app/(app)/authorization/roles/navigation.tsx b/apps/dashboard/app/(app)/authorization/roles/navigation.tsx index 50f30fd559..d6e34308e3 100644 --- a/apps/dashboard/app/(app)/authorization/roles/navigation.tsx +++ b/apps/dashboard/app/(app)/authorization/roles/navigation.tsx @@ -22,7 +22,7 @@ export function Navigation() { } className="flex-1 w-full"> Authorization - + Roles diff --git a/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts b/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts index 91fd4a8e05..281712138b 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/permissions/query.ts @@ -1,3 +1,5 @@ +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"; @@ -12,13 +14,14 @@ export const permissions = z.object({ 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 Permissions = z.infer; +export type Permission = z.infer; const permissionsResponse = z.object({ permissions: z.array(permissions), @@ -47,62 +50,69 @@ export const queryPermissions = t.procedure 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 - 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 - 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 - ) as total_roles, - - -- 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 + 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 { @@ -113,6 +123,7 @@ export const queryPermissions = t.procedure updated_at_m: number; role_items: string | null; total_roles: number; + total_connected_keys: number; grand_total: number; }[]; @@ -129,7 +140,7 @@ export const queryPermissions = t.procedure const hasMore = rows.length > DEFAULT_LIMIT; const items = hasMore ? rows.slice(0, -1) : rows; - const permissionsResponseData: Permissions[] = items.map((row) => { + 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() !== "") @@ -145,6 +156,7 @@ export const queryPermissions = t.procedure row.total_roles <= MAX_ITEMS_TO_SHOW ? { items: roleItems } : { items: roleItems, totalCount: Number(row.total_roles) }, + totalConnectedKeys: Number(row.total_connected_keys) || 0, }; }); diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index b8ed2cda35..c08d377bd6 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -20,6 +20,7 @@ import { updateApiIpWhitelist } from "./api/updateIpWhitelist"; import { updateApiName } from "./api/updateName"; import { fetchAuditLog } from "./audit/fetch"; import { auditLogsSearch } from "./audit/llm-search"; +import { queryPermissions } from "./authorization/permissions/query"; import { getConnectedKeysAndPerms } from "./authorization/roles/connected-keys-and-perms"; import { deleteRoleWithRelations } from "./authorization/roles/delete"; import { queryKeys } from "./authorization/roles/keys/query-keys"; @@ -162,6 +163,9 @@ export const router = t.router({ createIssue: createPlainIssue, }), authorization: t.router({ + permissions: t.router({ + query: queryPermissions, + }), roles: t.router({ query: queryRoles, keys: t.router({ From 40a47279fab36169ff125f953dba5ac357fb3609 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 10 Jun 2025 15:18:35 +0300 Subject: [PATCH 35/47] fix: loaders --- .../keys-table-action.popover.constants.tsx | 26 +++++++++---------- .../components/table/components/skeletons.tsx | 26 +++++++++---------- .../components/table/permissions-list.tsx | 25 +++++++++--------- 3 files changed, 38 insertions(+), 39 deletions(-) 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 index 8fb80b183a..136e7192f4 100644 --- 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 @@ -3,39 +3,39 @@ import type { MenuItem } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_compon 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 type { Permission } from "@/lib/trpc/routers/authorization/permissions/query"; import { Clone, PenWriting3, Trash } from "@unkey/icons"; import { DeleteRole } from "./components/delete-role"; import { EditRole } from "./components/edit-role"; -type RolesTableActionsProps = { - role: Roles; +type PermissionsTableActionsProps = { + permission: Permission; }; -export const RolesTableActions = ({ role }: RolesTableActionsProps) => { +export const PermissionsTableActions = ({ permission }: PermissionsTableActionsProps) => { const trpcUtils = trpc.useUtils(); - const getRolesTableActionItems = (role: Roles): MenuItem[] => { + const getPermissionsTableActionItems = (permission: Permission): MenuItem[] => { return [ { - id: "edit-role", - label: "Edit role...", + id: "edit-permission", + label: "Edit permission...", icon: , - ActionComponent: (props) => , + ActionComponent: (props) => , prefetch: async () => { await trpcUtils.authorization.roles.connectedKeysAndPerms.prefetch({ - roleId: role.roleId, + roleId: permission.roleId, }); }, }, { id: "copy", - label: "Copy role", + label: "Copy permission", className: "mt-1", icon: , onClick: () => { navigator.clipboard - .writeText(JSON.stringify(role)) + .writeText(JSON.stringify(permission)) .then(() => { toast.success("Role data copied to clipboard"); }) @@ -50,12 +50,12 @@ export const RolesTableActions = ({ role }: RolesTableActionsProps) => { id: "delete-role", label: "Delete role", icon: , - ActionComponent: (props) => , + ActionComponent: (props) => , }, ]; }; - const menuItems = getRolesTableActionItems(role); + const menuItems = getPermissionsTableActionItems(permission); return ; }; diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/skeletons.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/skeletons.tsx index 69ef3ac60b..a6faaaab52 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/skeletons.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/skeletons.tsx @@ -1,5 +1,5 @@ import { cn } from "@/lib/utils"; -import { ChartActivity2, Dots, HandHoldingKey, Key2, Tag } from "@unkey/icons"; +import { ChartActivity2, Dots, HandHoldingKey, Tag } from "@unkey/icons"; export const RoleColumnSkeleton = () => (
@@ -16,32 +16,30 @@ export const RoleColumnSkeleton = () => ( ); export const SlugColumnSkeleton = () => ( -
+
+
+ +
+
+
); export const AssignedKeysColumnSkeleton = () => (
- +
- +
); -export const PermissionsColumnSkeleton = () => ( -
-
- -
-
-
- -
-
+export const AssignedToKeysColumnSkeleton = () => ( +
+
); diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx index ffd8388279..87e3ce7b2f 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx @@ -7,14 +7,15 @@ 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, - PermissionsColumnSkeleton, RoleColumnSkeleton, SlugColumnSkeleton, } from "./components/skeletons"; @@ -113,7 +114,7 @@ export const PermissionsList = () => { ), }, { - key: "roles", + key: "used_in_roles", header: "Used in Roles", width: "20%", render: (permission) => ( @@ -169,14 +170,14 @@ export const PermissionsList = () => { ); }, }, - // { - // key: "action", - // header: "", - // width: "15%", - // render: (role) => { - // return ; - // }, - // }, + { + key: "action", + header: "", + width: "15%", + render: (permission) => { + return ; + }, + }, ], [selectedPermissions, toggleSelection, hoveredPermissionName, selectedPermission?.permissionId], ); @@ -253,8 +254,8 @@ export const PermissionsList = () => { > {column.key === "permission" && } {column.key === "slug" && } - {column.key === "assignedKeys" && } - {column.key === "permissions" && } + {column.key === "used_in_roles" && } + {column.key === "assigned_to_keys" && } {column.key === "last_updated" && } {column.key === "action" && } From d0152bad88da00501b5e1286dfaf6ed636ed7ab1 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 10 Jun 2025 15:46:24 +0300 Subject: [PATCH 36/47] feat: add upsert form --- .../actions/components/edit-permission.tsx | 25 ++ .../actions/components/edit-role.tsx | 61 ----- .../keys-table-action.popover.constants.tsx | 29 +-- .../components/selection-controls/index.tsx | 17 +- .../components/table/permissions-list.tsx | 2 +- .../hooks/use-upsert-permission.ts | 57 +++++ .../components/upsert-permission/index.tsx | 229 ++++++++++++++++++ .../upsert-permission.schema.ts | 38 +++ .../authorization/permissions/navigation.tsx | 18 +- .../authorization/permissions/upsert.ts | 201 +++++++++++++++ apps/dashboard/lib/trpc/routers/index.ts | 2 + 11 files changed, 591 insertions(+), 88 deletions(-) create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/edit-permission.tsx delete mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/edit-role.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/hooks/use-upsert-permission.ts create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/index.tsx create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/upsert-permission.schema.ts create mode 100644 apps/dashboard/lib/trpc/routers/authorization/permissions/upsert.ts 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/edit-role.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/edit-role.tsx deleted file mode 100644 index adfc3cca12..0000000000 --- a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/edit-role.tsx +++ /dev/null @@ -1,61 +0,0 @@ -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/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 index 136e7192f4..47a47aae6f 100644 --- 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 @@ -2,31 +2,22 @@ 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 { Permission } from "@/lib/trpc/routers/authorization/permissions/query"; -import { Clone, PenWriting3, Trash } from "@unkey/icons"; -import { DeleteRole } from "./components/delete-role"; -import { EditRole } from "./components/edit-role"; +import { Clone, PenWriting3 } from "@unkey/icons"; +import { EditPermission } from "./components/edit-permission"; type PermissionsTableActionsProps = { permission: Permission; }; export const PermissionsTableActions = ({ permission }: PermissionsTableActionsProps) => { - const trpcUtils = trpc.useUtils(); - const getPermissionsTableActionItems = (permission: Permission): MenuItem[] => { return [ { id: "edit-permission", label: "Edit permission...", icon: , - ActionComponent: (props) => , - prefetch: async () => { - await trpcUtils.authorization.roles.connectedKeysAndPerms.prefetch({ - roleId: permission.roleId, - }); - }, + ActionComponent: (props) => , }, { id: "copy", @@ -46,12 +37,14 @@ export const PermissionsTableActions = ({ permission }: PermissionsTableActionsP }, divider: true, }, - { - id: "delete-role", - label: "Delete role", - icon: , - ActionComponent: (props) => , - }, + // { + // id: "delete-permision", + // label: "Delete permission", + // icon: , + // ActionComponent: (props) => ( + // + // ), + // }, ]; }; 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 index 068625af5d..8bc28bf548 100644 --- 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 @@ -7,11 +7,14 @@ import { useRef, useState } from "react"; import { useDeleteRole } from "../actions/components/hooks/use-delete-role"; type SelectionControlsProps = { - selectedRoles: Set; + selectedPermissions: Set; setSelectedRoles: (keys: Set) => void; }; -export const SelectionControls = ({ selectedRoles, setSelectedRoles }: SelectionControlsProps) => { +export const SelectionControls = ({ + selectedPermissions, + setSelectedRoles, +}: SelectionControlsProps) => { const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); const deleteButtonRef = useRef(null); @@ -25,14 +28,14 @@ export const SelectionControls = ({ selectedRoles, setSelectedRoles }: Selection const performRoleDeletion = () => { deleteRole.mutate({ - roleIds: Array.from(selectedRoles), + roleIds: Array.from(selectedPermissions), }); }; return ( <> - {selectedRoles.size > 0 && ( + {selectedPermissions.size > 0 && (
- +
selected
@@ -93,9 +96,9 @@ export const SelectionControls = ({ selectedRoles, setSelectedRoles }: Selection triggerRef={deleteButtonRef} title="Confirm role deletion" description={`This action is irreversible. All data associated with ${ - selectedRoles.size > 1 ? "these roles" : "this role" + selectedPermissions.size > 1 ? "these roles" : "this role" } will be permanently deleted.`} - confirmButtonText={`Delete role${selectedRoles.size > 1 ? "s" : ""}`} + confirmButtonText={`Delete role${selectedPermissions.size > 1 ? "s" : ""}`} cancelButtonText="Cancel" variant="danger" /> diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx index 87e3ce7b2f..424bf84036 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx @@ -199,7 +199,7 @@ export const PermissionsList = () => { hasMore, headerContent: ( ), 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..0d8092ac4f --- /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/navigation.tsx b/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx index 330bd59624..02de121cbe 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/navigation.tsx @@ -1,6 +1,21 @@ "use client"; +import { NavbarActionButton } from "@/components/navigation/action-button"; import { Navbar } from "@/components/navigation/navbar"; -import { ShieldKey } from "@unkey/icons"; +import { Plus, ShieldKey } from "@unkey/icons"; +import dynamic from "next/dynamic"; + +const UpsertPermissionDialog = dynamic( + () => import("./components/upsert-permission").then((mod) => mod.UpsertPermissionDialog), + { + ssr: false, + loading: () => ( + + + Create new permission + + ), + }, +); export function Navigation() { return ( @@ -11,6 +26,7 @@ export function Navigation() { Permissions + ); } 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/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index c08d377bd6..f7a437d962 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -21,6 +21,7 @@ import { updateApiName } from "./api/updateName"; import { fetchAuditLog } from "./audit/fetch"; import { auditLogsSearch } from "./audit/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"; @@ -165,6 +166,7 @@ export const router = t.router({ authorization: t.router({ permissions: t.router({ query: queryPermissions, + upsert: upsertPermission, }), roles: t.router({ query: queryRoles, From cb1245b5b4d6281552ddcf5465d011096ad7a75e Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 10 Jun 2025 16:05:53 +0300 Subject: [PATCH 37/47] feat: add delete permissin --- ...{delete-role.tsx => delete-permission.tsx} | 62 ++++----- .../components/hooks/use-delete-permission.ts | 59 ++++++++ .../components/hooks/use-delete-role.ts | 58 -------- .../use-fetch-connected-keys-and-perms.ts | 22 --- .../{role-info.tsx => permission-info.tsx} | 22 +-- .../keys-table-action.popover.constants.tsx | 19 ++- .../components/selection-controls/index.tsx | 28 ++-- .../components/table/permissions-list.tsx | 2 +- .../components/upsert-permission/index.tsx | 2 +- .../authorization/permissions/delete.ts | 126 ++++++++++++++++++ apps/dashboard/lib/trpc/routers/index.ts | 2 + 11 files changed, 256 insertions(+), 146 deletions(-) rename apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/{delete-role.tsx => delete-permission.tsx} (68%) create mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/hooks/use-delete-permission.ts delete mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/hooks/use-delete-role.ts delete mode 100644 apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/hooks/use-fetch-connected-keys-and-perms.ts rename apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/{role-info.tsx => permission-info.tsx} (60%) create mode 100644 apps/dashboard/lib/trpc/routers/authorization/permissions/delete.ts diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/delete-role.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/delete-permission.tsx similarity index 68% rename from apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/delete-role.tsx rename to apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/delete-permission.tsx index 65684abf89..88e0bc5bc4 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/delete-role.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/delete-permission.tsx @@ -1,32 +1,34 @@ import type { ActionComponentProps } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/keys-table-action.popover"; import { ConfirmPopover } from "@/components/confirmation-popover"; -import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; +import type { 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 { useDeleteRole } from "./hooks/use-delete-role"; -import { RoleInfo } from "./role-info"; +import { useDeletePermission } from "./hooks/use-delete-permission"; +import { PermissionInfo } from "./permission-info"; -const deleteRoleFormSchema = z.object({ +const deletePermissionFormSchema = z.object({ confirmDeletion: z.boolean().refine((val) => val === true, { - message: "Please confirm that you want to permanently delete this role", + message: "Please confirm that you want to permanently delete this permission", }), }); -type DeleteRoleFormValues = z.infer; +type DeletePermissionFormValues = z.infer; -type DeleteRoleProps = { roleDetails: Roles } & ActionComponentProps; +type DeletePermissionProps = { + permissionDetails: Permission; +} & ActionComponentProps; -export const DeleteRole = ({ roleDetails, isOpen, onClose }: DeleteRoleProps) => { +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(deleteRoleFormSchema), + const methods = useForm({ + resolver: zodResolver(deletePermissionFormSchema), mode: "onChange", shouldFocusError: true, shouldUnregister: true, @@ -43,7 +45,7 @@ export const DeleteRole = ({ roleDetails, isOpen, onClose }: DeleteRoleProps) => const confirmDeletion = watch("confirmDeletion"); - const deleteRole = useDeleteRole(() => { + const deletePermission = useDeletePermission(() => { onClose(); }); @@ -64,14 +66,14 @@ export const DeleteRole = ({ roleDetails, isOpen, onClose }: DeleteRoleProps) => setIsConfirmPopoverOpen(true); }; - const performRoleDeletion = async () => { + const performPermissionDeletion = async () => { try { setIsLoading(true); - await deleteRole.mutateAsync({ - roleIds: roleDetails.roleId, + await deletePermission.mutateAsync({ + permissionIds: permissionDetails.permissionId, }); } catch { - // `useDeleteRole` already shows a toast, but we still need to + // `useDeletePermission` already shows a toast, but we still need to // prevent unhandled‐rejection noise in the console. } finally { setIsLoading(false); @@ -81,17 +83,17 @@ export const DeleteRole = ({ roleDetails, isOpen, onClose }: DeleteRoleProps) => return ( <> -
+
Changes may take up to 60s to propagate globally @@ -109,7 +111,7 @@ export const DeleteRole = ({ roleDetails, isOpen, onClose }: DeleteRoleProps) =>
} > - +
@@ -118,10 +120,10 @@ export const DeleteRole = ({ roleDetails, isOpen, onClose }: DeleteRoleProps) =>
- 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. + 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.
size="md" checked={field.value} onCheckedChange={field.onChange} - label="I understand this will permanently delete the role and detach it from all assigned keys and permissions" + label="I understand this will permanently delete the permission and detach it from all assigned keys and roles" error={errors.confirmDeletion?.message} /> )} @@ -146,11 +148,11 @@ export const DeleteRole = ({ roleDetails, isOpen, onClose }: DeleteRoleProps) => 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/hooks/use-delete-role.ts b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/hooks/use-delete-role.ts deleted file mode 100644 index ec9e18b04a..0000000000 --- a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/hooks/use-delete-role.ts +++ /dev/null @@ -1,58 +0,0 @@ -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/permissions/components/table/components/actions/components/hooks/use-fetch-connected-keys-and-perms.ts b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/hooks/use-fetch-connected-keys-and-perms.ts deleted file mode 100644 index 4b43c320fe..0000000000 --- a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/hooks/use-fetch-connected-keys-and-perms.ts +++ /dev/null @@ -1,22 +0,0 @@ -"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/permissions/components/table/components/actions/components/role-info.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/permission-info.tsx similarity index 60% rename from apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/role-info.tsx rename to apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/permission-info.tsx index 96571fef32..3e021f77f9 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/role-info.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/actions/components/permission-info.tsx @@ -1,26 +1,28 @@ -import type { Roles } from "@/lib/trpc/routers/authorization/roles/query"; -import { Key2 } from "@unkey/icons"; +import type { Permission } from "@/lib/trpc/routers/authorization/permissions/query"; +import { HandHoldingKey } from "@unkey/icons"; import { InfoTooltip } from "@unkey/ui"; -export const RoleInfo = ({ roleDetails }: { roleDetails: Roles }) => { +export const PermissionInfo = ({ + permissionDetails, +}: { + permissionDetails: Permission; +}) => { return (
- +
-
- {roleDetails.name ?? "Unnamed Role"} -
+
{permissionDetails.name}
- {roleDetails.description} + {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 index 47a47aae6f..4ddf2aeda6 100644 --- 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 @@ -3,7 +3,8 @@ import type { MenuItem } from "@/app/(app)/apis/[apiId]/keys/[keyAuthId]/_compon 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 } from "@unkey/icons"; +import { Clone, PenWriting3, Trash } from "@unkey/icons"; +import { DeletePermission } from "./components/delete-permission"; import { EditPermission } from "./components/edit-permission"; type PermissionsTableActionsProps = { @@ -28,7 +29,7 @@ export const PermissionsTableActions = ({ permission }: PermissionsTableActionsP navigator.clipboard .writeText(JSON.stringify(permission)) .then(() => { - toast.success("Role data copied to clipboard"); + toast.success("Permission data copied to clipboard"); }) .catch((error) => { console.error("Failed to copy to clipboard:", error); @@ -37,14 +38,12 @@ export const PermissionsTableActions = ({ permission }: PermissionsTableActionsP }, divider: true, }, - // { - // id: "delete-permision", - // label: "Delete permission", - // icon: , - // ActionComponent: (props) => ( - // - // ), - // }, + { + id: "delete-permision", + label: "Delete permission", + icon: , + ActionComponent: (props) => , + }, ]; }; 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 index 8bc28bf548..27d1377b45 100644 --- 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 @@ -4,31 +4,31 @@ 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"; +import { useDeletePermission } from "../actions/components/hooks/use-delete-permission"; type SelectionControlsProps = { selectedPermissions: Set; - setSelectedRoles: (keys: Set) => void; + setSelectedPermissions: (keys: Set) => void; }; export const SelectionControls = ({ selectedPermissions, - setSelectedRoles, + setSelectedPermissions, }: SelectionControlsProps) => { const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); const deleteButtonRef = useRef(null); - const deleteRole = useDeleteRole(() => { - setSelectedRoles(new Set()); + const deletePermission = useDeletePermission(() => { + setSelectedPermissions(new Set()); }); const handleDeleteButtonClick = () => { setIsDeleteConfirmOpen(true); }; - const performRoleDeletion = () => { - deleteRole.mutate({ - roleIds: Array.from(selectedPermissions), + const performPermissionDelete = () => { + deletePermission.mutate({ + permissionIds: Array.from(selectedPermissions), }); }; @@ -67,8 +67,8 @@ export const SelectionControls = ({ variant="outline" size="sm" className="text-gray-12 font-medium text-[13px]" - disabled={deleteRole.isLoading} - loading={deleteRole.isLoading} + disabled={deletePermission.isLoading} + loading={deletePermission.isLoading} onClick={handleDeleteButtonClick} ref={deleteButtonRef} > @@ -79,7 +79,7 @@ export const SelectionControls = ({ size="icon" variant="ghost" className="[&_svg]:size-[14px] ml-3" - onClick={() => setSelectedRoles(new Set())} + onClick={() => setSelectedPermissions(new Set())} > @@ -92,13 +92,13 @@ export const SelectionControls = ({ 1 ? "these roles" : "this role" + selectedPermissions.size > 1 ? "these permissions" : "this permission" } will be permanently deleted.`} - confirmButtonText={`Delete role${selectedPermissions.size > 1 ? "s" : ""}`} + confirmButtonText={`Delete permission${selectedPermissions.size > 1 ? "s" : ""}`} cancelButtonText="Cancel" variant="danger" /> diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx index 424bf84036..42a561fa22 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx @@ -200,7 +200,7 @@ export const PermissionsList = () => { headerContent: ( ), countInfoText: ( 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 index 0d8092ac4f..abd5bc2001 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/index.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/index.tsx @@ -203,7 +203,7 @@ export const UpsertPermissionDialog = ({ placeholder="manage.domains" label="Slug" maxLength={50} - description="A unique identifier used in code. These are not customer facing." + description="A unique identifier used in code." error={errors.slug?.message} variant="default" required 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/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index f7a437d962..83f1d711a4 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -20,6 +20,7 @@ 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 { queryPermissions } from "./authorization/permissions/query"; import { upsertPermission } from "./authorization/permissions/upsert"; import { getConnectedKeysAndPerms } from "./authorization/roles/connected-keys-and-perms"; @@ -167,6 +168,7 @@ export const router = t.router({ permissions: t.router({ query: queryPermissions, upsert: upsertPermission, + delete: deletePermissionWithRelations, }), roles: t.router({ query: queryRoles, From b7a97ea012d185dca7be983bd1501b18fe82e548 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 10 Jun 2025 16:13:54 +0300 Subject: [PATCH 38/47] feat: add filters --- .../components/control-cloud/index.tsx | 15 +++++++-------- .../components/logs-filters/index.tsx | 19 +++++++++---------- .../controls/components/logs-search/index.tsx | 2 +- .../permissions/components/controls/index.tsx | 6 +++--- .../components/table/permissions-list.tsx | 2 +- .../(app)/authorization/permissions/page.tsx | 6 ++++-- .../roles/components/table/roles-list.tsx | 2 +- 7 files changed, 26 insertions(+), 26 deletions(-) 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 index 45df55b6f4..e35d0135e3 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/components/control-cloud/index.tsx @@ -1,26 +1,25 @@ import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; import { ControlCloud } from "@/components/logs/control-cloud"; -import type { RolesFilterField } from "../../filters.schema"; +import type { PermissionsFilterField } from "../../filters.schema"; import { useFilters } from "../../hooks/use-filters"; -const FIELD_DISPLAY_NAMES: Record = { +const FIELD_DISPLAY_NAMES: Record = { name: "Name", description: "Description", - permissionSlug: "Permission slug", - permissionName: "Permission name", - keyId: "Key ID", - keyName: "Key name", + 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 RolesFilterField]; + return FIELD_DISPLAY_NAMES[field as PermissionsFilterField]; } return field.charAt(0).toUpperCase() + field.slice(1); }; -export const RolesListControlCloud = () => { +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 index ddbefe1791..49848e5286 100644 --- 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 @@ -4,27 +4,26 @@ import { BarsFilter } from "@unkey/icons"; import { Button } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; import { - type RolesFilterField, - rolesFilterFieldConfig, - rolesListFilterFieldNames, + type PermissionsFilterField, + permissionsFilterFieldConfig, + permissionsListFilterFieldNames, } from "../../../../filters.schema"; import { useFilters } from "../../../../hooks/use-filters"; -const FIELD_DISPLAY_CONFIG: Record = { +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" }, + 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 = rolesListFilterFieldNames.map((fieldName) => { - const fieldConfig = rolesFilterFieldConfig[fieldName]; + const filterItems = permissionsListFilterFieldNames.map((fieldName) => { + const fieldConfig = permissionsFilterFieldConfig[fieldName]; const displayConfig = FIELD_DISPLAY_CONFIG[fieldName]; if (!displayConfig) { 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 index 03280904ae..d617e937e9 100644 --- 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 @@ -4,7 +4,7 @@ import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; import { useFilters } from "../../../../hooks/use-filters"; -export const RolesSearch = () => { +export const PermissionSearch = () => { const { filters, updateFilters } = useFilters(); const queryLLMForStructuredOutput = trpc.authorization.roles.llmSearch.useMutation({ diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/controls/index.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/controls/index.tsx index 8a6471f97a..b94665b9eb 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/controls/index.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/components/controls/index.tsx @@ -1,12 +1,12 @@ import { ControlsContainer, ControlsLeft } from "@/components/logs/controls-container"; import { LogsFilters } from "./components/logs-filters"; -import { RolesSearch } from "./components/logs-search"; +import { PermissionSearch } from "./components/logs-search"; -export function RoleListControls() { +export function PermissionListControls() { return ( - + diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx index 42a561fa22..dcefa7e928 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/permissions-list.tsx @@ -160,7 +160,7 @@ export const PermissionsList = () => { { key: "last_updated", header: "Last Updated", - width: "20%", + width: "12%", render: (permission) => { return (
- {/* */} - {/* */} + +
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 ( Date: Tue, 10 Jun 2025 16:22:13 +0300 Subject: [PATCH 39/47] feat: add llm search --- .../controls/components/logs-search/index.tsx | 2 +- .../components/selection-controls/index.tsx | 2 +- .../components/table/permissions-list.tsx | 4 +- .../permissions/llm-search/index.ts | 20 + .../permissions/llm-search/utils.ts | 390 ++++++++++++++++++ apps/dashboard/lib/trpc/routers/index.ts | 2 + 6 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 apps/dashboard/lib/trpc/routers/authorization/permissions/llm-search/index.ts create mode 100644 apps/dashboard/lib/trpc/routers/authorization/permissions/llm-search/utils.ts 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 index d617e937e9..88ebd2ccbc 100644 --- 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 @@ -7,7 +7,7 @@ import { useFilters } from "../../../../hooks/use-filters"; export const PermissionSearch = () => { const { filters, updateFilters } = useFilters(); - const queryLLMForStructuredOutput = trpc.authorization.roles.llmSearch.useMutation({ + const queryLLMForStructuredOutput = trpc.authorization.permissions.llmSearch.useMutation({ onSuccess(data) { if (data?.filters.length === 0 || !data) { toast.error( 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 index 27d1377b45..26aaddfa41 100644 --- 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 @@ -73,7 +73,7 @@ export const SelectionControls = ({ ref={deleteButtonRef} > - Delete roles + Delete permissions + {footerText && ( +
{footerText}
+ )} +
+ } + > + + + + + + + ); +}; From 5e196661441a862a964543e8a30f99a7dd32ff9d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 10 Jun 2025 13:30:18 +0000 Subject: [PATCH 41/47] [autofix.ci] apply automated fixes --- .../authorization/_components/rbac-form.tsx | 42 ++++++------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/apps/dashboard/app/(app)/authorization/_components/rbac-form.tsx b/apps/dashboard/app/(app)/authorization/_components/rbac-form.tsx index 25da6a97cf..10e11bda4a 100644 --- a/apps/dashboard/app/(app)/authorization/_components/rbac-form.tsx +++ b/apps/dashboard/app/(app)/authorization/_components/rbac-form.tsx @@ -73,7 +73,7 @@ export const RBACForm = (props: Props) => { // Set default values based on form type const defaultValues = { name: props.type === "update" ? props.item.name : "", - description: props.type === "update" ? props.item.description ?? "" : "", + description: props.type === "update" ? (props.item.description ?? "") : "", }; const { @@ -151,13 +151,9 @@ export const RBACForm = (props: Props) => { // Determine loading state based on active mutation const isLoading = - (itemType === "permission" && - type === "create" && - createPermission.isLoading) || + (itemType === "permission" && type === "create" && createPermission.isLoading) || (itemType === "role" && type === "create" && createRole.isLoading) || - (itemType === "permission" && - type === "update" && - updatePermission.isLoading) || + (itemType === "permission" && type === "update" && updatePermission.isLoading) || (itemType === "role" && type === "update" && updateRole.isLoading) || isSubmitting; @@ -174,8 +170,7 @@ export const RBACForm = (props: Props) => { await createRole.mutateAsync({ name: values.name, description: values.description || undefined, - permissionIds: ((props as CreateProps).additionalParams - ?.permissionIds || []) as string[], + permissionIds: ((props as CreateProps).additionalParams?.permissionIds || []) as string[], }); } else if (type === "update" && itemType === "permission") { await updatePermission.mutateAsync({ @@ -200,16 +195,14 @@ export const RBACForm = (props: Props) => { itemType === "permission" ? (
A unique key to identify your permission. We suggest using{" "} - . (dot) separated names, - to structure your hierarchy. For example we use{" "} - api.create_key or{" "} - api.update_api in our - own permissions. + . (dot) separated names, to structure your + hierarchy. For example we use api.create_key{" "} + or api.update_api in our own permissions.
) : (
- A unique name for your role. You will use this when managing roles - through the API. These are not customer facing. + A unique name for your role. You will use this when managing roles through the API. These + are not customer facing.
); @@ -218,8 +211,7 @@ export const RBACForm = (props: Props) => { ? "Add a description to help others understand what this permission allows." : "Add a description to help others understand what this role represents."; - const defaultNamePlaceholder = - itemType === "permission" ? "domain.create" : "domain.manager"; + const defaultNamePlaceholder = itemType === "permission" ? "domain.create" : "domain.manager"; const defaultDescriptionPlaceholder = itemType === "permission" ? "Create a new domain in this account." @@ -245,17 +237,11 @@ export const RBACForm = (props: Props) => { > {buttonText} - {footerText && ( -
{footerText}
- )} + {footerText &&
{footerText}
}
} > -
+ { error={errors.description?.message} {...register("description")} rows={3} - placeholder={ - descriptionPlaceholder || defaultDescriptionPlaceholder - } + placeholder={descriptionPlaceholder || defaultDescriptionPlaceholder} /> From 4db9ba1a3f5470e4fefba3a46d0123664bcd1585 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 11 Jun 2025 20:13:57 +0300 Subject: [PATCH 42/47] fix: coderabbit issues --- .../actions/components/permission-info.tsx | 15 +++------------ .../roles/permissions/schema-with-helpers.ts | 17 +++++++++-------- 2 files changed, 12 insertions(+), 20 deletions(-) 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 index 3e021f77f9..f8bed2ef48 100644 --- 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 @@ -1,6 +1,5 @@ import type { Permission } from "@/lib/trpc/routers/authorization/permissions/query"; import { HandHoldingKey } from "@unkey/icons"; -import { InfoTooltip } from "@unkey/ui"; export const PermissionInfo = ({ permissionDetails, @@ -14,17 +13,9 @@ export const PermissionInfo = ({
{permissionDetails.name}
- -
- {permissionDetails.description} -
-
+
+ {permissionDetails.description} +
); 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, })), }); From 6f87d118dfaa77c71d522cf866124ed158e4de2b Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 11 Jun 2025 20:25:01 +0300 Subject: [PATCH 43/47] fix: some minor issues --- .../controls/components/logs-search/index.tsx | 12 +- .../components/table/permissions-list.tsx | 2 +- .../(app)/authorization/permissions/page.tsx | 2 +- .../permissions/llm-search/utils.ts | 318 ++---------------- 4 files changed, 36 insertions(+), 298 deletions(-) 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 index 88ebd2ccbc..adde229294 100644 --- 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 @@ -45,12 +45,12 @@ export const PermissionSearch = () => { return ( { onLoadMore={loadMore} columns={columns} onRowClick={setSelectedPermission} - selectedItem={setSelectedPermission} + selectedItem={selectedPermission} keyExtractor={(permission) => permission.permissionId} rowClassName={(permission) => getRowClassName(permission, selectedPermission)} loadMoreFooterProps={{ diff --git a/apps/dashboard/app/(app)/authorization/permissions/page.tsx b/apps/dashboard/app/(app)/authorization/permissions/page.tsx index 28f89e7905..af4651c677 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/page.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/page.tsx @@ -4,7 +4,7 @@ import { PermissionListControls } from "./components/controls"; import { PermissionsList } from "./components/table/permissions-list"; import { Navigation } from "./navigation"; -export default function RolesPage() { +export default function PermissionsPage() { return (
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 index 72906deb4e..6013f10321 100644 --- a/apps/dashboard/lib/trpc/routers/authorization/permissions/llm-search/utils.ts +++ b/apps/dashboard/lib/trpc/routers/authorization/permissions/llm-search/utils.ts @@ -81,310 +81,48 @@ 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" : ""}`; + return `- ${field}: ${operators}`; }) .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. + return `Convert natural language queries into permission filters. Use context to infer the correct field and operator. -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" } - ] - } -] +FIELD OPERATORS: +${operatorsByField} -Query: "find permissions with slugs containing user" -Result: [ - { - field: "slug", - filters: [ - { operator: "contains", value: "user" } - ] - } -] +OPERATOR RULES: +- "is": exact matches (IDs, specific slugs like "api.read") +- "contains": partial matches (names, descriptions, general terms) +- "startsWith/endsWith": prefix/suffix patterns -Query: "show permissions ending with .create" -Result: [ - { - field: "slug", - filters: [ - { operator: "endsWith", value: ".create" } - ] - } -] +EXAMPLES: -# Role-based Permission Searches -Query: "permissions assigned to admin role" -Result: [ - { - field: "roleName", - filters: [ - { operator: "contains", value: "admin" } - ] - } -] +Query: "admin permissions" +→ [{"field": "name", "filters": [{"operator": "contains", "value": "admin"}]}] -Query: "find permissions for role_123" -Result: [ - { - field: "roleId", - filters: [ - { operator: "is", value: "role_123" } - ] - } -] +Query: "api.read permission" +→ [{"field": "slug", "filters": [{"operator": "is", "value": "api.read"}]}] -Query: "permissions for moderator and editor roles" -Result: [ - { - field: "roleName", - filters: [ - { operator: "contains", value: "moderator" }, - { operator: "contains", value: "editor" } - ] - } -] +Query: "permissions for database access" +→ [{"field": "description", "filters": [{"operator": "contains", "value": "database"}]}] -# Complex Combinations Query: "admin permissions with database access" -Result: [ - { - field: "name", - filters: [ - { operator: "contains", value: "admin" } - ] - }, - { - field: "description", - filters: [ - { operator: "contains", value: "database" } - ] - } +→ [ + {"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: "permissions assigned to admin role" +→ [{"field": "roleName", "filters": [{"operator": "contains", "value": "admin"}]}] -Query: "find read and write permissions" -Result: [ - { - field: "name", - filters: [ - { operator: "contains", value: "read" }, - { operator: "contains", value: "write" } - ] - } -] +Query: "permissions starting with api_" +→ [{"field": "name", "filters": [{"operator": "startsWith", "value": "api_"}]}] -# Ambiguity Resolution Examples -Query: "api permissions" -Result: [ - { - field: "name", - filters: [{ - operator: "contains", - value: "api" - }] - }, - { - field: "description", - filters: [{ - operator: "contains", - 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 -Query: "user management permissions" -Result: [ - { - field: "name", - filters: [{ - operator: "contains", - value: "user" - }] - }, - { - field: "description", - filters: [{ - operator: "contains", - value: "management" - }] - } -]`; +OUTPUT: Always return valid filters with field, operator, and non-empty value.`; }; From 8e6f0a6cf5e10a3df76e18fd8c3a7b55a3190047 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 11 Jun 2025 20:36:32 +0300 Subject: [PATCH 44/47] fix: typo --- .../components/table/components/selection-controls/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 26aaddfa41..d8fc0a969f 100644 --- 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 @@ -94,7 +94,7 @@ export const SelectionControls = ({ onOpenChange={setIsDeleteConfirmOpen} onConfirm={performPermissionDelete} triggerRef={deleteButtonRef} - title="Confirm role deletion" + title="Confirm permission deletion" description={`This action is irreversible. All data associated with ${ selectedPermissions.size > 1 ? "these permissions" : "this permission" } will be permanently deleted.`} From 606d7cb1253e4ef99d9cf6f49707242cf8cf6856 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 11 Jun 2025 20:39:00 +0300 Subject: [PATCH 45/47] fix: typo --- .../components/table/components/assigned-items-cell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/assigned-items-cell.tsx b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/assigned-items-cell.tsx index 976f8f40d7..a6fd24aa8c 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/table/components/assigned-items-cell.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/components/table/components/assigned-items-cell.tsx @@ -54,7 +54,7 @@ export const AssignedItemsCell = ({ {hasMore && (
- {totalCount - items.length} more roles... + {totalCount - items.length} more permissions...
)} From bd3f4ed371d083e80cf8225aaa8034c0dfe79f91 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 17:35:57 +0000 Subject: [PATCH 46/47] [autofix.ci] apply automated fixes --- packages/hono/package.json | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) 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", From 3f9dea5c4e2d53d98100588320026da97b33b73f Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 17 Jun 2025 20:45:31 +0300 Subject: [PATCH 47/47] fix: pr comments --- .../actions/components/delete-permission.tsx | 11 ++--------- .../components/upsert-permission/index.tsx | 16 +++------------- .../upsert-permission.schema.ts | 1 - packages/hono/package.json | 11 ++--------- 4 files changed, 7 insertions(+), 32 deletions(-) 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 index 88e0bc5bc4..9409a4fd06 100644 --- 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 @@ -50,15 +50,8 @@ export const DeletePermission = ({ permissionDetails, isOpen, onClose }: DeleteP }); 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(); - } + if (!open && !isConfirmPopoverOpen) { + onClose(); } }; 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 index abd5bc2001..52778c8686 100644 --- a/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/index.tsx +++ b/apps/dashboard/app/(app)/authorization/permissions/components/upsert-permission/index.tsx @@ -94,19 +94,9 @@ export const UpsertPermissionDialog = ({ } 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()); - } + const hasSavedData = await loadSavedValues(); + if (!hasSavedData) { + reset(getDefaultValues(existingPermission)); } }; 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 index 3ecf8f5bfe..eed174318d 100644 --- 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 @@ -2,7 +2,6 @@ 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$/), { 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",