Managing users
The subscriber directory and the Admins screen: how the lists are built, how to search them, and every mutation you can make with its exact effect.
Last updated 2026-08-06
Summary#
Two screens deal with lists of people. Subscribers is the directory of everyone who has ever used the platform, built by aggregating usage and plan records. Admins is the staff list, and it is the only place where a person's access to the console can be changed. This page explains how each list is assembled, what you can filter and export, and precisely what every mutation does to the database.
Purpose#
The directory answers "who are our users and what shape is the base in". The Admins screen answers "who can act on that base". Keeping them apart matters: nothing you do on the Subscribers screen changes anything, while every button on the Admins screen changes who can reach production data.
To change one customer's plan, usage or suspension state, use the Customers screen instead. See Customer lookup and billing.
Requirements#
- An active admin identity. See Getting admin access.
MONITOR_DBbound, since both lists come from D1 through the worker.- The
ownerrole for any mutation on the Admins screen.
Permissions#
| Action | Role needed | Endpoint |
|---|---|---|
| Read the subscriber directory, search, filter, export CSV | admin | /api/admin/data |
| Read the admin list | admin | /api/admin/users |
| Add, suspend, activate, promote, demote or remove an admin | owner | /api/admin/users/mutate |
An admin who calls the mutate endpoint receives 403 {"error":"Only an owner can manage admins."}. The Add an admin card and the per-row buttons stay hidden for that role, driven by the me.role value the list endpoint returns alongside the rows.
Navigation Path#
Admin console → Main → Subscribers
Admin console → Operations → Admins
Step-by-Step Guide#
Browse the subscriber directory#
- Select Subscribers in the Main group. The table loads on first open and on every nav click.
- Type into the box labelled
Search by email or name.... The filter runs client side over the loaded rows. - Narrow with the plan select (
All Plans,Starter,Pro,Agency,Enterprise) or the status select (All Status,Active,Suspended). - Press Export CSV to download the filtered set as
subscribers.csvwith the columnsEmail, Name, Plan, Status, Joined. - Press View on any row to jump to the Customers screen with that address already loaded.
How the directory is assembled#
There is no subscriber table. /api/admin/data reads every row of usage_counters and every row of user_plans, aggregates usage per email across all months, then adds anyone who has a plan but no usage. The name column is the local part of the email address. Suspension state comes from a scan of platform_config for suspended:% keys whose value is 1, so it reflects the same switch the Customers screen flips.
The plan shown for a row is resolved in this order: the user_plans row, then this month's usage_counters.plan, then starter as the last resort. That last fallback is why an unrecognized row can appear as Starter rather than blank.
Note: The table has no pagination. Every subscriber renders. The plan filter also omits free and unlimited, and the plan badge has no color for those two values, so they render in the same grey as Starter. Use the Usage & Activity screen when you need those plans filtered.
Read the admin list#
Select Admins in the Operations group. The table Admin accounts has six columns: Email, Role, Status, Added by, Last seen, Actions. Rows are ordered by creation date ascending, so the seeded owners sit at the top.
Statusrenders green foractiveand red for anything else.Rolerenders purple forowner.Last seenis thelast_seen_atvalue, stored in milliseconds and stamped every time that admin's token is successfully resolved. It is a real last-request time, not a last-login time.Added byisseedfor addresses the first-run seeding inserted, and the acting owner's email for anyone added through the console.- Protected seed accounts show the literal word
protectedinstead of buttons.
If the table is empty it reads No admins.
Add an admin#
- In the Add an admin card, enter the address in the field labelled
name@example.com. - Choose
AdminorOwner. The select defaults toAdmin. - Press Add admin. A blank field returns
Enter an email.; success showsDone.and the list reloads.
The write is an upsert. An existing row for that address has its role replaced and its status forced back to active, which makes Add an admin the fastest way to un-suspend someone as well as the way to create them.
Change an existing admin#
Each unprotected row carries up to three buttons.
| Button | What it sends | Effect |
|---|---|---|
| Suspend | update with status: "suspended" | The row stays but the address is denied at sign-in, even if it is on the seed floor |
| Activate | update with status: "active" | Restores access with the existing role |
| Make owner | update with role: "owner" | Grants every mutation right |
| Make admin | update with role: "admin" | Reduces the account to read-only across the console |
| Remove | remove | Deletes the row outright |
Every mutate action in full#
POST /api/admin/users/mutate takes { token, action, email, role?, status? }. The address is trimmed and lower-cased, and must contain an @ at position 1 or later, otherwise the call returns 400 {"error":"A valid email is required."}.
| Action | Accepted extras | Database effect | Guards | Audit entry | ||
|---|---|---|---|---|---|---|
add | role (owner accepted, anything else becomes admin) | INSERT ... ON CONFLICT(email) DO UPDATE SET role = excluded.role, status = 'active', recording added_by and created_at | A seed address is forced to owner regardless of the role sent | admin.add, meta role=<role> | ||
update | role (owner or admin only), status (active or suspended only) | UPDATE admin_users SET role = COALESCE(?, role), status = COALESCE(?, status) | Suspending or demoting yourself returns 400 {"error":"You cannot suspend or demote your own account."}. Suspending or demoting a seed address returns 400 {"error":"This is a protected owner account."} | admin.update, meta `role=<r | -> status=<s | ->` |
remove | none | DELETE FROM admin_users WHERE email = ? | Removing yourself returns 400 {"error":"You cannot remove your own account."}. Removing a seed address returns 400 {"error":"This is a protected owner account."} | admin.remove | ||
| anything else | none | none | 400 {"error":"Unknown action."} | none |
Values outside the accepted sets are silently ignored rather than rejected. An update carrying role: "superuser" passes null into the COALESCE, so the role is left unchanged and the call still returns {ok:true}. Check the row in the table after any update rather than trusting the success message alone.
Undoing a change#
| Change | How to undo |
|---|---|
| Suspended an admin | Press Activate, or re-add the address, which forces status back to active |
| Demoted an admin | Press Make owner |
| Removed an admin | Re-add the address. The row is recreated, but created_at and last_seen_at start again and added_by becomes you |
| Added the wrong address | Press Remove |
Nothing on either screen notifies the person affected. There is no invitation email for admin access; you tell them out of band.
Troubleshooting#
| Symptom | Likely cause | Fix |
|---|---|---|
| No buttons on any admin row, and no Add an admin card | Your role is admin | An owner promotes you with Make owner |
A row shows protected and cannot be changed | The address is on the seed floor, either hardcoded or in ADMIN_EMAILS | Remove it from ADMIN_EMAILS and redeploy if it really must be demoted |
This is a protected owner account. | Same cause, on a suspend, demote or remove | As above |
You cannot suspend or demote your own account. | The self-action guard | Ask another owner to make the change |
| A newly added admin still cannot sign in | Their Supabase user does not exist or the email is unconfirmed | Confirm the address in Supabase, then retry. See Getting admin access |
Last seen is blank for an active admin | They have not made an authenticated request since the column started being stamped | Not an error; it fills on their next request |
| A subscriber is missing from the directory | They have neither a user_plans row nor any usage_counters row | Look them up directly on the Customers screen |
| A subscriber shows the wrong plan | Their plan is resolved by code override, not by the stored row | Check DEV_UNLIMITED_EMAILS and FORCE_FREE_EMAILS in the worker |
FAQs#
Does removing an admin sign them out? Not immediately. Their existing Supabase access token remains valid until it expires, though every admin endpoint will reject it as soon as the row is gone, because the token is re-resolved on each request.
Why does the directory show more people than we have paying customers? It counts anyone with a usage or plan record. The Paying customers figure on the Usage & Activity screen is the one that excludes Starter.
Can I change a customer's plan from the Subscribers screen? No. Subscribers is read-only. Press View to open the same customer on the Customers screen, where the plan control lives.
Is there an export of the admin list? No. CSV export exists for Subscribers, Usage, Run Log and the audit log, not for the admin table. The audit log records every admin change if you need a history.
Why did an update return success but change nothing? Because unrecognized role or status values are coalesced away rather than rejected. Re-read the row to confirm.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.