Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ Each account card has a **switch** button that makes that account the current on
http://localhost:3456/teamclaude/dashboard
```

The page is a static asset and loads without a key; the data does not — its script fetches `/teamclaude/status` with the proxy key, which it asks for once and keeps in the browser's localStorage (a 401 after a key rotation brings the prompt back). Loopback browsers are key-exempt as everywhere else. On deployments that put the proxy behind TLS this works remotely too: `https://your-proxy.example.com/teamclaude/dashboard`.
The page is a static asset and loads without a key; the data does not — its script fetches `/teamclaude/status` first, and asks for the proxy key only if the server refuses the request without one. Loopback browsers are key-exempt as everywhere else, so on the proxy's own machine there is no prompt. A key that is entered is kept in the browser's localStorage, and a 401 after a key rotation brings the prompt back. On deployments that put the proxy behind TLS this works remotely too: `https://your-proxy.example.com/teamclaude/dashboard`.

## Auto-update

Expand Down
17 changes: 13 additions & 4 deletions src/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
// /teamclaude/status (same origin) with the proxy key and re-renders every few
// seconds. That split is what lets the asset be served without the key (a
// browser address bar cannot send x-api-key) while every byte of actual status
// stays behind the existing gate. The key is asked for once and kept in
// localStorage; a 401 (wrong or rotated key) brings the prompt back.
// stays behind the existing gate. The key is asked for only when the server
// refuses the page without one (401/403; loopback browsers are exempt), and is
// kept in localStorage; a later refusal (wrong or rotated key) asks again.
//
// Self-contained on purpose: no external scripts, styles, or fonts, so the
// page works on air-gapped deployments and adds no third-party surface. All
Expand Down Expand Up @@ -894,7 +895,10 @@ ${SHARED_HELPERS}
function poll() {
fetch('/teamclaude/status', { headers: { 'x-api-key': localStorage.getItem(KEY) || '' } })
.then(function (res) {
if (res.status === 401) { localStorage.removeItem(KEY); showKeybox(); return null; }
// 403 is the loopback exemption refusing a key-less request (a Host
// that does not name this machine, e.g. behind a local reverse proxy
// that adds no forwarding headers). A valid key clears that gate too.
if (res.status === 401 || res.status === 403) { localStorage.removeItem(KEY); showKeybox(); return null; }
if (!res.ok) throw new Error('status ' + res.status);
return res.json();
})
Expand All @@ -909,6 +913,9 @@ ${SHARED_HELPERS}
var err = document.getElementById('err');
err.style.display = 'block';
err.textContent = 'Cannot reach the proxy: ' + e.message;
// The banner lives inside #app, which stays hidden until a first
// status lands; without this a first poll that fails is a blank page.
if (document.getElementById('keybox').style.display !== 'block') document.getElementById('app').style.display = '';
});
}

Expand Down Expand Up @@ -936,7 +943,9 @@ ${SHARED_HELPERS}
});
});

if (localStorage.getItem(KEY)) start(); else showKeybox();
// Poll before asking: a loopback browser is key-exempt, so the prompt is
// shown only once the server refuses the request without a valid key.
start();
})();
</script>
</body>
Expand Down
61 changes: 61 additions & 0 deletions test/dashboard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,67 @@ test('the page ships the same helper implementations it is tested against', () =
assert.doesNotThrow(() => new Function(script), 'inline script must parse');
});

// Run the page's whole inline script against a stub DOM, a stub localStorage and
// a fetch the test answers by hand. Elements absorb any method call, so render()
// runs without a real DOM; only the style and text the startup path sets are read.
function bootPage({ storedKey = null } = {}) {
const els = new Map();
const stubEl = () => {
const target = { style: {}, value: '', textContent: '', className: '', disabled: false };
return new Proxy(target, { get: (t, p) => (p in t ? t[p] : () => stubEl()) });
};
const byId = id => { if (!els.has(id)) els.set(id, stubEl()); return els.get(id); };
const store = new Map(storedKey ? [['teamclaude-dashboard-key', storedKey]] : []);
const localStorage = {
getItem: k => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: k => store.delete(k),
};
const requests = [];
const fetch = (url, init) => new Promise(resolve => requests.push({ url, init, resolve }));
const document = { getElementById: byId, createElement: () => stubEl() };
const html = renderDashboardHtml();
const script = html.slice(html.indexOf('<script>') + 8, html.indexOf('</script>'));
new Function('document', 'localStorage', 'fetch', 'setInterval', 'clearInterval', script)(
document, localStorage, fetch, () => 1, () => {});
const answer = async (status, body = {}) => {
requests.shift().resolve({ status, ok: status >= 200 && status < 300, json: async () => body });
await new Promise(r => setImmediate(r));
};
return { byId, store, requests, answer };
}

test('the page polls status before asking for a key, so a key-exempt browser is never prompted', async () => {
const page = bootPage();
assert.equal(page.requests.length, 1, 'polls on load with no stored key');
assert.equal(page.requests[0].url, '/teamclaude/status');
assert.equal(page.requests[0].init.headers['x-api-key'], '');
assert.notEqual(page.byId('keybox').style.display, 'block', 'no prompt before the server answers');

await page.answer(200, { accounts: [] });
assert.notEqual(page.byId('keybox').style.display, 'block');
assert.equal(page.byId('app').style.display, '');
});

for (const status of [401, 403]) {
test(`a ${status} on the status poll brings the key prompt up and drops the stored key`, async () => {
const page = bootPage({ storedKey: 'tc-stale' });
assert.equal(page.requests[0].init.headers['x-api-key'], 'tc-stale');
await page.answer(status);
assert.equal(page.byId('keybox').style.display, 'block');
assert.equal(page.byId('app').style.display, 'none');
assert.equal(page.store.size, 0);
});
}

test('a first poll that fails shows its error instead of a blank page', async () => {
const page = bootPage();
await page.answer(500);
assert.equal(page.byId('err').style.display, 'block');
assert.match(page.byId('err').textContent, /status 500/);
assert.equal(page.byId('app').style.display, '');
});

test('dashboard page is self-contained: no external resources', () => {
const html = renderDashboardHtml();
assert.match(html, /^<!doctype html>/);
Expand Down