From 323ee9189f5b4b3408b8fd3344d1de2feba34e9b Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 4 Jun 2026 11:46:10 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(users):=20=E2=9C=A8=20add=20user=20cre?= =?UTF-8?q?ation=20functionality=20with=20form=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/locales/@vitnode/core/en.json | 21 +++++ .../(vitnode-core)/core/users/page.tsx | 20 +++-- apps/docs/src/locales/@vitnode/core/en.json | 21 +++++ .../admin/users/routes/create.route.ts | 77 ++++++++++++++++++ .../modules/admin/users/users.admin.module.ts | 3 +- .../vitnode/src/app_admin/core/users/page.tsx | 20 +++-- packages/vitnode/src/lib/fetcher/core.ts | 3 +- packages/vitnode/src/locales/en.json | 21 +++++ .../core/users/actions/create/content.tsx | 48 ++++++++++++ .../core/users/actions/create/create.tsx | 52 +++++++++++++ .../core/users/actions/create/mutation-api.ts | 32 ++++++++ .../core/users/actions/create/use-form.ts | 78 +++++++++++++++++++ 12 files changed, 380 insertions(+), 16 deletions(-) create mode 100644 packages/vitnode/src/api/modules/admin/users/routes/create.route.ts create mode 100644 packages/vitnode/src/views/admin/views/core/users/actions/create/content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/users/actions/create/create.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/users/actions/create/mutation-api.ts create mode 100644 packages/vitnode/src/views/admin/views/core/users/actions/create/use-form.ts diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index 95d925bb5..31767c197 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -266,6 +266,27 @@ "title": "No users found", "description": "Try adjusting your search criteria." } + }, + "create": { + "title": "Add User", + "desc": "Create a new account for a user.", + "name": { + "label": "Username", + "min_length": "Username must be at least 3 characters.", + "max_length": "Username must be at most 32 characters.", + "exists": "This username is already taken." + }, + "email": { + "label": "Email", + "invalid": "Please enter a valid email address.", + "exists": "This email is already registered." + }, + "password": { + "label": "Password", + "invalid": "Password must be at least 8 characters." + }, + "submit": "Create User", + "success": "User \"{name}\" has been created." } }, "debug": { diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/page.tsx index 8e1bd16a2..2d4e42a72 100644 --- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/page.tsx +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/page.tsx @@ -4,8 +4,10 @@ import { getTranslations } from "next-intl/server"; import dynamic from "next/dynamic"; 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 { CreateUserAdmin } from "@vitnode/core/views/admin/views/core/users/actions/create/create"; const UsersAdminView = dynamic(async () => import("@vitnode/core/views/admin/views/core/users/users-admin-view").then(module => ({ @@ -30,12 +32,16 @@ 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 95d925bb5..31767c197 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -266,6 +266,27 @@ "title": "No users found", "description": "Try adjusting your search criteria." } + }, + "create": { + "title": "Add User", + "desc": "Create a new account for a user.", + "name": { + "label": "Username", + "min_length": "Username must be at least 3 characters.", + "max_length": "Username must be at most 32 characters.", + "exists": "This username is already taken." + }, + "email": { + "label": "Email", + "invalid": "Please enter a valid email address.", + "exists": "This email is already registered." + }, + "password": { + "label": "Password", + "invalid": "Password must be at least 8 characters." + }, + "submit": "Create User", + "success": "User \"{name}\" has been created." } }, "debug": { 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 new file mode 100644 index 000000000..ecb8041d7 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/create.route.ts @@ -0,0 +1,77 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { PasswordModel } from "@/api/models/password"; +import { UserModel } from "@/api/models/user"; +import { CONFIG_PLUGIN } from "@/config"; + +const nameRegex = /^(?!.* {2})[\p{L}\p{N}._@ -]*$/u; + +export const zodCreateUserAdminSchema = z.object({ + email: z.email().toLowerCase().openapi({ + example: "test@test.com", + }), + name: z + .string() + .openapi({ example: "test" }) + .min(3) + .refine(val => nameRegex.test(val), { + message: "Invalid name", + }), + password: z.string().min(8).openapi({ + example: "Test123!", + }), +}); + +export const createUserAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: "Create a new user (Admin only)", + path: "/create", + request: { + body: { + required: true, + content: { + "application/json": { + schema: zodCreateUserAdminSchema, + }, + }, + }, + }, + responses: { + 201: { + content: { + "application/json": { + schema: z.object({ + id: z.number(), + name: z.string(), + email: z.email(), + }), + }, + }, + description: "User created", + }, + 403: { + description: "Access Denied", + }, + 409: { + description: "Email or name already exists", + }, + }, + }, + handler: async c => { + const { password, ...input } = c.req.valid("json"); + const hashedPassword = await new PasswordModel().encryptPassword(password); + const data = await new UserModel().signUp({ ...input, hashedPassword }, c); + + return c.json( + { + id: data.id, + name: data.name, + email: data.email, + }, + 201, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts b/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts index 60363dd34..af7760bba 100644 --- a/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts @@ -1,10 +1,11 @@ import { buildModule } from "@/api/lib/module"; import { CONFIG_PLUGIN } from "@/config"; +import { createUserAdminRoute } from "./routes/create.route"; import { listUsersAdminRoute } from "./routes/list.route"; export const usersAdminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "users", - routes: [listUsersAdminRoute], + routes: [listUsersAdminRoute, createUserAdminRoute], }); diff --git a/packages/vitnode/src/app_admin/core/users/page.tsx b/packages/vitnode/src/app_admin/core/users/page.tsx index d0e96b657..0d9a094e2 100644 --- a/packages/vitnode/src/app_admin/core/users/page.tsx +++ b/packages/vitnode/src/app_admin/core/users/page.tsx @@ -4,8 +4,10 @@ import { getTranslations } from "next-intl/server"; import dynamic from "next/dynamic"; 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 { CreateUserAdmin } from "@/views/admin/views/core/users/actions/create/create"; const UsersAdminView = dynamic(async () => import("@/views/admin/views/core/users/users-admin-view").then(module => ({ @@ -30,12 +32,16 @@ export default async function Page( ]); return ( -
- - - }> - - -
+ +
+ + + + + }> + + +
+
); } diff --git a/packages/vitnode/src/lib/fetcher/core.ts b/packages/vitnode/src/lib/fetcher/core.ts index 39e49a400..0ffe1f52e 100644 --- a/packages/vitnode/src/lib/fetcher/core.ts +++ b/packages/vitnode/src/lib/fetcher/core.ts @@ -130,7 +130,8 @@ export async function coreFetcher< } if (response.status >= 400) { - const errorText = await response.text(); + // Clone so the response body stays readable for the caller + const errorText = await response.clone().text(); // eslint-disable-next-line no-console console.error( `\x1b[34m[VitNode - API]\x1b[0m \x1b[31m${response.status}\x1b[0m - \x1b[33m${url.toString()}\x1b[0m\n\x1b[36mError: ${errorText}\x1b[0m`, diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 95d925bb5..31767c197 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -266,6 +266,27 @@ "title": "No users found", "description": "Try adjusting your search criteria." } + }, + "create": { + "title": "Add User", + "desc": "Create a new account for a user.", + "name": { + "label": "Username", + "min_length": "Username must be at least 3 characters.", + "max_length": "Username must be at most 32 characters.", + "exists": "This username is already taken." + }, + "email": { + "label": "Email", + "invalid": "Please enter a valid email address.", + "exists": "This email is already registered." + }, + "password": { + "label": "Password", + "invalid": "Password must be at least 8 characters." + }, + "submit": "Create User", + "success": "User \"{name}\" has been created." } }, "debug": { diff --git a/packages/vitnode/src/views/admin/views/core/users/actions/create/content.tsx b/packages/vitnode/src/views/admin/views/core/users/actions/create/content.tsx new file mode 100644 index 000000000..5c9146e55 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/actions/create/content.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormInput } from "@/components/form/fields/input"; + +import { useFormCreateUserAdmin } from "./use-form"; + +export const ContentCreateUserAdmin = () => { + const t = useTranslations("admin.user.create"); + const { onSubmit, formSchema } = useFormCreateUserAdmin(); + + return ( + ( + + ), + }, + { + id: "email", + component: props => ( + + ), + }, + { + id: "password", + component: props => ( + + ), + }, + ]} + formSchema={formSchema} + mode="all" + onSubmit={onSubmit} + submitButtonProps={{ + children: t("submit"), + }} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/actions/create/create.tsx b/packages/vitnode/src/views/admin/views/core/users/actions/create/create.tsx new file mode 100644 index 000000000..041f777f1 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/actions/create/create.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { UserPlusIcon } 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 ContentCreateUserAdmin = dynamic(async () => + import("./content").then(module => ({ + default: module.ContentCreateUserAdmin, + })), +); + +export const CreateUserAdmin = () => { + const t = useTranslations("admin.user.create"); + + return ( + + + + + + + + + + {t("title")} + + {t("desc")} + + + }> + + + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/actions/create/mutation-api.ts b/packages/vitnode/src/views/admin/views/core/users/actions/create/mutation-api.ts new file mode 100644 index 000000000..ae8b7aace --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/actions/create/mutation-api.ts @@ -0,0 +1,32 @@ +"use server"; + +import type { z } from "zod"; + +import { revalidatePath } from "next/cache"; + +import type { zodCreateUserAdminSchema } from "@/api/modules/admin/users/routes/create.route"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { fetcher } from "@/lib/fetcher"; + +export const mutationApi = async ( + input: z.infer, +) => { + const res = await fetcher(adminModule, { + path: "/create", + method: "post", + module: "admin/users", + args: { + body: input, + }, + }); + + if (res.status !== 201) { + return { error: await res.text() }; + } + + const data = await res.json(); + revalidatePath("/[locale]/admin", "layout"); + + return { data }; +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/actions/create/use-form.ts b/packages/vitnode/src/views/admin/views/core/users/actions/create/use-form.ts new file mode 100644 index 000000000..a220d0d20 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/actions/create/use-form.ts @@ -0,0 +1,78 @@ +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; + +import { useDialog } from "@/components/ui/dialog"; + +import { mutationApi } from "./mutation-api"; + +export const useFormCreateUserAdmin = () => { + const t = useTranslations("admin.user.create"); + const tError = useTranslations("core.global.errors"); + const { setOpen, setIsDirty } = useDialog(); + + const formSchema = z.object({ + name: z + .string({ message: tError("field_required") }) + .min(3, t("name.min_length")) + .max(32, t("name.max_length")) + .default(""), + email: z.email({ message: t("email.invalid") }).default(""), + password: z + .string({ message: tError("field_required") }) + .min(8, t("password.invalid")) + .default(""), + }); + + const onSubmit: AutoFormOnSubmit = async ( + values, + form, + ) => { + const mutation = await mutationApi(values); + + if (mutation.data) { + setIsDirty?.(false); + setOpen?.(false); + toast.success(t("success", { name: mutation.data.name })); + + return; + } + + const errorMessages = { + "Email already exists": { + field: "email", + message: t("email.exists"), + }, + "Name already exists": { + field: "name", + message: t("name.exists"), + }, + } as const; + + const errorConfig = + errorMessages[mutation.error as unknown as keyof typeof errorMessages]; + + if (errorConfig) { + form.setError( + errorConfig.field, + { + type: "manual", + message: errorConfig.message, + }, + { + shouldFocus: true, + }, + ); + + return; + } + + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + }; + + return { onSubmit, formSchema }; +}; From 9a34699f34242641b31862fcc2e918c1e9d4f89f Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 4 Jun 2026 13:42:30 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(users):=20=E2=9C=A8=20add=20user=20pro?= =?UTF-8?q?file=20view=20and=20user=20retrieval=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/locales/@vitnode/core/en.json | 7 ++ .../core/users/[nameCode]/page.tsx | 65 ++++++++++++++++ apps/docs/src/app/[locale]/layout.tsx | 39 ++-------- apps/docs/src/app/layout.tsx | 27 ++++++- apps/docs/src/locales/@vitnode/core/en.json | 7 ++ packages/vitnode/src/api/models/user.ts | 2 + .../api/models/user/get-user-by-name-code.ts | 35 +++++++++ .../modules/admin/users/routes/show.route.ts | 67 ++++++++++++++++ .../modules/admin/users/users.admin.module.ts | 3 +- .../app_admin/core/users/[nameCode]/page.tsx | 65 ++++++++++++++++ packages/vitnode/src/locales/en.json | 7 ++ .../views/admin/views/core/users/actions.tsx | 23 ++++++ .../core/users/show/show-user-admin-view.tsx | 78 +++++++++++++++++++ .../views/core/users/users-admin-view.tsx | 8 ++ 14 files changed, 400 insertions(+), 33 deletions(-) create mode 100644 apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[nameCode]/page.tsx create mode 100644 packages/vitnode/src/api/models/user/get-user-by-name-code.ts create mode 100644 packages/vitnode/src/api/modules/admin/users/routes/show.route.ts create mode 100644 packages/vitnode/src/app_admin/core/users/[nameCode]/page.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/users/actions.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index 31767c197..07d6f52f9 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -261,12 +261,19 @@ "user": "User", "createdAt": "Created At", "emailNotVerified": "Email Not Verified", + "view": "View profile", "searchPlaceholder": "Search users by email or username...", "noResults": { "title": "No users found", "description": "Try adjusting your search criteria." } }, + "show": { + "title": "User Profile", + "joined": "Joined", + "emailNotVerified": "Email Not Verified", + "coverPlaceholder": "No cover image" + }, "create": { "title": "Add User", "desc": "Create a new account for a user.", diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[nameCode]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[nameCode]/page.tsx new file mode 100644 index 000000000..7203dad7e --- /dev/null +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[nameCode]/page.tsx @@ -0,0 +1,65 @@ +import type { Metadata } from "next/dist/types"; + +import { getTranslations } from "next-intl/server"; +import dynamic from "next/dynamic"; +import React from "react"; + +import { adminModule } from "@vitnode/core/api/modules/admin/admin.module"; +import { I18nProvider } from "@vitnode/core/components/i18n-provider"; +import { Loader } from "@vitnode/core/components/ui/loader"; +import { fetcher } from "@vitnode/core/lib/fetcher"; + +const ShowUserAdminView = dynamic(async () => + import("@vitnode/core/views/admin/views/core/users/show/show-user-admin-view").then( + module => ({ + default: module.ShowUserAdminView, + }), + ), +); + +export const generateMetadata = async ({ + params, +}: { + params: Promise<{ nameCode: string }>; +}): Promise => { + const { nameCode } = await params; + const t = await getTranslations("admin.user.show"); + const res = await fetcher(adminModule, { + path: "/{nameCode}", + method: "get", + module: "admin/users", + args: { + params: { nameCode }, + }, + }); + + if (!res.ok) { + return { + title: t("title"), + }; + } + + const data = await res.json(); + + return { + title: `${data.name} - ${t("title")}`, + }; +}; + +export default async function Page({ + params, +}: { + params: Promise<{ nameCode: string }>; +}) { + const { nameCode } = await params; + + return ( + +
+ }> + + +
+
+ ); +} diff --git a/apps/docs/src/app/[locale]/layout.tsx b/apps/docs/src/app/[locale]/layout.tsx index c7de97abc..a7720aba3 100644 --- a/apps/docs/src/app/[locale]/layout.tsx +++ b/apps/docs/src/app/[locale]/layout.tsx @@ -6,45 +6,22 @@ import { RootLayout, } from "@vitnode/core/views/layouts/root-layout"; import { RootProvider } from "fumadocs-ui/provider/next"; -import { Geist, Geist_Mono } from "next/font/google"; import { vitNodeConfig } from "@/vitnode.config"; import SearchDialogFumadocs from "../../components/fumadocs/search-dialog"; -import { Body } from "./(main)/layout.client"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); export const generateMetadata = (): Metadata => generateMetadataRootLayout(vitNodeConfig); -// export const generateStaticParams = () => -// vitNodeConfig.i18n.locales.map(locale => ({ locale: locale.code })); - -export default async function LocaleLayout(props: RootLayoutProps) { - const { locale } = await props.params; - +export default function LocaleLayout(props: RootLayoutProps) { return ( - - - - - - - + + + ); } diff --git a/apps/docs/src/app/layout.tsx b/apps/docs/src/app/layout.tsx index ef3b6627d..88d964c08 100644 --- a/apps/docs/src/app/layout.tsx +++ b/apps/docs/src/app/layout.tsx @@ -1,9 +1,34 @@ import "./global.css"; +import { getLocale } from "next-intl/server"; +import { Geist, Geist_Mono } from "next/font/google"; + +import { Body } from "./[locale]/(main)/layout.client"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + export default async function RootLayout({ children, }: { children: React.ReactNode; }) { - return children; + const locale = await getLocale(); + + return ( + + + {children} + + + ); } diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index 31767c197..07d6f52f9 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -261,12 +261,19 @@ "user": "User", "createdAt": "Created At", "emailNotVerified": "Email Not Verified", + "view": "View profile", "searchPlaceholder": "Search users by email or username...", "noResults": { "title": "No users found", "description": "Try adjusting your search criteria." } }, + "show": { + "title": "User Profile", + "joined": "Joined", + "emailNotVerified": "Email Not Verified", + "coverPlaceholder": "No cover image" + }, "create": { "title": "Add User", "desc": "Create a new account for a user.", diff --git a/packages/vitnode/src/api/models/user.ts b/packages/vitnode/src/api/models/user.ts index e2b8608ff..aff8a70fd 100644 --- a/packages/vitnode/src/api/models/user.ts +++ b/packages/vitnode/src/api/models/user.ts @@ -1,9 +1,11 @@ import { getUserById } from "./user/get-user-by-id"; +import { getUserByNameCode } from "./user/get-user-by-name-code"; import { signInWithPassword } from "./user/sign-in-with-passwords"; import { signUp } from "./user/sign-up"; export class UserModel { getUserById = getUserById; + getUserByNameCode = getUserByNameCode; signInWithPassword = signInWithPassword; signUp = signUp; } diff --git a/packages/vitnode/src/api/models/user/get-user-by-name-code.ts b/packages/vitnode/src/api/models/user/get-user-by-name-code.ts new file mode 100644 index 000000000..43d8f7257 --- /dev/null +++ b/packages/vitnode/src/api/models/user/get-user-by-name-code.ts @@ -0,0 +1,35 @@ +import type { Context } from "hono"; + +import { eq } from "drizzle-orm"; + +import { core_users } from "@/database/users"; + +export const getUserByNameCode = async ({ + nameCode, + c, +}: { + c: Context; + nameCode: string; +}) => { + const [user] = await c + .get("db") + .select({ + id: core_users.id, + email: core_users.email, + name: core_users.name, + nameCode: core_users.nameCode, + createdAt: core_users.createdAt, + newsletter: core_users.newsletter, + avatarColor: core_users.avatarColor, + emailVerified: core_users.emailVerified, + roleId: core_users.roleId, + birthday: core_users.birthday, + language: core_users.language, + }) + .from(core_users) + .where(eq(core_users.nameCode, nameCode)) + .limit(1); + if (!user) return null; + + return user; +}; 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 new file mode 100644 index 000000000..3344abfcf --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/show.route.ts @@ -0,0 +1,67 @@ +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/lib/route"; +import { UserModel } from "@/api/models/user"; +import { CONFIG_PLUGIN } from "@/config"; + +export const showUserAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: "Get a single user by name SEO (Admin only)", + path: "/{nameCode}", + request: { + params: z.object({ + nameCode: z.string().openapi({ example: "test" }), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + nameCode: z.string(), + createdAt: z.date(), + newsletter: z.boolean(), + avatarColor: z.string(), + emailVerified: z.boolean(), + roleId: z.number(), + birthday: z.date().nullable(), + language: z.string(), + }), + }, + }, + description: "User found", + }, + 403: { + description: "Access Denied", + }, + 404: { + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + description: "User not found", + }, + }, + }, + handler: async c => { + const { nameCode } = c.req.valid("param"); + const user = await new UserModel().getUserByNameCode({ + nameCode, + c, + }); + + if (!user) { + return c.json({ error: "User not found" }, 404); + } + + return c.json(user, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts b/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts index af7760bba..beac6fb2f 100644 --- a/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts @@ -3,9 +3,10 @@ import { CONFIG_PLUGIN } from "@/config"; import { createUserAdminRoute } from "./routes/create.route"; import { listUsersAdminRoute } from "./routes/list.route"; +import { showUserAdminRoute } from "./routes/show.route"; export const usersAdminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "users", - routes: [listUsersAdminRoute, createUserAdminRoute], + routes: [listUsersAdminRoute, createUserAdminRoute, showUserAdminRoute], }); diff --git a/packages/vitnode/src/app_admin/core/users/[nameCode]/page.tsx b/packages/vitnode/src/app_admin/core/users/[nameCode]/page.tsx new file mode 100644 index 000000000..ee3597791 --- /dev/null +++ b/packages/vitnode/src/app_admin/core/users/[nameCode]/page.tsx @@ -0,0 +1,65 @@ +import type { Metadata } from "next/dist/types"; + +import { getTranslations } from "next-intl/server"; +import dynamic from "next/dynamic"; +import React from "react"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { I18nProvider } from "@/components/i18n-provider"; +import { Loader } from "@/components/ui/loader"; +import { fetcher } from "@/lib/fetcher"; + +const ShowUserAdminView = dynamic(async () => + import("@/views/admin/views/core/users/show/show-user-admin-view").then( + module => ({ + default: module.ShowUserAdminView, + }), + ), +); + +export const generateMetadata = async ({ + params, +}: { + params: Promise<{ nameCode: string }>; +}): Promise => { + const { nameCode } = await params; + const t = await getTranslations("admin.user.show"); + const res = await fetcher(adminModule, { + path: "/{nameCode}", + method: "get", + module: "admin/users", + args: { + params: { nameCode }, + }, + }); + + if (!res.ok) { + return { + title: t("title"), + }; + } + + const data = await res.json(); + + return { + title: `${data.name} - ${t("title")}`, + }; +}; + +export default async function Page({ + params, +}: { + params: Promise<{ nameCode: string }>; +}) { + const { nameCode } = await params; + + return ( + +
+ }> + + +
+
+ ); +} diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 31767c197..07d6f52f9 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -261,12 +261,19 @@ "user": "User", "createdAt": "Created At", "emailNotVerified": "Email Not Verified", + "view": "View profile", "searchPlaceholder": "Search users by email or username...", "noResults": { "title": "No users found", "description": "Try adjusting your search criteria." } }, + "show": { + "title": "User Profile", + "joined": "Joined", + "emailNotVerified": "Email Not Verified", + "coverPlaceholder": "No cover image" + }, "create": { "title": "Add User", "desc": "Create a new account for a user.", diff --git a/packages/vitnode/src/views/admin/views/core/users/actions.tsx b/packages/vitnode/src/views/admin/views/core/users/actions.tsx new file mode 100644 index 000000000..c8258dc0d --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/actions.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { EyeIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; + +import { buttonVariants } from "@/components/ui/button"; +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { Link } from "@/lib/navigation"; + +export const UsersAdminActions = ({ nameCode }: { nameCode: string }) => { + const t = useTranslations("admin.user.list"); + + return ( + + + + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx b/packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx new file mode 100644 index 000000000..d96e6a6e8 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx @@ -0,0 +1,78 @@ +import { CalendarIcon, ImageIcon, MailIcon } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { Avatar } from "@/components/avatar"; +import { DateFormat } from "@/components/date-format"; +import { Card, CardContent } from "@/components/ui/card"; +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { fetcher } from "@/lib/fetcher"; + +export const ShowUserAdminView = async ({ nameCode }: { nameCode: string }) => { + const t = await getTranslations("admin.user.show"); + const res = await fetcher(adminModule, { + path: "/{nameCode}", + method: "get", + module: "admin/users", + args: { + params: { nameCode }, + }, + }); + + if (res.status !== 200) { + notFound(); + } + + const user = await res.json(); + + return ( + + {/* Cover placeholder */} +
+ + {t("coverPlaceholder")} +
+ + +
+ {/* Avatar placeholder */} + + +
+
+

+ {user.name} +

+ {!user.emailVerified && ( + + + + )} +
+ + @{user.nameCode} + +
+
+ +
+
+ + {user.email} +
+
+ + + {t("joined")} + +
+
+
+
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/users-admin-view.tsx b/packages/vitnode/src/views/admin/views/core/users/users-admin-view.tsx index 3f5de9da1..b21977acb 100644 --- a/packages/vitnode/src/views/admin/views/core/users/users-admin-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/users-admin-view.tsx @@ -8,6 +8,8 @@ import { DataTable } from "@/components/table/data-table"; import { TooltipWithContent } from "@/components/ui/tooltip"; import { fetcher } from "@/lib/fetcher"; +import { UsersAdminActions } from "./actions"; + export const UsersAdminView = async ({ searchParams, }: { @@ -57,6 +59,12 @@ export const UsersAdminView = async ({ label: t("createdAt"), cell: ({ row }) => , }, + { + id: "actions", + label: "", + className: "w-10", + cell: ({ row }) => , + }, ]} customNoResults={{ title: t("noResults.title"), From ccb01285846de4ce15d55eeddcb1741d65fcf57c Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 4 Jun 2026 17:57:41 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat(users):=20=E2=9C=A8=20Add=20roles=20ma?= =?UTF-8?q?nagement=20and=20email=20verification=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced roles management functionality with routes for listing and showing roles. - Added `Verify Email` feature for users, allowing admin to verify user emails. - Updated user and role components to reflect new functionalities. - Enhanced localization for roles and email verification messages. --- apps/api/src/locales/@vitnode/core/en.json | 19 +- apps/docs/content/docs/dev/meta.json | 1 + .../docs/dev/working-with-users/meta.json | 4 + .../docs/dev/working-with-users/roles.mdx | 124 ++++++++++++ .../docs/dev/working-with-users/users.mdx | 186 ++++++++++++++++++ .../(vitnode-core)/core/users/roles/page.tsx | 46 +++++ apps/docs/src/locales/@vitnode/core/en.json | 19 +- .../src/api/modules/admin/admin.module.ts | 8 +- .../modules/admin/roles/roles.admin.module.ts | 11 ++ .../modules/admin/roles/routes/list.route.ts | 142 +++++++++++++ .../modules/admin/roles/routes/show.route.ts | 115 +++++++++++ .../admin/users/routes/verify-email.route.ts | 67 +++++++ .../modules/admin/users/users.admin.module.ts | 8 +- .../src/app_admin/core/users/roles/page.tsx | 46 +++++ .../vitnode/src/components/role-format.tsx | 35 ++++ .../vitnode/src/components/user-format.tsx | 34 ++++ packages/vitnode/src/locales/en.json | 19 +- .../views/admin/layouts/sidebar/nav/nav.tsx | 4 +- .../views/admin/views/core/users/actions.tsx | 34 +++- .../actions/verify-email/mutation-api.ts | 26 +++ .../actions/verify-email/verify-email.tsx | 66 +++++++ .../core/users/roles/roles-admin-view.tsx | 70 +++++++ .../core/users/show/show-user-admin-view.tsx | 9 +- 23 files changed, 1076 insertions(+), 17 deletions(-) create mode 100644 apps/docs/content/docs/dev/working-with-users/meta.json create mode 100644 apps/docs/content/docs/dev/working-with-users/roles.mdx create mode 100644 apps/docs/content/docs/dev/working-with-users/users.mdx create mode 100644 apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/roles/page.tsx create mode 100644 packages/vitnode/src/api/modules/admin/roles/roles.admin.module.ts create mode 100644 packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts create mode 100644 packages/vitnode/src/app_admin/core/users/roles/page.tsx create mode 100644 packages/vitnode/src/components/role-format.tsx create mode 100644 packages/vitnode/src/components/user-format.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/users/actions/verify-email/mutation-api.ts create mode 100644 packages/vitnode/src/views/admin/views/core/users/actions/verify-email/verify-email.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/users/roles/roles-admin-view.tsx diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json index 07d6f52f9..7ec497809 100644 --- a/apps/api/src/locales/@vitnode/core/en.json +++ b/apps/api/src/locales/@vitnode/core/en.json @@ -216,7 +216,8 @@ "dashboard": "Dashboard", "users": { "title": "Users", - "list": "User List" + "list": "User List", + "roles": "Roles" }, "user_bar": { "home_page": "Home Page", @@ -274,6 +275,10 @@ "emailNotVerified": "Email Not Verified", "coverPlaceholder": "No cover image" }, + "verify_email": { + "label": "Verify Email", + "success": "Email has been verified." + }, "create": { "title": "Add User", "desc": "Create a new account for a user.", @@ -296,6 +301,18 @@ "success": "User \"{name}\" has been created." } }, + "role": { + "list": { + "desc": "Manage the roles of your application.", + "role": "Role", + "usersCount": "Number of users", + "createdAt": "Created At", + "noResults": { + "title": "No roles found", + "description": "Try adjusting your search criteria." + } + } + }, "debug": { "title": "Debug Panel", "desc": "Check logs, errors, and other debug information.", diff --git a/apps/docs/content/docs/dev/meta.json b/apps/docs/content/docs/dev/meta.json index 69c79a5ff..1ca164515 100644 --- a/apps/docs/content/docs/dev/meta.json +++ b/apps/docs/content/docs/dev/meta.json @@ -13,6 +13,7 @@ "plugins", "api", "database", + "working-with-users", "advanced", "---Adapters---", "captcha", diff --git a/apps/docs/content/docs/dev/working-with-users/meta.json b/apps/docs/content/docs/dev/working-with-users/meta.json new file mode 100644 index 000000000..4f1634a1d --- /dev/null +++ b/apps/docs/content/docs/dev/working-with-users/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Working with Users", + "pages": ["users", "roles", "..."] +} diff --git a/apps/docs/content/docs/dev/working-with-users/roles.mdx b/apps/docs/content/docs/dev/working-with-users/roles.mdx new file mode 100644 index 000000000..46459ec4e --- /dev/null +++ b/apps/docs/content/docs/dev/working-with-users/roles.mdx @@ -0,0 +1,124 @@ +--- +title: Roles +description: Helper functions and components to work with roles. +--- + +Roles in VitNode are stored in the `core_roles` table. Every user belongs to exactly one role through their `roleId`, and a role carries the flags and color used for permissions and display. + +A role exposes the following flags: + +| Field | Description | +| ----------- | ------------------------------------------------- | +| `protected` | Built-in role that cannot be deleted. | +| `default` | Role assigned to newly registered users. | +| `root` | Super-admin role with full access to everything. | +| `guest` | Role representing non-authenticated visitors. | +| `color` | Color used to style the members of the role (nullable). | + + + A role has no `name` column. Its display name lives in the `core_languages_words` + i18n table (seeded per language during database setup), so it can be translated + per locale. Resolve the name for the active language and pass it to the + components below. + + +## Backend + +### Reading a user's role + +Every user references a role through `roleId`. Load the role by querying `core_roles` with that id: + +```ts title="plugins/{plugin_name}/src/api/routes/role.route.ts" +import { eq } from "drizzle-orm"; +import { buildRoute } from "@vitnode/core/api/lib/route"; +import { core_roles } from "@vitnode/core/database/roles"; + +export const roleRoute = buildRoute({ + handler: async c => { + const user = c.get("user"); + if (!user) return c.json({ message: "Not signed in" }, 401); + + const [role] = await c + .get("db") + .select() + .from(core_roles) + .where(eq(core_roles.id, user.roleId)) + .limit(1); + + return c.json({ role }); + }, +}); +``` + +### Checking admin access + +Membership in an admin role is resolved with `checkIfUserIsAdmin` from the `SessionAdminModel`. It returns `true` when the user — by their own id or their `roleId` — has admin permission. + +```ts title="plugins/{plugin_name}/src/api/routes/admin-only.route.ts" +import { buildRoute } from "@vitnode/core/api/lib/route"; +import { SessionAdminModel } from "@vitnode/core/api/models/session-admin"; + +export const adminOnlyRoute = buildRoute({ + handler: async c => { + const user = c.get("user"); + const isAdmin = user + ? await new SessionAdminModel(c).checkIfUserIsAdmin(user.id) + : false; + + if (!isAdmin) return c.json({ message: "Forbidden" }, 403); + + return c.json({ ok: true }); + }, +}); +``` + +## Frontend + +On the frontend a user's role is identified by the `roleId` carried on the session user, available through [`getSessionApi()`](/docs/dev/working-with-users/users#current-session). + +### Formatting a role + +Use the `RoleFormat` component to render a role's name styled with its `color`. +Because role names are translatable, the `name` is the full list of translations; +`RoleFormat` resolves it to the active locale with `getLocale()`, so it is an +async **Server Component**. + +```tsx title="role-badge.tsx" +import { RoleFormat } from "@vitnode/core/components/role-format"; + +; +``` + +The `role` prop has the following shape: + +```ts +{ + id: number; + name: { + languageCode: string; + name: string; + }[]; + color: string | null; +} +``` + + + `RoleFormat` calls `getLocale()` from `next-intl/server`, so it can only be + rendered on the server. The admin roles list route returns the `name` array in + exactly this shape. + + + + Role details such as `color` and its flags live in the `core_roles` table on + the backend. To display them on the frontend, return the data you need from a + backend route and consume it with the [`fetcher`](/docs/dev/fetcher). + diff --git a/apps/docs/content/docs/dev/working-with-users/users.mdx b/apps/docs/content/docs/dev/working-with-users/users.mdx new file mode 100644 index 000000000..71696c308 --- /dev/null +++ b/apps/docs/content/docs/dev/working-with-users/users.mdx @@ -0,0 +1,186 @@ +--- +title: Users +description: Helper functions and components to work with users. +--- + +VitNode ships with a small set of helpers for reading the currently authenticated user and for looking users up by their `id` or `nameCode`. Backend helpers run inside your API route handlers (Hono context), while frontend helpers run in your React Server Components. + +## Backend + +### Current user + +The global middleware resolves the session cookie on every request and exposes the authenticated user through the Hono context. Read it with `c.get("user")` inside any route handler — it returns the user object, or `null` for guests. + +```ts title="plugins/{plugin_name}/src/api/routes/me.route.ts" +import { buildRoute } from "@vitnode/core/api/lib/route"; + +export const meRoute = buildRoute({ + handler: async c => { + const user = c.get("user"); + + if (!user) { + return c.json({ message: "Not signed in" }, 401); + } + + return c.json({ user }); + }, +}); +``` + +The user object has the following shape: + +```ts +{ + id: number; + email: string; + name: string; + nameCode: string; + avatarColor: string; + newsletter: boolean; + emailVerified: boolean; + roleId: number; + birthday: Date | null; + createdAt: Date; +} +``` + + + `roleId` points to the role the user belongs to. See [Roles](/docs/dev/working-with-users/roles) for more. + + +### Looking up a user + +Use the `UserModel` when you need to fetch a user that is **not** the one making the request. It exposes `getUserById` and `getUserByNameCode`, both of which return the user, or `null` when no match is found. + +```ts title="plugins/{plugin_name}/src/api/routes/user.route.ts" +import { buildRoute } from "@vitnode/core/api/lib/route"; +import { UserModel } from "@vitnode/core/api/models/user"; + +export const userRoute = buildRoute({ + handler: async c => { + const user = await new UserModel().getUserById({ id: 3, c }); + // or: getUserByNameCode({ nameCode: "john_doe", c }) + + if (!user) { + return c.json({ message: "User not found" }, 404); + } + + return c.json({ user }); + }, +}); +``` + +## Frontend + +### Current session + +On the server (React Server Components) read the current session with `getSessionApi()`. It returns `{ user }`, where `user` is the authenticated user — including an `isAdmin` flag — or `null` for guests. + +```tsx title="app/[locale]/profile/page.tsx" +import { getSessionApi } from "@vitnode/core/lib/api/get-session-api"; + +export default async function Page() { + const { user } = await getSessionApi(); + + if (!user) { + return

You are not signed in.

; + } + + return

Welcome back, {user.name}!

; +} +``` + +To type a Client Component that receives the user as a prop, use the exported `SessionApi` type: + +```tsx title="profile.tsx" +"use client"; + +import type { SessionApi } from "@vitnode/core/lib/api/get-session-api"; + +export const Profile = ({ + user, +}: { + user: NonNullable; +}) => { + return {user.name}; +}; +``` + + + `getSessionApi()` relies on the [`fetcher`](/docs/dev/fetcher), so it can only run + on the server. Fetch the session in a Server Component and pass what you need + down to Client Components as props. + + +### Current admin session + +Admin pages have their own session, separate from the public one. Read it on the server with `getSessionAdminApi()` — it returns the admin `user` and the current `vitnode_version`, and automatically redirects to `/admin` when the visitor is not an authenticated administrator. + +```tsx title="app/[locale]/admin/page.tsx" +import { getSessionAdminApi } from "@vitnode/core/lib/api/get-session-admin-api"; + +export default async function Page() { + const session = await getSessionAdminApi(); + if (!session) return null; + + return

Signed in as {session.user.name}

; +} +``` + +### Avatar + +Render a user's avatar with the `Avatar` component. It generates a letter avatar from the user's `name` and `avatarColor`. + +```tsx title="user-card.tsx" +import { Avatar } from "@vitnode/core/components/avatar"; + +; +``` + +The `user` prop requires `name`, `nameCode`, and `avatarColor`, and `size` sets the avatar's width and height in pixels. + +### Formatting a user + +Use the `UserFormat` component to render a user's nickname together with their role. Pass the `format` prop to color the nickname with the role's `color`. + +```tsx title="user-link.tsx" +import { UserFormat } from "@vitnode/core/components/user-format"; + +; +``` + +The `user` prop has the following shape: + +```ts +{ + id: number; + name: string; + nameCode: string; + role: { + id: number; + name: string; + color: string | null; + }; +} +``` + +| Prop | Description | +| -------- | ------------------------------------------------------------------- | +| `user` | The user to render, including their `role`. | +| `format` | When set, colors the nickname using the role's `color`. | + + + To render a role on its own, use the [`RoleFormat`](/docs/dev/working-with-users/roles#formatting-a-role) component. + 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 new file mode 100644 index 000000000..0aa328562 --- /dev/null +++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/roles/page.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from "next/dist/types"; + +import { getTranslations } from "next-intl/server"; +import dynamic from "next/dynamic"; +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"; + +const RolesAdminView = dynamic(async () => + import("@vitnode/core/views/admin/views/core/users/roles/roles-admin-view").then( + module => ({ + default: module.RolesAdminView, + }), + ), +); + +export const generateMetadata = async (): Promise => { + const t = await getTranslations("admin.global.nav.users"); + + return { + title: t("roles"), + }; +}; + +export default async function Page( + props: React.ComponentProps, +) { + const [t, tNav] = await Promise.all([ + getTranslations("admin.role.list"), + getTranslations("admin.global.nav.users"), + ]); + + return ( + +
+ + + }> + + +
+
+ ); +} diff --git a/apps/docs/src/locales/@vitnode/core/en.json b/apps/docs/src/locales/@vitnode/core/en.json index 07d6f52f9..7ec497809 100644 --- a/apps/docs/src/locales/@vitnode/core/en.json +++ b/apps/docs/src/locales/@vitnode/core/en.json @@ -216,7 +216,8 @@ "dashboard": "Dashboard", "users": { "title": "Users", - "list": "User List" + "list": "User List", + "roles": "Roles" }, "user_bar": { "home_page": "Home Page", @@ -274,6 +275,10 @@ "emailNotVerified": "Email Not Verified", "coverPlaceholder": "No cover image" }, + "verify_email": { + "label": "Verify Email", + "success": "Email has been verified." + }, "create": { "title": "Add User", "desc": "Create a new account for a user.", @@ -296,6 +301,18 @@ "success": "User \"{name}\" has been created." } }, + "role": { + "list": { + "desc": "Manage the roles of your application.", + "role": "Role", + "usersCount": "Number of users", + "createdAt": "Created At", + "noResults": { + "title": "No roles found", + "description": "Try adjusting your search criteria." + } + } + }, "debug": { "title": "Debug Panel", "desc": "Check logs, errors, and other debug information.", diff --git a/packages/vitnode/src/api/modules/admin/admin.module.ts b/packages/vitnode/src/api/modules/admin/admin.module.ts index 8f56c4612..cfd134f01 100644 --- a/packages/vitnode/src/api/modules/admin/admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/admin.module.ts @@ -3,6 +3,7 @@ import { CONFIG_PLUGIN } from "@/config"; import { advancedAdminModule } from "./advanced/advanced.admin.module"; import { debugAdminModule } from "./debug/debug.admin.module"; +import { rolesAdminModule } from "./roles/roles.admin.module"; import { sendNotificationRoute } from "./routes/notifications.route"; import { sessionAdminRoute } from "./routes/session.route"; import { usersAdminModule } from "./users/users.admin.module"; @@ -11,6 +12,11 @@ export const adminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "admin", routes: [sessionAdminRoute, sendNotificationRoute], - modules: [usersAdminModule, debugAdminModule, advancedAdminModule], + modules: [ + usersAdminModule, + rolesAdminModule, + debugAdminModule, + advancedAdminModule, + ], cronJobs: [], }); 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 new file mode 100644 index 000000000..64645d8d2 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/roles/roles.admin.module.ts @@ -0,0 +1,11 @@ +import { buildModule } from "@/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; + +import { listRolesAdminRoute } from "./routes/list.route"; +import { showRoleAdminRoute } from "./routes/show.route"; + +export const rolesAdminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "roles", + routes: [listRolesAdminRoute, showRoleAdminRoute], +}); diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts new file mode 100644 index 000000000..2d97055a2 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/roles/routes/list.route.ts @@ -0,0 +1,142 @@ +import { z } from "@hono/zod-openapi"; +import { and, count, eq, inArray } from "drizzle-orm"; + +import { buildRoute } from "@/api/lib/route"; +import { + withPagination, + zodPaginationPageInfo, + zodPaginationQuery, +} from "@/api/lib/with-pagination"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_languages_words } from "@/database/languages"; +import { core_roles } from "@/database/roles"; +import { core_users } from "@/database/users"; + +const rolesAdminListSchema = z.object({ + edges: z.array( + z.object({ + id: z.number(), + // Every translation of the role name — resolved to the active locale on + // the frontend (see the `RoleFormat` component). + name: z.array( + z.object({ + name: z.string(), + languageCode: z.string(), + }), + ), + color: z.string().nullable(), + protected: z.boolean(), + default: z.boolean(), + root: z.boolean(), + guest: z.boolean(), + createdAt: z.date(), + usersCount: z.number(), + }), + ), + pageInfo: zodPaginationPageInfo, +}); + +export const listRolesAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: "Get list of all roles (Admin only)", + path: "/list", + request: { + query: zodPaginationQuery.extend({ + order: z.enum(["asc", "desc"]).optional(), + orderBy: z.enum(["id", "createdAt"]).optional(), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: rolesAdminListSchema, + }, + }, + description: "List of roles", + }, + 403: { + description: "Access Denied", + }, + }, + }, + handler: async c => { + const query = c.req.valid("query"); + + const data = await withPagination({ + params: { + query, + }, + primaryCursor: core_roles.id, + query: async ({ limit, where, orderBy }) => + await c + .get("db") + .select({ + id: core_roles.id, + color: core_roles.color, + protected: core_roles.protected, + default: core_roles.default, + root: core_roles.root, + guest: core_roles.guest, + createdAt: core_roles.createdAt, + }) + .from(core_roles) + .where(where) + .orderBy(orderBy) + .limit(limit), + table: core_roles, + orderBy: { + column: query.orderBy + ? core_roles[query.orderBy] + : core_roles.createdAt, + order: query.order ?? "desc", + }, + c, + }); + + const roleIds = data.edges.map(role => role.id); + const names = roleIds.length + ? await c + .get("db") + .select({ + itemId: core_languages_words.itemId, + languageCode: core_languages_words.languageCode, + value: core_languages_words.value, + }) + .from(core_languages_words) + .where( + and( + eq(core_languages_words.tableName, "core_roles"), + eq(core_languages_words.variable, "name"), + eq(core_languages_words.pluginCode, "core"), + inArray(core_languages_words.itemId, roleIds), + ), + ) + : []; + const userCounts = roleIds.length + ? await c + .get("db") + .select({ + roleId: core_users.roleId, + total: count(), + }) + .from(core_users) + .where(inArray(core_users.roleId, roleIds)) + .groupBy(core_users.roleId) + : []; + + return c.json({ + pageInfo: data.pageInfo, + edges: data.edges.map(role => ({ + ...role, + name: names + .filter(word => word.itemId === role.id) + .map(word => ({ name: word.value, languageCode: word.languageCode })), + usersCount: + userCounts.find(item => item.roleId === role.id)?.total ?? 0, + })), + }); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts new file mode 100644 index 000000000..28e5852b0 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/roles/routes/show.route.ts @@ -0,0 +1,115 @@ +import { z } from "@hono/zod-openapi"; +import { and, eq } from "drizzle-orm"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_languages_words } from "@/database/languages"; +import { core_roles } from "@/database/roles"; + +const roleAdminSchema = z.object({ + id: z.number(), + // Every translation of the role name — resolved to the active locale on the + // frontend (see the `RoleFormat` component). + name: z.array( + z.object({ + name: z.string(), + languageCode: z.string(), + }), + ), + color: z.string().nullable(), + protected: z.boolean(), + default: z.boolean(), + root: z.boolean(), + guest: z.boolean(), + createdAt: z.date(), +}); + +export const showRoleAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + description: "Get a single role by id (Admin only)", + path: "/{id}", + request: { + params: z.object({ + id: z.string().openapi({ example: "1" }), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: roleAdminSchema, + }, + }, + description: "Role found", + }, + 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 roleId = Number(id); + if (!Number.isInteger(roleId)) { + return c.json({ error: "Role not found" }, 404); + } + + const [role] = await c + .get("db") + .select({ + id: core_roles.id, + color: core_roles.color, + protected: core_roles.protected, + default: core_roles.default, + root: core_roles.root, + guest: core_roles.guest, + createdAt: core_roles.createdAt, + }) + .from(core_roles) + .where(eq(core_roles.id, roleId)) + .limit(1); + + if (!role) { + return c.json({ error: "Role not found" }, 404); + } + + const names = await c + .get("db") + .select({ + languageCode: core_languages_words.languageCode, + value: core_languages_words.value, + }) + .from(core_languages_words) + .where( + and( + eq(core_languages_words.tableName, "core_roles"), + eq(core_languages_words.variable, "name"), + eq(core_languages_words.pluginCode, "core"), + eq(core_languages_words.itemId, roleId), + ), + ); + + return c.json( + { + ...role, + name: names.map(word => ({ + name: word.value, + languageCode: word.languageCode, + })), + }, + 200, + ); + }, +}); 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 new file mode 100644 index 000000000..b4dbfbac6 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts @@ -0,0 +1,67 @@ +import { z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; +import { core_users } from "@/database/users"; + +export const verifyEmailUserAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "post", + description: "Verify a user's email by name SEO (Admin only)", + path: "/{nameCode}/verify-email", + request: { + params: z.object({ + nameCode: z.string().openapi({ example: "test" }), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + name: z.string(), + emailVerified: z.boolean(), + }), + }, + }, + description: "Email verified", + }, + 403: { + description: "Access Denied", + }, + 404: { + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + description: "User not found", + }, + }, + }, + handler: async c => { + const { nameCode } = c.req.valid("param"); + const [updated] = await c + .get("db") + .update(core_users) + .set({ emailVerified: true }) + .where(eq(core_users.nameCode, nameCode)) + .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/admin/users/users.admin.module.ts b/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts index beac6fb2f..34cf4f2e4 100644 --- a/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/users/users.admin.module.ts @@ -4,9 +4,15 @@ import { CONFIG_PLUGIN } from "@/config"; import { createUserAdminRoute } from "./routes/create.route"; import { listUsersAdminRoute } from "./routes/list.route"; import { showUserAdminRoute } from "./routes/show.route"; +import { verifyEmailUserAdminRoute } from "./routes/verify-email.route"; export const usersAdminModule = buildModule({ pluginId: CONFIG_PLUGIN.pluginId, name: "users", - routes: [listUsersAdminRoute, createUserAdminRoute, showUserAdminRoute], + routes: [ + listUsersAdminRoute, + createUserAdminRoute, + showUserAdminRoute, + verifyEmailUserAdminRoute, + ], }); diff --git a/packages/vitnode/src/app_admin/core/users/roles/page.tsx b/packages/vitnode/src/app_admin/core/users/roles/page.tsx new file mode 100644 index 000000000..1d85543a8 --- /dev/null +++ b/packages/vitnode/src/app_admin/core/users/roles/page.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from "next/dist/types"; + +import { getTranslations } from "next-intl/server"; +import dynamic from "next/dynamic"; +import React from "react"; + +import { I18nProvider } from "@/components/i18n-provider"; +import { DataTableSkeleton } from "@/components/table/data-table"; +import { HeaderContent } from "@/components/ui/header-content"; + +const RolesAdminView = dynamic(async () => + import("@/views/admin/views/core/users/roles/roles-admin-view").then( + module => ({ + default: module.RolesAdminView, + }), + ), +); + +export const generateMetadata = async (): Promise => { + const t = await getTranslations("admin.global.nav.users"); + + return { + title: t("roles"), + }; +}; + +export default async function Page( + props: React.ComponentProps, +) { + const [t, tNav] = await Promise.all([ + getTranslations("admin.role.list"), + getTranslations("admin.global.nav.users"), + ]); + + return ( + +
+ + + }> + + +
+
+ ); +} diff --git a/packages/vitnode/src/components/role-format.tsx b/packages/vitnode/src/components/role-format.tsx new file mode 100644 index 000000000..3a4a57b59 --- /dev/null +++ b/packages/vitnode/src/components/role-format.tsx @@ -0,0 +1,35 @@ +import { getLocale } from "next-intl/server"; + +import { cn } from "@/lib/utils"; + +export const RoleFormat = async ({ + role, + className, + style, + ...props +}: Omit, "role"> & { + role: { + color: null | string; + id: number; + name: { languageCode: string; name: string }[]; + }; +}) => { + // `getLocale()` resolves to the active locale (the config's `defaultLocale` + // when the request has none), so match it against the translations and fall + // back to whatever translation exists. + const locale = await getLocale(); + const name = + role.name.find(item => item.languageCode === locale)?.name ?? + role.name[0]?.name ?? + ""; + + return ( + + {name} + + ); +}; diff --git a/packages/vitnode/src/components/user-format.tsx b/packages/vitnode/src/components/user-format.tsx new file mode 100644 index 000000000..92e76d08f --- /dev/null +++ b/packages/vitnode/src/components/user-format.tsx @@ -0,0 +1,34 @@ +import { cn } from "@/lib/utils"; + +export const UserFormat = ({ + user, + format, + className, + style, + ...props +}: React.ComponentProps<"span"> & { + format?: boolean; + user: { + id: number; + name: string; + nameCode: string; + role: { + color: null | string; + id: number; + name: string; + }; + }; +}) => { + return ( + + {user.name} + + ); +}; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 07d6f52f9..7ec497809 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -216,7 +216,8 @@ "dashboard": "Dashboard", "users": { "title": "Users", - "list": "User List" + "list": "User List", + "roles": "Roles" }, "user_bar": { "home_page": "Home Page", @@ -274,6 +275,10 @@ "emailNotVerified": "Email Not Verified", "coverPlaceholder": "No cover image" }, + "verify_email": { + "label": "Verify Email", + "success": "Email has been verified." + }, "create": { "title": "Add User", "desc": "Create a new account for a user.", @@ -296,6 +301,18 @@ "success": "User \"{name}\" has been created." } }, + "role": { + "list": { + "desc": "Manage the roles of your application.", + "role": "Role", + "usersCount": "Number of users", + "createdAt": "Created At", + "noResults": { + "title": "No roles found", + "description": "Try adjusting your search criteria." + } + } + }, "debug": { "title": "Debug Panel", "desc": "Check logs, errors, and other debug information.", diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx index 066555faa..b85492c46 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav.tsx @@ -41,8 +41,8 @@ export const NavSidebarAdmin = async ({ href: "/admin/core/users", }, { - title: "test", - href: "/admin/core/test", + title: t("users.roles"), + href: "/admin/core/users/roles", }, ], }, diff --git a/packages/vitnode/src/views/admin/views/core/users/actions.tsx b/packages/vitnode/src/views/admin/views/core/users/actions.tsx index c8258dc0d..99c84d44e 100644 --- a/packages/vitnode/src/views/admin/views/core/users/actions.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/actions.tsx @@ -7,17 +7,33 @@ import { buttonVariants } from "@/components/ui/button"; import { TooltipWithContent } from "@/components/ui/tooltip"; import { Link } from "@/lib/navigation"; -export const UsersAdminActions = ({ nameCode }: { nameCode: string }) => { +import { VerifyEmailUserAdmin } from "./actions/verify-email/verify-email"; + +export const UsersAdminActions = ({ + nameCode, + emailVerified, +}: { + emailVerified: boolean; + nameCode: string; +}) => { const t = useTranslations("admin.user.list"); return ( - - - - - + <> + + + + + + + + ); }; diff --git a/packages/vitnode/src/views/admin/views/core/users/actions/verify-email/mutation-api.ts b/packages/vitnode/src/views/admin/views/core/users/actions/verify-email/mutation-api.ts new file mode 100644 index 000000000..8a755192f --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/actions/verify-email/mutation-api.ts @@ -0,0 +1,26 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { fetcher } from "@/lib/fetcher"; + +export const mutationApi = async (nameCode: string) => { + const res = await fetcher(adminModule, { + path: "/{nameCode}/verify-email", + method: "post", + module: "admin/users", + args: { + params: { nameCode }, + }, + }); + + if (res.status !== 200) { + return { error: await res.text() }; + } + + const data = await res.json(); + revalidatePath("/[locale]/admin", "layout"); + + return { data }; +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/actions/verify-email/verify-email.tsx b/packages/vitnode/src/views/admin/views/core/users/actions/verify-email/verify-email.tsx new file mode 100644 index 000000000..f5f913e28 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/actions/verify-email/verify-email.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { CheckIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useActionState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { TooltipWithContent } from "@/components/ui/tooltip"; + +import { mutationApi } from "./mutation-api"; + +export const VerifyEmailUserAdmin = ({ + nameCode, + emailVerified, + iconOnly = false, +}: { + emailVerified: boolean; + iconOnly?: boolean; + nameCode: string; +}) => { + const t = useTranslations("admin.user.verify_email"); + const tError = useTranslations("core.global.errors"); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, formAction, isPending] = useActionState(async () => { + const mutation = await mutationApi(nameCode); + + if (mutation?.error) { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + + return; + } + + toast.success(t("success"), { + description: mutation.data?.name, + }); + }, null); + + if (emailVerified) { + return null; + } + + return ( +
+ {iconOnly ? ( + + + + ) : ( + + )} +
+ ); +}; 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 new file mode 100644 index 000000000..724ef34a6 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/users/roles/roles-admin-view.tsx @@ -0,0 +1,70 @@ +import { ShieldIcon } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; + +import { adminModule } from "@/api/modules/admin/admin.module"; +import { DateFormat } from "@/components/date-format"; +import { RoleFormat } from "@/components/role-format"; +import { DataTable } from "@/components/table/data-table"; +import { fetcher } from "@/lib/fetcher"; + +export const RolesAdminView = async ({ + searchParams, +}: { + searchParams: Promise>; +}) => { + const t = await getTranslations("admin.role.list"); + const query = await searchParams; + const res = await fetcher(adminModule, { + path: "/list", + method: "get", + module: "admin/roles", + args: { + query, + }, + withPagination: true, + }); + + if (res.status !== 200) { + return notFound(); + } + + const data = await res.json(); + + return ( + , + }, + { + id: "usersCount", + label: t("usersCount"), + cell: ({ row }) => row.usersCount, + }, + { + id: "createdAt", + label: t("createdAt"), + cell: ({ row }) => , + }, + ]} + customNoResults={{ + title: t("noResults.title"), + description: t("noResults.description"), + icon: , + }} + edges={data.edges} + id="roles-table" + order={{ + columns: ["createdAt"], + defaultOrder: { + column: "createdAt", + order: "desc", + }, + }} + pageInfo={data.pageInfo} + /> + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx b/packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx index d96e6a6e8..6041a4a1d 100644 --- a/packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/show/show-user-admin-view.tsx @@ -9,6 +9,8 @@ import { Card, CardContent } from "@/components/ui/card"; import { TooltipWithContent } from "@/components/ui/tooltip"; import { fetcher } from "@/lib/fetcher"; +import { VerifyEmailUserAdmin } from "../actions/verify-email/verify-email"; + export const ShowUserAdminView = async ({ nameCode }: { nameCode: string }) => { const t = await getTranslations("admin.user.show"); const res = await fetcher(adminModule, { @@ -43,7 +45,7 @@ export const ShowUserAdminView = async ({ nameCode }: { nameCode: string }) => { user={user} /> -
+

{user.name} @@ -58,6 +60,11 @@ export const ShowUserAdminView = async ({ nameCode }: { nameCode: string }) => { @{user.nameCode}

+ +