Working with Users

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"):

plugins/blog/src/api/modules/posts/routes/create.route.ts
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:

src/components/user-greeting.tsx
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.

plugins/blog/src/admin/current-admin.tsx
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:

HookReturns
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:

ColumnTypeDescription
idserialUnique permanent identifier
namevarchar(255)Display name
nameCodevarchar(255)URL-safe slug for profile URLs (/users/[nameCode])
emailvarchar(255)Unique account login email
firstNamevarchar(128)Given name, set by the user (nullable)
lastNamevarchar(128)Family name, set by the user (nullable)
phonevarchar(32)Contact number, their own (nullable)
headlinevarchar(100)One line about the user, their own (nullable)
showRealNamebooleanShow the real name publicly instead of name
roleIdintegerPrimary role ID (FK to core_roles)
avatarColorvarchar(6)Hex color for letter avatars
avatarIdintegerUploaded avatar, a core_files row (nullable)
coverIdintegerUploaded profile cover, a core_files row (nullable)
languagevarchar(32)Preferred UI locale (defaults to en)
emailVerifiedbooleanVerification 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.

MethodPathWho
GET/api/@vitnode/core/users/me/policyThe signed-in user - may they edit?
PATCH/api/@vitnode/core/users/meThe 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:

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 holding users:can_edit - plus users:can_edit_admin when 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:

SettingColumnDefault
Allow editing personal informationallowEditPersonalInfotrue
Allow avatar uploadallowUploadAvatartrue
Maximum avatar size (kB)maxAvatarSize2048
Allow cover image uploadallowUploadCovertrue
Maximum cover size (kB)maxCoverSize5120

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

MethodPathWho
GET/api/@vitnode/core/users/images/policySigned-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_files id, or null when the image was removed.

Learn More