diff --git a/AGENTS.md b/AGENTS.md index 619dcfa3..aa5642b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ State / architecture / decisions / "why": Obsidian Vault, `AINode` (cluster ops: - **A join writes its own keys into `config.json` and touches nothing else** (`cluster/join.py::merge_config_keys`). It merges into the raw JSON; a `NodeConfig.load(); setattr; save()` round trip rewrites the file from the dataclass, so it ADDS every key this release happens to default differently and DROPS every key the dataclass does not declare. A joiner is `cluster_role: "worker"` AND `distributed_mode: "member"`: those are the two spellings of one decision, and `member` is not a value `cluster_role` accepts (the `PATCH /api/config` validator rejects it). - **The installer generates a `cluster_secret`, and the join flow is how the value reaches a second node** (#169's follow-up). Nothing else distributes it: a node whose secret differs from the rest is dark to them once discovery is signed, so anything that MINTS a secret on an existing cluster has to say so out loud. `ainode config` masks it and `GET /api/config` scrubs it. - **There is no browser onboarding wizard, and reviving one is not the fix for a first-run gap** (#208). The 19 KB template, its three `/api/onboarding/*` routes and `handle_index`'s redirect to them are deleted: the installer writes `"onboarded": true` and every non-TTY start sets it before the server binds, so `/onboarding` only ever answered a redirect back to `/`, and its complete handler never wrote a cluster key. The TTY wizard (`onboarding/setup.py`) stays. Browser-side joining is one card in `web/static/js/join.js` on a key-protected `POST /api/cluster/join-self`, which restarts nothing (the restart would kill its own response) and `app.js` holds exactly one line that mounts it. +- **A PERSON signs in at the front door and a PROGRAM sends a key, and the browser decides which before it fetches anything else** (#261). `app.js::init` reads `GET /api/auth/me` (open, like `/api/auth/status`) and nothing else: `AINodeAuth.bootDecision` turns that one payload into the shell or the full-page sign-in screen (`web/static/js/signin.js`, never a modal, never a shell of panels that all 401), and a payload that never arrives (a release older than the route) reads as "carry on on the API key" rather than as "signed out". Every request from the dashboard carries `X-AINode-Client: dashboard` and `credentials: 'same-origin'`, both stamped once in `AINodeAuth.headers`/`fetch`, because the server ignores the session cookie on a request that does not carry the header: a call site that builds its own headers is 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, a rejected stored key goes to Config > API access, which is where a key is fixed. Pasting a key into a browser still works and is collapsed, because a browser driving a node it has no account on is a real case. The decisions stay in `auth.js` (DOM-free, so `tests/test_auth_usable.py` runs the whole boot table under node); `signin.js` is markup and focus. - **An address this node hands to a client is never a loopback spelling, and there is ONE derivation of it** (`api/server_routes.py`, the `own_addresses` / `lan_address` / `peer_host` block). `GET /api/cluster/endpoint` and `/api/status`'s `endpoint_hint` exist so a client stranded by the node it holds can reach another one, so "localhost" (which `/api/nodes` still reports for the local row, deliberately, for the browser on that node) would be the one answer guaranteed wrong on the caller's machine: a node with nothing routable reports an empty host and a null url instead. The local address comes from the request's `Host` header ONLY when it names an address this node really answers on, because that header is caller-controlled and would otherwise let one request set what the node advertises to the next reader; a peer's comes from `peer_host`, which `api/server.py::_node_host` also calls, so the endpoint and the node rows cannot drift apart. Deriving it reads the host (an `ip` subprocess, a routing lookup) and `/api/status` is polled constantly, so it is cached for `ADDRESS_CACHE_SECONDS` keyed on the config fields that feed it: never put a name resolution or a subprocess on that path uncached. `/api/cluster/endpoint` is one of the paths exempt from the API key rule (`auth/middleware.py::SKIP_PATHS`, beside `/api/health`, `/api/auth/status` and `POST /api/cluster/join`) and may carry nothing but names, addresses, ports, schemes, roles and versions: a client that cannot reach its node has no key to present for a route that only tells it where to knock. - Handoffs use the threadmaster-handoff runbook; ops state lives in `ops/` (runbooks under `ops/runbooks/`). - Distribution is `docker pull` only, from GHCR (there is no Docker Hub mirror), and end users never hand-edit vLLM commands: the engine emits the flags (see `engine/AGENTS.md`). diff --git a/ainode/web/static/css/style.css b/ainode/web/static/css/style.css index c9e8c3ac..01c8e76d 100644 --- a/ainode/web/static/css/style.css +++ b/ainode/web/static/css/style.css @@ -6520,3 +6520,330 @@ select { .metrics-store-warn { color: var(--amber); } + +/* ============================================================ + THE FRONT DOOR (#261): the sign-in screen, the header's user chip, + and the account / user rows under Config. + ============================================================ */ + +/* A full page, not a modal: while this is up the shell is display:none and + nothing else has been fetched. */ +.signin-screen { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: var(--bg-primary); + background-image: radial-gradient(circle, #151515 1px, transparent 1px); + background-size: 20px 20px; + overflow-y: auto; +} + +body.signin-mode { overflow: hidden; } + +.signin-card { + width: 100%; + max-width: 380px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + padding: 28px 26px 20px; +} + +.signin-brand { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 18px; +} + +.signin-logo { width: 30px; height: 30px; } + +.signin-wordmark { + font-size: 19px; + font-weight: 700; + letter-spacing: -0.4px; +} +.signin-wordmark-ai { color: var(--nvidia-green); } +.signin-wordmark-node { color: var(--text-primary); } + +.signin-title { + font-size: 22px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 6px; +} + +.signin-lead { + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); + margin-bottom: 18px; +} + +.signin-label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 6px; +} + +.signin-input { + width: 100%; + background: var(--bg-surface); + border: 1px solid var(--border-hover); + border-radius: var(--radius-sm); + padding: 11px 13px; + margin-bottom: 14px; + color: var(--text-primary); + font-size: 14px; + font-family: var(--font-sans); +} +.signin-input:focus { + outline: none; + border-color: var(--nvidia-green); + box-shadow: 0 0 0 2px var(--nvidia-green-glow); +} + +.signin-submit { + width: 100%; + background: var(--nvidia-green); + border: 1px solid var(--nvidia-green); + border-radius: var(--radius-sm); + color: #000; + font-family: var(--font-sans); + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + padding: 11px 14px; + margin-top: 4px; + cursor: pointer; +} +.signin-submit:hover:not(:disabled) { background: var(--nvidia-green-bright); } +.signin-submit:disabled { opacity: 0.6; cursor: default; } + +/* The one line that says what went wrong, and what to do about it. */ +.signin-error { + margin-top: 14px; + padding: 10px 12px; + border: 1px solid var(--red); + border-radius: var(--radius-sm); + background: var(--red-glow); + color: var(--text-primary); + font-size: 12px; + line-height: 1.5; +} + +.signin-command { + background: var(--bg-surface); + border: 1px solid var(--border-hover); + border-radius: var(--radius-sm); + padding: 11px 12px; + margin-bottom: 12px; + overflow-x: auto; +} +.signin-command code { + font-family: var(--font-mono); + font-size: 12px; + color: var(--nvidia-green); + white-space: nowrap; +} + +.signin-note { + font-size: 11px; + line-height: 1.6; + color: var(--text-secondary); + margin-bottom: 16px; +} +.signin-note code { font-family: var(--font-mono); color: var(--text-primary); } + +.signin-programs { + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid var(--border); + font-size: 11px; + line-height: 1.6; + color: var(--text-muted); +} + +.signin-footer { + margin-top: 12px; + font-size: 11px; + color: var(--text-muted); + display: flex; + align-items: center; +} + +/* -- the header chip: who is signed in, and the way out -------------------- */ + +.user-chip { + display: inline-flex; + align-items: center; + gap: 2px; + border: 1px solid var(--border); + border-radius: 20px; + padding: 2px 3px 2px 2px; + white-space: nowrap; +} + +.user-chip-name { + background: transparent; + border: none; + border-radius: 20px; + padding: 3px 9px; + font-family: var(--font-sans); + font-size: 11px; + font-weight: 600; + color: var(--text-primary); + cursor: pointer; + max-width: 20ch; + overflow: hidden; + text-overflow: ellipsis; +} +.user-chip-name:hover { color: var(--nvidia-green); } +.user-chip[data-role="admin"] .user-chip-name::after { + content: "admin"; + margin-left: 6px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.6px; + text-transform: uppercase; + color: var(--nvidia-green); +} + +.user-chip-signout { + background: transparent; + border: none; + border-left: 1px solid var(--border); + padding: 3px 9px; + font-family: var(--font-sans); + font-size: 11px; + color: var(--text-secondary); + cursor: pointer; +} +.user-chip-signout:hover { color: var(--red); } + +/* -- account and user rows ------------------------------------------------- */ + +/* Flex rather than a grid, and wrapping on the row's OWN width: the config + panel is narrower than the viewport, so a media query cannot tell when three + buttons have squeezed "last seen" into a column three words wide. */ +.auth-row { + display: flex; + flex-wrap: wrap; + gap: 10px 12px; + align-items: center; + padding: 13px 0; + border-bottom: 1px solid var(--border); +} +.auth-row:last-child { border-bottom: none; } + +.auth-row-main { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; + flex: 1 1 260px; +} + +.auth-row-name { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.auth-row-meta { + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.5; + color: var(--text-secondary); +} + +.auth-row-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; + margin-left: auto; +} + +.auth-badge { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + padding: 2px 7px; + border-radius: 10px; + border: 1px solid var(--border-hover); + color: var(--text-secondary); +} +.auth-badge-admin { border-color: var(--nvidia-green); color: var(--nvidia-green); } +.auth-badge-member { border-color: var(--border-hover); color: var(--text-secondary); } +.auth-badge-current { border-color: var(--cyan); color: var(--cyan); } +.auth-badge-off { border-color: var(--amber); color: var(--amber); } + +.auth-form { display: block; margin-top: 4px; } +.auth-form .form-input { margin-bottom: 12px; } + +.auth-form-add { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto auto; + gap: 10px; + align-items: center; +} +.auth-form-add .form-input { margin-bottom: 0; } + +.auth-inline-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + margin-top: 6px; +} + +@media (max-width: 720px) { + .auth-form-add { grid-template-columns: minmax(0, 1fr); } + .auth-row-actions { justify-content: flex-start; margin-left: 0; } +} + +/* -- the collapsed "paste a key" block in API access ----------------------- */ + +.config-details { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + margin-top: 10px; +} +.config-details > summary { + cursor: pointer; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + list-style: none; +} +.config-details > summary::-webkit-details-marker { display: none; } +.config-details > summary::before { + content: "\25B8"; + margin-right: 8px; + color: var(--text-muted); +} +.config-details[open] > summary::before { content: "\25BE"; } +.config-details > summary:hover { color: var(--text-primary); } + +/* The person, under the port's own wording, in the API access Status card. */ +.api-access-who { + display: block; + margin-top: 4px; + font-size: 11px; + color: var(--text-secondary); +} diff --git a/ainode/web/static/js/app.js b/ainode/web/static/js/app.js index 3f8412ea..be3d7193 100644 --- a/ainode/web/static/js/app.js +++ b/ainode/web/static/js/app.js @@ -33,10 +33,15 @@ const AINode = { modelsSearch: '', modelsSort: 'recommended', configSection: 'credentials', - // /api/auth/status, polled on its own because it is the one API route that - // still answers when the key is missing (#167). + // /api/auth/status, polled on its own because it answers when the key is + // missing (#167). Who is signed in lives in AINodeAuth.me, from + // /api/auth/me: one home for the credential, same as the key (#261). authStatus: null, authBlocked: null, + // Config > Account and Config > Users, per render. null means "not read yet", + // so an empty list is never drawn as an answer nobody asked for. + authSessions: null, + authUsers: null, configData: { secrets: null, cluster: null, @@ -64,8 +69,35 @@ const AINode = { // ======================================================================== init() { - // First: a node that wants a key must be able to say so before any panel - // tries to render (#167). + var self = this; + // The front door comes before everything else (#261). /api/auth/me is open, + // so it answers on a node that wants a sign-in, and a release older than the + // route answers nothing at all: bootDecision reads that as "carry on as + // before, on the API key". Until it resolves, nothing is fetched and nothing + // is rendered, because a shell of panels that all 401 is the bug this fixes. + AINodeAuth.loadMe().then(function (me) { + var decision = AINodeAuth.bootDecision(me); + if (decision.show === 'signin') { + AINodeSignIn.show({ + state: decision.state, + onSignedIn: function () { self.start(); }, + }); + return; + } + self.start(); + }); + }, + + /** + * The dashboard itself: bindings, polling, first render. Called once per page + * load, after the front door has let this browser through, and again when a + * person signs back in after a session ended mid-visit (which only restarts + * the clocks, because the listeners and the panels are still there). + */ + start() { + if (this._started) { this.resume(); return; } + this._started = true; + // A node that wants a key must be able to say so before any panel renders. this.initAuth(); this.loadChatSettings(); this.loadConversations(); @@ -87,19 +119,44 @@ const AINode = { setTimeout(() => this.reconcileActiveDownloads(), 500); }, + /** Back from the sign-in screen: restart the clocks, redraw what is on screen. */ + resume() { + this.startPolling(); + this.refreshAuthStatus(); + this.renderUserChip(); + if (this.state.currentView === 'config') this.renderConfigSection(); + }, + + /** Every interval this page runs, stopped. The front door polls nothing. */ + stopPolling() { + var self = this; + ['pollInterval', 'metricsInterval', 'loadTickInterval', 'versionInterval', + 'authInterval'].forEach(function (key) { + if (self.state[key]) { + clearInterval(self.state[key]); + self.state[key] = null; + } + }); + if (typeof this.stopServerLogPolling === 'function') this.stopServerLogPolling(); + }, + // ======================================================================== // API KEY (see static/js/auth.js for the wrapper every fetch goes through) // ======================================================================== initAuth() { var self = this; - // One handler for the whole UI: any 401, from any panel, opens the panel - // that fixes it instead of leaving an empty shell behind. + // One handler for the whole UI: any 401, from any panel, lands on the one + // screen that fixes it instead of leaving an empty shell behind. AINodeAuth.onUnauthorized(function (info) { self.onUnauthorized(info); }); var chip = document.getElementById('api-access-chip'); if (chip) chip.addEventListener('click', function () { self.openApiAccess(); }); + var who = document.getElementById('user-chip-name'); + if (who) who.addEventListener('click', function () { self.openConfigSection('account'); }); + var out = document.getElementById('sign-out'); + if (out) out.addEventListener('click', function () { self.signOut(); }); this.refreshAuthStatus(); - this.state.authInterval = setInterval(function () { self.refreshAuthStatus(); }, 15000); + this.renderUserChip(); }, initTopology() { @@ -526,6 +583,9 @@ const AINode = { // Version check every 30 minutes this.checkVersion(); this.state.versionInterval = setInterval(function () { self.checkVersion(); }, 30 * 60 * 1000); + // How this port is protected, on its own cadence: /api/auth/status answers + // when nothing else does (#167), and the chips read it. + this.state.authInterval = setInterval(function () { self.refreshAuthStatus(); }, 15000); // Initial fetch this.refresh(); }, @@ -5825,6 +5885,8 @@ const AINode = { renderConfigSection() { switch (this.state.configSection) { case 'credentials': return this.renderConfigCredentials(); + case 'account': return this.renderConfigAccount(); + case 'users': return this.renderConfigUsers(); case 'api': return this.renderConfigApiAccess(); case 'cluster': return this.renderConfigCluster(); case 'node': return this.renderConfigNode(); @@ -6047,10 +6109,469 @@ const AINode = { }); }, + // ----- Account ------------------------------------------------------------ + // Who you are on this node, your password, and the browsers signed in as you. + // Everything here is about a PERSON; keys for programs stay in API access. + + /** A server timestamp as local text. Never invents one: absent reads absent. */ + _authWhen(value) { + if (value === null || value === undefined || value === '') return 'unknown'; + var when = null; + if (typeof value === 'number') { + // Seconds or milliseconds; a node writes seconds, so scale the small ones. + when = new Date(value < 1e12 ? value * 1000 : value); + } else { + when = new Date(String(value)); + } + if (isNaN(when.getTime())) return String(value); + return when.toLocaleString(); + }, + + /** A response body, or null when there is no JSON in it. */ + async _authBody(resp) { + try { return await resp.json(); } catch (e) { return null; } + }, + + /** The server's own message for a refusal, or the fallback. */ + _authMessage(body, fallback) { + if (body && body.error && body.error.message) return String(body.error.message); + if (body && body.message) return String(body.message); + return fallback; + }, + + async renderConfigAccount() { + var mount = this._configMount(); + if (!mount) return; + var self = this; + mount.innerHTML = '
Loading your account…
'; + await AINodeAuth.loadMe(); + this.renderUserChip(); + var me = AINodeAuth.me || {}; + var user = AINodeAuth.user(); + + var html = ''; + html += '

Account

'; + html += '

You sign in once and stay signed in until you sign out. A program is different: it sends an API key, and those live in API access.

'; + + if (!user) { + // No session: say which of the two reasons it is, and what to do next. + html += '
'; + html += '

You are not signed in

'; + if (!AINodeAuth.me) { + html += '

This node is running a release without accounts, so there is nobody to sign in as. Update it to sign in with a name and password (the version is in Config > About).

'; + } else if (!me.auth_enabled) { + html += '

This node is open, so nobody signs in and there are no accounts. Turn sign-in on in API access, then create the first account on the node: ' + this.esc(AINodeAuth.userAddCommand()) + '

'; + } else if (AINodeAuth.hasKey()) { + html += '

This browser is driving the node with a stored API key, which belongs to a program rather than a person, so there is no account to show. Forget the key in API access and reload to sign in with a name and password.

'; + } else { + html += '

Reload this page to sign in.

'; + } + html += ''; + html += '
'; + mount.innerHTML = html; + var open = document.getElementById('account-open-api'); + if (open) open.addEventListener('click', function () { self.openApiAccess(); }); + return; + } + + // -- who you are --------------------------------------------------------- + html += '
'; + html += '

You

'; + html += '
'; + html += '
'; + html += '
' + this.esc(user.name) + ''; + html += ' ' + this.esc(user.role || 'member') + ''; + html += '
'; + html += '
' + (user.role === 'admin' + ? 'An admin manages accounts and keys, and can turn sign-in on and off.' + : 'A member signs in and uses the dashboard. Account and key management need an admin.') + '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + + // -- change password ----------------------------------------------------- + html += '
'; + html += '

Change password

'; + html += '

Your current password proves it is you. The new one takes effect at once: this browser stays signed in, and every other browser signed in as you is signed out.

'; + html += '
'; + html += ' '; + html += ' '; + html += ' '; + html += ' '; + html += ' '; + html += ' '; + html += ' '; + html += '
'; + html += ''; + html += '
'; + + // -- your sessions ------------------------------------------------------- + var currentId = (user.session && user.session.id) ? String(user.session.id) : ''; + var resp = await AINodeAuth.fetch('/api/auth/sessions'); + var body = await this._authBody(resp); + html += '
'; + html += '

Your sessions

'; + if (resp.status === 404) { + html += '
This node is running a release that does not list sessions. Update it to manage them here.
'; + } else if (!resp.ok) { + html += '
' + this.esc(this._authMessage(body, + 'This node did not list your sessions (HTTP ' + resp.status + ').')) + '
'; + } else { + var sessions = Array.isArray(body) ? body : ((body && body.sessions) || []); + this.state.authSessions = sessions; + if (!sessions.length) { + html += '
No sessions listed.
'; + } else { + html += '

One row per browser signed in as you. Revoke one and it signs out on its next request.

'; + sessions.forEach(function (s) { + var mine = currentId && String(s.id) === currentId; + html += '
'; + html += '
'; + html += '
Session ' + self.esc(s.id); + if (mine) html += ' this browser'; + html += '
'; + html += '
Signed in ' + self.esc(self._authWhen(s.created_at)) + + ' · last seen ' + self.esc(self._authWhen(s.last_seen)) + + (s.agent ? ' · ' + self.esc(String(s.agent).slice(0, 80)) : '') + + '
'; + html += '
'; + html += '
'; + html += '
'; + }); + } + } + html += '
'; + + // -- the pointer to keys ------------------------------------------------- + html += '
'; + html += '

API keys for your programs

'; + html += '

A script, the desktop app or another node sends Authorization: Bearer <key> instead of signing in. Create and revoke those in API access.

'; + html += ''; + html += '
'; + + mount.innerHTML = html; + + var signout = document.getElementById('account-signout'); + if (signout) signout.addEventListener('click', function () { self.signOut(); }); + var openApi = document.getElementById('account-open-api'); + if (openApi) openApi.addEventListener('click', function () { self.openApiAccess(); }); + + var form = document.getElementById('account-password-form'); + if (form) { + form.addEventListener('submit', function (ev) { + ev.preventDefault(); + self.accountChangePassword(); + }); + } + mount.querySelectorAll('[data-revoke-session]').forEach(function (btn) { + btn.addEventListener('click', function () { + self.accountRevokeSession(btn.dataset.revokeSession, btn.dataset.sessionCurrent === '1'); + }); + }); + }, + + async accountChangePassword() { + var self = this; + var cur = document.getElementById('account-pw-current'); + var next = document.getElementById('account-pw-new'); + var again = document.getElementById('account-pw-confirm'); + var result = document.getElementById('account-pw-result'); + var show = function (ok, message) { + if (!result) { self.toast(message, ok ? 'success' : 'error'); return; } + result.style.display = ''; + result.className = 'config-test-result ' + (ok ? 'ok' : 'err'); + result.textContent = message; + }; + var current = cur ? cur.value : ''; + var wanted = next ? next.value : ''; + var confirmed = again ? again.value : ''; + if (!current || !wanted) { show(false, 'Fill in your current password and the new one.'); return; } + if (wanted !== confirmed) { show(false, 'The two new passwords are different. Type the new one again.'); return; } + var btn = document.getElementById('account-pw-save'); + if (btn) { btn.disabled = true; btn.textContent = 'Changing...'; } + var resp = await AINodeAuth.fetch('/api/auth/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ current: current, new: wanted }), + }); + var body = await this._authBody(resp); + if (btn) { btn.disabled = false; btn.textContent = 'Change password'; } + if (!resp.ok) { + show(false, resp.status === 403 + ? this._authMessage(body, 'That is not your current password.') + : this._authMessage(body, 'This node did not change your password (HTTP ' + resp.status + ').')); + return; + } + if (cur) cur.value = ''; + if (next) next.value = ''; + if (again) again.value = ''; + // The node mints a fresh session for this browser and revokes the account's + // other ones, so the sessions list under this card is stale the moment the + // change lands. Re-read who we are, redraw, then say what happened on the + // line the redraw just rebuilt. + await AINodeAuth.loadMe(); + await this.renderConfigAccount(); + var after = document.getElementById('account-pw-result'); + var done = 'Password changed. You stay signed in here, and your other browsers are signed out.'; + if (!after) { this.toast(done, 'success'); return; } + after.style.display = ''; + after.className = 'config-test-result ok'; + after.textContent = done; + }, + + async accountRevokeSession(sessionId, isCurrent) { + if (!sessionId) return; + if (isCurrent && !confirm('Revoke this session?\n\nIt is the one this browser is using, so you will be signed out.')) return; + if (!isCurrent && !confirm('Revoke session ' + sessionId + '?\n\nThat browser signs out on its next request.')) return; + var resp = await AINodeAuth.fetch('/api/auth/sessions/' + encodeURIComponent(sessionId), { method: 'DELETE' }); + if (!resp.ok) { + var body = await this._authBody(resp); + this.toast(this._authMessage(body, 'Could not revoke session ' + sessionId + '.'), 'error'); + return; + } + if (isCurrent) { + AINodeAuth.me = null; + this.renderUserChip(); + this.requireSignIn('You signed this browser out.'); + return; + } + this.toast('Session ' + sessionId + ' revoked.', 'success'); + this.renderConfigAccount(); + }, + + // ----- Users -------------------------------------------------------------- + // Accounts on this node. Admin only: the server decides, and this panel + // renders the answer it gives rather than guessing from a role it read once. + + async renderConfigUsers() { + var mount = this._configMount(); + if (!mount) return; + var self = this; + mount.innerHTML = '
Loading accounts…
'; + var resp = await AINodeAuth.fetch('/api/auth/users'); + var body = await this._authBody(resp); + + var html = ''; + html += '

Users

'; + html += '

Accounts that can sign in to this node. An admin manages accounts, API keys and the sign-in switch; a member signs in and uses the dashboard. Passwords are stored hashed in ~/.ainode/auth.json and can also be managed on the node with ainode auth user.

'; + + if (resp.status === 403) { + html += '
Only an admin can manage accounts on this node. Ask an admin to add or change one.
'; + mount.innerHTML = html; + return; + } + if (resp.status === 404) { + html += '
This node is running a release without accounts. Update it to sign in with a name and password (the version is in Config > About).
'; + mount.innerHTML = html; + return; + } + if (!resp.ok) { + html += '
' + this.esc(this._authMessage(body, + 'This node did not list accounts (HTTP ' + resp.status + ').')) + '
'; + mount.innerHTML = html; + return; + } + + var users = Array.isArray(body) ? body : ((body && body.users) || []); + this.state.authUsers = users; + var me = AINodeAuth.user(); + // The node counts its own admins; fall back to counting the rows when it + // does not, rather than printing a number nobody stated. + var admins = (body && typeof body.admin_count === 'number') + ? body.admin_count + : users.filter(function (u) { return u.role === 'admin' && !u.disabled; }).length; + + html += '
'; + html += '

Accounts

'; + if (!users.length) { + html += '
No accounts yet. Add one below, or run ' + this.esc(AINodeAuth.userAddCommand()) + ' on the node.
'; + } else { + users.forEach(function (u) { + var isMe = me && me.name === u.name; + html += '
'; + html += '
'; + html += '
' + self.esc(u.name); + html += ' ' + self.esc(u.role || 'member') + ''; + if (u.disabled) html += ' disabled'; + if (isMe) html += ' you'; + html += '
'; + var signedIn = ''; + if (typeof u.sessions === 'number') { + signedIn = ' · ' + (u.sessions === 0 ? 'not signed in anywhere' + : u.sessions + (u.sessions === 1 ? ' browser signed in' : ' browsers signed in')); + } + html += '
Added ' + self.esc(self._authWhen(u.created_at)) + self.esc(signedIn) + '
'; + html += ' '; + html += '
'; + html += '
'; + html += ' '; + html += u.disabled + ? ' ' + : ' '; + html += ' '; + html += '
'; + html += '
'; + }); + if (admins <= 1) { + html += '

