Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 21 additions & 1 deletion apps/api/src/locales/@vitnode/core/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -568,6 +576,18 @@
"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",
"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",
"content": "Content"
Expand Down
55 changes: 47 additions & 8 deletions apps/docs/content/docs/dev/events/built-in-events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -118,12 +118,51 @@ 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.

<TypeTable
type={{
roleId: {
description: "Id of the deleted role.",
type: "number",
},
}}
/>

**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`.

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
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`)

Expand Down Expand Up @@ -197,5 +236,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.)
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -26,7 +28,14 @@ export const generateMetadata = async () => {
export default async function Page(
props: React.ComponentProps<typeof CronTableView>,
) {
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 (
<I18nProvider namespaces={["admin.advanced.cron"]}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () =>
Expand All @@ -28,16 +30,22 @@ export const generateMetadata = async (): Promise<Metadata> => {
export default async function Page(
props: React.ComponentProps<typeof RolesAdminView>,
) {
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 (
<I18nProvider namespaces="admin.role">
<div className="p-4">
<HeaderContent desc={t("desc")} h1={tNav("roles")}>
<ActionsRolesAdmin />
{canCreate && <ActionsRolesAdmin />}
</HeaderContent>

<React.Suspense fallback={<DataTableSkeleton columns={2} />}>
Expand Down
22 changes: 21 additions & 1 deletion apps/docs/src/locales/@vitnode/core/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -568,6 +576,18 @@
"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",
"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",
"content": "Content"
Expand Down
10 changes: 9 additions & 1 deletion apps/docs/src/locales/@vitnode/core/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
4 changes: 0 additions & 4 deletions packages/vitnode/src/api/models/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> => {
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,
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -14,5 +15,6 @@ export const rolesAdminModule = buildModule({
showRoleAdminRoute,
createRoleAdminRoute,
updateRoleAdminRoute,
deleteRoleAdminRoute,
],
});
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Loading