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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion apps/api/src/locales/@vitnode/core/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,8 @@
"dashboard": "Dashboard",
"users": {
"title": "Users",
"list": "User List"
"list": "User List",
"roles": "Roles"
},
"user_bar": {
"home_page": "Home Page",
Expand Down Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/dev/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"plugins",
"api",
"database",
"working-with-users",
"advanced",
"---Adapters---",
"captcha",
Expand Down
4 changes: 4 additions & 0 deletions apps/docs/content/docs/dev/working-with-users/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"title": "Working with Users",
"pages": ["users", "roles", "..."]
}
124 changes: 124 additions & 0 deletions apps/docs/content/docs/dev/working-with-users/roles.mdx
Original file line number Diff line number Diff line change
@@ -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). |

<Callout type="info" title="Role names are translatable">
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.
</Callout>

## 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";

<RoleFormat
role={{
id: 2,
name: [
{ languageCode: "en", name: "Administrator" },
{ languageCode: "pl", name: "Administrator" },
],
color: "#ef4444",
}}
/>;
```

The `role` prop has the following shape:

```ts
{
id: number;
name: {
languageCode: string;
name: string;
}[];
color: string | null;
}
```

<Callout type="info" title="Server Component only">
`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.
</Callout>

<Callout type="info" title="Exposing role metadata">
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).
</Callout>
186 changes: 186 additions & 0 deletions apps/docs/content/docs/dev/working-with-users/users.mdx
Original file line number Diff line number Diff line change
@@ -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;
}
```

<Callout type="info" title="Roles">
`roleId` points to the role the user belongs to. See [Roles](/docs/dev/working-with-users/roles) for more.
</Callout>

### 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 <p>You are not signed in.</p>;
}

return <p>Welcome back, {user.name}!</p>;
}
```

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<SessionApi["user"]>;
}) => {
return <span>{user.name}</span>;
};
```

<Callout type="warn" title="Server-side only">
`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.
</Callout>

### 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 <p>Signed in as {session.user.name}</p>;
}
```

### 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";

<Avatar size={24} user={user} />;
```

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";

<UserFormat
format
user={{
id: 1,
name: "John Doe",
nameCode: "john_doe",
role: {
id: 2,
name: "Administrator",
color: "#ef4444",
},
}}
/>;
```

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

<Callout type="info" title="Roles">
To render a role on its own, use the [`RoleFormat`](/docs/dev/working-with-users/roles#formatting-a-role) component.
</Callout>
Loading
Loading