This node has one admin. It refuses to remove or disable the only admin, so nobody can lock everybody out.

'; + } + } + html += '
'; + + // -- add an account ------------------------------------------------------ + html += '
'; + html += '

Add an account

'; + html += '

The person signs in with this name and password, and can change the password from their own Account page.

'; + html += '
'; + html += ' '; + html += ' '; + html += ' '; + html += ' '; + html += '
'; + html += ''; + html += '
'; + + mount.innerHTML = html; + + var form = document.getElementById('user-add-form'); + if (form) { + form.addEventListener('submit', function (ev) { + ev.preventDefault(); + self.usersAdd(); + }); + } + mount.querySelectorAll('[data-reset-password]').forEach(function (btn) { + btn.addEventListener('click', function () { + var row = document.getElementById('user-pw-' + btn.dataset.resetPassword); + if (!row) return; + var open = row.style.display !== 'none'; + row.style.display = open ? 'none' : ''; + if (!open) { + var input = row.querySelector('[data-new-password]'); + if (input) input.focus(); + } + }); + }); + mount.querySelectorAll('[data-save-password]').forEach(function (btn) { + btn.addEventListener('click', function () { + // By element, not by a selector built from a name: a name is the user's + // text and has no business inside a CSS selector. + var name = btn.dataset.savePassword; + var row = document.getElementById('user-pw-' + name); + var input = row ? row.querySelector('[data-new-password]') : null; + self.usersSetPassword(name, input ? input.value : ''); + }); + }); + mount.querySelectorAll('[data-disable-user]').forEach(function (btn) { + btn.addEventListener('click', function () { self.usersSetEnabled(btn.dataset.disableUser, false); }); + }); + mount.querySelectorAll('[data-enable-user]').forEach(function (btn) { + btn.addEventListener('click', function () { self.usersSetEnabled(btn.dataset.enableUser, true); }); + }); + mount.querySelectorAll('[data-remove-user]').forEach(function (btn) { + btn.addEventListener('click', function () { self.usersRemove(btn.dataset.removeUser); }); + }); + }, + + async usersAdd() { + var self = this; + var nameEl = document.getElementById('user-add-name'); + var passEl = document.getElementById('user-add-password'); + var roleEl = document.getElementById('user-add-role'); + var result = document.getElementById('user-add-result'); + var show = function (ok, message) { + if (!result) { self.toast(message, ok ? 'success' : 'error'); return; } + result.style.display = ''; + result.className = 'config-test-result ' + (ok ? 'ok' : 'err'); + result.textContent = message; + }; + var name = (nameEl && nameEl.value || '').trim(); + var password = passEl ? passEl.value : ''; + var role = (roleEl && roleEl.value) || 'member'; + if (!name || !password) { show(false, 'Enter a name and a password.'); return; } + var btn = document.getElementById('user-add-btn'); + if (btn) btn.disabled = true; + var resp = await AINodeAuth.fetch('/api/auth/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name, password: password, role: role }), + }); + var body = await this._authBody(resp); + if (btn) btn.disabled = false; + if (!resp.ok) { + show(false, this._authMessage(body, 'Could not add ' + name + ' (HTTP ' + resp.status + ').')); + return; + } + this.toast('Added ' + name + ' as ' + role + '. Tell them to sign in and change the password.', 'success'); + this.renderConfigUsers(); + }, + + async usersSetPassword(name, password) { + if (!name) return; + if (!password) { this.toast('Type the new password first.', 'error'); return; } + var resp = await AINodeAuth.fetch('/api/auth/users/' + encodeURIComponent(name) + '/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: password }), + }); + if (!resp.ok) { + var body = await this._authBody(resp); + this.toast(this._authMessage(body, 'Could not set a password for ' + name + '.'), 'error'); + return; + } + this.toast('Password set for ' + name + '. Tell them what it is, and to change it.', 'success'); + this.renderConfigUsers(); + }, + + async usersSetEnabled(name, enabled) { + if (!name) return; + if (!enabled && !confirm('Disable ' + name + '?\n\nTheir sessions end and they cannot sign in until you enable them again.')) return; + var path = '/api/auth/users/' + encodeURIComponent(name) + (enabled ? '/enable' : '/disable'); + var resp = await AINodeAuth.fetch(path, { method: 'POST' }); + if (!resp.ok) { + var body = await this._authBody(resp); + this.toast(this._authMessage(body, + 'Could not ' + (enabled ? 'enable ' : 'disable ') + name + '.'), 'error'); + return; + } + this.toast(name + (enabled ? ' can sign in again.' : ' is disabled.'), 'success'); + this.renderConfigUsers(); + }, + + async usersRemove(name) { + if (!name) return; + if (!confirm('Remove ' + name + '?\n\nTheir sessions end at once. API keys are separate and are not touched.')) return; + var resp = await AINodeAuth.fetch('/api/auth/users/' + encodeURIComponent(name), { method: 'DELETE' }); + if (!resp.ok) { + var body = await this._authBody(resp); + // A 409 is the node refusing to remove its last admin. Show its own words. + this.toast(this._authMessage(body, 'Could not remove ' + name + '.'), 'error'); + return; + } + this.toast(name + ' removed.', 'success'); + var me = AINodeAuth.user(); + if (me && me.name === name) { + AINodeAuth.me = null; + this.renderUserChip(); + this.requireSignIn('You removed your own account.'); + return; + } + this.renderConfigUsers(); + }, + // ----- API access --------------------------------------------------------- - // The panel that makes auth usable: whether this port wants a key, the switch - // that turns it on, a key shown once, the keys that exist, and a box to paste - // one into on a fresh browser. A 401 anywhere in the UI lands here (#167). + // Keys for programs, and the switch that closes this port. People sign in at + // the front door (static/js/signin.js): the one hand-pasted key left here is + // for a browser driving a node it has no account on. A 401 with a stored key + // lands here (#167); a 401 with no credential lands on the front door (#261). async refreshAuthStatus() { // /api/auth/status answers with no key on purpose, so this stays truthful @@ -6061,14 +6582,29 @@ const AINode = { return st; }, + /** + * The chip, written from the user's side: who they are, or what this port is. + * The node's own wording for the port lives in apiStateText(), which is what + * the API access panel shows. + */ authChipText() { var st = this.state.authStatus; + var user = AINodeAuth.user(); + if (user) return 'Signed in as ' + user.name; if (!st) return 'API access'; - if (st.enabled) { - return st.authenticated ? 'API key required, key set' : 'API key required, no key'; - } - // Must read the same as /api/status's auth.label (api/server.py - // ::auth_status_fields) -- one fact, one wording. + if (!st.enabled) return 'Open, no key required'; + return AINodeAuth.hasKey() ? 'Using an API key' : 'Sign-in required'; + }, + + /** + * How this port is protected, in the node's words: it must read the same as + * /api/status's auth.label (api/server.py::auth_status_fields), because the + * installer summary and the API answer quote it too. One fact, one wording. + */ + apiStateText() { + var st = this.state.authStatus; + if (!st) return 'API access'; + if (st.enabled) return 'API key required'; return st.key_count ? 'API open, key set but not required' : 'API open, no key set'; }, @@ -6079,38 +6615,121 @@ const AINode = { var st = this.state.authStatus || {}; label.textContent = this.authChipText(); var state = 'open'; - if (st.enabled) state = st.authenticated ? 'keyed' : 'locked'; + if (st.enabled) state = (AINodeAuth.user() || AINodeAuth.hasKey()) ? 'keyed' : 'locked'; chip.dataset.state = state; chip.title = st.enabled - ? 'This node requires an API key. Click to manage keys.' - : 'Anyone who can reach this port can load models and change config. Click to require a key.'; - }, - - openApiAccess() { - this.state.configSection = 'api'; + ? 'This node requires a sign-in, or an API key for a program. Click to manage keys.' + : 'Anyone who can reach this port can load models and change config. Click to require a sign-in.'; + this.renderUserChip(); + }, + + /** The name in the header, and the way out. Hidden when nobody is signed in. */ + renderUserChip() { + var wrap = document.getElementById('user-chip'); + var name = document.getElementById('user-chip-name'); + if (!wrap || !name) return; + var user = AINodeAuth.user(); + if (!user) { wrap.style.display = 'none'; return; } + wrap.style.display = ''; + name.textContent = user.name; + wrap.dataset.role = user.role || 'member'; + name.title = 'Account: your name, password and sessions' + + (user.role ? ' (' + user.role + ')' : ''); + }, + + /** Land on one Config section, sidebar in step with it. */ + openConfigSection(section) { + this.state.configSection = section; if (this.state.currentView !== 'config') { this.navigate('config'); } var nav = document.getElementById('config-nav'); if (nav) { nav.querySelectorAll('.config-nav-item').forEach(function (b) { - b.classList.toggle('active', b.dataset.section === 'api'); + b.classList.toggle('active', b.dataset.section === section); }); } this.renderConfigSection(); }, + openApiAccess() { + this.openConfigSection('api'); + }, + + /** + * Put the front door back up, with the reason on it. Stops every poll first: + * a page behind the sign-in screen must not be firing requests that 401. + */ + async requireSignIn(reason) { + if (AINodeSignIn.isUp()) return; + this.stopPolling(); + var self = this; + var me = await AINodeAuth.loadMe(); + if (!AINodeAuth.needsSignIn(me)) { + // The credential is good after all (a node that is open, or a browser + // holding a key): carry on instead of demanding a password for nothing. + this.start(); + return; + } + AINodeSignIn.show({ + state: AINodeAuth.bootDecision(me).state, + reason: reason || '', + onSignedIn: function () { self.start(); }, + }); + }, + onUnauthorized(info) { - // A blank dashboard is the bug. Say what happened and open the one panel - // that fixes it. + // A blank dashboard is the bug, and there are two credentials to be refused + // on. A person whose session ended goes back to the front door with the + // reason on it; a browser sending a key this node rejects goes to the panel + // that holds the key box, because that is where a key is fixed. A release + // with no front door (/api/auth/me unanswered) keeps the old behaviour. this.state.authBlocked = info || { hadKey: AINodeAuth.hasKey() }; - this.toast(info && info.hadKey + var hadKey = !!(info && info.hadKey); + if (!hadKey && AINodeAuth.me) { + this.requireSignIn(info && info.hadSession + ? 'This node ended your session. Sign in again.' + : 'This node requires a sign-in.'); + return; + } + this.toast(hadKey ? 'This node rejected the stored API key. Paste a current one.' : 'This node requires an API key.', 'error'); this.refreshAuthStatus(); this.openApiAccess(); }, + /** + * Sign out: end the session, then show the front door again. The stored API + * key is left alone on purpose. It belongs to a program, and Config > API + * access has the button that forgets it. + */ + async signOut() { + await AINodeAuth.signOut(); + this.state.authBlocked = null; + this.state.authSessions = null; + this.state.authUsers = null; + this.stopPolling(); + var self = this; + var me = await AINodeAuth.loadMe(); + this.renderUserChip(); + if (!AINodeAuth.needsSignIn(me)) { + // A key in this browser still opens every route, so claiming to be signed + // out behind a sign-in screen would be a lie. Say what is still true. + this.start(); + this.toast(AINodeAuth.hasKey() + ? 'Signed out. This browser still sends a stored API key, so the dashboard keeps working. Forget it in Config > API access.' + : 'Signed out. This node is open, so the dashboard keeps working.', 'info'); + this.refreshAuthStatus(); + return; + } + AINodeSignIn.show({ + state: AINodeAuth.bootDecision(me).state, + reason: '', + onSignedIn: function () { self.start(); }, + }); + }, + // The TLS sentence in the API access panel, from /api/status's tls block // (api/server.py::tls_status_fields). Three states, because "I could not read // it" is not the same answer as "there is none": a browser with no key gets a @@ -6150,56 +6769,81 @@ const AINode = { keys = await this.fetchJSON('/api/auth/keys'); } var stored = AINodeAuth.getKey(); + var me = AINodeAuth.me || {}; + var signedIn = AINodeAuth.user(); var html = ''; html += '

