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.') + '
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 += '
';
+
+ // -- 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 += '
';
+ 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 += '
';
+
+ 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 += '
';
+ html += '
';
+ html += ' ';
+ html += ' AINode';
+ html += '
';
+
+ if (state === 'no-accounts') {
+ html += '
Sign in
';
+ html += '
This node requires a sign-in but has no accounts yet. Create the first one on the node, then reload this page:
';
+ html += '
' + esc(AINodeAuth.userAddCommand()) + '
';
+ html += '
It asks for a password, stores it hashed in ~/.ainode/auth.json, and takes effect with no restart.
';
+ html += ' ';
+ } else {
+ html += '
Sign in
';
+ html += '
This node is private. Sign in to use the dashboard.
';
+ html += ' ';
+ }
+
+ html += '
' + esc(o.reason) : ' hidden>') + '
';
+ html += '
Using this node from a program? It takes an API key (Config > API access).
';
+ 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 @@
-