Skip to content

Dashboard: a real front door, so nobody pastes a key to read a page (#261) - #264

Merged
webdevtodayjason merged 3 commits into
mainfrom
fable/login-dashboard
Sep 22, 2026
Merged

webdevtodayjason merged 3 commits into
mainfrom
fable/login-dashboard

Conversation

@webdevtodayjason

@webdevtodayjason webdevtodayjason commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

The dashboard half of the front door (#261). A person signs in and stays signed in until they sign out; a program sends an API key. Nobody pastes a key to read a page any more.

The backend half is PR #266 (fable/login-backend). This one is written against that contract and is safe to merge in either order: when /api/auth/me does not answer, the dashboard behaves exactly as it does today, on the API key alone. Nothing under ainode/auth/ is touched here.

What a user sees

Boot. The page asks GET /api/auth/me and nothing else before it decides. On a node with sign-in on and nobody signed in, that is the only request the browser makes: the shell is not rendered, no panel polls, no 401 storm. Verified in a headless browser: api calls before sign-in: ["/api/auth/me"].

  • Sign-in off: no sign-in at all, exactly as today.
  • Signed in already: straight into the dashboard.
  • A key stored in this browser: straight into the dashboard, and the chip says "Using an API key" instead of a name. The desktop app and anyone driving a node from a browser with no account keep working.
  • Sign-in on, no session, no key: the sign-in screen.
  • Sign-in on and this node has no accounts yet: the sign-in screen with nothing to type and the exact command to run, ainode auth user add <name> --admin, plus a Reload button.

Sign in. A full page, not a modal: AINode mark, "Sign in", Name, Password, one button. Enter submits from either field (a real form, so the browser's password manager sees a real login: autocomplete="username" and autocomplete="current-password"). One error line, one line of what to do:

  • wrong name or password: "Wrong name or password."
  • too many tries: "Too many tries. Wait 300 seconds and sign in again." (from Retry-After)
  • no accounts: the state above, with the command
  • node unreachable: "Could not reach this node. Check it is running, then try again."

Under the form: "Using this node from a program? It takes an API key (Config > API access)." Footer: Made in Texas, with the same small Texas mark the dashboard footer uses.

Header. A user chip with the name and the role, next to a Sign out control. The name opens Config > Account. The existing API chip stays and now speaks from the user's side: "Signed in as jason", "Using an API key", "Open, no key required", "Sign-in required".

Account (new Config section). Who you are (name, role, and what the role may do). Change password (current, new, new again; a wrong current password says "That is not your current password"). Your sessions, one row each with when it signed in, when it was last seen and which browser it is, a Revoke button, and "this browser" on the current one (revoking that one signs you out and puts the door back up). A short pointer to API keys for programs.

Users (new Config section, admins). One row per account: name, role, added, how many browsers are signed in as them, disabled, and "you" on your own. Reset password (an inline box), Disable / Enable, Remove. Add an account takes name, password and role. The page renders what the server answers rather than a role it read once, so a member sees one line ("Only an admin can manage accounts on this node. Ask an admin to add or change one.") and the last-admin refusal is shown in the node's own words.

API access. Now about keys for programs and the sign-in switch. The description names both new open paths (/api/auth/login, /api/auth/me) beside the ones that were already keyless, and says who sends what. The Status card shows the node's own wording ("API key required") with who you are under it. "Paste a key" still works, collapsed under "Use a key in this browser instead", and opens by itself when a key is stored or a key was just rejected; "Forget the stored key" lives there. "Stop requiring a key" only appears to a caller this node has actually authenticated (#262). Keys list, create and revoke are unchanged.

Sign out. Ends the session and puts the sign-in screen back. It does not forget a stored API key: that key belongs to a program, and API access has the button that forgets it. If a key is still stored, the dashboard keeps working and says so rather than pretending to be locked.

Any 401 during the session. Routed by which credential failed. No credential (session revoked, sign-in switched on while you were reading) drops back to the sign-in screen with the reason on it: "This node ended your session. Sign in again." A stored key the node rejects still lands on Config > API access, because that is where a key is fixed. The no-credential case no longer routes to API access at all.

How it is built

  • AINodeAuth (static/js/auth.js) gains the session half: loadMe, bootDecision, needsSignIn, signIn, signOut, signInError, userAddCommand. All decisions, no DOM, so the whole boot table runs under node.
  • Every request now carries X-AINode-Client: dashboard and credentials: 'same-origin', stamped once in AINodeAuth.headers / fetch. The server ignores the session cookie without that header on a write, so one call site building its own headers would be one call site whose cookie is dropped. fetch also takes skip401Notify for the calls whose 401 is expected (the login POST answers on the screen instead of firing the session-ended handler at it).
  • static/js/signin.js is the screen: markup, focus, Enter, the error line. Same split as join.js, and screenHTML is pure so its copy is testable.
  • app.js splits init() (decide) from start() (bind, poll, render), with resume() and stopPolling() so a session that ends mid-visit stops every clock, shows the door, and picks up again on the way back in without rebinding anything.

Tests

tests/test_auth_usable.py grows a second node harness over the real auth.js and signin.js:

  • the boot decision table, all five rows plus "this release has no front door"
  • the CSRF header and same-origin on GET, POST and DELETE, and that a caller's own X-AINode-Client is left alone
  • the login POST's shape, and that /api/auth/me is the authority on who is signed in
  • a wrong password does not fire the 401 handler; 409 carries the CLI command; 429 says how long to wait
  • sign out ends the session and leaves a stored key alone
  • a 401 mid-session says which credential failed
  • the screen's copy: autocomplete hints, Made in Texas, the programs note, and no password box on the no-accounts state

Plus source-walking tests that the shell holds the door and the two new sections, that nothing is fetched before the decision, that the API access copy names the two new open paths, and that the disable button stays behind its authenticated gate.

pytest tests/ on this branch: 2769 passed, 1 failed, and the failure is test_download_fit.py::test_free_space_is_measured_on_the_nearest_parent_that_exists, which compares two live free-disk readings that drifted 4 KB apart on this machine between the two calls. It is unrelated to this diff.

Driven end to end in headless Chrome against a stub shaped like PR #266's contract: every boot row, wrong password, sign in, Enter to submit, account page, change password (wrong then right), users list, add a user, the last-admin refusal, the member 403 note, API access, sign out, sign back in, and a reload that stays signed in. No console errors, no exceptions.

Where the contract needed a decision

  • Who may manage users is decided by the server, not by the role in /api/auth/me. A browser holding an API key has no role at all but is fully authenticated, so gating the page on role === "admin" would have hidden it from a legitimate caller. The page calls GET /api/auth/users and renders the answer: 403 becomes the member note, 404 becomes "this release has no accounts", 200 becomes the list. Confirmed against A login for people: accounts, sessions and a cookie beside the API key (#261) #266: a member gets 403, never an empty 200, and an operator key gets the list.
  • POST /api/auth/enable and the key it returns. When a signed-in person turns sign-in on, the returned key is NOT stored in their browser: they keep their session, and the key is shown once as a key for their programs. A browser with no session still gets it stored, which is what stops it locking itself out.
  • ?user= on /api/auth/sessions is unused. The Account page shows your own sessions. Another admin's sessions have no screen yet.
  • The auth chip's old wording moved rather than changed. authChipText now speaks about the person; the node's own words for the port (API key required, API open, key set but not required, API open, no key set, which api/server.py::auth_status_fields also emits) moved into apiStateText and are printed in the API access Status card, so the dashboard, /api/status and the installer summary still read the same.

Checked against PR #266

Three of the backend's behaviours changed this branch (second commit):

  • A self-service password change mints a fresh session for this browser and revokes every other session of the account. The Change password card said the opposite; it now says what happens, and a successful change re-reads /api/auth/me and redraws before reporting, so the sessions list and the "this browser" marker are true.
  • Session rows carry agent, shown so a person can tell which browser a row is. User rows carry sessions (a count) and the payload carries admin_count; both are used, and the one-admin note prefers the node's own count, which is the number the last-admin 409 is computed from.
  • POST /api/auth/logout sits behind the middleware on purpose, so a browser whose session is already gone meets a 401 rather than the handler. That is signed out, which is what signOut() already did with it; the reason is written down next to it now.

GET /api/auth/status reports authenticated: true for a session cookie, so the OR in the #262 gate is harmless but not required.

Merge order. The two branches merge clean: git merge-tree --write-tree reports no conflict, AGENTS.md included (the backend's bullet extends the auth.json one near the top of the list, this one sits with the web/UI bullets, and neither restates the other). The single leftover is that PR #266 carries COPY_PENDING_IN_THE_PANEL = {"/api/auth/login", "/api/auth/me"} in tests/test_fleet_auth.py, which excuses those two paths from the panel-copy check because that branch cannot touch app.js. This branch's panel copy already names both. Whichever of the two merges SECOND deletes that set with its comment block and the COPY_PENDING_IN_THE_PANEL <= SKIP_PATHS assertion, and drops - COPY_PENDING_IN_THE_PANEL from the missing comprehension, so all seven keyless paths are checked against the panel copy again. Neither order goes red.

Changelog text for the release PR

Sign in to the dashboard. A node that requires auth now asks for a name and a password instead of an API key. You sign in once and stay signed in until you sign out: no more pasting a key meant for a script into a browser. Keys are still how programs authenticate, and a browser with no account on a node can still use one (Config > API access, "Use a key in this browser instead"). New in Config: Account (your name and role, change password, your sessions with a Revoke per browser) and Users (admins add, reset, disable and remove accounts; the node refuses to remove its last admin). The header shows who is signed in, with Sign out beside it. When a session ends or auth is switched on while you are reading, the dashboard drops back to the sign-in screen with one line saying why, rather than leaving a page of panels that all fail.

webdevtodayjason and others added 3 commits September 21, 2026 19:13
…261)

A person signs in and stays signed in until they sign out. A program sends
an API key. Those were the same credential before this, which meant the only
way to open a dashboard on a node that required auth was to paste a key
meant for a script into a browser.

The browser side:

* app.js::init reads GET /api/auth/me and nothing else before deciding.
  AINodeAuth.bootDecision turns that one payload into the shell or the
  sign-in screen. A node that never answers the route (an older release)
  reads as "carry on on the API key", never as "signed out", so this is
  safe to ship ahead of the backend.
* static/js/signin.js is the screen: a full page, not a modal. Name,
  password, Enter submits, the browser's password manager sees a real
  login (autocomplete username / current-password). One error line for a
  wrong password, for the 429 (it says how long to wait) and for the 409,
  which shows the exact command that creates the first account. Made in
  Texas on the footer.
* Every request the dashboard makes now carries X-AINode-Client: dashboard
  and credentials: same-origin, stamped once in AINodeAuth.headers/fetch.
  The server ignores the session cookie without that header, so a call site
  that built its own headers would be a call site whose cookie is dropped.
* A 401 is routed by WHICH credential failed: no credential goes back to
  the front door with a one-line reason ("This node ended your session.
  Sign in again."), a rejected stored key still goes to Config > API
  access, which is where a key is fixed.
* Header: a user chip with the name, the role, and Sign out. Signing out
  ends the session and leaves a stored key alone, because that key belongs
  to a program and API access has the button that forgets it.
* Config > Account: who you are, change password, your sessions with
  created and last-seen times and a Revoke per session (this browser is
  marked), and a pointer to keys for programs.
* Config > Users: admins add, reset, disable, enable and remove accounts.
  The page renders the server's answer rather than a role it read once, so
  a member gets one line and the last-admin refusal is the node's own
  words.
* Config > API access is now about keys for programs and the auth switch.
  The copy names both new open paths (/api/auth/login, /api/auth/me) and
  says who sends what. "Paste a key" still works, collapsed under "Use a
  key in this browser instead", for a browser driving a node it has no
  account on. "Stop requiring a key" only shows to a caller this node has
  authenticated (#262).
* The chip copy is written from the user's side: "Signed in as <name>",
  "Using an API key", "Open, no key required", "Sign-in required". The
  node's own wording for the port moved to apiStateText, which is what the
  API access Status card prints, so /api/status, the installer summary and
  this panel still read the same.

tests/test_auth_usable.py gains the boot decision table (auth off, auth on
plus session, auth on plus key, auth on plus nothing, auth on with no
accounts), the CSRF header on GET and non-GET, that a wrong password is
answered on the screen instead of firing the session-ended handler, and
that sign out does not forget a program's key. All of it runs under node
against the real auth.js and signin.js.

Backend for this is a separate PR; this half was driven against a stubbed
server in headless Chrome, every boot row and the whole sign-in, account,
users and sign-out flow, with no console errors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The backend half (PR 266) landed its route contract, and three of its
behaviours were not what this branch's copy claimed:

* A self-service password change mints a FRESH session for this browser and
  revokes every OTHER session of the account. The card said the opposite
  ("your other sessions stay signed in"), and the sessions list under it was
  stale the moment the change landed. The copy now says what happens, and a
  successful change re-reads /api/auth/me and redraws the page before it
  reports, so the list and the "this browser" marker are true.
* A session row carries `agent`, so a person can tell which browser a row
  is. Shown when the node sends one, trimmed.
* A user row carries `sessions` (a count) and the payload carries
  `admin_count`. The count is shown as "1 browser signed in" / "not signed
  in anywhere", and the one-admin note prefers the node's own count to a
  count taken off the rows.

Also: POST /api/auth/logout is behind the middleware on purpose, so a
browser whose session is already gone meets a 401 rather than the handler.
That IS signed out, which is what signOut() already did with it; the reason
is now written down next to it.

And the account and user rows wrap on the row's own width instead of the
viewport's: the config panel is narrower than the window, so three buttons
squeezed "last seen" into a column three words wide and no media query
could see it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…used two of them goes

PR 266 carried COPY_PENDING_IN_THE_PANEL so its test could pass before the dashboard
named /api/auth/login and /api/auth/me; this branch names them, so the set, its
subset assertion and the subtraction in the missing-path check are gone and all
seven keyless paths are checked against the panel copy again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@webdevtodayjason
webdevtodayjason merged commit 0ac1a91 into main Sep 22, 2026
1 check passed
@webdevtodayjason
webdevtodayjason deleted the fable/login-dashboard branch September 22, 2026 00:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant