A separate admin page: server settings, accounts and the log

/admin, with Server, Accounts and Log sections chosen by the URL's hash. The
server sends the page and /admin.js to admins only (anyone else asking for the
page goes back to the app, and the script is 403), and removes the header's link
to it from everyone else's page rather than hiding it. The API keeps refusing
all of it to non-admins as before.

Settings becomes personal: theme, OPML import and export, and the schedule and
download folder to read. The server fields, the Users dialog and the Log dialog
move out of dialogs.ts into admin.ts.

The CSS moves out of index.html into web/app.css, which both pages load as
/app.css?v=<hash>, served immutable like the scripts. The smoke test checks both
pages.

Closes #19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 15:28:28 +00:00
parent 2d158a4540
commit aeb686163b
13 changed files with 1260 additions and 1097 deletions

View File

@@ -1,5 +1,5 @@
// Builds web/index.html from web/src as build.rs does, runs its script against a stub DOM,
// and fails on anything thrown.
// Builds a page from web/src as build.rs does, runs its script against a stub DOM, and fails
// on anything thrown: web/index.html, then web/admin.html in a second run of this file.
//
// This exists because a ReferenceError at load once blanked the whole UI: a patch
// anchored on a function that no longer existed, so `prefsModal` was referenced but
@@ -8,10 +8,14 @@
//
// node tests/page-smoke.js
const vm = require('vm');
const PAGE = process.argv[2] || 'index.html';
const { buildPage } = require('../web/build.mjs');
// What ships: minified, so an id may have lost its quotes.
const { html, js: script, script: file } = buildPage('index.html');
const { html, js: script, script: file } = buildPage(PAGE);
if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && PAGE !== 'login.html') {
console.error(`FAIL: ${PAGE} does not load /app.css?v=<hash>`); process.exit(1);
}
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
}
@@ -64,11 +68,12 @@ const ctx = {
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
};
ctx.globalThis = ctx;
ctx.window.location = { href: '' };
ctx.window.location = { href: '', hash: '' };
ctx.location = ctx.window.location;
try {
vm.createContext(ctx);
vm.runInContext(script, ctx, { filename: 'index.html<script>', timeout: 5000 });
vm.runInContext(script, ctx, { filename: `${PAGE}<script>`, timeout: 5000 });
} catch (e) {
console.error('FAIL: the page script threw while loading\n ' + e.stack.split('\n').slice(0, 3).join('\n '));
process.exit(1);
@@ -82,19 +87,21 @@ const feed = {
schedule: 'every 6h', schedule_mins: 360, every_mins: 360,
last_checked: 1, next_check: 2, entries: 1, downloaded: 0, unread: 1, last_error: null,
};
const drive = [
const drive = PAGE === 'admin.html' ? [
['drawServer', () => ctx.drawServer()],
['drawAccounts', () => ctx.drawAccounts()],
['drawLogView', () => ctx.drawLogView()],
] : [
['settingsModal', () => ctx.settingsModal(feed)],
['settingsModal (no override)', () => ctx.settingsModal({ ...feed, schedule: null, schedule_mins: null })],
['downloadLatestModal', () => ctx.downloadLatestModal(feed)],
['removeFeed', () => ctx.removeFeed(feed)],
['prefsModal', () => ctx.prefsModal()],
['usersModal', () => ctx.usersModal()],
['opmlModal', () => ctx.opmlModal()],
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
['logsModal', () => ctx.logsModal()],
['keysModal', () => ctx.keysModal()],
// `const S` is not reachable from here: top-level const/let do not become properties
// of a vm context the way var and function declarations do.
@@ -112,10 +119,18 @@ for (const [name, fn] of drive) {
}
}
// theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it
// touches them. The admin page has no Settings, so those are the ones it may ask for and not find.
const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']);
missing.splice(0, missing.length, ...missing.filter(sel => !OPTIONAL.has(sel)));
if (missing.length) {
console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', '));
process.exit(1);
}
console.log('OK: page script loads clean, every selector it wires at load exists');
// logsModal arms a poll timer; without this the pending interval keeps node alive.
console.log(`OK: ${PAGE}: its script loads clean, every selector it wires at load exists`);
// The admin page's log arms a poll timer; without an exit the pending interval keeps node alive.
if (PAGE === 'index.html') {
const r = require('child_process').spawnSync(process.execPath, [__filename, 'admin.html'], { stdio: 'inherit' });
process.exit(r.status);
}
process.exit(0);

