Dashboard: a real front door, so nobody pastes a key to read a page (#261) - #264
Merged
Merged
Conversation
…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
force-pushed
the
fable/login-dashboard
branch
from
September 22, 2026 00:14
a30d8c1 to
cf55cfb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/medoes not answer, the dashboard behaves exactly as it does today, on the API key alone. Nothing underainode/auth/is touched here.What a user sees
Boot. The page asks
GET /api/auth/meand 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"].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"andautocomplete="current-password"). One error line, one line of what to do:Retry-After)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.X-AINode-Client: dashboardandcredentials: 'same-origin', stamped once inAINodeAuth.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.fetchalso takesskip401Notifyfor 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.jsis the screen: markup, focus, Enter, the error line. Same split asjoin.js, andscreenHTMLis pure so its copy is testable.app.jssplitsinit()(decide) fromstart()(bind, poll, render), withresume()andstopPolling()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.pygrows a second node harness over the realauth.jsandsignin.js:same-originon GET, POST and DELETE, and that a caller's ownX-AINode-Clientis left alone/api/auth/meis the authority on who is signed inPlus 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 istest_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
/api/auth/me. A browser holding an API key has no role at all but is fully authenticated, so gating the page onrole === "admin"would have hidden it from a legitimate caller. The page callsGET /api/auth/usersand 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/enableand 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/sessionsis unused. The Account page shows your own sessions. Another admin's sessions have no screen yet.authChipTextnow 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, whichapi/server.py::auth_status_fieldsalso emits) moved intoapiStateTextand are printed in the API access Status card, so the dashboard,/api/statusand the installer summary still read the same.Checked against PR #266
Three of the backend's behaviours changed this branch (second commit):
/api/auth/meand redraws before reporting, so the sessions list and the "this browser" marker are true.agent, shown so a person can tell which browser a row is. User rows carrysessions(a count) and the payload carriesadmin_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/logoutsits 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 whatsignOut()already did with it; the reason is written down next to it now.GET /api/auth/statusreportsauthenticated: truefor 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-treereports no conflict, AGENTS.md included (the backend's bullet extends theauth.jsonone 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 carriesCOPY_PENDING_IN_THE_PANEL = {"/api/auth/login", "/api/auth/me"}intests/test_fleet_auth.py, which excuses those two paths from the panel-copy check because that branch cannot touchapp.js. This branch's panel copy already names both. Whichever of the two merges SECOND deletes that set with its comment block and theCOPY_PENDING_IN_THE_PANEL <= SKIP_PATHSassertion, and drops- COPY_PENDING_IN_THE_PANELfrom themissingcomprehension, 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.