Admin
The back office, where you look at your users, ban one, or sign in as one to see what they see.
apps/admin is the back office. It is the app you use to run the product, not
the app your users see. You run it on admin.<domain>.
It has one page that matters: the list of your users. From there you can search them, ban one, or sign in as one.
The app is built with TanStack Start and runs on Cloudflare Workers by default. Vercel is supported too; see Deployment. A Worker is a small server that Cloudflare runs close to you. It is smaller than the other two apps on purpose. It has no languages, no blog and no billing.
It has its own sign-in
This is the part to understand first.
The admin app does not share a sign-in with the web app. It runs on its own
address, and it holds its own session. A session is the proof that someone has
signed in. So you sign in here a second time, even if you are already signed in
on app.<domain>.
The app has its own sign-in page at /sign-in. It also has its own handler at
src/routes/api.auth.$.ts, which answers the sign-in requests of this app.
The users are the same users. Both apps read the same database. Only the session is separate.
The guard
A session is not enough. The user must also be an admin. The file
src/routes/_protected.tsx checks both:
if (!(session && isAdmin(session.user.role))) {
throw redirect({ to: "/sign-in" });
}
So there are three answers. An admin gets in. A normal user is sent back to
/sign-in. A visitor with no session is sent back too.
Those three cases are tested in src/routes/_protected.node.test.tsx, against
a real database.
The role of a user is a column in the user table. To make yourself an
admin, set your role to admin there. You can do that with
pnpm run db:studio, which opens the database in your browser.
The users page
The page is at /users. The page at / sends you there.
The table shows the email, the name, the role and the status of each user.
Status is Active or Banned.
You can:
- Search the users by email.
- Sort by email, name, role or status.
- Show the details of one user.
- Select several users, and ban them all at once.
- Export what you see as a CSV file.
Each row has two buttons.
Ban stops a user from signing in. The button turns into Unban afterwards, so the same button undoes it.
Impersonate signs you in as that user. You then see the app the way they see it. It is the fastest way to understand a bug someone reports.
The table reads 25 users at a time. The search, the sort and the page are all kept in the address. So you can copy a link and get the same view back.
The frame of a page
Every page is wrapped in AdminShell, from src/components/admin-shell.tsx.
It draws the bar at the top, with the name of the app and the sign-out button.
Under the bar it draws the title of the page and the text below it.
<AdminShell description="Every user of the app" title="Users">
<DataTable {...props} />
</AdminShell>
Use it for any page you add, so your page looks like the ones that ship.
Running it
pnpm run dev:admin
This starts the app on port 3003. Open http://localhost:3003.
pnpm run env:admin
This prints the environment the app loads. An environment variable is a value you set outside the code, like a password.
The app needs a Postgres database running. Postgres is the database that stores
your users. See Local development for the
docker compose up that starts it.
The app declares no variables of its own. Its .env.schema takes the whole
root .env.schema as it is.
Where the database address comes from
One file decides: src/lib/database-url.ts. On Cloudflare it reads the
Hyperdrive binding. Hyperdrive is a Cloudflare service that sits in front of
Postgres and makes queries faster.
export const databaseUrl = (): string => cloudflareEnv.HYPERDRIVE.connectionString;
The binding is set in wrangler.jsonc. There is a TODO comment next to the
id that ships. Replace it with the id of your own database before you
deploy.
What it uses from the project
@zap-ts/authentication— the sign-in, the session, the admin check@zap-ts/ui— the table, the buttons, the layout@zap-ts/tanstack-start— the sign-in form and shared page pieces@zap-ts/observability— errors you need to see@zap-ts/database— used by the test only
There is no billing package here, and no mail package.
Keeping it private
Search engines are told to stay away. Every page carries noindex, nofollow.
That is not a lock. It is a request, and a crawler can ignore it. Real safety
comes from the guard and from the role check. Keep the number of admins
small.
What to change first
- The Hyperdrive
idinwrangler.jsonc. - The name in
src/components/admin-shell.tsxand insrc/routes/__root.tsx. - The labels on the sign-in page, in
src/routes/sign-in.tsx. They are written in the file, not translated.
Deploying
pnpm run deploy:admin:cloudflare
pnpm run deploy:admin:vercel
The host is part of the name, so a deploy never goes somewhere you did not
name. There is no deploy on its own.
The Cloudflare one runs varlock-wrangler deploy. It sends your environment
variables up with the app. You do not set them by hand in the Cloudflare
dashboard. The Vercel one runs varlock run -- vercel deploy --prod, and there
you set the variables yourself. See Deployment.
Point admin.<domain> at this Worker, and nothing else at it.
Related
- Web — the app your users sign in to
- Marketing — the public site
- Authentication — the session and the admin check
- Database — the
usertable and itsrolecolumn - Going to production