From 784ad813eccc8735df9cb23fb789e829bca10d4e Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Fri, 24 Jul 2026 23:49:54 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(roles):=20=E2=9C=A8=20add=20delete=20r?= =?UTF-8?q?ole=20functionality=20with=20confirmation=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/locales/@vitnode/core/en.json | 10 + apps/docs/src/locales/@vitnode/core/en.json | 10 + .../modules/admin/roles/roles.admin.module.ts | 2 + .../admin/roles/routes/delete.route.ts | 176 ++++++++++++ packages/vitnode/src/locales/en.json | 10 + .../core/users/roles/roles-admin-view.tsx | 6 +- .../roles/table/actions/delete-action.tsx | 252 ++++++++++++++++++ .../actions/delete-role.action.server.ts | 35 +++ .../users/roles/table/actions/row-actions.tsx | 32 +++ .../actions/search-roles.action.server.ts | 40 +++ 10 files changed, 570 insertions(+), 3 deletions(-) create mode 100644 packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts create mode 100644 packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-action.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-role.action.server.ts create mode 100644 packages/vitnode/src/views/admin/views/core/users/roles/table/actions/row-actions.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/users/roles/table/actions/search-roles.action.server.ts diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index 528a31ba2..837c7b0c9 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -568,6 +568,16 @@ "submit": "Save changes", "success": "Role updated" }, + "delete": { + "title": "Delete role", + "desc": "Are you sure you want to delete the role \"{name}\"? This action cannot be undone.", + "descWithUsers": "The role \"{name}\" is assigned to {count, plural, one {# user} other {# users}}. Choose another role to move them into before deleting. This action cannot be undone.", + "moveToLabel": "Move users to", + "selectRole": "Select a role...", + "confirm": "Delete role", + "cancel": "Cancel", + "success": "Role deleted" + }, "tabs": { "general": "General", "content": "Content" diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index 528a31ba2..837c7b0c9 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -568,6 +568,16 @@ "submit": "Save changes", "success": "Role updated" }, + "delete": { + "title": "Delete role", + "desc": "Are you sure you want to delete the role \"{name}\"? This action cannot be undone.", + "descWithUsers": "The role \"{name}\" is assigned to {count, plural, one {# user} other {# users}}. Choose another role to move them into before deleting. This action cannot be undone.", + "moveToLabel": "Move users to", + "selectRole": "Select a role...", + "confirm": "Delete role", + "cancel": "Cancel", + "success": "Role deleted" + }, "tabs": { "general": "General", "content": "Content" diff --git a/packages/vitnode/src/api/modules/admin/roles/roles.admin.module.ts b/packages/vitnode/src/api/modules/admin/roles/roles.admin.module.ts index 9b794a9b9..9f6062812 100644 --- a/packages/vitnode/src/api/modules/admin/roles/roles.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/roles/roles.admin.module.ts @@ -2,6 +2,7 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; import { createRoleAdminRoute } from "./routes/create.route"; +import { deleteRoleAdminRoute } from "./routes/delete.route"; import { listRolesAdminRoute } from "./routes/list.route"; import { showRoleAdminRoute } from "./routes/show.route"; import { updateRoleAdminRoute } from "./routes/update.route"; @@ -14,5 +15,6 @@ export const rolesAdminModule = buildModule({ showRoleAdminRoute, createRoleAdminRoute, updateRoleAdminRoute, + deleteRoleAdminRoute, ], }); diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts new file mode 100644 index 000000000..cf2768c9b --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts @@ -0,0 +1,176 @@ +import { z } from "@hono/zod-openapi"; +import { and, count, eq, inArray } from "drizzle-orm"; + +import { buildRoute } from "@/api/lib/route"; +import { assertCanAssignPrimaryRole } from "@/api/modules/admin/users/lib/assert-edit-user-permission"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_languages_words } from "@/database/languages"; +import { core_roles } from "@/database/roles"; +import { core_users, core_users_secondary_roles } from "@/database/users"; + +export const deleteRoleAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "delete", + description: "Delete a role by id (Admin only)", + path: "/{id}", + request: { + params: z.object({ + id: z.string().openapi({ example: "1" }), + }), + query: z.object({ + // When the role still has members, they must be reassigned to this + // role before it can be removed (see the handler for why). + moveToRoleId: z.string().optional(), + }), + }, + responses: { + 200: { + description: "Role deleted", + }, + 400: { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "Role still has members or an invalid target was given", + }, + 403: { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "Role cannot be deleted", + }, + 404: { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "Role not found", + }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const { moveToRoleId } = c.req.valid("query"); + const db = c.get("db"); + + const roleId = Number(id); + if (!Number.isInteger(roleId)) { + return c.json({ error: "Role not found" }, 404); + } + + const [role] = await db + .select({ + id: core_roles.id, + protected: core_roles.protected, + default: core_roles.default, + root: core_roles.root, + guest: core_roles.guest, + }) + .from(core_roles) + .where(eq(core_roles.id, roleId)) + .limit(1); + + if (!role) { + return c.json({ error: "Role not found" }, 404); + } + + // System roles are managed by the platform (root/guest access, the default + // role for new sign-ups) and must always exist. + if (role.protected || role.default || role.root || role.guest) { + return c.json({ error: "This role cannot be deleted" }, 403); + } + + // Members point at their primary role through a NOT NULL, RESTRICT foreign + // key, so anyone in this role has to be moved elsewhere first. Secondary + // roles and staff permission entries reference the role with ON DELETE + // CASCADE, so those clean themselves up. + const [{ total: usersCount }] = await db + .select({ total: count() }) + .from(core_users) + .where(eq(core_users.roleId, roleId)); + + let targetRoleId: null | number = null; + if (usersCount > 0) { + if (moveToRoleId === undefined) { + return c.json({ error: "Role has assigned users" }, 400); + } + + targetRoleId = Number(moveToRoleId); + if (!Number.isInteger(targetRoleId) || targetRoleId === roleId) { + return c.json({ error: "Invalid target role" }, 400); + } + + const [target] = await db + .select({ id: core_roles.id, guest: core_roles.guest }) + .from(core_roles) + .where(eq(core_roles.id, targetRoleId)) + .limit(1); + + // The guest role can never be a user's primary role, so it is not a valid + // move target. + if (!target || target.guest) { + return c.json({ error: "Invalid target role" }, 400); + } + + // Moving members into an admin-granting role would make them admins, so + // it needs the same permission as promoting a user to admin - a + // `can_edit`-only admin must not be able to escalate members this way. + await assertCanAssignPrimaryRole(c, targetRoleId); + } + + await db.transaction(async tx => { + if (targetRoleId != null) { + // Grab the members before the move so we can drop any secondary-role + // rows that would collide with their new primary role. + const members = await tx + .select({ id: core_users.id }) + .from(core_users) + .where(eq(core_users.roleId, roleId)); + const memberIds = members.map(member => member.id); + + await tx + .update(core_users) + .set({ roleId: targetRoleId }) + .where(eq(core_users.roleId, roleId)); + + // A role cannot sit in both the primary and secondary slot for the same + // user, so remove the now-duplicate secondary entries. + if (memberIds.length > 0) { + await tx + .delete(core_users_secondary_roles) + .where( + and( + eq(core_users_secondary_roles.roleId, targetRoleId), + inArray(core_users_secondary_roles.userId, memberIds), + ), + ); + } + } + + // Role names live in `core_languages_words`, so remove them here to avoid + // leaving orphaned translations behind. + await tx + .delete(core_languages_words) + .where( + and( + eq(core_languages_words.pluginCode, "core"), + eq(core_languages_words.tableName, "core_roles"), + eq(core_languages_words.variable, "name"), + eq(core_languages_words.itemId, roleId), + ), + ); + + await tx.delete(core_roles).where(eq(core_roles.id, roleId)); + }); + + await c.get("events").emit("role.deleted", { roleId }); + + return c.body(null, 200); + }, +}); diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 528a31ba2..837c7b0c9 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -568,6 +568,16 @@ "submit": "Save changes", "success": "Role updated" }, + "delete": { + "title": "Delete role", + "desc": "Are you sure you want to delete the role \"{name}\"? This action cannot be undone.", + "descWithUsers": "The role \"{name}\" is assigned to {count, plural, one {# user} other {# users}}. Choose another role to move them into before deleting. This action cannot be undone.", + "moveToLabel": "Move users to", + "selectRole": "Select a role...", + "confirm": "Delete role", + "cancel": "Cancel", + "success": "Role deleted" + }, "tabs": { "general": "General", "content": "Content" diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/roles-admin-view.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/roles-admin-view.tsx index 2d7250390..296b7712e 100644 --- a/packages/vitnode/src/views/admin/views/core/users/roles/roles-admin-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/roles/roles-admin-view.tsx @@ -10,7 +10,7 @@ import { TooltipWithContent } from "@/components/ui/tooltip"; import { fetcher } from "@/lib/fetcher"; import { Link } from "@/lib/navigation"; -import { EditAction } from "./table/actions/edit-action"; +import { RowActions } from "./table/actions/row-actions"; export const RolesAdminView = async ({ searchParams, @@ -72,8 +72,8 @@ export const RolesAdminView = async ({ id: "actions", header: "", align: "right", - className: "w-10", - cell: ({ row }) => , + className: "w-20", + cell: ({ row }) => , }, ]} customNoResults={{ diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-action.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-action.tsx new file mode 100644 index 000000000..96b1b5933 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-action.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { CheckIcon, ChevronsUpDownIcon, Trash2Icon } from "lucide-react"; +import { useLocale, useTranslations } from "next-intl"; +import React from "react"; +import { toast } from "sonner"; +import { useDebouncedCallback } from "use-debounce"; + +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Spinner } from "@/components/ui/spinner"; +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +import type { RoleOption } from "./search-roles.action.server"; + +import { deleteRole } from "./delete-role.action.server"; +import { searchRolesForMove } from "./search-roles.action.server"; + +const useRoleName = () => { + const locale = useLocale(); + + return (role: RoleOption) => + role.name.find(item => item.languageCode === locale)?.name ?? + role.name[0]?.name ?? + ""; +}; + +const RoleName = ({ role }: { role: RoleOption }) => { + const resolveName = useRoleName(); + + return ( + + {resolveName(role)} + + ); +}; + +const MoveRolePicker = ({ + excludeId, + onSelect, + value, +}: { + excludeId: number; + onSelect: (role: RoleOption) => void; + value: null | RoleOption; +}) => { + const t = useTranslations("core.global"); + const tRole = useTranslations("admin.role.delete"); + const [open, setOpen] = React.useState(false); + const [options, setOptions] = React.useState([]); + const [isSearching, setIsSearching] = React.useState(false); + + const runSearch = React.useCallback(async (search: string) => { + setIsSearching(true); + try { + setOptions(await searchRolesForMove(search)); + } finally { + setIsSearching(false); + } + }, []); + const debouncedSearch = useDebouncedCallback(runSearch, 400); + + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (next) { + setOptions([]); + void runSearch(""); + } + }; + + const visibleOptions = options.filter(option => option.id !== excludeId); + + return ( + + + } + > + {value ? ( + + ) : ( + {tRole("selectRole")} + )} + + + + + + + {isSearching && visibleOptions.length === 0 ? ( +
+ +
+ ) : ( + <> + {t("results_not_found")} + + {visibleOptions.map(role => ( + { + onSelect(role); + setOpen(false); + }} + value={String(role.id)} + > + + + + ))} + + + )} +
+
+
+
+ ); +}; + +export const DeleteAction = ({ + data, +}: { + data: { + color: null | string; + id: number; + name: { languageCode: string; name: string }[]; + usersCount: number; + }; +}) => { + const t = useTranslations("admin.role.delete"); + const tError = useTranslations("core.global.errors"); + const resolveName = useRoleName(); + const [open, setOpen] = React.useState(false); + const [target, setTarget] = React.useState(null); + const [isPending, startTransition] = React.useTransition(); + + const hasUsers = data.usersCount > 0; + const roleName = resolveName(data); + const canSubmit = !hasUsers || target != null; + + const onConfirm = () => { + if (!canSubmit) { + return; + } + + startTransition(async () => { + const result = await deleteRole({ + id: data.id, + moveToRoleId: hasUsers ? target?.id : undefined, + }); + + if (result.error) { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + + return; + } + + toast.success(t("success")); + setOpen(false); + }); + }; + + return ( + + + + } + > + + + + + + + {t("title")} + + {hasUsers + ? t("descWithUsers", { count: data.usersCount, name: roleName }) + : t("desc", { name: roleName })} + + + + {hasUsers && ( +
+ {t("moveToLabel")} + +
+ )} + + + {t("cancel")} + + +
+
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-role.action.server.ts b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-role.action.server.ts new file mode 100644 index 000000000..0a2dfc336 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-role.action.server.ts @@ -0,0 +1,35 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { fetcher } from "@/lib/fetcher"; + +export const deleteRole = async ({ + id, + moveToRoleId, +}: { + id: number; + moveToRoleId?: number; +}): Promise<{ error?: { status: number } }> => { + const res = await fetcher(adminModule, { + path: "/{id}", + method: "delete", + module: "admin/roles", + args: { + params: { id: String(id) }, + query: + moveToRoleId !== undefined + ? { moveToRoleId: String(moveToRoleId) } + : {}, + }, + }); + + if (res.status !== 200) { + return { error: { status: res.status } }; + } + + revalidatePath("/[locale]/admin", "layout"); + + return {}; +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/row-actions.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/row-actions.tsx new file mode 100644 index 000000000..c2e033834 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/row-actions.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { DeleteAction } from "./delete-action"; +import { EditAction } from "./edit-action"; + +interface RoleRowData { + allowUploadFiles: boolean; + color: null | string; + default: boolean; + guest: boolean; + id: number; + maxStorageForSubmit: null | number; + name: { languageCode: string; name: string }[]; + protected: boolean; + root: boolean; + totalMaxStorage: null | number; + usersCount: number; +} + +export const RowActions = ({ data }: { data: RoleRowData }) => { + // System roles (root/guest access and the default role for new sign-ups, plus + // anything flagged protected) are required by the platform and cannot be + // removed - only edited. + const isSystem = data.protected || data.default || data.root || data.guest; + + return ( +
+ + {!isSystem && } +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/search-roles.action.server.ts b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/search-roles.action.server.ts new file mode 100644 index 000000000..3f770214a --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/search-roles.action.server.ts @@ -0,0 +1,40 @@ +"use server"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { fetcher } from "@/lib/fetcher"; + +export interface RoleOption { + color: null | string; + id: number; + name: { languageCode: string; name: string }[]; +} + +// Roles a member can be moved into when their current role is deleted. The +// guest role is never a valid primary role, so it is filtered out here. +export const searchRolesForMove = async ( + search: string, +): Promise => { + const res = await fetcher(adminModule, { + path: "/list", + method: "get", + module: "admin/roles", + args: { + query: { search, first: "20" }, + }, + withPagination: true, + }); + + if (res.status !== 200) { + return []; + } + + const data = await res.json(); + + return data.edges + .filter(role => !role.guest) + .map(role => ({ + id: role.id, + color: role.color, + name: role.name, + })); +}; From 0d329cd1280c4acb209257d89ed00428d7a9225c Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 25 Jul 2026 10:22:18 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(roles):=20=E2=9C=A8=20add=20role=20man?= =?UTF-8?q?agement=20permissions=20and=20delete=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + apps/api/src/locales/@vitnode/core/en.json | 14 +++++- .../docs/dev/events/built-in-events.mdx | 37 ++++++++++++---- .../core/advanced/cron/page.tsx | 11 ++++- .../(vitnode-core)/core/users/roles/page.tsx | 12 +++++- apps/docs/src/locales/@vitnode/core/en.json | 14 +++++- apps/docs/src/locales/@vitnode/core/pl.json | 10 ++++- packages/vitnode/src/api/models/events.ts | 4 -- .../admin/advanced/cron/routes/get.route.ts | 1 + .../admin/advanced/cron/routes/run.route.ts | 1 + .../roles/lib/assert-manage-admin-role.ts | 43 +++++++++++++++++++ .../admin/roles/routes/create.route.ts | 1 + .../admin/roles/routes/delete.route.ts | 9 ++++ .../modules/admin/roles/routes/list.route.ts | 15 +++++++ .../admin/roles/routes/update.route.ts | 5 +++ packages/vitnode/src/api/plugin.ts | 10 ++++- packages/vitnode/src/locales/en.json | 14 +++++- .../routes/admin/core/advanced/cron/page.tsx | 11 ++++- .../routes/admin/core/users/roles/page.tsx | 12 +++++- .../layouts/sidebar/nav/get-admin-nav.tsx | 10 +++++ .../advanced/cron/run-action/run-action.tsx | 11 +++++ .../roles/table/actions/delete-action.tsx | 10 ++++- .../users/roles/table/actions/row-actions.tsx | 39 ++++++++++++++++- 23 files changed, 266 insertions(+), 29 deletions(-) create mode 100644 packages/vitnode/src/api/modules/admin/roles/lib/assert-manage-admin-role.ts diff --git a/AGENTS.md b/AGENTS.md index 44a1602a1..486a25788 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ import { Activity } from "react"; - Always add breadcrumbs using `@breadcrumb` in the page component (Parallel Routes). - Name `x.server.ts` files if inside is 'use server' code in Next.js. +- Always add staff permissions when it's new admin api. ### Improved Caching APIs diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index 837c7b0c9..8aabdd5b0 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -8,7 +8,12 @@ "@vitnode/core:users:can_edit": "Edit users", "@vitnode/core:users:can_edit_admin": "Edit users with administrator permission", "@vitnode/core:roles": "Roles", - "@vitnode/core:roles:can_manage": "Manage roles", + "@vitnode/core:roles:can_view": "View roles list", + "@vitnode/core:roles:can_create": "Create roles", + "@vitnode/core:roles:can_edit": "Edit roles", + "@vitnode/core:roles:can_edit_admin": "Edit roles with administrator permission", + "@vitnode/core:roles:can_delete": "Delete roles", + "@vitnode/core:roles:can_delete_admin": "Delete roles with administrator permission", "@vitnode/core:debug": "Debug Panel", "@vitnode/core:debug:can_view": "View debug panel", "@vitnode/core:debug:can_clear_cache": "Clear cache", @@ -22,6 +27,9 @@ "@vitnode/core:files:can_delete": "Delete files", "@vitnode/core:queue": "Queue Tasks", "@vitnode/core:queue:can_view": "View queue tasks", + "@vitnode/core:cron": "Cron Jobs", + "@vitnode/core:cron:can_view": "View cron jobs", + "@vitnode/core:cron:can_run": "Run cron jobs", "@vitnode/core:staff_moderators": "Staff: Moderators", "@vitnode/core:staff_moderators:can_view": "View moderators list", "@vitnode/core:staff_moderators:can_create": "Create moderators", @@ -576,7 +584,9 @@ "selectRole": "Select a role...", "confirm": "Delete role", "cancel": "Cancel", - "success": "Role deleted" + "success": "Role deleted", + "successDesc": "The role has been permanently deleted.", + "successDescMoved": "{count, plural, one {# user was} other {# users were}} moved to \"{name}\" and the role was permanently deleted." }, "tabs": { "general": "General", diff --git a/apps/docs/content/docs/dev/events/built-in-events.mdx b/apps/docs/content/docs/dev/events/built-in-events.mdx index 453e89d64..f5459739a 100644 --- a/apps/docs/content/docs/dev/events/built-in-events.mdx +++ b/apps/docs/content/docs/dev/events/built-in-events.mdx @@ -15,7 +15,7 @@ the emitting plugin are needed, the event map is global. | `user.deleted` | `{ userId, email }` | _Declared only_ - core has no user deletion flow yet | | `role.created` | `{ roleId }` | A role is created in the AdminCP | | `role.updated` | `{ roleId }` | A role is edited in the AdminCP | -| `role.deleted` | `{ roleId }` | _Declared only_ - core has no role deletion flow yet | +| `role.deleted` | `{ roleId }` | A role is deleted in the AdminCP | | `blog.post.created` | `{ postId, categoryId }` | A blog post is created | | `blog.post.updated` | `{ postId, categoryId }` | A blog post is edited | | `blog.post.deleted` | `{ postId, categoryId }` | A blog post is deleted | @@ -118,12 +118,33 @@ translated names, which live in `core_languages_words`). **Use cases:** provision plugin-side permission defaults for a new role, or refresh externally-cached permission matrices when a role changes. -### user.deleted / role.deleted (declared only) +### role.deleted -These events exist in the `VitNodeEvents` map so listeners and payloads are -already typed, but **core never emits them today** - there is no user or role -deletion flow yet. They are the agreed-upon names for plugins that implement -deletion themselves, and core will emit them once deletion lands. +Emitted after a role is deleted in the AdminCP. Its translated names in +`core_languages_words` are removed with it, and its secondary-role assignments +and staff-permission entries are dropped by database cascade. If the role still +had members, they are reassigned to another role **before** the delete, so by +the time this fires no user references the removed role. + + + +**Use cases:** clean up plugin-owned data keyed by role id (permission matrices, +per-role settings, externally-cached role lists), or audit-log the removal using +the envelope's `actor`. + +### user.deleted (declared only) + +This event exists in the `VitNodeEvents` map so listeners and payloads are +already typed, but **core never emits it today** - there is no user deletion +flow yet. It is the agreed-upon name for plugins that implement account deletion +themselves, and core will emit it once deletion lands. ## Blog (`@vitnode/blog`) @@ -197,5 +218,5 @@ High-frequency or consumer-less events are added only when a listener needs them, to keep the catalog meaningful: there is currently no `user.signedIn`, `user.passwordResetRequested`, or `file.uploaded`. If you need one of these, open an issue or PR - adding an event is a one-line `emit` plus an entry in the -`VitNodeEvents` map. (`user.deleted` and `role.deleted` are a special case: -they are declared in the map already, but wait on core growing deletion flows.) +`VitNodeEvents` map. (`user.deleted` is a special case: it is declared in the +map already, but waits on core growing a user-deletion flow.) diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/advanced/cron/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/advanced/cron/page.tsx index 937545455..59fb7d266 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/advanced/cron/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/advanced/cron/page.tsx @@ -1,10 +1,12 @@ import { getTranslations } from "next-intl/server"; import dynamic from "next/dynamic"; +import { notFound } from "next/navigation"; import React from "react"; import { I18nProvider } from "@vitnode/core/components/i18n-provider"; import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; import { HeaderContent } from "@vitnode/core/components/ui/header-content"; +import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api"; const CronTableView = dynamic(async () => import("@vitnode/core/views/admin/views/core/advanced/cron/cron-table-view").then( @@ -26,7 +28,14 @@ export const generateMetadata = async () => { export default async function Page( props: React.ComponentProps, ) { - const t = await getTranslations("admin.advanced.cron"); + const [t, canView] = await Promise.all([ + getTranslations("admin.advanced.cron"), + checkAdminPermissionApi({ module: "cron", permission: "can_view" }), + ]); + + if (!canView) { + notFound(); + } return ( diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/roles/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/roles/page.tsx index c3b5e5dbb..c486e462a 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/roles/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/roles/page.tsx @@ -2,11 +2,13 @@ import type { Metadata } from "next/dist/types"; import { getTranslations } from "next-intl/server"; import dynamic from "next/dynamic"; +import { notFound } from "next/navigation"; import React from "react"; import { I18nProvider } from "@vitnode/core/components/i18n-provider"; import { DataTableSkeleton } from "@vitnode/core/components/table/data-table"; import { HeaderContent } from "@vitnode/core/components/ui/header-content"; +import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api"; import { ActionsRolesAdmin } from "@vitnode/core/views/admin/views/core/users/roles/actions/actions"; const RolesAdminView = dynamic(async () => @@ -28,16 +30,22 @@ export const generateMetadata = async (): Promise => { export default async function Page( props: React.ComponentProps, ) { - const [t, tNav] = await Promise.all([ + const [t, tNav, canView, canCreate] = await Promise.all([ getTranslations("admin.role.list"), getTranslations("admin.global.nav.users"), + checkAdminPermissionApi({ module: "roles", permission: "can_view" }), + checkAdminPermissionApi({ module: "roles", permission: "can_create" }), ]); + if (!canView) { + notFound(); + } + return (
- + {canCreate && } }> diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index 837c7b0c9..8aabdd5b0 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -8,7 +8,12 @@ "@vitnode/core:users:can_edit": "Edit users", "@vitnode/core:users:can_edit_admin": "Edit users with administrator permission", "@vitnode/core:roles": "Roles", - "@vitnode/core:roles:can_manage": "Manage roles", + "@vitnode/core:roles:can_view": "View roles list", + "@vitnode/core:roles:can_create": "Create roles", + "@vitnode/core:roles:can_edit": "Edit roles", + "@vitnode/core:roles:can_edit_admin": "Edit roles with administrator permission", + "@vitnode/core:roles:can_delete": "Delete roles", + "@vitnode/core:roles:can_delete_admin": "Delete roles with administrator permission", "@vitnode/core:debug": "Debug Panel", "@vitnode/core:debug:can_view": "View debug panel", "@vitnode/core:debug:can_clear_cache": "Clear cache", @@ -22,6 +27,9 @@ "@vitnode/core:files:can_delete": "Delete files", "@vitnode/core:queue": "Queue Tasks", "@vitnode/core:queue:can_view": "View queue tasks", + "@vitnode/core:cron": "Cron Jobs", + "@vitnode/core:cron:can_view": "View cron jobs", + "@vitnode/core:cron:can_run": "Run cron jobs", "@vitnode/core:staff_moderators": "Staff: Moderators", "@vitnode/core:staff_moderators:can_view": "View moderators list", "@vitnode/core:staff_moderators:can_create": "Create moderators", @@ -576,7 +584,9 @@ "selectRole": "Select a role...", "confirm": "Delete role", "cancel": "Cancel", - "success": "Role deleted" + "success": "Role deleted", + "successDesc": "The role has been permanently deleted.", + "successDescMoved": "{count, plural, one {# user was} other {# users were}} moved to \"{name}\" and the role was permanently deleted." }, "tabs": { "general": "General", diff --git a/apps/docs/src/locales/@vitnode/core/pl.json b/apps/docs/src/locales/@vitnode/core/pl.json index 2087987d6..c9fe6d7b9 100644 --- a/apps/docs/src/locales/@vitnode/core/pl.json +++ b/apps/docs/src/locales/@vitnode/core/pl.json @@ -8,7 +8,12 @@ "@vitnode/core:users:can_edit": "Edytowanie użytkowników", "@vitnode/core:users:can_edit_admin": "Edytowanie użytkowników z uprawnieniami administratora", "@vitnode/core:roles": "Role", - "@vitnode/core:roles:can_manage": "Zarządzanie rolami", + "@vitnode/core:roles:can_view": "Wyświetlanie listy ról", + "@vitnode/core:roles:can_create": "Tworzenie ról", + "@vitnode/core:roles:can_edit": "Edytowanie ról", + "@vitnode/core:roles:can_edit_admin": "Edytowanie ról z uprawnieniami administratora", + "@vitnode/core:roles:can_delete": "Usuwanie ról", + "@vitnode/core:roles:can_delete_admin": "Usuwanie ról z uprawnieniami administratora", "@vitnode/core:debug": "Panel debugowania", "@vitnode/core:debug:can_view": "Wyświetlanie panelu debugowania", "@vitnode/core:debug:can_clear_cache": "Czyszczenie pamięci podręcznej", @@ -22,6 +27,9 @@ "@vitnode/core:files:can_delete": "Usuwanie plików", "@vitnode/core:queue": "Zadania w kolejce", "@vitnode/core:queue:can_view": "Wyświetlanie zadań w kolejce", + "@vitnode/core:cron": "Zadania Cron", + "@vitnode/core:cron:can_view": "Wyświetlanie zadań cron", + "@vitnode/core:cron:can_run": "Uruchamianie zadań cron", "@vitnode/core:staff_moderators": "Zespół: Moderatorzy", "@vitnode/core:staff_moderators:can_view": "Wyświetlanie listy moderatorów", "@vitnode/core:staff_moderators:can_create": "Tworzenie moderatorów", diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index 39eb12873..4b5426581 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -23,10 +23,6 @@ export interface VitNodeEvents { "role.created": { roleId: number; }; - /** - * Declared for plugins implementing role deletion - core has no role - * deletion flow yet and never emits this itself. - */ "role.deleted": { roleId: number; }; diff --git a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts index b82c2c4da..b49a25c9c 100644 --- a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts +++ b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/get.route.ts @@ -11,6 +11,7 @@ import { core_cron } from "@/database/cron"; export const getCronsRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "cron", permission: "can_view" }, route: { method: "get", description: "Get Admin Cron Logs", diff --git a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts index 93b03c529..0433bb834 100644 --- a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts +++ b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts @@ -10,6 +10,7 @@ import { getNextCronRunDate } from "@/lib/api/get-next-cron-run-date"; export const runCronRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "cron", permission: "can_run" }, route: { method: "post", description: "Run a specific cron job", diff --git a/packages/vitnode/src/api/modules/admin/roles/lib/assert-manage-admin-role.ts b/packages/vitnode/src/api/modules/admin/roles/lib/assert-manage-admin-role.ts new file mode 100644 index 000000000..f0e2793dd --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/roles/lib/assert-manage-admin-role.ts @@ -0,0 +1,43 @@ +import type { Context } from "hono"; + +import { eq } from "drizzle-orm"; + +import { assertStaffPermission } from "@/api/lib/check-staff-permission"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_admin_permissions } from "@/database/admins"; + +/** + * A role grants admin access when it has a row in `core_admin_permissions`. + * Editing or deleting such a role is a higher-privilege action, so it requires + * the elevated `_admin` variant of the permission on top of the base route + * guard - mirroring how editing an admin *user* requires `users:can_edit_admin` + * (see `assertCanAssignPrimaryRole`). + * + * Roles that do not grant admin access pass through untouched. + */ +export const assertCanManageAdminRole = async ( + c: Context, + { + roleId, + permission, + }: { + permission: "can_delete_admin" | "can_edit_admin"; + roleId: number; + }, +): Promise => { + const [adminRole] = await c + .get("db") + .select({ id: core_admin_permissions.id }) + .from(core_admin_permissions) + .where(eq(core_admin_permissions.roleId, roleId)) + .limit(1); + + if (!adminRole) return; + + await assertStaffPermission(c, { + type: "admin", + plugin: CONFIG_PLUGIN.pluginId, + module: "roles", + permission, + }); +}; diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts index 8cc1395e6..0e8f736f2 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts @@ -29,6 +29,7 @@ export const zodCreateRoleAdminSchema = z.object({ export const createRoleAdminRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "roles", permission: "can_create" }, route: { method: "post", description: "Create a new role (Admin only)", diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts index cf2768c9b..fed9b1844 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts @@ -8,8 +8,11 @@ import { core_languages_words } from "@/database/languages"; import { core_roles } from "@/database/roles"; import { core_users, core_users_secondary_roles } from "@/database/users"; +import { assertCanManageAdminRole } from "../lib/assert-manage-admin-role"; + export const deleteRoleAdminRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "roles", permission: "can_delete" }, route: { method: "delete", description: "Delete a role by id (Admin only)", @@ -86,6 +89,12 @@ export const deleteRoleAdminRoute = buildRoute({ return c.json({ error: "This role cannot be deleted" }, 403); } + // Deleting a role that grants admin access requires the elevated permission. + await assertCanManageAdminRole(c, { + roleId, + permission: "can_delete_admin", + }); + // Members point at their primary role through a NOT NULL, RESTRICT foreign // key, so anyone in this role has to be moved elsewhere first. Secondary // roles and staff permission entries reference the role with ON DELETE diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts index 04ca95361..efe9f5138 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts @@ -8,6 +8,7 @@ import { zodPaginationQuery, } from "@/api/lib/with-pagination"; import { CONFIG_PLUGIN } from "@/config"; +import { core_admin_permissions } from "@/database/admins"; import { core_languages_words } from "@/database/languages"; import { core_roles } from "@/database/roles"; import { core_users } from "@/database/users"; @@ -35,6 +36,10 @@ const rolesAdminListSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), usersCount: z.number(), + // A role grants admin access when it has a row in + // `core_admin_permissions`. Editing/deleting such roles needs the + // elevated `can_edit_admin` / `can_delete_admin` permission. + grantsAdmin: z.boolean(), }), ), pageInfo: zodPaginationPageInfo, @@ -155,6 +160,15 @@ export const listRolesAdminRoute = buildRoute({ .where(inArray(core_users.roleId, roleIds)) .groupBy(core_users.roleId) : []; + // Roles with a `core_admin_permissions` row grant admin access. + const adminRoles = roleIds.length + ? await c + .get("db") + .selectDistinct({ roleId: core_admin_permissions.roleId }) + .from(core_admin_permissions) + .where(inArray(core_admin_permissions.roleId, roleIds)) + : []; + const adminRoleIds = new Set(adminRoles.map(row => row.roleId)); return c.json({ pageInfo: data.pageInfo, @@ -165,6 +179,7 @@ export const listRolesAdminRoute = buildRoute({ .map(word => ({ name: word.value, languageCode: word.languageCode })), usersCount: userCounts.find(item => item.roleId === role.id)?.total ?? 0, + grantsAdmin: adminRoleIds.has(role.id), })), }); }, diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts index fdd97f745..23955074b 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts @@ -6,6 +6,7 @@ import { saveLanguageWords } from "@/api/lib/save-language-words"; import { CONFIG_PLUGIN } from "@/config"; import { core_roles } from "@/database/roles"; +import { assertCanManageAdminRole } from "../lib/assert-manage-admin-role"; import { zodRoleNameSchema, zodRoleStorageSchema } from "./create.route"; export const zodUpdateRoleAdminSchema = z @@ -23,6 +24,7 @@ export const zodUpdateRoleAdminSchema = z export const updateRoleAdminRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "roles", permission: "can_edit" }, route: { method: "patch", description: "Update a role by id (Admin only)", @@ -82,6 +84,9 @@ export const updateRoleAdminRoute = buildRoute({ return c.json({ error: "Role not found" }, 404); } + // Editing a role that grants admin access requires the elevated permission. + await assertCanManageAdminRole(c, { roleId, permission: "can_edit_admin" }); + const values: Partial = { updatedAt: new Date(), }; diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index 8fb405b25..6da77f3f7 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -29,7 +29,14 @@ export const newBuildPluginApiCore = buildApiPlugin({ { permission: "can_edit", dependsOn: ["can_view"] }, { permission: "can_edit_admin", dependsOn: ["can_view"] }, ], - roles: ["can_manage"], + roles: [ + "can_view", + { permission: "can_create", dependsOn: ["can_view"] }, + { permission: "can_edit", dependsOn: ["can_view"] }, + { permission: "can_edit_admin", dependsOn: ["can_edit"] }, + { permission: "can_delete", dependsOn: ["can_view"] }, + { permission: "can_delete_admin", dependsOn: ["can_delete"] }, + ], debug: [ "can_view", { permission: "can_clear_cache", dependsOn: ["can_view"] }, @@ -45,6 +52,7 @@ export const newBuildPluginApiCore = buildApiPlugin({ { permission: "can_delete", dependsOn: ["can_view"] }, ], queue: ["can_view"], + cron: ["can_view", { permission: "can_run", dependsOn: ["can_view"] }], staff_moderators: [ "can_view", { permission: "can_create", dependsOn: ["can_view"] }, diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 837c7b0c9..8aabdd5b0 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -8,7 +8,12 @@ "@vitnode/core:users:can_edit": "Edit users", "@vitnode/core:users:can_edit_admin": "Edit users with administrator permission", "@vitnode/core:roles": "Roles", - "@vitnode/core:roles:can_manage": "Manage roles", + "@vitnode/core:roles:can_view": "View roles list", + "@vitnode/core:roles:can_create": "Create roles", + "@vitnode/core:roles:can_edit": "Edit roles", + "@vitnode/core:roles:can_edit_admin": "Edit roles with administrator permission", + "@vitnode/core:roles:can_delete": "Delete roles", + "@vitnode/core:roles:can_delete_admin": "Delete roles with administrator permission", "@vitnode/core:debug": "Debug Panel", "@vitnode/core:debug:can_view": "View debug panel", "@vitnode/core:debug:can_clear_cache": "Clear cache", @@ -22,6 +27,9 @@ "@vitnode/core:files:can_delete": "Delete files", "@vitnode/core:queue": "Queue Tasks", "@vitnode/core:queue:can_view": "View queue tasks", + "@vitnode/core:cron": "Cron Jobs", + "@vitnode/core:cron:can_view": "View cron jobs", + "@vitnode/core:cron:can_run": "Run cron jobs", "@vitnode/core:staff_moderators": "Staff: Moderators", "@vitnode/core:staff_moderators:can_view": "View moderators list", "@vitnode/core:staff_moderators:can_create": "Create moderators", @@ -576,7 +584,9 @@ "selectRole": "Select a role...", "confirm": "Delete role", "cancel": "Cancel", - "success": "Role deleted" + "success": "Role deleted", + "successDesc": "The role has been permanently deleted.", + "successDescMoved": "{count, plural, one {# user was} other {# users were}} moved to \"{name}\" and the role was permanently deleted." }, "tabs": { "general": "General", diff --git a/packages/vitnode/src/routes/admin/core/advanced/cron/page.tsx b/packages/vitnode/src/routes/admin/core/advanced/cron/page.tsx index 7a09a3883..167f78c98 100644 --- a/packages/vitnode/src/routes/admin/core/advanced/cron/page.tsx +++ b/packages/vitnode/src/routes/admin/core/advanced/cron/page.tsx @@ -1,10 +1,12 @@ import { getTranslations } from "next-intl/server"; import dynamic from "next/dynamic"; +import { notFound } from "next/navigation"; import React from "react"; import { I18nProvider } from "@/components/i18n-provider"; import { DataTableSkeleton } from "@/components/table/data-table"; import { HeaderContent } from "@/components/ui/header-content"; +import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; const CronTableView = dynamic(async () => import("@/views/admin/views/core/advanced/cron/cron-table-view").then( @@ -26,7 +28,14 @@ export const generateMetadata = async () => { export default async function Page( props: React.ComponentProps, ) { - const t = await getTranslations("admin.advanced.cron"); + const [t, canView] = await Promise.all([ + getTranslations("admin.advanced.cron"), + checkAdminPermissionApi({ module: "cron", permission: "can_view" }), + ]); + + if (!canView) { + notFound(); + } return ( diff --git a/packages/vitnode/src/routes/admin/core/users/roles/page.tsx b/packages/vitnode/src/routes/admin/core/users/roles/page.tsx index 883d4974e..03c0cfdbc 100644 --- a/packages/vitnode/src/routes/admin/core/users/roles/page.tsx +++ b/packages/vitnode/src/routes/admin/core/users/roles/page.tsx @@ -2,11 +2,13 @@ import type { Metadata } from "next/dist/types"; import { getTranslations } from "next-intl/server"; import dynamic from "next/dynamic"; +import { notFound } from "next/navigation"; import React from "react"; import { I18nProvider } from "@/components/i18n-provider"; import { DataTableSkeleton } from "@/components/table/data-table"; import { HeaderContent } from "@/components/ui/header-content"; +import { checkAdminPermissionApi } from "@/lib/api/get-session-admin-api"; import { ActionsRolesAdmin } from "@/views/admin/views/core/users/roles/actions/actions"; const RolesAdminView = dynamic(async () => @@ -28,16 +30,22 @@ export const generateMetadata = async (): Promise => { export default async function Page( props: React.ComponentProps, ) { - const [t, tNav] = await Promise.all([ + const [t, tNav, canView, canCreate] = await Promise.all([ getTranslations("admin.role.list"), getTranslations("admin.global.nav.users"), + checkAdminPermissionApi({ module: "roles", permission: "can_view" }), + checkAdminPermissionApi({ module: "roles", permission: "can_create" }), ]); + if (!canView) { + notFound(); + } + return (
- + {canCreate && } }> diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx index faede5a10..fe7a7d10a 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/get-admin-nav.tsx @@ -160,6 +160,11 @@ export const getAdminNav = async ({ { title: t("admin.global.nav.users.roles"), href: "/admin/core/users/roles", + permission: { + plugin: CONFIG_PLUGIN.pluginId, + module: "roles", + permission: "can_view", + }, }, ], }, @@ -205,6 +210,11 @@ export const getAdminNav = async ({ { title: t("admin.global.nav.advanced.cron"), href: "/admin/core/advanced/cron", + permission: { + plugin: CONFIG_PLUGIN.pluginId, + module: "cron", + permission: "can_view", + }, }, { title: t("admin.global.nav.advanced.queue"), diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/run-action.tsx b/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/run-action.tsx index fce76113f..f27ca126c 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/run-action.tsx +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/run-action/run-action.tsx @@ -5,14 +5,21 @@ import { useTranslations } from "next-intl"; import { useActionState } from "react"; import { toast } from "sonner"; +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; import { Button } from "@/components/ui/button"; import { TooltipWithContent } from "@/components/ui/tooltip"; +import { CONFIG_PLUGIN } from "@/config"; import { mutationApi } from "./mutation-api.server"; export const RunActionCronTable = ({ id }: { id: number }) => { const t = useTranslations("admin.advanced.cron.list.actions.runNow"); const tError = useTranslations("core.global.errors"); + const canRun = useAdminStaffPermission({ + plugin: CONFIG_PLUGIN.pluginId, + module: "cron", + permission: "can_run", + }); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_, formAction, isPending] = useActionState(async () => { const mutation = await mutationApi(id); @@ -27,6 +34,10 @@ export const RunActionCronTable = ({ id }: { id: number }) => { toast.success(t("success")); }, null); + if (!canRun) { + return null; + } + return (
diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-action.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-action.tsx index 96b1b5933..837f9289d 100644 --- a/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-action.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/delete-action.tsx @@ -196,7 +196,15 @@ export const DeleteAction = ({ return; } - toast.success(t("success")); + toast.success(t("success"), { + description: + hasUsers && target + ? t("successDescMoved", { + count: data.usersCount, + name: resolveName(target), + }) + : t("successDesc"), + }); setOpen(false); }); }; diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/row-actions.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/row-actions.tsx index c2e033834..e31d06f92 100644 --- a/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/row-actions.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/row-actions.tsx @@ -1,5 +1,8 @@ "use client"; +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { CONFIG_PLUGIN } from "@/config"; + import { DeleteAction } from "./delete-action"; import { EditAction } from "./edit-action"; @@ -7,6 +10,7 @@ interface RoleRowData { allowUploadFiles: boolean; color: null | string; default: boolean; + grantsAdmin: boolean; guest: boolean; id: number; maxStorageForSubmit: null | number; @@ -18,15 +22,46 @@ interface RoleRowData { } export const RowActions = ({ data }: { data: RoleRowData }) => { + const canEdit = useAdminStaffPermission({ + plugin: CONFIG_PLUGIN.pluginId, + module: "roles", + permission: "can_edit", + }); + const canEditAdmin = useAdminStaffPermission({ + plugin: CONFIG_PLUGIN.pluginId, + module: "roles", + permission: "can_edit_admin", + }); + const canDelete = useAdminStaffPermission({ + plugin: CONFIG_PLUGIN.pluginId, + module: "roles", + permission: "can_delete", + }); + const canDeleteAdmin = useAdminStaffPermission({ + plugin: CONFIG_PLUGIN.pluginId, + module: "roles", + permission: "can_delete_admin", + }); + // System roles (root/guest access and the default role for new sign-ups, plus // anything flagged protected) are required by the platform and cannot be // removed - only edited. const isSystem = data.protected || data.default || data.root || data.guest; + // A role that grants admin access needs the elevated permission on top of the + // base one - mirroring the backend guard in the update/delete routes. + const showEdit = canEdit && (!data.grantsAdmin || canEditAdmin); + const showDelete = + canDelete && !isSystem && (!data.grantsAdmin || canDeleteAdmin); + + if (!showEdit && !showDelete) { + return null; + } + return (
- - {!isSystem && } + {showEdit && } + {showDelete && }
); }; From 15cca5cec4d9145826cbdb85477ba1cfd6f6eb36 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 25 Jul 2026 10:39:57 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat(events):=20=E2=9C=A8=20add=20`role.del?= =?UTF-8?q?eted`=20event=20with=20cleanup=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/dev/events/built-in-events.mdx | 18 ++++++++++++++++++ .../modules/admin/roles/routes/delete.route.ts | 7 ------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/docs/content/docs/dev/events/built-in-events.mdx b/apps/docs/content/docs/dev/events/built-in-events.mdx index f5459739a..09a4df9ab 100644 --- a/apps/docs/content/docs/dev/events/built-in-events.mdx +++ b/apps/docs/content/docs/dev/events/built-in-events.mdx @@ -139,6 +139,24 @@ the time this fires no user references the removed role. per-role settings, externally-cached role lists), or audit-log the removal using the envelope's `actor`. +The event fires only after the delete transaction commits, so by the time your +listener runs the role row is gone - key your cleanup off `payload.roleId` +rather than re-reading `core_roles`: + +```ts title="Example: clean up plugin data on role deletion" +export const roleCleanupListener = buildEventListener({ + event: "role.deleted", + name: "cleanup-role-settings", + handler: async (c, payload) => { + // Remove any plugin-owned rows keyed by the deleted role. + await c + .get("db") + .delete(blog_role_settings) + .where(eq(blog_role_settings.roleId, payload.roleId)); + }, +}); +``` + ### user.deleted (declared only) This event exists in the `VitNodeEvents` map so listeners and payloads are diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts index fed9b1844..e2e57104a 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts @@ -121,22 +121,15 @@ export const deleteRoleAdminRoute = buildRoute({ .where(eq(core_roles.id, targetRoleId)) .limit(1); - // The guest role can never be a user's primary role, so it is not a valid - // move target. if (!target || target.guest) { return c.json({ error: "Invalid target role" }, 400); } - // Moving members into an admin-granting role would make them admins, so - // it needs the same permission as promoting a user to admin - a - // `can_edit`-only admin must not be able to escalate members this way. await assertCanAssignPrimaryRole(c, targetRoleId); } await db.transaction(async tx => { if (targetRoleId != null) { - // Grab the members before the move so we can drop any secondary-role - // rows that would collide with their new primary role. const members = await tx .select({ id: core_users.id }) .from(core_users)