From 8c7cb7d2b7bc2d2f0e0b5d03c1b8415d3996f203 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sun, 12 Jul 2026 20:19:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(roles):=20=E2=9C=A8=20add=20create=20and?= =?UTF-8?q?=20edit=20role=20functionality=20with=20multi-language=20suppor?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/locales/@vitnode/core/en.json | 15 +++ .../(vitnode-core)/core/users/roles/page.tsx | 5 +- apps/docs/src/locales/@vitnode/core/en.json | 15 +++ .../src/api/lib/save-language-words.ts | 2 +- .../modules/admin/roles/roles.admin.module.ts | 9 +- .../admin/roles/routes/create.route.ts | 73 ++++++++++++ .../admin/roles/routes/update.route.ts | 103 ++++++++++++++++ .../vitnode/src/lib/helpers/multi-lang.ts | 17 ++- packages/vitnode/src/locales/en.json | 15 +++ .../routes/admin/core/users/roles/page.tsx | 5 +- .../core/users/roles/actions/actions.tsx | 47 ++++++++ .../actions/create-edit/create-edit.test.tsx | 111 ++++++++++++++++++ .../roles/actions/create-edit/create-edit.tsx | 86 ++++++++++++++ .../roles/actions/create-edit/mutation-api.ts | 50 ++++++++ .../core/users/roles/roles-admin-view.tsx | 9 ++ .../users/roles/table/actions/edit-action.tsx | 65 ++++++++++ 16 files changed, 618 insertions(+), 9 deletions(-) create mode 100644 packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts create mode 100644 packages/vitnode/src/views/admin/views/core/users/roles/actions/actions.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/mutation-api.ts create mode 100644 packages/vitnode/src/views/admin/views/core/users/roles/table/actions/edit-action.tsx diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index ef462dc08..ebfd410cf 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -456,6 +456,21 @@ "title": "No roles found", "description": "Try adjusting your search criteria." } + }, + "create": { + "title": "Create role", + "desc": "Add a new role to your application.", + "submit": "Create", + "success": "Role created" + }, + "edit": { + "title": "Edit role", + "submit": "Save changes", + "success": "Role updated" + }, + "form": { + "name": "Name", + "color": "Color" } }, "staff": { 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 0aa328562..c3b5e5dbb 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 @@ -7,6 +7,7 @@ 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 { ActionsRolesAdmin } from "@vitnode/core/views/admin/views/core/users/roles/actions/actions"; const RolesAdminView = dynamic(async () => import("@vitnode/core/views/admin/views/core/users/roles/roles-admin-view").then( @@ -35,7 +36,9 @@ export default async function Page( return (
- + + + }> diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index ef462dc08..ebfd410cf 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -456,6 +456,21 @@ "title": "No roles found", "description": "Try adjusting your search criteria." } + }, + "create": { + "title": "Create role", + "desc": "Add a new role to your application.", + "submit": "Create", + "success": "Role created" + }, + "edit": { + "title": "Edit role", + "submit": "Save changes", + "success": "Role updated" + }, + "form": { + "name": "Name", + "color": "Color" } }, "staff": { diff --git a/packages/vitnode/src/api/lib/save-language-words.ts b/packages/vitnode/src/api/lib/save-language-words.ts index 98d6613f1..6abf4f6e5 100644 --- a/packages/vitnode/src/api/lib/save-language-words.ts +++ b/packages/vitnode/src/api/lib/save-language-words.ts @@ -36,7 +36,7 @@ export const saveLanguageWords = async ( ), ); - if (values.length === 0) { + if (!values || values.length === 0) { return; } 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 64645d8d2..9b794a9b9 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 @@ -1,11 +1,18 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; +import { createRoleAdminRoute } from "./routes/create.route"; import { listRolesAdminRoute } from "./routes/list.route"; import { showRoleAdminRoute } from "./routes/show.route"; +import { updateRoleAdminRoute } from "./routes/update.route"; export const rolesAdminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "roles", - routes: [listRolesAdminRoute, showRoleAdminRoute], + routes: [ + listRolesAdminRoute, + showRoleAdminRoute, + createRoleAdminRoute, + updateRoleAdminRoute, + ], }); 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 new file mode 100644 index 000000000..e6769ff3f --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts @@ -0,0 +1,73 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { saveLanguageWords } from "@/api/lib/save-language-words"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_roles } from "@/database/roles"; + +// Role names are not a column on `core_roles` - every translation lives in +// `core_languages_words`, so the name is the full list of per-language values. +export const zodRoleNameSchema = z + .array( + z.object({ + languageCode: z.string(), + value: z.string().min(1).max(255), + }), + ) + .min(1); + +export const zodCreateRoleAdminSchema = z.object({ + name: zodRoleNameSchema, + color: z.string().max(19).optional(), +}); + +export const createRoleAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: "Create a new role (Admin only)", + path: "/create", + request: { + body: { + required: true, + content: { + "application/json": { + schema: zodCreateRoleAdminSchema, + }, + }, + }, + }, + responses: { + 201: { + content: { + "application/json": { + schema: z.object({ id: z.number() }), + }, + }, + description: "Role created", + }, + 403: { + description: "Access Denied", + }, + }, + }, + handler: async c => { + const { name, color } = c.req.valid("json"); + + const [role] = await c + .get("db") + .insert(core_roles) + .values({ color: color?.trim() ? color : null, updatedAt: new Date() }) + .returning({ id: core_roles.id }); + + await saveLanguageWords(c, { + pluginCode: "core", + tableName: "core_roles", + variable: "name", + itemId: role.id, + values: name, + }); + + return c.json({ id: role.id }, 201); + }, +}); 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 new file mode 100644 index 000000000..4afab926d --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts @@ -0,0 +1,103 @@ +import { z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; + +import { buildRoute } from "@/api/lib/route"; +import { saveLanguageWords } from "@/api/lib/save-language-words"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_roles } from "@/database/roles"; + +import { zodRoleNameSchema } from "./create.route"; + +export const zodUpdateRoleAdminSchema = z + .object({ + name: zodRoleNameSchema, + color: z.string().max(19), + }) + .partial() + .refine(body => Object.values(body).some(value => value !== undefined), { + message: "At least one field is required", + }); + +export const updateRoleAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "patch", + description: "Update a role by id (Admin only)", + path: "/{id}", + request: { + params: z.object({ + id: z.string().openapi({ example: "1" }), + }), + body: { + required: true, + content: { + "application/json": { + schema: zodUpdateRoleAdminSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ id: z.number() }), + }, + }, + description: "Role updated", + }, + 403: { + description: "Access Denied", + }, + 404: { + content: { + "application/json": { + schema: z.object({ error: z.string() }), + }, + }, + description: "Role not found", + }, + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const body = c.req.valid("json"); + 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 }) + .from(core_roles) + .where(eq(core_roles.id, roleId)) + .limit(1); + + if (!role) { + return c.json({ error: "Role not found" }, 404); + } + + const values: Partial = { + updatedAt: new Date(), + }; + if (body.color !== undefined) { + values.color = body.color.trim() ? body.color : null; + } + + await db.update(core_roles).set(values).where(eq(core_roles.id, roleId)); + + if (body.name !== undefined) { + await saveLanguageWords(c, { + pluginCode: "core", + tableName: "core_roles", + variable: "name", + itemId: roleId, + values: body.name, + }); + } + + return c.json({ id: roleId }, 200); + }, +}); diff --git a/packages/vitnode/src/lib/helpers/multi-lang.ts b/packages/vitnode/src/lib/helpers/multi-lang.ts index 719321157..cf23cb21f 100644 --- a/packages/vitnode/src/lib/helpers/multi-lang.ts +++ b/packages/vitnode/src/lib/helpers/multi-lang.ts @@ -35,12 +35,19 @@ export const multiLangValueSchema = ({ }; export const getLangValue = ( - value: MultiLangValue | undefined, + value: MultiLangValue | string | undefined, languageCode: string, -): string => - (Array.isArray(value) ? value : []).find( - item => item.languageCode === languageCode, - )?.value ?? ""; +): string => { + if (typeof value === "string") { + return value; + } + + return ( + (Array.isArray(value) ? value : []).find( + item => item.languageCode === languageCode, + )?.value ?? "" + ); +}; export const upsertLangValue = ( value: MultiLangValue | undefined, diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index ef462dc08..ebfd410cf 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -456,6 +456,21 @@ "title": "No roles found", "description": "Try adjusting your search criteria." } + }, + "create": { + "title": "Create role", + "desc": "Add a new role to your application.", + "submit": "Create", + "success": "Role created" + }, + "edit": { + "title": "Edit role", + "submit": "Save changes", + "success": "Role updated" + }, + "form": { + "name": "Name", + "color": "Color" } }, "staff": { 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 1d85543a8..883d4974e 100644 --- a/packages/vitnode/src/routes/admin/core/users/roles/page.tsx +++ b/packages/vitnode/src/routes/admin/core/users/roles/page.tsx @@ -7,6 +7,7 @@ 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 { ActionsRolesAdmin } from "@/views/admin/views/core/users/roles/actions/actions"; const RolesAdminView = dynamic(async () => import("@/views/admin/views/core/users/roles/roles-admin-view").then( @@ -35,7 +36,9 @@ export default async function Page( return (
- + + + }> diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/actions/actions.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/actions/actions.tsx new file mode 100644 index 000000000..969919b56 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/roles/actions/actions.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { PlusIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import dynamic from "next/dynamic"; +import React from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Loader } from "@/components/ui/loader"; + +const CreateEditRoleAdmin = dynamic(async () => + import("./create-edit/create-edit").then(mod => ({ + default: mod.CreateEditRoleAdmin, + })), +); + +export const ActionsRolesAdmin = () => { + const t = useTranslations("admin.role.create"); + + return ( + + }> + + {t("title")} + + + + + {t("title")} + {t("desc")} + + + }> + + + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.test.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.test.tsx new file mode 100644 index 000000000..82cf5792e --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { LanguagesProvider } from "@/components/languages-provider"; + +import { CreateEditRoleAdmin } from "./create-edit"; +import { createRole, editRole } from "./mutation-api"; + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/lib/navigation", () => ({ + usePathname: () => "/admin/core/users/roles", + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("./mutation-api", () => ({ + createRole: vi.fn(), + editRole: vi.fn(), +})); + +const LANGUAGES = [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, +]; + +const renderForm = ( + data?: React.ComponentProps["data"], +) => + render( + + + , + ); + +const submitForm = (el: HTMLElement) => { + const form = el.closest("form"); + if (!form) throw new Error("Form not found"); + fireEvent.submit(form); +}; + +describe("CreateEditRoleAdmin", () => { + beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn(); + Element.prototype.hasPointerCapture = vi.fn(() => false); + Element.prototype.setPointerCapture = vi.fn(); + Element.prototype.releasePointerCapture = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders a per-language name select in create mode", () => { + renderForm(); + + expect(screen.getByText("form.name")).toBeDefined(); + expect(screen.getByText("form.color")).toBeDefined(); + // The multiLang language select (only shown with > 1 language). + expect(screen.getByRole("combobox")).toBeDefined(); + }); + + it("submits the name as a { languageCode, value }[] array on create", async () => { + renderForm(); + + const [nameInput] = screen.getAllByRole("textbox"); + fireEvent.change(nameInput, { target: { value: "Administrator" } }); + + submitForm(nameInput); + + await waitFor(() => { + expect(createRole).toHaveBeenCalledWith({ + name: [{ languageCode: "en", value: "Administrator" }], + color: "", + }); + }); + expect(editRole).not.toHaveBeenCalled(); + }); + + it("pre-fills the translations for edit and submits via editRole", async () => { + renderForm({ + id: 5, + color: "#ef4444", + name: [ + { languageCode: "en", name: "Admin" }, + { languageCode: "pl", name: "Administrator" }, + ], + }); + + const [nameInput, colorInput] = + screen.getAllByRole("textbox"); + expect(nameInput.value).toBe("Admin"); + expect(colorInput.value).toBe("#ef4444"); + + submitForm(nameInput); + + await waitFor(() => { + expect(editRole).toHaveBeenCalledWith({ + id: 5, + name: [ + { languageCode: "en", value: "Admin" }, + { languageCode: "pl", value: "Administrator" }, + ], + color: "#ef4444", + }); + }); + expect(createRole).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.tsx new file mode 100644 index 000000000..229a2b532 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/create-edit.tsx @@ -0,0 +1,86 @@ +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { AutoForm, type AutoFormOnSubmit } from "@/components/form/auto-form"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { useDialog } from "@/components/ui/dialog"; +import { multiLangValueSchema } from "@/lib/helpers/multi-lang"; +import { usePathname, useRouter } from "@/lib/navigation"; + +import { createRole, editRole } from "./mutation-api"; + +export interface RoleData { + color: null | string; + id: number; + name: { languageCode: string; name: string }[]; +} + +export const CreateEditRoleAdmin = ({ data }: { data?: RoleData }) => { + const t = useTranslations("admin.role"); + const tCore = useTranslations("core.global.errors"); + const { setOpen } = useDialog(); + const { push } = useRouter(); + const pathname = usePathname(); + + const formSchema = z.object({ + name: multiLangValueSchema({ minLength: 1, maxLength: 255 }) + .min(1) + .default( + data?.name.map(item => ({ + languageCode: item.languageCode, + value: item.name, + })) ?? [], + ), + color: z + .string() + .max(19) + .default(data?.color ?? ""), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const mutation = data?.id + ? await editRole({ id: data.id, ...values }) + : await createRole(values); + + if (mutation?.error) { + toast.error(tCore("title"), { + description: tCore("internal_server_error"), + }); + + return; + } + + toast.success(t(data ? "edit.success" : "create.success")); + setOpen?.(false); + push(pathname); + }; + + return ( + ( + + ), + }, + { + id: "color", + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ + children: t(`${data ? "edit" : "create"}.submit`), + }} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/mutation-api.ts b/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/mutation-api.ts new file mode 100644 index 000000000..807193c87 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/roles/actions/create-edit/mutation-api.ts @@ -0,0 +1,50 @@ +"use server"; + +import type { z } from "zod"; + +import { revalidatePath } from "next/cache"; + +import type { zodCreateRoleAdminSchema } from "@/api/modules/admin/roles/routes/create.route"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { fetcher } from "@/lib/fetcher"; + +export const createRole = async ( + body: z.infer, +) => { + const res = await fetcher(adminModule, { + path: "/create", + method: "post", + module: "admin/roles", + args: { + body, + }, + }); + + if (res.status !== 201) { + return { error: await res.text() }; + } + + revalidatePath("/[locale]/admin", "layout"); +}; + +export const editRole = async ({ + id, + ...body +}: z.infer & { id: number }) => { + const res = await fetcher(adminModule, { + path: "/{id}", + method: "patch", + module: "admin/roles", + args: { + params: { id: id.toString() }, + body, + }, + }); + + if (res.status !== 200) { + return { error: await res.text() }; + } + + revalidatePath("/[locale]/admin", "layout"); +}; 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 0f28c2512..d29fe0ff9 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,6 +10,8 @@ import { TooltipWithContent } from "@/components/ui/tooltip"; import { fetcher } from "@/lib/fetcher"; import { Link } from "@/lib/navigation"; +import { EditAction } from "./table/actions/edit-action"; + export const RolesAdminView = async ({ searchParams, }: { @@ -66,6 +68,13 @@ export const RolesAdminView = async ({ header: t("updatedAt"), cell: ({ row }) => , }, + { + id: "actions", + header: "", + align: "right", + className: "w-10", + cell: ({ row }) => , + }, ]} customNoResults={{ title: t("noResults.title"), diff --git a/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/edit-action.tsx b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/edit-action.tsx new file mode 100644 index 000000000..77f2ebb28 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/roles/table/actions/edit-action.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { PencilIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import dynamic from "next/dynamic"; +import React from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Loader } from "@/components/ui/loader"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +const CreateEditRoleAdmin = dynamic(async () => + import("../../actions/create-edit/create-edit").then(mod => ({ + default: mod.CreateEditRoleAdmin, + })), +); + +export const EditAction = ( + props: Required>, +) => { + const t = useTranslations("admin.role.edit"); + + return ( + + + + + } + > + + + } + /> + {t("title")} + + + + + + {t("title")} + + + }> + + + + + ); +};