View File

@@ -125,19 +125,25 @@ test('a theme this browser kept before themes were on the account goes up to it
await fresh.close();
});
test('settings opens and saves the global schedule', async ({ page }) => {
await page.locator('#prefs').click();
await expect(page.locator('#modal.on')).toBeVisible();
await expect(page.locator('#gnum')).toBeVisible();
test('the admin page saves the global schedule', async ({ page }) => {
// Reached from the header's Admin link, and on its own page, not in Settings (issue #19).
await page.locator('#admin').click();
await expect(page).toHaveURL(/\/admin$/);
await expect(page.locator('#atabs a.on')).toHaveText('Server');
await page.locator('#gnum').fill('4');
await page.locator('#gunit').selectOption('h');
await page.locator('#gsave').click();
await expect(page.locator('#modal.on')).toBeHidden();
await expect(page.locator('#toasts')).toContainText('Settings saved');
// It must survive a reload, i.e. actually reach the config.
await page.locator('#prefs').click();
await page.reload();
await expect(page.locator('#gnum')).toHaveValue('4');
await expect(page.locator('#gunit')).toHaveValue('h');
// And Settings in the app no longer has it.
await page.goto('/');
await page.locator('#prefs').click();
await expect(page.locator('#modalCard')).toContainText('Theme');
await expect(page.locator('#gnum')).toHaveCount(0);
});
test('episodes show with their metadata, and the text opens below', async ({ page }) => {
@@ -365,7 +371,7 @@ test('a feed URL is editable and has a copy button', async ({ page }) => {
});
test('the log view has tabs and shows daemon traffic', async ({ page }) => {
await page.locator('#logs').click();
await page.goto('/admin#log');
await expect(page.locator('#logbox')).toBeVisible();
await expect(page.locator('#logtabs button')).toHaveCount(4);
@@ -563,18 +569,22 @@ test('a second person has their own feeds and their own read state', async ({ br
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
await expect(page.locator('#feedlist')).toContainText('No feeds.');
// Settings stays: Sam has their own subscriptions to export and import, and the
// schedule and quota are worth seeing even without a say in them. Only the log and the
// users screen -- and the server -- are an admin's alone.
// Settings stays: Sam has their own theme and subscriptions. The admin page -- the server's
// settings, the accounts and the log -- is an admin's alone, and Sam is not even sent the
// link to it, never mind the page.
await expect(page.locator('#prefs')).toBeVisible();
await page.locator('#prefs').click();
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
await expect(page.locator('#gsave')).toBeHidden();
await expect(page.locator('#gusers')).toBeHidden();
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
await page.locator('#modalCard .cardacts .btn').first().click();
// Hiding the button is not the guard; the server is.
await expect(page.locator('#admin')).toHaveCount(0);
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
// Asking for it anyway goes back to the app, and its script is refused.
await page.goto('/admin');
await expect(page).toHaveURL(/:8791\/$/);
expect((await page.request.get('/admin.js')).status()).toBe(403);
// Hiding the way in is not the guard; the server is.
expect((await page.request.get('/api/users')).status()).toBe(403);
await expect(page.locator('#logs')).toBeHidden();
expect((await page.request.get('/api/logs')).status()).toBe(403);
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
@@ -630,11 +640,12 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
// Every row says "Admin" on its checkbox, so match the name exactly.
const userRow = (page, name) =>
page.locator('#modalCard [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
page.locator('#accounts [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
async function openUsers(page) {
await page.locator('#prefs').click();
await page.locator('#gusers').click();
await page.goto('/admin');
await page.locator('#atabs a', { hasText: 'Accounts' }).click();
await expect(page).toHaveURL(/\/admin#accounts$/);
await expect(userRow(page, 'admin')).toBeVisible();
}
@@ -960,9 +971,7 @@ test('one action, one icon: the toolbar, the page and every dialog agree', async
const dialogs = [
() => page.locator('#addFeed').click(),
() => page.locator('#prefs').click(),
async () => { await page.locator('#prefs').click(); await page.locator('#gusers').click(); },
async () => { await page.locator('#prefs').click(); await page.locator('#gopml').click(); },
() => page.locator('#logs').click(),
() => page.locator('#content .acts [data-a="settings"]').click(),
() => page.locator('#content .acts [data-a="dl"]').click(),
() => page.locator('#content .acts [data-a="rm"]').click(),