[];
+ permissionStaff?: PermissionStaffConfig;
pluginId: P;
}): BuildPluginApiReturn {
// Run for checking if the plugin is valid
@@ -43,5 +47,6 @@ export function buildApiPlugin
({
hono,
cronJobs,
webSockets,
+ permissionStaff,
};
}
diff --git a/packages/vitnode/src/api/lib/route.ts b/packages/vitnode/src/api/lib/route.ts
index a080d7522..ba06ce27f 100644
--- a/packages/vitnode/src/api/lib/route.ts
+++ b/packages/vitnode/src/api/lib/route.ts
@@ -1,4 +1,5 @@
import type { RouteConfig, RouteHandler } from "@hono/zod-openapi";
+import type { MiddlewareHandler } from "hono";
import { createRoute as createRouteHono } from "@hono/zod-openapi";
@@ -7,6 +8,13 @@ import {
type EnvVitNode,
pluginMiddleware,
} from "../middlewares/global.middleware";
+import { assertStaffPermission } from "./check-staff-permission";
+
+export interface AdminStaffPermission {
+ module: string;
+ permission: string;
+ plugin?: string;
+}
export const buildRoute = <
Plugin extends string,
@@ -19,7 +27,9 @@ export const buildRoute = <
route,
handler,
pluginId,
+ adminStaffPermission,
}: {
+ adminStaffPermission?: AdminStaffPermission;
handler: RouteHandler;
pluginId: Plugin;
route: R;
@@ -31,18 +41,35 @@ export const buildRoute = <
const tags = [pluginTag, ...(route.tags ?? [])];
+ const middleware: MiddlewareHandler[] = [pluginMiddleware(pluginId)];
+
+ if (adminStaffPermission) {
+ const { plugin, module, permission } = adminStaffPermission;
+ middleware.push(async (c, next) => {
+ await assertStaffPermission(c, {
+ type: "admin",
+ plugin: plugin ?? pluginId,
+ module,
+ permission,
+ });
+ await next();
+ });
+ }
+
+ if (route.withCaptcha) {
+ middleware.push(captchaMiddleware());
+ }
+
+ if (Array.isArray(route.middleware)) {
+ middleware.push(...route.middleware);
+ } else if (route.middleware) {
+ middleware.push(route.middleware);
+ }
+
return {
route: createRouteHono({
tags,
- middleware: [
- pluginMiddleware(pluginId),
- ...(route.withCaptcha ? [captchaMiddleware()] : []),
- ...(Array.isArray(route.middleware)
- ? route.middleware
- : route.middleware
- ? [route.middleware]
- : []),
- ],
+ middleware,
...route,
}),
handler: handler as Route["handler"],
diff --git a/packages/vitnode/src/api/lib/staff-permission.ts b/packages/vitnode/src/api/lib/staff-permission.ts
new file mode 100644
index 000000000..85db5b927
--- /dev/null
+++ b/packages/vitnode/src/api/lib/staff-permission.ts
@@ -0,0 +1,24 @@
+import type {
+ PermissionsStaffArgs,
+ StaffPermissionSet,
+} from "./permission-staff";
+
+export const hasStaffPermission = (
+ set: StaffPermissionSet,
+ args: PermissionsStaffArgs,
+): boolean => {
+ if (set.root) return true;
+
+ return set.permissions.some(
+ permission =>
+ permission.plugin === args.plugin &&
+ permission.module === args.module &&
+ permission.permission === args.permission,
+ );
+};
+
+export const staffPermissionKey = ({
+ plugin,
+ module,
+ permission,
+}: PermissionsStaffArgs): string => `${plugin}:${module}:${permission}`;
diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts
index 229c70ff3..7c7a1532e 100644
--- a/packages/vitnode/src/api/middlewares/global.middleware.ts
+++ b/packages/vitnode/src/api/middlewares/global.middleware.ts
@@ -12,6 +12,7 @@ import { CONFIG } from "@/lib/config";
import { realtime } from "@/ws/registry";
import type { BuildCronReturn } from "../lib/cron";
+import type { PermissionStaffCatalogEntry } from "../lib/permission-staff";
import type { WebSocketConfig } from "../lib/websocket";
import type { SSOApiPlugin } from "../models/sso";
@@ -19,6 +20,7 @@ import {
loggerMiddleware,
type LoggerMiddlewareType,
} from "../lib/logger-middleware";
+import { normalizePermissionStaffModules } from "../lib/permission-staff";
declare module "hono" {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
@@ -64,6 +66,7 @@ export interface EnvVariablesVitNode {
title: string;
};
pathToMessages: (path: string) => Promise<{ default: object }>;
+ permissionStaff: PermissionStaffCatalogEntry[];
plugins: { id: string }[];
webSockets: WebSocketConfig[];
};
@@ -131,6 +134,16 @@ export const globalMiddleware = ({
})),
);
+ const permissionStaffMetadata: PermissionStaffCatalogEntry[] = plugins.map(
+ plugin => ({
+ pluginId: plugin.pluginId,
+ admin: normalizePermissionStaffModules(plugin.permissionStaff?.admin),
+ moderator: normalizePermissionStaffModules(
+ plugin.permissionStaff?.moderator,
+ ),
+ }),
+ );
+
const ipHeaderKeys = [
"x-forwarded-for",
"x-real-ip",
@@ -190,6 +203,7 @@ export const globalMiddleware = ({
plugins: pluginsMetadata,
cron: cronMetadata,
webSockets: webSocketsMetadata,
+ permissionStaff: permissionStaffMetadata,
});
const user = await new SessionModel(c).getUser();
diff --git a/packages/vitnode/src/api/modules/admin/routes/session.route.ts b/packages/vitnode/src/api/modules/admin/routes/session.route.ts
index 0a48349d3..6ab9ec763 100644
--- a/packages/vitnode/src/api/modules/admin/routes/session.route.ts
+++ b/packages/vitnode/src/api/modules/admin/routes/session.route.ts
@@ -1,6 +1,7 @@
import { HTTPException } from "hono/http-exception";
import { z } from "zod";
+import { resolveStaffPermissions } from "@/api/lib/check-staff-permission";
import { buildRoute } from "@/api/lib/route";
import { CONFIG_PLUGIN } from "@/config";
@@ -27,6 +28,16 @@ export const sessionAdminRoute = buildRoute({
roleId: z.number(),
birthday: z.date().nullable(),
}),
+ permissions: z.object({
+ root: z.boolean(),
+ permissions: z.array(
+ z.object({
+ plugin: z.string(),
+ module: z.string(),
+ permission: z.string(),
+ }),
+ ),
+ }),
vitnode_version: z.string(),
}),
},
@@ -38,12 +49,18 @@ export const sessionAdminRoute = buildRoute({
},
},
},
- handler: c => {
+ handler: async c => {
const user = c.get("admin")?.user;
if (!user) throw new HTTPException(403);
+ const permissions = await resolveStaffPermissions(c, {
+ type: "admin",
+ user,
+ });
+
return c.json({
user,
+ permissions,
vitnode_version: CONFIG_PLUGIN.version,
});
},
diff --git a/packages/vitnode/src/api/modules/admin/staff/lib/resolve-staff-edges.ts b/packages/vitnode/src/api/modules/admin/staff/lib/resolve-staff-edges.ts
index 6c60873d7..c7eb9eb65 100644
--- a/packages/vitnode/src/api/modules/admin/staff/lib/resolve-staff-edges.ts
+++ b/packages/vitnode/src/api/modules/admin/staff/lib/resolve-staff-edges.ts
@@ -2,19 +2,27 @@ import type { Context } from "hono";
import { eq, inArray } from "drizzle-orm";
+import { getUserRoleIds } from "@/api/lib/check-staff-permission";
import { resolveRoleNames } from "@/api/lib/resolve-role-names";
import { core_roles } from "@/database/roles";
import { core_users } from "@/database/users";
interface RawStaffEdge {
createdAt: Date;
+ data?: null | { unrestricted: boolean };
id: number;
+ protected: boolean;
roleId: null | number;
updatedAt: Date;
userId: null | number;
}
export const resolveStaffEdges = async (c: Context, edges: RawStaffEdge[]) => {
+ const currentUser = c.get("admin")?.user;
+ const currentUserRoleIds = currentUser
+ ? new Set(await getUserRoleIds(c, currentUser))
+ : new Set();
+
const entryRoleIds = edges
.map(edge => edge.roleId)
.filter((id): id is number => id != null);
@@ -55,10 +63,18 @@ export const resolveStaffEdges = async (c: Context, edges: RawStaffEdge[]) => {
const entryRole = entryRoles.find(role => role.id === edge.roleId);
const user = users.find(item => item.id === edge.userId);
+ const self =
+ currentUser != null &&
+ ((edge.userId != null && edge.userId === currentUser.id) ||
+ (edge.roleId != null && currentUserRoleIds.has(edge.roleId)));
+
return {
id: edge.id,
createdAt: edge.createdAt,
updatedAt: edge.updatedAt,
+ unrestricted: edge.data?.unrestricted ?? false,
+ protected: edge.protected,
+ self,
role: entryRole
? {
id: entryRole.id,
diff --git a/packages/vitnode/src/api/modules/admin/staff/lib/schema.ts b/packages/vitnode/src/api/modules/admin/staff/lib/schema.ts
index 6c2a5ff20..df1bc164a 100644
--- a/packages/vitnode/src/api/modules/admin/staff/lib/schema.ts
+++ b/packages/vitnode/src/api/modules/admin/staff/lib/schema.ts
@@ -14,7 +14,7 @@ export const staffListAdminQuery = zodPaginationQuery.extend({
// A role reference resolved for the `RoleFormat` component (it picks the active
// locale from the translated names on the frontend).
-const staffRoleSchema = z.object({
+export const staffRoleSchema = z.object({
id: z.number(),
color: z.string().nullable(),
name: z.array(
@@ -25,25 +25,42 @@ const staffRoleSchema = z.object({
),
});
-export const staffListAdminSchema = z.object({
- edges: z.array(
- z.object({
+// A single resolved staff entry — the shape returned by `resolveStaffEdges`.
+export const staffEntrySchema = z.object({
+ id: z.number(),
+ createdAt: z.date(),
+ updatedAt: z.date(),
+ unrestricted: z.boolean(),
+ protected: z.boolean(),
+ self: z.boolean(),
+ role: staffRoleSchema.nullable(),
+ user: z
+ .object({
id: z.number(),
- createdAt: z.date(),
- updatedAt: z.date(),
- // A staff entry grants permissions to a whole role...
- role: staffRoleSchema.nullable(),
- // ...or to a single user (rendered with their own role formatting).
- user: z
- .object({
- id: z.number(),
- name: z.string(),
- nameCode: z.string(),
- avatarColor: z.string(),
- role: staffRoleSchema,
- })
- .nullable(),
- }),
- ),
+ name: z.string(),
+ nameCode: z.string(),
+ avatarColor: z.string(),
+ role: staffRoleSchema,
+ })
+ .nullable(),
+});
+
+export const staffListAdminSchema = z.object({
+ edges: z.array(staffEntrySchema),
pageInfo: zodPaginationPageInfo,
});
+
+export const permissionsStaffArgsSchema = z.object({
+ plugin: z.string(),
+ module: z.string(),
+ permission: z.string(),
+});
+
+export const staffTypeSchema = z.enum(["admin", "moderator"]);
+
+// Maps a staff entry type to the admin permission-catalog module that gates
+// managing it, so moderator and administrator staff can be governed separately.
+export const staffPermissionModuleByType = {
+ admin: "staff_admins",
+ moderator: "staff_moderators",
+} as const;
diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/admins.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/admins.route.ts
index cac5e4797..d98cb5184 100644
--- a/packages/vitnode/src/api/modules/admin/staff/routes/admins.route.ts
+++ b/packages/vitnode/src/api/modules/admin/staff/routes/admins.route.ts
@@ -8,6 +8,7 @@ import { staffListAdminQuery, staffListAdminSchema } from "../lib/schema";
export const listAdminsStaffAdminRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: { module: "staff_admins", permission: "can_view" },
route: {
method: "get",
description: "Get list of administrators staff (Admin only)",
@@ -46,6 +47,8 @@ export const listAdminsStaffAdminRoute = buildRoute({
userId: core_admin_permissions.userId,
createdAt: core_admin_permissions.createdAt,
updatedAt: core_admin_permissions.updatedAt,
+ data: core_admin_permissions.data,
+ protected: core_admin_permissions.protected,
})
.from(core_admin_permissions)
.where(where)
diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts
new file mode 100644
index 000000000..e1a5b82fb
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts
@@ -0,0 +1,107 @@
+import { z } from "@hono/zod-openapi";
+import { eq, or } from "drizzle-orm";
+
+import { assertStaffPermission } from "@/api/lib/check-staff-permission";
+import { buildRoute } from "@/api/lib/route";
+import { CONFIG_PLUGIN } from "@/config";
+import { core_admin_permissions } from "@/database/admins";
+import { core_moderators_permissions } from "@/database/moderators";
+
+import { staffPermissionModuleByType, staffTypeSchema } from "../lib/schema";
+
+const tableByType = {
+ admin: core_admin_permissions,
+ moderator: core_moderators_permissions,
+} as const;
+
+export const createStaffAdminRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "post",
+ description: "Create a staff entry for a role or a user (Admin only)",
+ path: "/entry/{type}",
+ request: {
+ params: z.object({
+ type: staffTypeSchema,
+ }),
+ body: {
+ content: {
+ "application/json": {
+ schema: z
+ .object({
+ roleId: z.number().nullable().optional(),
+ userId: z.number().nullable().optional(),
+ })
+ // Exactly one of role/user must be provided.
+ .refine(
+ value => Boolean(value.roleId) !== Boolean(value.userId),
+ { message: "Provide exactly one of roleId or userId" },
+ ),
+ },
+ },
+ },
+ },
+ responses: {
+ 201: {
+ content: {
+ "application/json": {
+ schema: z.object({ id: z.number() }),
+ },
+ },
+ description: "Staff entry created",
+ },
+ 403: {
+ description: "Access Denied",
+ },
+ 409: {
+ content: {
+ "application/json": {
+ schema: z.object({ error: z.string() }),
+ },
+ },
+ description: "Staff entry already exists",
+ },
+ },
+ },
+ handler: async c => {
+ const { type } = c.req.valid("param");
+ await assertStaffPermission(c, {
+ type: "admin",
+ plugin: CONFIG_PLUGIN.pluginId,
+ module: staffPermissionModuleByType[type],
+ permission: "can_create",
+ });
+
+ const { roleId, userId } = c.req.valid("json");
+ const table = tableByType[type];
+
+ // Prevent assigning the same role/user twice.
+ const [existing] = await c
+ .get("db")
+ .select({ id: table.id })
+ .from(table)
+ .where(
+ or(
+ roleId ? eq(table.roleId, roleId) : undefined,
+ userId ? eq(table.userId, userId) : undefined,
+ ),
+ )
+ .limit(1);
+
+ if (existing) {
+ return c.json({ error: "Staff entry already exists" }, 409);
+ }
+
+ const [created] = await c
+ .get("db")
+ .insert(table)
+ .values({
+ roleId: roleId ?? null,
+ userId: userId ?? null,
+ data: { unrestricted: false, permissions: [] },
+ })
+ .returning({ id: table.id });
+
+ return c.json({ id: created.id }, 201);
+ },
+});
diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts
new file mode 100644
index 000000000..4f394f6a9
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts
@@ -0,0 +1,104 @@
+import { z } from "@hono/zod-openapi";
+import { eq } from "drizzle-orm";
+import { HTTPException } from "hono/http-exception";
+
+import {
+ assertStaffPermission,
+ getUserRoleIds,
+} from "@/api/lib/check-staff-permission";
+import { buildRoute } from "@/api/lib/route";
+import { CONFIG_PLUGIN } from "@/config";
+import { core_admin_permissions } from "@/database/admins";
+import { core_moderators_permissions } from "@/database/moderators";
+
+import { staffPermissionModuleByType, staffTypeSchema } from "../lib/schema";
+
+const tableByType = {
+ admin: core_admin_permissions,
+ moderator: core_moderators_permissions,
+} as const;
+
+export const deleteStaffAdminRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "delete",
+ description: "Remove a staff entry (Admin only)",
+ path: "/entry/{type}/{id}",
+ request: {
+ params: z.object({
+ type: staffTypeSchema,
+ id: z.string().openapi({ example: "1" }),
+ }),
+ },
+ responses: {
+ 200: {
+ description: "Staff entry removed",
+ },
+ 403: {
+ description: "Access Denied",
+ },
+ 404: {
+ content: {
+ "application/json": {
+ schema: z.object({ error: z.string() }),
+ },
+ },
+ description: "Staff entry not found",
+ },
+ },
+ },
+ handler: async c => {
+ const { type, id } = c.req.valid("param");
+ await assertStaffPermission(c, {
+ type: "admin",
+ plugin: CONFIG_PLUGIN.pluginId,
+ module: staffPermissionModuleByType[type],
+ permission: "can_delete",
+ });
+
+ const entryId = Number(id);
+ if (!Number.isInteger(entryId)) {
+ return c.json({ error: "Staff entry not found" }, 404);
+ }
+
+ const table = tableByType[type];
+ const [entry] = await c
+ .get("db")
+ .select({
+ protected: table.protected,
+ userId: table.userId,
+ roleId: table.roleId,
+ })
+ .from(table)
+ .where(eq(table.id, entryId))
+ .limit(1);
+
+ if (!entry) {
+ return c.json({ error: "Staff entry not found" }, 404);
+ }
+ // Protected entries are managed by the system and cannot be removed.
+ if (entry.protected) {
+ throw new HTTPException(403, { message: "Forbidden" });
+ }
+
+ // An admin cannot remove the entry that governs their own access — their own
+ // user entry or an entry for any role they belong to (primary or secondary).
+ const currentUser = c.get("admin")?.user;
+ const currentUserRoleIds = currentUser
+ ? await getUserRoleIds(c, currentUser)
+ : [];
+ const isSelf =
+ currentUser != null &&
+ ((entry.userId != null && entry.userId === currentUser.id) ||
+ (entry.roleId != null && currentUserRoleIds.includes(entry.roleId)));
+ if (isSelf) {
+ throw new HTTPException(403, {
+ message: "You cannot remove your own staff permissions.",
+ });
+ }
+
+ await c.get("db").delete(table).where(eq(table.id, entryId));
+
+ return c.body(null, 200);
+ },
+});
diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/moderators.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/moderators.route.ts
index 455053ff1..c6a908ea9 100644
--- a/packages/vitnode/src/api/modules/admin/staff/routes/moderators.route.ts
+++ b/packages/vitnode/src/api/modules/admin/staff/routes/moderators.route.ts
@@ -8,6 +8,7 @@ import { staffListAdminQuery, staffListAdminSchema } from "../lib/schema";
export const listModeratorsStaffAdminRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: { module: "staff_moderators", permission: "can_view" },
route: {
method: "get",
description: "Get list of moderators staff (Admin only)",
@@ -46,6 +47,8 @@ export const listModeratorsStaffAdminRoute = buildRoute({
userId: core_moderators_permissions.userId,
createdAt: core_moderators_permissions.createdAt,
updatedAt: core_moderators_permissions.updatedAt,
+ data: core_moderators_permissions.data,
+ protected: core_moderators_permissions.protected,
})
.from(core_moderators_permissions)
.where(where)
diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/permission-catalog.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/permission-catalog.route.ts
new file mode 100644
index 000000000..98e84721f
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/staff/routes/permission-catalog.route.ts
@@ -0,0 +1,46 @@
+import { z } from "@hono/zod-openapi";
+
+import { buildRoute } from "@/api/lib/route";
+import { CONFIG_PLUGIN } from "@/config";
+
+const permissionStaffModulesSchema = z.record(
+ z.string(),
+ z.array(
+ z.object({
+ permission: z.string(),
+ dependsOn: z.array(z.string()),
+ }),
+ ),
+);
+
+export const permissionCatalogStaffAdminRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "get",
+ description:
+ "Get the staff permission catalog declared by every plugin (Admin only)",
+ path: "/permission-catalog",
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.array(
+ z.object({
+ pluginId: z.string(),
+ admin: permissionStaffModulesSchema,
+ moderator: permissionStaffModulesSchema,
+ }),
+ ),
+ },
+ },
+ description: "Staff permission catalog",
+ },
+ 403: {
+ description: "Access Denied",
+ },
+ },
+ },
+ handler: c => {
+ return c.json(c.get("core").permissionStaff, 200);
+ },
+});
diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/show-permissions.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/show-permissions.route.ts
new file mode 100644
index 000000000..5a484b514
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/staff/routes/show-permissions.route.ts
@@ -0,0 +1,104 @@
+import { z } from "@hono/zod-openapi";
+import { eq } from "drizzle-orm";
+
+import { assertStaffPermission } from "@/api/lib/check-staff-permission";
+import { buildRoute } from "@/api/lib/route";
+import { CONFIG_PLUGIN } from "@/config";
+import { core_admin_permissions } from "@/database/admins";
+import { core_moderators_permissions } from "@/database/moderators";
+
+import { resolveStaffEdges } from "../lib/resolve-staff-edges";
+import {
+ permissionsStaffArgsSchema,
+ staffEntrySchema,
+ staffPermissionModuleByType,
+ staffTypeSchema,
+} from "../lib/schema";
+
+const tableByType = {
+ admin: core_admin_permissions,
+ moderator: core_moderators_permissions,
+} as const;
+
+export const showPermissionsStaffAdminRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "get",
+ description:
+ "Get a single staff entry with its granted permissions (Admin only)",
+ path: "/entry/{type}/{id}",
+ request: {
+ params: z.object({
+ type: staffTypeSchema,
+ id: z.string().openapi({ example: "1" }),
+ }),
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: staffEntrySchema.extend({
+ permissions: z.array(permissionsStaffArgsSchema),
+ }),
+ },
+ },
+ description: "Staff entry",
+ },
+ 403: {
+ description: "Access Denied",
+ },
+ 404: {
+ content: {
+ "application/json": {
+ schema: z.object({ error: z.string() }),
+ },
+ },
+ description: "Staff entry not found",
+ },
+ },
+ },
+ handler: async c => {
+ const { type, id } = c.req.valid("param");
+ await assertStaffPermission(c, {
+ type: "admin",
+ plugin: CONFIG_PLUGIN.pluginId,
+ module: staffPermissionModuleByType[type],
+ permission: "can_edit",
+ });
+
+ const entryId = Number(id);
+ if (!Number.isInteger(entryId)) {
+ return c.json({ error: "Staff entry not found" }, 404);
+ }
+
+ const table = tableByType[type];
+ const [entry] = await c
+ .get("db")
+ .select({
+ id: table.id,
+ roleId: table.roleId,
+ userId: table.userId,
+ createdAt: table.createdAt,
+ updatedAt: table.updatedAt,
+ data: table.data,
+ protected: table.protected,
+ })
+ .from(table)
+ .where(eq(table.id, entryId))
+ .limit(1);
+
+ if (!entry) {
+ return c.json({ error: "Staff entry not found" }, 404);
+ }
+
+ const [resolved] = await resolveStaffEdges(c, [entry]);
+
+ return c.json(
+ {
+ ...resolved,
+ permissions: entry.data?.permissions ?? [],
+ },
+ 200,
+ );
+ },
+});
diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts
new file mode 100644
index 000000000..002515b4d
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts
@@ -0,0 +1,199 @@
+import { z } from "@hono/zod-openapi";
+import { eq } from "drizzle-orm";
+import { HTTPException } from "hono/http-exception";
+
+import type { PermissionsStaffArgs } from "@/api/lib/permission-staff";
+
+import {
+ assertStaffPermission,
+ getUserRoleIds,
+} from "@/api/lib/check-staff-permission";
+import { buildRoute } from "@/api/lib/route";
+import { staffPermissionKey } from "@/api/lib/staff-permission";
+import { CONFIG_PLUGIN } from "@/config";
+import { core_admin_permissions } from "@/database/admins";
+import { core_moderators_permissions } from "@/database/moderators";
+
+import {
+ permissionsStaffArgsSchema,
+ staffPermissionModuleByType,
+ staffTypeSchema,
+} from "../lib/schema";
+
+const tableByType = {
+ admin: core_admin_permissions,
+ moderator: core_moderators_permissions,
+} as const;
+
+export const updatePermissionsStaffAdminRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "patch",
+ description: "Update the granted permissions of a staff entry (Admin only)",
+ path: "/entry/{type}/{id}",
+ request: {
+ params: z.object({
+ type: staffTypeSchema,
+ id: z.string().openapi({ example: "1" }),
+ }),
+ body: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ unrestricted: z.boolean(),
+ permissions: z.array(permissionsStaffArgsSchema),
+ }),
+ },
+ },
+ },
+ },
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ unrestricted: z.boolean(),
+ permissions: z.array(permissionsStaffArgsSchema),
+ }),
+ },
+ },
+ description: "Updated permissions",
+ },
+ 403: {
+ description: "Access Denied",
+ },
+ 404: {
+ content: {
+ "application/json": {
+ schema: z.object({ error: z.string() }),
+ },
+ },
+ description: "Staff entry not found",
+ },
+ },
+ },
+ handler: async c => {
+ const { type, id } = c.req.valid("param");
+ await assertStaffPermission(c, {
+ type: "admin",
+ plugin: CONFIG_PLUGIN.pluginId,
+ module: staffPermissionModuleByType[type],
+ permission: "can_edit",
+ });
+
+ const { unrestricted, permissions } = c.req.valid("json");
+
+ const entryId = Number(id);
+ if (!Number.isInteger(entryId)) {
+ return c.json({ error: "Staff entry not found" }, 404);
+ }
+
+ const table = tableByType[type];
+
+ const [entry] = await c
+ .get("db")
+ .select({
+ protected: table.protected,
+ userId: table.userId,
+ roleId: table.roleId,
+ })
+ .from(table)
+ .where(eq(table.id, entryId))
+ .limit(1);
+
+ if (!entry) {
+ return c.json({ error: "Staff entry not found" }, 404);
+ }
+ // Protected entries are managed by the system and cannot be edited.
+ if (entry.protected) {
+ throw new HTTPException(403, { message: "Forbidden" });
+ }
+
+ // An admin cannot edit the entry that governs their own access — their own
+ // user entry or an entry for any role they belong to (primary or
+ // secondary). This stops them from escalating their own permissions.
+ const currentUser = c.get("admin")?.user;
+ const currentUserRoleIds = currentUser
+ ? await getUserRoleIds(c, currentUser)
+ : [];
+ const isSelf =
+ currentUser != null &&
+ ((entry.userId != null && entry.userId === currentUser.id) ||
+ (entry.roleId != null && currentUserRoleIds.includes(entry.roleId)));
+ if (isSelf) {
+ throw new HTTPException(403, {
+ message: "You cannot edit your own staff permissions.",
+ });
+ }
+
+ // Only persist permissions that actually exist in the catalog for this
+ // staff type — silently drops anything unknown/forged. Also record each
+ // permission's dependencies (the keys of the permissions it `dependsOn`
+ // within the same module) so we can drop grants whose gate is missing.
+ const allowed = new Set();
+ const dependencies = new Map();
+ for (const plugin of c.get("core").permissionStaff) {
+ for (const [module, modulePermissions] of Object.entries(plugin[type])) {
+ for (const entry of modulePermissions) {
+ const key = staffPermissionKey({
+ plugin: plugin.pluginId,
+ module,
+ permission: entry.permission,
+ });
+ allowed.add(key);
+ dependencies.set(
+ key,
+ entry.dependsOn.map(dependency =>
+ staffPermissionKey({
+ plugin: plugin.pluginId,
+ module,
+ permission: dependency,
+ }),
+ ),
+ );
+ }
+ }
+ }
+
+ const seen = new Set();
+ const granted = new Map();
+ // When unrestricted, the explicit list is irrelevant — store none.
+ if (!unrestricted) {
+ for (const permission of permissions) {
+ const key = staffPermissionKey(permission);
+ if (!allowed.has(key) || seen.has(key)) continue;
+ seen.add(key);
+ granted.set(key, permission);
+ }
+
+ // Drop any granted permission whose dependencies aren't all granted too,
+ // repeating until stable so a broken chain (a → b → c) collapses fully.
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const key of granted.keys()) {
+ const deps = dependencies.get(key) ?? [];
+ if (deps.some(dependency => !granted.has(dependency))) {
+ granted.delete(key);
+ changed = true;
+ }
+ }
+ }
+ }
+
+ const sanitized: PermissionsStaffArgs[] = [...granted.values()];
+
+ const [updated] = await c
+ .get("db")
+ .update(table)
+ .set({ data: { unrestricted, permissions: sanitized } })
+ .where(eq(table.id, entryId))
+ .returning({ id: table.id });
+
+ if (!updated) {
+ return c.json({ error: "Staff entry not found" }, 404);
+ }
+
+ return c.json({ unrestricted, permissions: sanitized }, 200);
+ },
+});
diff --git a/packages/vitnode/src/api/modules/admin/staff/staff.admin.module.ts b/packages/vitnode/src/api/modules/admin/staff/staff.admin.module.ts
index fb58b6309..7b66659c2 100644
--- a/packages/vitnode/src/api/modules/admin/staff/staff.admin.module.ts
+++ b/packages/vitnode/src/api/modules/admin/staff/staff.admin.module.ts
@@ -2,10 +2,23 @@ import { buildModule } from "@/api/lib/module";
import { CONFIG_PLUGIN } from "@/config";
import { listAdminsStaffAdminRoute } from "./routes/admins.route";
+import { createStaffAdminRoute } from "./routes/create.route";
+import { deleteStaffAdminRoute } from "./routes/delete.route";
import { listModeratorsStaffAdminRoute } from "./routes/moderators.route";
+import { permissionCatalogStaffAdminRoute } from "./routes/permission-catalog.route";
+import { showPermissionsStaffAdminRoute } from "./routes/show-permissions.route";
+import { updatePermissionsStaffAdminRoute } from "./routes/update-permissions.route";
export const staffAdminModule = buildModule({
pluginId: CONFIG_PLUGIN.pluginId,
name: "staff",
- routes: [listModeratorsStaffAdminRoute, listAdminsStaffAdminRoute],
+ routes: [
+ listModeratorsStaffAdminRoute,
+ listAdminsStaffAdminRoute,
+ permissionCatalogStaffAdminRoute,
+ showPermissionsStaffAdminRoute,
+ updatePermissionsStaffAdminRoute,
+ createStaffAdminRoute,
+ deleteStaffAdminRoute,
+ ],
});
diff --git a/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.ts b/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.ts
new file mode 100644
index 000000000..301342c07
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/users/lib/assert-edit-user-permission.ts
@@ -0,0 +1,22 @@
+import type { Context } from "hono";
+
+import { assertStaffPermission } from "@/api/lib/check-staff-permission";
+import { SessionAdminModel } from "@/api/models/session-admin";
+import { CONFIG_PLUGIN } from "@/config";
+
+export const assertCanEditAdminTarget = async (
+ c: Context,
+ userId: number,
+): Promise => {
+ const isTargetAdmin = await new SessionAdminModel(c).checkIfUserIsAdmin(
+ userId,
+ );
+ if (!isTargetAdmin) return;
+
+ await assertStaffPermission(c, {
+ type: "admin",
+ plugin: CONFIG_PLUGIN.pluginId,
+ module: "users",
+ permission: "can_edit_admin",
+ });
+};
diff --git a/packages/vitnode/src/api/modules/admin/users/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/create.route.ts
index ecb8041d7..0ea5110b7 100644
--- a/packages/vitnode/src/api/modules/admin/users/routes/create.route.ts
+++ b/packages/vitnode/src/api/modules/admin/users/routes/create.route.ts
@@ -25,6 +25,7 @@ export const zodCreateUserAdminSchema = z.object({
export const createUserAdminRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: { module: "users", permission: "can_create" },
route: {
method: "post",
description: "Create a new user (Admin only)",
diff --git a/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts
index 0557e5da5..2bde26cce 100644
--- a/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts
+++ b/packages/vitnode/src/api/modules/admin/users/routes/list.route.ts
@@ -14,6 +14,7 @@ import { core_users, core_users_secondary_roles } from "@/database/users";
export const listUsersAdminRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: { module: "users", permission: "can_view" },
route: {
method: "get",
description: "Get list of all users",
diff --git a/packages/vitnode/src/api/modules/admin/users/routes/show.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/show.route.ts
index 43b4a47e0..62cc90dd1 100644
--- a/packages/vitnode/src/api/modules/admin/users/routes/show.route.ts
+++ b/packages/vitnode/src/api/modules/admin/users/routes/show.route.ts
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { resolveRoleNames } from "@/api/lib/resolve-role-names";
import { buildRoute } from "@/api/lib/route";
+import { SessionAdminModel } from "@/api/models/session-admin";
import { UserModel } from "@/api/models/user";
import { CONFIG_PLUGIN } from "@/config";
import { core_roles } from "@/database/roles";
@@ -21,6 +22,7 @@ const roleSchema = z.object({
export const showUserAdminRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: { module: "users", permission: "can_view" },
route: {
method: "get",
description: "Get a single user by id (Admin only)",
@@ -48,6 +50,7 @@ export const showUserAdminRoute = buildRoute({
secondaryRoles: z.array(roleSchema),
birthday: z.date().nullable(),
language: z.string(),
+ isAdmin: z.boolean(),
}),
},
},
@@ -108,9 +111,15 @@ export const showUserAdminRoute = buildRoute({
...secondaryRoleRows.map(role => role.id),
]);
+ // Whether the listed user is themselves an administrator. The frontend uses
+ // this to require the elevated `can_edit_admin` permission before showing
+ // edit controls (the backend enforces the same rule on write).
+ const isAdmin = await new SessionAdminModel(c).checkIfUserIsAdmin(user.id);
+
return c.json(
{
...user,
+ isAdmin,
role: {
id: user.roleId,
color: primaryRole?.color ?? null,
diff --git a/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts
index 4a2c36366..29eeb5793 100644
--- a/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts
+++ b/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts
@@ -6,6 +6,8 @@ import { CONFIG_PLUGIN } from "@/config";
import { core_roles } from "@/database/roles";
import { core_users, core_users_secondary_roles } from "@/database/users";
+import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission";
+
const nameRegex = /^(?!.* {2})[\p{L}\p{N}._@ -]*$/u;
export const zodUpdateUserAdminSchema = z
@@ -38,6 +40,7 @@ export const zodUpdateUserAdminSchema = z
export const updateUserAdminRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: { module: "users", permission: "can_edit" },
route: {
method: "patch",
description: "Update a user's name or email by id (Admin only)",
@@ -118,6 +121,8 @@ export const updateUserAdminRoute = buildRoute({
return c.json({ error: "User not found" }, 404);
}
+ await assertCanEditAdminTarget(c, userId);
+
const values: Partial = {};
if (body.email !== undefined) {
diff --git a/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts
index 5e73a915b..a7f9b63cf 100644
--- a/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts
+++ b/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts
@@ -5,8 +5,11 @@ import { buildRoute } from "@/api/lib/route";
import { CONFIG_PLUGIN } from "@/config";
import { core_users } from "@/database/users";
+import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission";
+
export const verifyEmailUserAdminRoute = buildRoute({
pluginId: CONFIG_PLUGIN.pluginId,
+ adminStaffPermission: { module: "users", permission: "can_edit" },
route: {
method: "post",
description: "Verify a user's email by id (Admin only)",
@@ -50,20 +53,29 @@ export const verifyEmailUserAdminRoute = buildRoute({
return c.json({ error: "User not found" }, 404);
}
- const [updated] = await c
- .get("db")
+ const db = c.get("db");
+
+ const [user] = await db
+ .select({ id: core_users.id })
+ .from(core_users)
+ .where(eq(core_users.id, userId))
+ .limit(1);
+
+ if (!user) {
+ return c.json({ error: "User not found" }, 404);
+ }
+
+ await assertCanEditAdminTarget(c, userId);
+
+ const [updated] = await db
.update(core_users)
.set({ emailVerified: true })
- .where(eq(core_users.id, userId))
+ .where(eq(core_users.id, user.id))
.returning({
name: core_users.name,
emailVerified: core_users.emailVerified,
});
- if (!updated) {
- return c.json({ error: "User not found" }, 404);
- }
-
return c.json(
{ name: updated.name, emailVerified: updated.emailVerified },
200,
diff --git a/packages/vitnode/src/api/modules/users/routes/permissions.route.ts b/packages/vitnode/src/api/modules/users/routes/permissions.route.ts
new file mode 100644
index 000000000..213d475df
--- /dev/null
+++ b/packages/vitnode/src/api/modules/users/routes/permissions.route.ts
@@ -0,0 +1,47 @@
+import { z } from "zod";
+
+import { resolveStaffPermissions } from "@/api/lib/check-staff-permission";
+import { buildRoute } from "@/api/lib/route";
+import { CONFIG_PLUGIN } from "@/config";
+
+export const permissionsRoute = buildRoute({
+ pluginId: CONFIG_PLUGIN.pluginId,
+ route: {
+ method: "get",
+ description:
+ "Get the current user's effective moderator permissions (public site)",
+ path: "/permissions",
+ responses: {
+ 200: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ root: z.boolean(),
+ permissions: z.array(
+ z.object({
+ plugin: z.string(),
+ module: z.string(),
+ permission: z.string(),
+ }),
+ ),
+ }),
+ },
+ },
+ description: "Effective moderator permissions",
+ },
+ },
+ },
+ handler: async c => {
+ const user = c.get("user");
+ if (!user) {
+ return c.json({ root: false, permissions: [] }, 200);
+ }
+
+ const permissions = await resolveStaffPermissions(c, {
+ type: "moderator",
+ user,
+ });
+
+ return c.json(permissions, 200);
+ },
+});
diff --git a/packages/vitnode/src/api/modules/users/users.module.ts b/packages/vitnode/src/api/modules/users/users.module.ts
index 9c1fc6ec6..0af6965ac 100644
--- a/packages/vitnode/src/api/modules/users/users.module.ts
+++ b/packages/vitnode/src/api/modules/users/users.module.ts
@@ -2,6 +2,7 @@ import { buildModule } from "@/api/lib/module";
import { CONFIG_PLUGIN } from "@/config";
import { changePasswordRoute } from "./routes/change-password.route";
+import { permissionsRoute } from "./routes/permissions.route";
import { resetPasswordRoute } from "./routes/reset-passowrd.route";
import { sessionRoute } from "./routes/session.route";
import { signInRoute } from "./routes/sign-in.route";
@@ -21,6 +22,7 @@ export const usersModule = buildModule({
testRoute,
resetPasswordRoute,
changePasswordRoute,
+ permissionsRoute,
],
modules: [ssoUserModule],
});
diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts
index 12c04d132..de772a6da 100644
--- a/packages/vitnode/src/api/plugin.ts
+++ b/packages/vitnode/src/api/plugin.ts
@@ -9,4 +9,34 @@ import { usersModule } from "./modules/users/users.module";
export const newBuildPluginApiCore = buildApiPlugin({
pluginId: CONFIG_PLUGIN.pluginId,
modules: [middlewareModule, usersModule, adminModule, cronModule],
+ permissionStaff: {
+ moderator: {
+ users: ["can_edit"],
+ },
+ admin: {
+ users: [
+ "can_view",
+ { permission: "can_create", dependsOn: ["can_view"] },
+ { permission: "can_edit", dependsOn: ["can_view"] },
+ { permission: "can_edit_admin", dependsOn: ["can_view"] },
+ ],
+ roles: ["can_manage"],
+ debug: [
+ "can_view",
+ { permission: "can_clear_cache", dependsOn: ["can_view"] },
+ ],
+ staff_moderators: [
+ "can_view",
+ { permission: "can_create", dependsOn: ["can_view"] },
+ { permission: "can_edit", dependsOn: ["can_view"] },
+ { permission: "can_delete", dependsOn: ["can_view"] },
+ ],
+ staff_admins: [
+ "can_view",
+ { permission: "can_create", dependsOn: ["can_view"] },
+ { permission: "can_edit", dependsOn: ["can_view"] },
+ { permission: "can_delete", dependsOn: ["can_view"] },
+ ],
+ },
+ },
});
diff --git a/packages/vitnode/src/components/staff-permission/provider.tsx b/packages/vitnode/src/components/staff-permission/provider.tsx
new file mode 100644
index 000000000..95055184a
--- /dev/null
+++ b/packages/vitnode/src/components/staff-permission/provider.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import React from "react";
+
+import type {
+ PermissionsStaffArgs,
+ StaffPermissionSet,
+} from "@/api/lib/permission-staff";
+
+import { hasStaffPermission } from "@/api/lib/staff-permission";
+
+const AdminStaffPermissionContext = React.createContext({
+ root: false,
+ permissions: [],
+});
+
+/**
+ * Makes the current **admin's** effective permissions available to client
+ * components. Rendered once near the top of the admin layout.
+ */
+export const AdminStaffPermissionProvider = ({
+ value,
+ children,
+}: {
+ children: React.ReactNode;
+ value: StaffPermissionSet;
+}) => (
+
+ {children}
+
+);
+
+/** Returns the current admin's raw effective permission set. */
+export const useAdminStaffPermissions = (): StaffPermissionSet =>
+ React.use(AdminStaffPermissionContext);
+
+/** Returns whether the current admin holds a given permission. */
+export const useAdminStaffPermission = (
+ args: PermissionsStaffArgs,
+): boolean => {
+ const set = useAdminStaffPermissions();
+
+ return hasStaffPermission(set, args);
+};
+
+/**
+ * Renders `children` only when the current admin holds the given permission,
+ * otherwise `fallback` (defaults to nothing).
+ */
+export const AdminStaffPermissionGate = ({
+ plugin,
+ module,
+ permission,
+ children,
+ fallback = null,
+}: PermissionsStaffArgs & {
+ children: React.ReactNode;
+ fallback?: React.ReactNode;
+}) => {
+ const allowed = useAdminStaffPermission({ plugin, module, permission });
+
+ return <>{allowed ? children : fallback}>;
+};
diff --git a/packages/vitnode/src/components/theme-provider.tsx b/packages/vitnode/src/components/theme-provider.tsx
index 94356b1e8..b1a3986c9 100644
--- a/packages/vitnode/src/components/theme-provider.tsx
+++ b/packages/vitnode/src/components/theme-provider.tsx
@@ -1,7 +1,7 @@
"use client";
import { useServerInsertedHTML } from "next/navigation";
-import * as React from "react";
+import React from "react";
const MEDIA = "(prefers-color-scheme: dark)";
const colorSchemes = ["light", "dark"];
diff --git a/packages/vitnode/src/components/ui/accordion.tsx b/packages/vitnode/src/components/ui/accordion.tsx
index 2753fc0aa..c360dbcb9 100644
--- a/packages/vitnode/src/components/ui/accordion.tsx
+++ b/packages/vitnode/src/components/ui/accordion.tsx
@@ -2,7 +2,7 @@
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
-import * as React from "react";
+import React from "react";
import { cn } from "@/lib/utils";
diff --git a/packages/vitnode/src/components/ui/alert-dialog.tsx b/packages/vitnode/src/components/ui/alert-dialog.tsx
index bd653acf3..d80c4b729 100644
--- a/packages/vitnode/src/components/ui/alert-dialog.tsx
+++ b/packages/vitnode/src/components/ui/alert-dialog.tsx
@@ -67,7 +67,7 @@ function AlertDialogOverlay({
return (
new Date()),
protected: t.boolean().notNull().default(false),
- // data: t.jsonb().$type<{ permissions: PermissionsStaffArgs[] }>().default({
- // permissions: [],
- // }),
+ data: t
+ .jsonb()
+ .$type()
+ .notNull()
+ .default({ unrestricted: false, permissions: [] }),
}),
t => [
index("core_admin_permissions_role_id_idx").on(t.roleId),
diff --git a/packages/vitnode/src/database/moderators.ts b/packages/vitnode/src/database/moderators.ts
index 9e2f22e35..cc85ef3cb 100644
--- a/packages/vitnode/src/database/moderators.ts
+++ b/packages/vitnode/src/database/moderators.ts
@@ -1,6 +1,8 @@
import { relations } from "drizzle-orm";
import { index, pgTable } from "drizzle-orm/pg-core";
+import type { StaffPermissionsData } from "@/api/lib/permission-staff";
+
import { core_roles } from "./roles";
import { core_users } from "./users";
@@ -20,6 +22,11 @@ export const core_moderators_permissions = pgTable(
.notNull()
.$onUpdate(() => new Date()),
protected: t.boolean().notNull().default(false),
+ data: t
+ .jsonb()
+ .$type()
+ .notNull()
+ .default({ unrestricted: false, permissions: [] }),
}),
t => [
index("core_moderators_permissions_role_id_idx").on(t.roleId),
diff --git a/packages/vitnode/src/hooks/use-mobile.ts b/packages/vitnode/src/hooks/use-mobile.ts
index 50c341613..90940c989 100644
--- a/packages/vitnode/src/hooks/use-mobile.ts
+++ b/packages/vitnode/src/hooks/use-mobile.ts
@@ -1,4 +1,4 @@
-import * as React from "react";
+import React from "react";
const MOBILE_BREAKPOINT = 768;
diff --git a/packages/vitnode/src/lib/api/get-moderator-permissions-api.ts b/packages/vitnode/src/lib/api/get-moderator-permissions-api.ts
new file mode 100644
index 000000000..ca84a06e2
--- /dev/null
+++ b/packages/vitnode/src/lib/api/get-moderator-permissions-api.ts
@@ -0,0 +1,31 @@
+import type {
+ PermissionsStaffArgs,
+ StaffPermissionSet,
+} from "@/api/lib/permission-staff";
+
+import { hasStaffPermission } from "@/api/lib/staff-permission";
+import { usersModule } from "@/api/modules/users/users.module";
+import { fetcher } from "@/lib/fetcher";
+
+export const getModeratorPermissionsApi =
+ async (): Promise => {
+ const res = await fetcher(usersModule, {
+ path: "/permissions",
+ method: "get",
+ module: "users",
+ });
+
+ if (res.status !== 200) {
+ return { root: false, permissions: [] };
+ }
+
+ return await res.json();
+ };
+
+export const checkModeratorPermissionApi = async (
+ args: PermissionsStaffArgs,
+): Promise => {
+ const set = await getModeratorPermissionsApi();
+
+ return hasStaffPermission(set, args);
+};
diff --git a/packages/vitnode/src/lib/api/get-session-admin-api.ts b/packages/vitnode/src/lib/api/get-session-admin-api.ts
index cf294e5eb..14f2c0a08 100644
--- a/packages/vitnode/src/lib/api/get-session-admin-api.ts
+++ b/packages/vitnode/src/lib/api/get-session-admin-api.ts
@@ -1,4 +1,8 @@
+import type { PermissionsStaffArgs } from "@/api/lib/permission-staff";
+
+import { hasStaffPermission } from "@/api/lib/staff-permission";
import { adminModule } from "@/api/modules/admin/admin.module";
+import { CONFIG_PLUGIN } from "@/config";
import { fetcher } from "@/lib/fetcher";
import { redirect } from "../navigation";
@@ -23,3 +27,20 @@ export const getSessionAdminApi = async () => {
return data;
};
+
+export const checkAdminPermissionApi = async ({
+ plugin = CONFIG_PLUGIN.pluginId,
+ module,
+ permission,
+}: Omit & {
+ plugin?: string;
+}): Promise => {
+ const session = await getSessionAdminApi();
+ if (!session) return false;
+
+ return hasStaffPermission(session.permissions, {
+ plugin,
+ module,
+ permission,
+ });
+};
diff --git a/packages/vitnode/src/lib/plugin.ts b/packages/vitnode/src/lib/plugin.ts
index 68c728e25..3cc12efed 100644
--- a/packages/vitnode/src/lib/plugin.ts
+++ b/packages/vitnode/src/lib/plugin.ts
@@ -1,10 +1,19 @@
+import type { PermissionsStaffArgs } from "../api/lib/permission-staff";
import type { ItemNavAdmin } from "../views/admin/layouts/sidebar/nav/item";
+/**
+ * A staff permission a nav item is gated by, scoped to the declaring plugin
+ * (the `plugin` is filled in automatically from the plugin's id). When set, the
+ * item is hidden from the admin sidebar unless the current admin holds it.
+ */
+export type AdminNavPermission = Omit;
+
interface AdminNavItem extends Pick<
React.ComponentProps,
"href" | "icon" | "isOpenInNewTab"
> {
id: string;
+ permission?: AdminNavPermission;
}
export interface BuildPluginReturn
{
diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json
index f19e5c2da..037363e32 100644
--- a/packages/vitnode/src/locales/en.json
+++ b/packages/vitnode/src/locales/en.json
@@ -1,4 +1,27 @@
{
+ "@vitnode/core": {
+ "title": "Core"
+ },
+ "@vitnode/core:users": "Users",
+ "@vitnode/core:users:can_view": "View users list",
+ "@vitnode/core:users:can_create": "Create users",
+ "@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:debug": "Debug Panel",
+ "@vitnode/core:debug:can_view": "View debug panel",
+ "@vitnode/core:debug:can_clear_cache": "Clear cache",
+ "@vitnode/core:staff_moderators": "Staff: Moderators",
+ "@vitnode/core:staff_moderators:can_view": "View moderators list",
+ "@vitnode/core:staff_moderators:can_create": "Create moderators",
+ "@vitnode/core:staff_moderators:can_edit": "Edit moderator permissions",
+ "@vitnode/core:staff_moderators:can_delete": "Remove moderators",
+ "@vitnode/core:staff_admins": "Staff: Administrators",
+ "@vitnode/core:staff_admins:can_view": "View administrators list",
+ "@vitnode/core:staff_admins:can_create": "Create administrators",
+ "@vitnode/core:staff_admins:can_edit": "Edit administrator permissions",
+ "@vitnode/core:staff_admins:can_delete": "Remove administrators",
"core": {
"global": {
"close": "Close",
@@ -230,8 +253,12 @@
"users": {
"title": "Users",
"list": "User List",
- "roles": "Roles",
- "staff": "Staff"
+ "roles": "Roles"
+ },
+ "staff": {
+ "title": "Staff",
+ "moderators": "Moderators",
+ "admins": "Administrators"
},
"user_bar": {
"home_page": "Home Page",
@@ -362,6 +389,14 @@
"staff": {
"title": "Staff",
"desc": "Manage the staff of your application.",
+ "protected": "Protected",
+ "self": "You cannot edit your own permissions",
+ "delete": {
+ "title": "Remove staff member?",
+ "desc": "This revokes the assigned staff access. This action cannot be undone.",
+ "confirm": "Yes, remove",
+ "success": "Staff member removed."
+ },
"tabs": {
"moderators": "Moderators",
"admins": "Administrators"
@@ -369,15 +404,73 @@
"table": {
"role": "Role",
"user": "User",
- "updatedAt": "Updated At"
+ "permissions": "Permissions",
+ "unrestricted": "Unrestricted",
+ "restricted": "Restricted",
+ "updatedAt": "Updated At",
+ "edit": "Edit permissions"
+ },
+ "edit": {
+ "title": "Edit permissions",
+ "subject": "For",
+ "back": "Back",
+ "save": "Save changes",
+ "success": "Permissions updated successfully.",
+ "error": "Failed to update permissions.",
+ "protected": "This entry is protected and its permissions cannot be edited.",
+ "self": "You cannot edit your own staff permissions, including the entry for your main role.",
+ "no_permissions": "No plugins have declared staff permissions yet.",
+ "select_all": "Enable all",
+ "clear_all": "Disable all",
+ "search_plugins": "Search plugins",
+ "search_empty": "No plugins match your search.",
+ "granted": "{granted}/{total} granted",
+ "requires": "Requires {permission}",
+ "mode": {
+ "label": "Access level",
+ "unrestricted": {
+ "label": "Unrestricted",
+ "desc": "Grant every permission, including ones added later."
+ },
+ "restricted": {
+ "label": "Restricted",
+ "desc": "Choose exactly which permissions apply."
+ }
+ }
+ },
+ "create": {
+ "admins": "Add administrator",
+ "moderators": "Add moderator",
+ "desc": "Grant staff access to a role or a specific user.",
+ "button": "Add member",
+ "back": "Back",
+ "assign_to": "Assign to",
+ "tabs": {
+ "role": "Role",
+ "role_desc": "Grant staff access to everyone with a role.",
+ "user": "User",
+ "user_desc": "Grant staff access to a single person."
+ },
+ "select_role": "Select a role",
+ "search_user": "Search by name or email",
+ "submit": "Add member",
+ "success": "Staff member added.",
+ "error": "Failed to add staff member.",
+ "already_exists": "This role or user is already a staff member."
},
"moderators": {
+ "title": "Moderators",
+ "desc": "Manage the moderators of your application.",
+ "create": "Add Moderator",
"noResults": {
"title": "No moderators found",
"description": "Assign a role or user to grant moderator permissions."
}
},
"admins": {
+ "title": "Administrators",
+ "desc": "Manage the administrators of your application.",
+ "create": "Add Administrator",
"noResults": {
"title": "No administrators found",
"description": "Assign a role or user to grant administrator permissions."
diff --git a/packages/vitnode/src/routes/admin/core/debug/page.tsx b/packages/vitnode/src/routes/admin/core/debug/page.tsx
index bec9ba007..881d4cc39 100644
--- a/packages/vitnode/src/routes/admin/core/debug/page.tsx
+++ b/packages/vitnode/src/routes/admin/core/debug/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";
import { ClearCacheAction } from "@/views/admin/views/core/debug/actions/clear-cache/clear-cache";
const SystemLogsView = dynamic(async () =>
@@ -27,13 +29,21 @@ export const generateMetadata = async () => {
export default async function Page(
props: React.ComponentProps,
) {
- const t = await getTranslations("admin.debug");
+ const [t, canView, canClearCache] = await Promise.all([
+ getTranslations("admin.debug"),
+ checkAdminPermissionApi({ module: "debug", permission: "can_view" }),
+ checkAdminPermissionApi({ module: "debug", permission: "can_clear_cache" }),
+ ]);
+
+ if (!canView) {
+ notFound();
+ }
return (
-
+ {canClearCache && }
diff --git a/packages/vitnode/src/routes/admin/core/staff/admins/create/page.tsx b/packages/vitnode/src/routes/admin/core/staff/admins/create/page.tsx
new file mode 100644
index 000000000..dffd7d64b
--- /dev/null
+++ b/packages/vitnode/src/routes/admin/core/staff/admins/create/page.tsx
@@ -0,0 +1,17 @@
+import React from "react";
+
+import { I18nProvider } from "@/components/i18n-provider";
+import { Loader } from "@/components/ui/loader";
+import { CreateStaffPermissionsView } from "@/views/admin/views/core/staff/create/create-staff-permissions-view";
+
+export default function Page() {
+ return (
+
+
+ }>
+
+
+
+
+ );
+}
diff --git a/packages/vitnode/src/routes/admin/core/staff/admins/edit/[id]/page.tsx b/packages/vitnode/src/routes/admin/core/staff/admins/edit/[id]/page.tsx
new file mode 100644
index 000000000..8398da931
--- /dev/null
+++ b/packages/vitnode/src/routes/admin/core/staff/admins/edit/[id]/page.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+
+import { I18nProvider } from "@/components/i18n-provider";
+import { Loader } from "@/components/ui/loader";
+import { EditStaffPermissionsView } from "@/views/admin/views/core/staff/edit/edit-staff-permissions-view";
+
+export default async function Page({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+
+ return (
+
+
+ }>
+
+
+
+
+ );
+}
diff --git a/packages/vitnode/src/routes/admin/core/users/staff/admins/page.tsx b/packages/vitnode/src/routes/admin/core/staff/admins/page.tsx
similarity index 56%
rename from packages/vitnode/src/routes/admin/core/users/staff/admins/page.tsx
rename to packages/vitnode/src/routes/admin/core/staff/admins/page.tsx
index ea0dd8ff1..724e77d6f 100644
--- a/packages/vitnode/src/routes/admin/core/users/staff/admins/page.tsx
+++ b/packages/vitnode/src/routes/admin/core/staff/admins/page.tsx
@@ -1,14 +1,14 @@
import React from "react";
-import { DataTableSkeleton } from "@/components/table/data-table";
-import { AdminsStaffAdminView } from "@/views/admin/views/core/staff/admins/admins-staff-view";
+import { I18nProvider } from "@/components/i18n-provider";
+import { AdminsStaffAdminView } from "@/views/admin/views/core/staff/views/admins/admins-staff-view";
export default function Page(
props: React.ComponentProps,
) {
return (
- }>
+
-
+
);
}
diff --git a/packages/vitnode/src/routes/admin/core/staff/moderators/create/page.tsx b/packages/vitnode/src/routes/admin/core/staff/moderators/create/page.tsx
new file mode 100644
index 000000000..8b5dfa8a3
--- /dev/null
+++ b/packages/vitnode/src/routes/admin/core/staff/moderators/create/page.tsx
@@ -0,0 +1,17 @@
+import React from "react";
+
+import { I18nProvider } from "@/components/i18n-provider";
+import { Loader } from "@/components/ui/loader";
+import { CreateStaffPermissionsView } from "@/views/admin/views/core/staff/create/create-staff-permissions-view";
+
+export default function Page() {
+ return (
+
+
+ }>
+
+
+
+
+ );
+}
diff --git a/packages/vitnode/src/routes/admin/core/staff/moderators/edit/[id]/page.tsx b/packages/vitnode/src/routes/admin/core/staff/moderators/edit/[id]/page.tsx
new file mode 100644
index 000000000..4aec66910
--- /dev/null
+++ b/packages/vitnode/src/routes/admin/core/staff/moderators/edit/[id]/page.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+
+import { I18nProvider } from "@/components/i18n-provider";
+import { Loader } from "@/components/ui/loader";
+import { EditStaffPermissionsView } from "@/views/admin/views/core/staff/edit/edit-staff-permissions-view";
+
+export default async function Page({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+
+ return (
+
+
+ }>
+
+
+
+
+ );
+}
diff --git a/packages/vitnode/src/routes/admin/core/users/staff/moderators/page.tsx b/packages/vitnode/src/routes/admin/core/staff/moderators/page.tsx
similarity index 56%
rename from packages/vitnode/src/routes/admin/core/users/staff/moderators/page.tsx
rename to packages/vitnode/src/routes/admin/core/staff/moderators/page.tsx
index f98b7bb3f..8a80c7e87 100644
--- a/packages/vitnode/src/routes/admin/core/users/staff/moderators/page.tsx
+++ b/packages/vitnode/src/routes/admin/core/staff/moderators/page.tsx
@@ -1,14 +1,14 @@
import React from "react";
-import { DataTableSkeleton } from "@/components/table/data-table";
-import { ModeratorsStaffAdminView } from "@/views/admin/views/core/staff/moderators/moderators-staff-view";
+import { I18nProvider } from "@/components/i18n-provider";
+import { ModeratorsStaffAdminView } from "@/views/admin/views/core/staff/views/moderators/moderators-staff-view";
export default function Page(
props: React.ComponentProps,
) {
return (
- }>
+
-
+
);
}
diff --git a/packages/vitnode/src/routes/admin/core/users/page.tsx b/packages/vitnode/src/routes/admin/core/users/page.tsx
index 0d9a094e2..37d5801ee 100644
--- a/packages/vitnode/src/routes/admin/core/users/page.tsx
+++ b/packages/vitnode/src/routes/admin/core/users/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 { CreateUserAdmin } from "@/views/admin/views/core/users/actions/create/create";
const UsersAdminView = dynamic(async () =>
@@ -26,16 +28,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.user.list"),
getTranslations("admin.global.nav.users"),
+ checkAdminPermissionApi({ module: "users", permission: "can_view" }),
+ checkAdminPermissionApi({ module: "users", permission: "can_create" }),
]);
+ if (!canView) {
+ notFound();
+ }
+
return (
-
+ {canCreate && }
}>
diff --git a/packages/vitnode/src/routes/admin/core/users/staff/layout.tsx b/packages/vitnode/src/routes/admin/core/users/staff/layout.tsx
deleted file mode 100644
index dacc297c7..000000000
--- a/packages/vitnode/src/routes/admin/core/users/staff/layout.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import { LayoutStaffAdmin } from "@/views/admin/views/core/staff/layout";
-
-export default function Layout(
- props: React.ComponentProps,
-) {
- return ;
-}
diff --git a/packages/vitnode/src/routes/breadcrumb/admin/core/staff/admins/create/page.tsx b/packages/vitnode/src/routes/breadcrumb/admin/core/staff/admins/create/page.tsx
new file mode 100644
index 000000000..21f67c0e4
--- /dev/null
+++ b/packages/vitnode/src/routes/breadcrumb/admin/core/staff/admins/create/page.tsx
@@ -0,0 +1,5 @@
+import { BreadcrumbStaffCreateAdmin } from "@/views/admin/layouts/breadcrumb/breadcrumb-staff-create-admin";
+
+export default function BreadcrumbSlot() {
+ return ;
+}
diff --git a/packages/vitnode/src/routes/breadcrumb/admin/core/staff/admins/edit/[id]/page.tsx b/packages/vitnode/src/routes/breadcrumb/admin/core/staff/admins/edit/[id]/page.tsx
new file mode 100644
index 000000000..6624b1395
--- /dev/null
+++ b/packages/vitnode/src/routes/breadcrumb/admin/core/staff/admins/edit/[id]/page.tsx
@@ -0,0 +1,11 @@
+import { BreadcrumbStaffEditAdmin } from "@/views/admin/layouts/breadcrumb/breadcrumb-staff-edit-admin";
+
+export default async function BreadcrumbSlot({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+
+ return ;
+}
diff --git a/packages/vitnode/src/routes/breadcrumb/admin/core/users/staff/admins/page.tsx b/packages/vitnode/src/routes/breadcrumb/admin/core/staff/admins/page.tsx
similarity index 100%
rename from packages/vitnode/src/routes/breadcrumb/admin/core/users/staff/admins/page.tsx
rename to packages/vitnode/src/routes/breadcrumb/admin/core/staff/admins/page.tsx
diff --git a/packages/vitnode/src/routes/breadcrumb/admin/core/staff/moderators/create/page.tsx b/packages/vitnode/src/routes/breadcrumb/admin/core/staff/moderators/create/page.tsx
new file mode 100644
index 000000000..05c04a02b
--- /dev/null
+++ b/packages/vitnode/src/routes/breadcrumb/admin/core/staff/moderators/create/page.tsx
@@ -0,0 +1,5 @@
+import { BreadcrumbStaffCreateAdmin } from "@/views/admin/layouts/breadcrumb/breadcrumb-staff-create-admin";
+
+export default function BreadcrumbSlot() {
+ return ;
+}
diff --git a/packages/vitnode/src/routes/breadcrumb/admin/core/staff/moderators/edit/[id]/page.tsx b/packages/vitnode/src/routes/breadcrumb/admin/core/staff/moderators/edit/[id]/page.tsx
new file mode 100644
index 000000000..8101841a7
--- /dev/null
+++ b/packages/vitnode/src/routes/breadcrumb/admin/core/staff/moderators/edit/[id]/page.tsx
@@ -0,0 +1,11 @@
+import { BreadcrumbStaffEditAdmin } from "@/views/admin/layouts/breadcrumb/breadcrumb-staff-edit-admin";
+
+export default async function BreadcrumbSlot({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+
+ return ;
+}
diff --git a/packages/vitnode/src/routes/breadcrumb/admin/core/users/staff/moderators/page.tsx b/packages/vitnode/src/routes/breadcrumb/admin/core/staff/moderators/page.tsx
similarity index 100%
rename from packages/vitnode/src/routes/breadcrumb/admin/core/users/staff/moderators/page.tsx
rename to packages/vitnode/src/routes/breadcrumb/admin/core/staff/moderators/page.tsx
diff --git a/packages/vitnode/src/views/admin/layouts/admin-layout.tsx b/packages/vitnode/src/views/admin/layouts/admin-layout.tsx
index 9f35d987a..4a702535f 100644
--- a/packages/vitnode/src/views/admin/layouts/admin-layout.tsx
+++ b/packages/vitnode/src/views/admin/layouts/admin-layout.tsx
@@ -1,5 +1,6 @@
import { cookies } from "next/headers";
+import { AdminStaffPermissionProvider } from "@/components/staff-permission/provider";
import { ThemeSwitcher } from "@/components/switchers/themes/theme-switcher";
import { Separator } from "@/components/ui/separator";
import {
@@ -38,29 +39,31 @@ export const AdminLayout = async ({
return (
-
-
-
-
-
- {breadcrumb != null && (
- <>
-
- {breadcrumb}
- >
- )}
-
-