API access

'; // The exempt set is auth/middleware.py::SKIP_PATHS plus SKIP_PREFIXES, spelled // out in full: this sentence listed three of the six, so an operator reading it // could not tell what an unauthenticated caller can still reach. - // tests/test_auth_usable.py checks every path in SKIP_PATHS appears here. - html += '

Who may call this node. AINode serves the dashboard, the OpenAI-compatible API and every management route on the same ports, so a key is the whole access control: with auth on, every /api and /v1 route needs Authorization: Bearer <key>. What still answers without one: / and /static/* (the shell that asks for the key), /api/health (a liveness probe has none), /api/auth/status (so this page can say a key is wanted), /api/cluster/endpoint (names, addresses and ports, so a stranded client can find another node) and /api/cluster/join (a joining node holds a one-time token instead). Peers need no key of their own: a node-to-node call carries a key derived from cluster_secret.

'; + // tests/test_fleet_auth.py checks every path in SKIP_PATHS appears here, and + // tests/test_auth_usable.py checks the two front-door paths by name. + html += '

Who may call this node. A person signs in on the dashboard and stays signed in; a program sends a key. AINode serves the dashboard, the OpenAI-compatible API and every management route on the same ports, so with sign-in on, every /api and /v1 route needs either that session or Authorization: Bearer <key>. What still answers without either: / and /static/* (the shell that asks who you are), /api/health (a liveness probe has no credential), /api/auth/status (so this page can say a key is wanted), /api/auth/me and /api/auth/login (the sign-in screen itself), /api/cluster/endpoint (names, addresses and ports, so a stranded client can find another node) and /api/cluster/join (a joining node holds a one-time token instead). Peers need no key of their own: a node-to-node call carries a key derived from cluster_secret.

'; if (this.state.authBlocked) { html += '
'; html += '

This node refused the last request

'; html += '

' + (this.state.authBlocked.hadKey ? 'The key stored in this browser is not one of this node\'s keys. Paste a current one below, or create a new key from a session that is authenticated.' - : 'Auth is on and this browser has no key. Paste one below.') + '

'; + : 'This node wants a sign-in or a key, and this browser sent neither. Reload to sign in, or paste a key below.') + '

'; html += '
'; } // -- state --------------------------------------------------------------- html += '
'; html += '

Status

'; - html += '
'; - html += '' + this.esc(this.authChipText()) + ''; + html += '
'; + html += '' + this.esc(this.apiStateText()) + ''; + html += '' + this.esc(this.authChipText()) + ''; html += '
'; if (!st.enabled) { html += '

Anyone who can reach this port can load and delete models, change config, start training and restart the cluster. That is fine on a private network and it is the default; turn it on before this node is reachable from anywhere else.

'; - html += ''; + html += '

Turning it on asks every browser for a name and password, and every program for a key. Create the first account on the node: ' + this.esc(AINodeAuth.userAddCommand()) + '

'; + html += ''; } else { - html += '

Keys are stored hashed in ~/.ainode/auth.json. This browser keeps its key in localStorage under ' + this.esc(AINodeAuth.STORAGE_KEY) + ', never in the page or a URL.

'; - html += ''; + html += '

Passwords and keys are stored hashed in ~/.ainode/auth.json. A signed-in person rides an HttpOnly ainode_session cookie, which never reaches the page; a browser using a key keeps it in localStorage under ' + this.esc(AINodeAuth.STORAGE_KEY) + ', never in the page or a URL.

'; + if (me.auth_enabled && me.has_users === false) { + html += '

No accounts exist yet, so nobody can sign in and every caller needs a key. Create the first account on the node: ' + this.esc(AINodeAuth.userAddCommand()) + '

'; + } + // The switch that opens the port is shown only to a caller this node has + // actually authenticated. An unauthenticated browser pressing it got a 401 + // and a panel that looked broken (#262). + if (st.authenticated || signedIn) { + html += ''; + } else { + html += '

Sign in, or paste a key below, to turn this back off.

'; + } } html += '
'; // -- the key this browser sends ----------------------------------------- + // Collapsed, and deliberately last in the reading order of this card: a + // person signs in at the front door and never types a key. This is for a + // browser driving a node it has no account on (a fleet node, a kiosk, the + // desktop app's embedded view), which is a real case and stays working. html += '
'; html += '

Key in this browser

'; html += '

' + (stored - ? 'Sending ' + this.esc(AINodeAuth.maskKey(stored)) + ' with every request.' - : 'No key stored. Paste one here to use this dashboard against a node that requires a key.') + '

'; - html += '
'; - html += ' '; - html += ' '; - if (stored) html += ' '; - html += '
'; + ? 'This browser is sending ' + this.esc(AINodeAuth.maskKey(stored)) + ' with every request.' + : 'Nothing stored, which is the normal case: you signed in instead.') + '

'; + var openKeyBox = !!(stored || (this.state.authBlocked && this.state.authBlocked.hadKey) + || (st.enabled && !signedIn)); + html += '
'; + html += ' Use a key in this browser instead'; + html += '

For a browser with no account on this node. The key is sent as a Bearer token on every request from this browser, exactly as a program would.

'; + html += '
'; + html += ' '; + html += ' '; + if (stored) html += ' '; + html += '
'; + html += '
'; html += '
'; // -- keys on the node ---------------------------------------------------- html += '
'; html += '

Keys on this node

'; if (keys === null) { - html += '
Paste a working key above to list and manage keys.
'; + html += '
Sign in, or paste a working key above, to list and manage keys.
'; } else { var rows = (keys.keys || []); if (!rows.length) { @@ -6306,23 +6950,34 @@ const AINode = { this.toast('Could not enable auth.', 'error'); return; } - if (data.api_key) { - // Store it straight away: the browser that flipped the switch must not - // lock itself out, which is exactly what used to happen. + // A signed-in person keeps their session across the switch, so a key does not + // need to be stored in their browser and quietly is not: the key this call + // minted is for their programs. A browser with no session is a different + // story, and storing it is what stops it locking itself out. + var keepsSession = !!AINodeAuth.user(); + if (data.api_key && !keepsSession) { AINodeAuth.setKey(data.api_key); this.state.authBlocked = null; - this.toast('Auth on. This browser is using the new key.', 'success'); + this.toast('Sign-in required from now on. This browser is using the new key.', 'success'); + } else if (data.api_key) { + this.state.authBlocked = null; + this.toast('Sign-in required from now on. You stay signed in here.', 'success'); } else { - this.toast(data.message || 'Auth on using the keys this node already has.', 'info'); + this.toast(data.message || 'Sign-in required from now on, using the keys this node already has.', 'info'); } + await AINodeAuth.loadMe(); await this.refreshAuthStatus(); await this.renderConfigApiAccess(); - if (data.api_key) this._showNewKey(data.key_id, data.api_key, 'Saved in this browser already.'); + if (data.api_key) { + this._showNewKey(data.key_id, data.api_key, keepsSession + ? 'This key is for your programs. You stay signed in here on your session.' + : 'Saved in this browser already.'); + } this.refresh(); }, async authDisable() { - if (!confirm('Stop requiring an API key?\n\nEvery /api and /v1 route on this node answers anyone who can reach the port.')) return; + if (!confirm('Stop requiring a sign-in?\n\nEvery /api and /v1 route on this node answers anyone who can reach the port, with no name and no key.')) return; var resp = await AINodeAuth.fetch('/api/auth/disable', { method: 'POST' }); if (!resp.ok) { this.toast('Could not disable auth.', 'error'); return; } this.state.authBlocked = null; diff --git a/ainode/web/static/js/auth.js b/ainode/web/static/js/auth.js index 34fe23c2..b1a0ab33 100644 --- a/ainode/web/static/js/auth.js +++ b/ainode/web/static/js/auth.js @@ -1,11 +1,20 @@ /* ============================================================ - * AINode API key: the ONE place the dashboard talks about auth. + * AINode auth: the ONE place the dashboard talks about who is calling. * - * Every request the UI makes goes through AINodeAuth.fetch(), which attaches - * `Authorization: Bearer ` when a key is stored and reports a 401 to - * whoever registered onUnauthorized(). Before this existed, enabling auth left - * the shell rendering and every panel 401ing, which is why the fleet ran with - * auth off (#167). + * Two credentials, one wrapper. A PERSON signs in at the front door and rides a + * session cookie (`ainode_session`, HttpOnly, sent by the browser itself on + * same-origin requests); a PROGRAM sends `Authorization: Bearer `. Nobody + * pastes a key to use the dashboard any more (#261). Every request the UI makes + * goes through AINodeAuth.fetch(), which attaches the key when one is stored, + * stamps `X-AINode-Client: dashboard` so the server honours the cookie, and + * reports a 401 to whoever registered onUnauthorized(). Before the wrapper + * existed, enabling auth left the shell rendering and every panel 401ing, which + * is why the fleet ran with auth off (#167). + * + * The session half is decisions plus requests, no markup: loadMe() reads + * /api/auth/me, bootDecision() turns that into "show the app" or "show the + * sign-in screen", and signIn()/signOut() are the two calls the screen makes. + * static/js/signin.js draws it. * * Deliberately dependency-free and DOM-free so it can be exercised under node * with a stub fetch and a stub storage (tests/test_auth_usable.py runs it that @@ -21,6 +30,22 @@ // the chat history, and "key" on its own would not say which key. var STORAGE_KEY = 'ainode.apiKey'; + // The CSRF rule, both halves of it in one place. A session cookie is sent by + // the browser on every same-origin request, including one a third-party page + // triggered, so the server only honours the cookie when the request also + // carries this header: a form post or an from another origin cannot set + // a header, and a cross-origin fetch that tries needs a preflight this node + // does not answer. Every request the dashboard makes carries it. + var CLIENT_HEADER = 'X-AINode-Client'; + var CLIENT_NAME = 'dashboard'; + + // What an operator runs on the node when auth is on and no account exists. + // One home for the text: the sign-in screen and the 409 message both read it. + function userAddCommand(name) { + var who = String(name || '').trim(); + return 'ainode auth user add ' + (who || '') + ' --admin'; + } + function defaultStorage() { try { return global.localStorage || null; @@ -32,11 +57,18 @@ var AINodeAuth = { STORAGE_KEY: STORAGE_KEY, + CLIENT_HEADER: CLIENT_HEADER, + CLIENT_NAME: CLIENT_NAME, + userAddCommand: userAddCommand, // Injectable for tests. In a browser these are localStorage and fetch. storage: defaultStorage(), fetchImpl: (typeof global.fetch === 'function') ? global.fetch.bind(global) : null, + // The last /api/auth/me payload: {user, auth_enabled, has_users}. null means + // nobody has asked yet, or this node is old enough not to answer the route. + me: null, + // Set on the last 401 seen, so a panel opened afterwards can say what failed. lastUnauthorized: null, _handler: null, @@ -97,9 +129,13 @@ // -- headers ----------------------------------------------------------- /** - * The caller's headers plus Authorization when a key is stored. Pure: it - * returns a new object and never mutates what it was given, and it never - * overwrites an Authorization the caller set itself. + * The caller's headers plus Authorization when a key is stored, and always + * `X-AINode-Client: dashboard`. Pure: it returns a new object and never + * mutates what it was given, and it never overwrites an Authorization the + * caller set itself. + * + * The client header rides on GETs as well as writes. It costs nothing, and a + * header that is sometimes there is a header somebody has to reason about. */ headers(existing) { var out = {}; @@ -115,41 +151,208 @@ }); var key = this.getKey(); if (key && !hasAuth) out.Authorization = 'Bearer ' + key; + var hasClient = Object.keys(out).some(function (k) { + return k.toLowerCase() === CLIENT_HEADER.toLowerCase(); + }); + if (!hasClient) out[CLIENT_HEADER] = CLIENT_NAME; return out; }, // -- the wrapper every request goes through ---------------------------- /** - * fetch() with the key attached and a 401 reported once. + * fetch() with the credentials attached and a 401 reported once. * * Returns the response untouched, so callers keep their own status - * handling: this adds a header and a notification, it does not swallow + * handling: this adds headers and a notification, it does not swallow * anything. A rejected request (node down, offline) is reported as it was. + * + * `credentials: 'same-origin'` is what sends the session cookie. It is the + * browser default for same-origin requests and set anyway, because the + * dashboard's whole front door depends on it and a default is not a contract. + * + * `skip401Notify: true` keeps a 401 the caller EXPECTS from firing the + * handler: a wrong password on the sign-in screen is answered on the screen, + * not by dropping the person back onto it with a toast. */ fetch(url, options) { var self = this; var opts = {}; var src = options || {}; Object.keys(src).forEach(function (k) { opts[k] = src[k]; }); + var quiet = opts.skip401Notify === true; + delete opts.skip401Notify; opts.headers = this.headers(src.headers); + if (!opts.credentials) opts.credentials = 'same-origin'; var impl = this.fetchImpl; if (typeof impl !== 'function') { return Promise.reject(new Error('no fetch implementation')); } return impl(url, opts).then(function (resp) { - if (resp && resp.status === 401) self._unauthorized(url, resp); + if (resp && resp.status === 401 && !quiet) self._unauthorized(url, resp); return resp; }); }, + // -- the session: who is at the keyboard ------------------------------- + + /** + * Read /api/auth/me. Open on purpose, so it answers before anybody signs in. + * + * null means "this node did not answer the question": a release older than + * the front door has no such route, and the dashboard then behaves exactly + * as it did before, on the API key alone. Never treat null as "signed out". + */ + loadMe() { + var self = this; + return this.fetch('/api/auth/me', { skip401Notify: true }).then(function (resp) { + if (!resp || !resp.ok) return null; + return resp.json(); + }).then(function (body) { + self.me = (body && typeof body === 'object') ? body : null; + return self.me; + }).catch(function () { + self.me = null; + return null; + }); + }, + + /** The signed-in person, or null when it is a key or an open node. */ + user() { + return (this.me && this.me.user) ? this.me.user : null; + }, + + isAdmin() { + var u = this.user(); + return !!(u && u.role === 'admin'); + }, + + /** + * What to render on load, from one /api/auth/me payload. Pure. + * + * {show: 'app'} auth off, signed in, or a stored key + * {show: 'signin', state: 'form'} auth on, nobody signed in + * {show: 'signin', state: 'no-accounts'} auth on and this node has none + * + * A stored key still gets the app: a program's credential in a browser is + * how the desktop app and a headless-node operator drive the dashboard, and + * taking that away would strand them. + */ + bootDecision(me) { + var m = me || null; + if (!m || !m.auth_enabled) return { show: 'app', state: 'open' }; + if (m.user) return { show: 'app', state: 'session' }; + if (this.hasKey()) return { show: 'app', state: 'key' }; + if (m.has_users === false) return { show: 'signin', state: 'no-accounts' }; + return { show: 'signin', state: 'form' }; + }, + + /** True when the sign-in screen, not the app, is what this browser gets. */ + needsSignIn(me) { + return this.bootDecision(me === undefined ? this.me : me).show === 'signin'; + }, + + /** + * The one-line error for a refused sign-in. Pure, so the wording is testable. + * `retryAfter` is the Retry-After header's seconds, when the server sent one. + */ + signInError(status, body, retryAfter) { + var served = (body && body.error && body.error.message) ? String(body.error.message) : ''; + if (status === 429) { + var wait = parseInt(retryAfter, 10); + if (!isFinite(wait) || wait < 1) wait = 30; + var unit = (wait === 1) ? ' second' : ' seconds'; + return 'Too many tries. Wait ' + wait + unit + ' and sign in again.'; + } + if (status === 409) { + return served || ('This node has no accounts yet. On the node, run: ' + userAddCommand()); + } + if (status === 401 || status === 403) return served || 'Wrong name or password.'; + if (status === 400) return served || 'Enter your name and password.'; + return served || 'This node could not sign you in. Try again, and check the node is running.'; + }, + + /** + * POST /api/auth/login. Resolves to {ok: true, user} or + * {ok: false, status, message, noAccounts}: the screen renders the message + * and never has to know a status code. + */ + signIn(name, password) { + var self = this; + var body = { name: String(name || '').trim(), password: String(password || '') }; + if (!body.name || !body.password) { + return Promise.resolve({ ok: false, status: 0, message: 'Enter your name and password.' }); + } + return this.fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + skip401Notify: true, + }).then(function (resp) { + var retryAfter = ''; + try { retryAfter = (resp.headers && resp.headers.get) ? resp.headers.get('Retry-After') : ''; } catch (e) { retryAfter = ''; } + return resp.json().catch(function () { return null; }).then(function (payload) { + if (resp.ok && payload && payload.user) { + // The cookie is set. Re-read /api/auth/me rather than trusting the + // login body, so one route is the authority on who is signed in. + return self.loadMe().then(function () { + return { ok: true, user: self.user() || payload.user }; + }); + } + return { + ok: false, + status: resp.status, + message: self.signInError(resp.status, payload, retryAfter), + noAccounts: resp.status === 409, + }; + }); + }).catch(function () { + return { + ok: false, + status: 0, + message: 'Could not reach this node. Check it is running, then try again.', + }; + }); + }, + + /** + * POST /api/auth/logout. The stored API key is NOT touched: signing out ends + * a person's session, and forgetting a program's key is a separate decision + * the person makes in Config > API access. + * + * Logout is a write, so it sits behind the middleware like any other: a + * caller with no credential left meets a 401 before the handler. That IS + * signed out, so every answer ends the same way here, and the caller is told + * only whether the node did the revoking. + */ + signOut() { + var self = this; + return this.fetch('/api/auth/logout', { method: 'POST', skip401Notify: true }) + .then(function (resp) { + self.me = null; + return !!(resp && resp.ok); + }) + .catch(function () { + self.me = null; + return false; + }); + }, + /** Register the one handler that opens the API access panel. */ onUnauthorized(fn) { this._handler = (typeof fn === 'function') ? fn : null; }, _unauthorized(url, resp) { - this.lastUnauthorized = { url: String(url), at: Date.now(), hadKey: this.hasKey() }; + // hadSession says WHICH credential just failed, which is the difference + // between "your session ended, sign in again" and "the key in this browser + // is not one of this node's keys". + this.lastUnauthorized = { + url: String(url), + at: Date.now(), + hadKey: this.hasKey(), + hadSession: !!this.user(), + }; if (!this._handler) return; // The dashboard fires a dozen requests per poll, so a 401 storm must open // the panel once, not a dozen times. 2s covers one poll cycle. diff --git a/ainode/web/static/js/signin.js b/ainode/web/static/js/signin.js new file mode 100644 index 00000000..af0d6bf2 --- /dev/null +++ b/ainode/web/static/js/signin.js @@ -0,0 +1,180 @@ +/* ============================================================ + * AINode sign-in: the front door (#261). + * + * A person signs in here and stays signed in until they sign out. Nobody pastes + * an API key to read a dashboard: keys are for programs, and the one place that + * still takes one by hand is a collapsed block in Config > API access. + * + * A full page, not a modal. When this screen is up the app shell is not in the + * document's flow and app.js has fetched nothing else: a node that wants a name + * and a password must not show a shell full of panels that all 401 (#167 in a + * new shape). + * + * The decisions live in AINodeAuth (bootDecision, signIn, signInError) so they + * run under node with no DOM; this file is markup, focus, Enter, and the error + * line. Same split as static/js/join.js. + * ============================================================ */ + +(function (global) { + 'use strict'; + + function esc(value) { + return String(value === null || value === undefined ? '' : value) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + var AINodeSignIn = { + + // -- the markup, as one pure function ----------------------------------- + + /** + * The screen for a state. Pure, so tests can read the copy. + * + * state: 'form' name and password, the normal case + * 'no-accounts' auth is on and this node has no accounts yet, so + * there is nothing to type: it shows the one command + * that creates the first one. + * reason: a one-line explanation of why this screen is up (a revoked + * session, auth switched on mid-visit). Empty on a cold load. + */ + screenHTML(opts) { + var o = opts || {}; + var state = o.state === 'no-accounts' ? 'no-accounts' : 'form'; + var html = ''; + html += ''; + return html; + }, + + // The small Texas mark the dashboard footer already uses, so the front door + // is signed the same way the rest of the UI is. + TEXAS_MARK: '', + + // -- showing it ---------------------------------------------------------- + + _root() { return document.getElementById('signin-screen'); }, + _shell() { return document.getElementById('app-shell'); }, + + /** + * Put the screen up. `onSignedIn` is called once, after the cookie is set, + * and is where app.js starts the app it did not start on load. + */ + show(opts) { + var o = opts || {}; + var root = this._root(); + if (!root) return; + var self = this; + this.onSignedIn = (typeof o.onSignedIn === 'function') ? o.onSignedIn : this.onSignedIn; + this.state = o.state === 'no-accounts' ? 'no-accounts' : 'form'; + root.innerHTML = this.screenHTML({ state: this.state, reason: o.reason || '' }); + root.style.display = ''; + var shell = this._shell(); + if (shell) shell.style.display = 'none'; + document.body.classList.add('signin-mode'); + + var reload = document.getElementById('signin-reload'); + if (reload) reload.addEventListener('click', function () { global.location.reload(); }); + + var form = document.getElementById('signin-form'); + if (form) { + // A form submit, so Enter in either field signs in and the browser's own + // password manager sees a real login. + form.addEventListener('submit', function (ev) { + ev.preventDefault(); + self.submit(); + }); + } + var name = document.getElementById('signin-name'); + if (name) name.focus(); + }, + + hide() { + var root = this._root(); + if (root) { + root.innerHTML = ''; + root.style.display = 'none'; + } + var shell = this._shell(); + if (shell) shell.style.display = ''; + document.body.classList.remove('signin-mode'); + }, + + /** True while the front door is the page. */ + isUp() { + var root = this._root(); + return !!(root && root.style.display !== 'none' && root.innerHTML !== ''); + }, + + error(message) { + var line = document.getElementById('signin-error'); + if (!line) return; + if (!message) { + line.hidden = true; + line.textContent = ''; + return; + } + line.hidden = false; + line.textContent = message; + }, + + submit() { + var self = this; + var nameEl = document.getElementById('signin-name'); + var passEl = document.getElementById('signin-password'); + var btn = document.getElementById('signin-submit'); + var name = nameEl ? nameEl.value : ''; + var password = passEl ? passEl.value : ''; + this.error(''); + if (btn) { btn.disabled = true; btn.textContent = 'Signing in...'; } + return AINodeAuth.signIn(name, password).then(function (result) { + if (btn) { btn.disabled = false; btn.textContent = 'Sign in'; } + if (result.ok) { + if (passEl) passEl.value = ''; + self.hide(); + if (typeof self.onSignedIn === 'function') self.onSignedIn(result.user); + return true; + } + if (result.noAccounts) { + // Nothing to type: swap to the state that says what to run. + self.show({ state: 'no-accounts', reason: result.message }); + return false; + } + self.error(result.message); + if (passEl) { passEl.value = ''; passEl.focus(); } + return false; + }); + }, + }; + + global.AINodeSignIn = AINodeSignIn; + // Loadable by a node test without a browser (the markup half is pure). + if (typeof module !== 'undefined' && module.exports) module.exports = AINodeSignIn; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/ainode/web/templates/index.html b/ainode/web/templates/index.html index 19ea86a6..166b0364 100644 --- a/ainode/web/templates/index.html +++ b/ainode/web/templates/index.html @@ -18,7 +18,12 @@ -
+ + + +