Signing in
The admin panel has one way in, and it admits one kind of person: a platform administrator. Everybody else is turned away at the door — not on the first screen behind it.
Why the refusal happens at the door
"Signed in — but you have no access" is a useful answer if you are guessing passwords. It says the email exists, and it says the password was right, and it says both to somebody who was never going to be let in.
So the role is checked inside the sign-in, between verifying the password and issuing tokens. Three different failures come back as one answer:
| What actually happened | What the caller gets |
|---|---|
| No account with that email | 401 INVALID_CREDENTIALS · Invalid email or password |
| Wrong password | 401 INVALID_CREDENTIALS · Invalid email or password |
| Right password, not a platform administrator | 401 INVALID_CREDENTIALS · Invalid email or password |
Same status, same code, same sentence, and no token of any kind in the body. The administrator of a team is in the third row: they run one tenant, and running one tenant is not running the platform.
The three endpoints
All under admin/auth, so PlatformAdminGuard covers them by declaration.
| Route | Guarded | What it is for |
|---|---|---|
POST /admin/auth/login | @Public() | Sign in. Refuses non-administrators itself, before minting anything. |
POST /admin/auth/refresh | @Public() | Renew. Re-reads the role from the database, so revoking it stops the renewals at once. |
GET /admin/auth/me | guard | The panel's boot question. 401 without a token, 403 for a valid token belonging to somebody who is not an administrator. |
The two doors are public because a person arriving at them has no token yet, and demanding one would make signing in impossible. That is the single opening the guard allows, and the reason the check has to live inside the handler.
Nothing here is a second account system
An administrator is an ordinary User with platformRole = 'admin'. The panel calls the cabinet's own AuthService for the password check and the tokens — same bcrypt comparison, same secret, same lifetimes — and adds one question to the result. The request and response bodies are the cabinet's DTOs, unchanged, which is also why UserDto still does not carry the platform role: there is no second user DTO for it to leak through.
Why the renewal re-asks
The role is not in the token. If renewal only checked the token, taking the role away would take effect when the refresh token expired — a month later. POST /admin/auth/refresh reloads the user and refuses a former administrator with 401 INVALID_TOKEN: the same answer a dead token gets, so the response does not distinguish "your session ended" from "you were demoted".
In the panel
A cookie is not a permission
The panel and the customer cabinet are served from the same host and talk to the same api, which accepts either one's tokens. A customer who is signed in to the cabinet and types the panel's address arrives holding a valid token. A cookie check would let them past and draw the shell.
So the panel asks GET /admin/auth/me once per document load, before anything is rendered, and acts on the answer. This is a courtesy to an honest browser, not a defence: it runs in the visitor's browser and can be skipped by anyone willing to skip it. What keeps tenant data away from a customer is the guard on the api.
401 and 403 are different exits
They differ in one way that matters: whether a fresh token would help.
- 401 — the token is missing or dead. The panel renews once, replays the request once, and ends the session only if the replay is refused too. This is the cabinet's measured loop (AGNT2-72): a dead-end refusal costs two api calls, not the 600+ request/refresh pairs that behaviour once produced.
- 403 — the token is fine and the person is not an administrator. A new token would fail identically, so there is no renewal at all. The session ends immediately.
Either exit is a full document navigation to /login, which drops every store, timer and in-flight request belonging to the dead session, and carries a reason in the query string:
/login?reason=expired | Your session has expired. Please sign in again. |
/login?reason=forbidden | This account is not an administrator of the platform. |
The reason travels in the URL rather than in a store because the navigation exists precisely to destroy every store — and, unlike sessionStorage, a URL survives a refresh, can be linked, and can be asserted from a test. Anything unrecognised in that query renders nothing at all: a login form must never show a sentence a stranger chose.
Cookies
agentfy_admin_access and agentfy_admin_refresh — deliberately not the cabinet's agentfy_access / agentfy_refresh. On a shared host, one name would mean signing out of the cabinet signs you out of the panel, and, worse, signing in to the cabinet quietly repopulates the panel's session with a token the panel never issued.
There is no registration
Nobody signs themselves up as an administrator of the platform: the role is granted by writing the row, out of band (see the role page). The panel has no /register route, and the login screen has no link to one.
Checking it by hand
With the api and the panel running:
# grant yourself the role (there is no endpoint for this, by design)
cd api && node scripts/platformAdmin.mjs grant you@example.com| Try this | Expect |
|---|---|
| Open any panel address without signing in | You land on /login |
| Sign in as an ordinary user, correct password | You stay on /login, toast reads INVALID_CREDENTIALS · Invalid email or password |
| Sign in as that user with a wrong password | Exactly the same answer |
| Sign in as a platform administrator | You are in, and the page names your account |
Revoke the role (platformAdmin.mjs revoke) and reload | /login?reason=forbidden, with the sentence |