Users & Sessions
Access signed-in user context in Hono API handlers, route loaders, and React components.
Every incoming request in VitNode resolves the session cookie prior to route execution. The current user profile is accessible on both server handlers and frontend components.
Quick start
1. In Hono API Routes
Access the authenticated user via c.get("user"):
import { HTTPException } from 'hono/http-exception'
handler: async (c) => {
const user = c.get('user')
if (!user) {
throw new HTTPException(401, { message: 'Unauthorized' })
}
const newPost = await createPost({ authorId: user.id })
return c.json(newPost)
}2. In Frontend Components
Consume the user session via TanStack Query:
import { useSessionQuery } from '@vitnode/core/tanstack/auth'
export const UserGreeting = () => {
const { data } = useSessionQuery()
const user = data?.user
if (!user) return <span>Welcome, guest!</span>
return <span>Welcome back, {user.name}!</span>
}useSessionQuery() wraps useQuery(sessionQueryOptions()), so it never
suspends: data is undefined on the very first render of a page that did not
warm it in a loader. Routes that want the session ready before paint call
ensureAuthState(queryClient) in their loader.
3. In the AdminCP
The AdminCP session is a second cookie, and a second query. isAdmin on the
public session means "may be offered the AdminCP", not "is inside it" - so an
admin screen never answers an admin question with the public session.
import { useAdminSessionQuery } from '@vitnode/core/tanstack/admin'
export const CurrentAdmin = () => {
const { data } = useAdminSessionQuery()
if (data.status !== 'granted') return null
return <span>Signed in as {data.session.user.name}</span>
}useAdminSessionQuery() wraps useSuspenseQuery(adminSessionQueryOptions()),
so data is always there and the screen suspends instead of rendering an empty
state. The AdminCP layout warms it, which is why every screen below it reads a
cache hit. Two shorthands sit on top of it:
| Hook | Returns |
|---|---|
useAdminAccess() | The whole access state - granted, or the reason it was refused |
useAdminUser() | The signed-in administrator, or null when access is refused |
User Model (core_users)
The canonical user table contains:
| Column | Type | Description |
|---|---|---|
id | serial | Unique permanent identifier |
name | varchar(255) | Display name |
nameCode | varchar(255) | URL-safe slug for profile URLs (/users/[nameCode]) |
email | varchar(255) | Unique account login email |
firstName | varchar(128) | Given name, set by the user (nullable) |
lastName | varchar(128) | Family name, set by the user (nullable) |
phone | varchar(32) | Contact number, their own (nullable) |
headline | varchar(100) | One line about the user, their own (nullable) |
showRealName | boolean | Show the real name publicly instead of name |
roleId | integer | Primary role ID (FK to core_roles) |
avatarColor | varchar(6) | Hex color for letter avatars |
avatarId | integer | Uploaded avatar, a core_files row (nullable) |
coverId | integer | Uploaded profile cover, a core_files row (nullable) |
language | varchar(32) | Preferred UI locale (defaults to en) |
emailVerified | boolean | Verification status |
Every user payload the API hands out - the session, the public profile, the
AdminCP list and detail, search hit authors - carries the two images resolved
to URLs as avatarUrl and coverUrl. Both are null until something is
uploaded, and the Avatar component falls back to the letter avatar on its own -
drawn from the first character of nameCode, never of the displayed name, so a
member who changes which name their profile shows keeps the same face in every
list they appear in.
Personal information
firstName, lastName, phone and headline are the user's own to set, at Settings →
Overview (/settings). All three are optional, all three are stored as null
rather than an empty string when cleared, and the session payload carries them
so anything already reading the session can show them without a second request.
| Method | Path | Who |
|---|---|---|
GET | /api/@vitnode/core/users/me/policy | The signed-in user - may they edit? |
PATCH | /api/@vitnode/core/users/me | The signed-in user, on their own row |
The body takes any subset of
{ firstName, lastName, phone, headline, showRealName } -
a field left out is left alone, and a text field sent as "" or whitespace is
cleared. Writing invalidates the user's cached session and emits
user.updated.
phone is the one field with a shape: digits and the punctuation international
numbers are written with (+, spaces, -, ( and )), up to 32 characters.
Anything else is rejected by the form and by the route. It is not on the
public profile payload - only the account's own session carries it.
Which name the public sees
showRealName decides whether a profile is headed by the nickname (name) or by
firstName lastName. The public profile route resolves it server-side and
returns the answer as name, so a real name the user has not published never
leaves the API. When the switch is on but neither half is set, the nickname is
used, so turning it on early cannot leave a profile with an empty heading.
The AdminCP and search always show name, the account's own nickname - staff
need the identifier the account is filed under.
What the profile header shows
GET /api/@vitnode/core/users/profile/{nameCode} answers with the header's whole
payload: name (already resolved), nameCode, headline, avatarUrl,
coverUrl, avatarColor, createdAt, the primary role and any
secondaryRoles. The header draws the headline, the role and the join date as
one row of meta items, each with its own icon, and stacks them centred under the
avatar on a phone.
headline follows the install switch: with users.personalInformation.headline
set to false the route returns null for everyone, so turning the field off
takes it off public profiles too, without touching what members had written.
The Edit profile button is the header's action slot. It is rendered only for
the account's own profile; a host that renders ProfileContent itself can pass
any action it likes - a follow button, a "message" link, nothing at all.
Who may edit it
Each role has Allow editing personal information on its Profile tab
(allowEditPersonalInfo, default true). A user may edit only when every
role they hold allows it - one role that switches it off is enough to lock the
card.
That is the opposite of the image allowances, which grant on any role, and
deliberately so: this column defaults to true, so under "any role grants" an
administrator who switched it off on one role would find it silently re-granted
by every other role the member happens to hold, and the setting would almost
never take effect.
The settings card hides its Edit button when the answer is no, and
PATCH /users/me answers 403 regardless of what the UI showed.
Turning fields off for the whole install
Roles decide who may edit. The API config decides which fields exist at all.
Every field is on unless you set it to false in vitnode.api.config.ts:
export const vitNodeApiConfig = buildApiConfig({
// ...
users: {
personalInformation: {
phone: false,
headline: false,
showRealName: false,
},
},
})The keys are firstName, lastName, phone, headline and showRealName. A
field switched off disappears from the settings card and from its form, and
PATCH /users/me refuses to write it even if a caller sends it anyway - so a
stale browser tab or a hand-rolled request cannot get around it.
Switching a field off never deletes anything: the column keeps whatever members
had already set, so turning it back on restores their values. With
showRealName off, every profile is headed by the nickname again, whatever
individual members had chosen. With every field off there is nothing left to
edit, so the Edit button disappears too.
AdminCP User Management
Administrators can view, edit, ban, and assign roles to users at Core → Users (/admin/core/users).
Where it happens
- Profile page (
/users/[nameCode]): the owner sees a camera button on the cover and on the avatar. It opens one dialog that both uploads and removes: with a picture already set, a pair of radio buttons chooses between them and the confirm button reads Upload or Delete to match; with nothing set yet, the dialog goes straight to the picker. - Settings overview (
/settings): the avatar and its camera button only, beside the name, code name, role and email the account carries, above the personal information the user sets for themselves. The cover is edited on the profile page. - AdminCP (
/admin/core/users/[id]): the same two buttons and the same dialog, for anybody holdingusers:can_edit- plususers:can_edit_adminwhen the target is an administrator. Staff can always set an image, even when the user's own roles cannot; the role's size cap still applies.
Role settings
Each role decides, on its Profile tab at Core → Users → Roles:
| Setting | Column | Default |
|---|---|---|
| Allow editing personal information | allowEditPersonalInfo | true |
| Allow avatar upload | allowUploadAvatar | true |
| Maximum avatar size (kB) | maxAvatarSize | 2048 |
| Allow cover image upload | allowUploadCover | true |
| Maximum cover size (kB) | maxCoverSize | 5120 |
A user with several roles is allowed as soon as one of them allows the image, and gets the most generous cap among those roles. The cap is shown in the dialog before a file is picked, and checked again on the server.
API
| Method | Path | Who |
|---|---|---|
GET | /api/@vitnode/core/users/images/policy | Signed-in user - what they may upload, and the caps in bytes |
POST | /api/@vitnode/core/users/images/{kind} | Signed-in user - multipart file, kind is avatar or cover |
DELETE | /api/@vitnode/core/users/images/{kind} | Signed-in user |
POST | /api/@vitnode/core/admin/users/{id}/images/{kind} | Staff with users:can_edit |
DELETE | /api/@vitnode/core/admin/users/{id}/images/{kind} | Staff with users:can_edit |
Accepted types are JPEG, PNG and WebP. The browser crops to 512×512 for an
avatar and 1800×600 for a cover and sends WebP where the browser can encode
it, so uploads are usually far under the cap. A refused upload answers 400
with { error } written for the person who picked the file, and a role that
does not allow the image answers 403.
Both writes emit an event -
user.avatar.updated and user.cover.updated
- with the new
core_filesid, ornullwhen the image was removed.