diff --git a/apps/api/src/locales/@vitnode/core/en.json b/apps/api/src/locales/@vitnode/core/en.json
index 95d925bb5..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",
@@ -261,11 +262,55 @@
"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"
+ },
+ "verify_email": {
+ "label": "Verify Email",
+ "success": "Email has been verified."
+ },
+ "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."
+ }
+ },
+ "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": {
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/[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]/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 (
-