Linuxus Administration
Locking accounts or disconnecting sessions preserves exercise data.
| User | Account and sessions | Disk usage | Classroom environment | Actions |
|---|
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df5b613..53d9bc8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,6 +78,25 @@ Before submitting a PR: --- +## Administrator UI checks + +Run the Go regression suite with `go -C src test -race ./...`. +The browser checks use Node 20 or newer, Playwright, and its Chromium browser: + +```bash +node scripts/test_admin_ui.mjs +``` + +The checks use simulated API responses and do not access deployed services or +user data. Set `PLAYWRIGHT_MODULE` to the absolute path of an existing Playwright +installation when it is not available through normal Node module resolution. +`PLAYWRIGHT_BROWSERS_PATH` selects a custom browser installation directory. +Set `LINUXUS_UI_SCREENSHOTS` to save desktop, mobile, and dialog screenshots. +These tools are only needed for development; the deployed binary embeds the +administrator HTML, CSS, and JavaScript. + +--- + ## 🐛 Reporting Issues If you find a bug: diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index cb5cba5..25e2fd6 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -198,17 +198,52 @@ Restart an environment to apply a changed template image. ## Administrator web interface -When the account named by `MANAGER_SERVICE_ADMIN_ID` logs in, the service page -shows an **Admin** link. The interface is also available at `/admin`. Create +Login, registration, the workspace, and administration share one interface. +When the account named by `MANAGER_SERVICE_ADMIN_ID` logs in, the navigation +shows **Workspace** and **Administration**. Switching views keeps the terminal +connected and preserves dashboard search and filters. Browser Back and Forward +follow the selected view. Opening administration directly does not start a +personal environment; it starts when Workspace is first opened. Regular users +see only Workspace. **Reconnect terminal** explicitly reloads the terminal. +Without JavaScript, Workspace remains available through server navigation. + +The administrator interface defaults to `/admin`. Set its public base +path in `.env`, without leading or trailing slashes: + +```dotenv +AUTH_SERVICE_SERVICE_URL_ADMIN='ops/classroom' +``` + +This example serves the page at `/ops/classroom`, its API at +`/ops/classroom/api/users`, and its assets below `/ops/classroom/assets/`. +Navigation links and browser requests follow the configured path. Omitted or +empty values keep the default `admin` route. The administrator path must not +overlap login, logout, service, terminal, signup, or reserved asset paths. +`config-check` prints the effective administrator URL. Rebuild and restart the +services to apply these changes; `init` includes the setting in new files and +leaves existing configuration files unchanged. + +Create accounts through registration or `add-user`; assigning an administrator ID does not create the account automatically. The interface shows users, account locks, session counts, container states, and usage for mounted home disks. Actions include locking, unlocking, password resets, disconnecting, restarting environments, and assigning classes or templates. -Status refreshes every 15 seconds. Disk service failures produce an explicit +The dashboard includes summary counts, user search, state filters, and home +storage meters. Rows are updated in place, preserving search text and keyboard +focus. Manual and automatic refreshes share a single request. Automatic updates +run 15 seconds after the preceding request finishes; they pause while a dialog +is open, an action is running, the tab is hidden, or Workspace is selected. +Leaving Administration cancels pending status reads; returning refreshes status +when automatic updates are enabled. The Auto-refresh checkbox +allows manual control. Failed updates retain the last successful display and +show an error with its last update time. Disk service failures produce an explicit warning while retaining available container and session status. The web interface rejects locking your own -administrator account to prevent accidental lockout. +administrator account to prevent accidental lockout. Password resets require +confirmation. Updating your own account invalidates the current session; the +interface stops polling and provides a sign-in link. Account changes that succeed +before a runtime error are explicitly reported, without automatic action retries. Every administrator API request rechecks the account and administrator role. Mutation requests require a session-bound CSRF token. Responses exclude account diff --git a/docs/USAGE.md b/docs/USAGE.md index d82b6b7..2c8142e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -139,7 +139,11 @@ validated normally; they are not filled from embedded defaults at runtime. Auth route paths must be distinct, clean paths without leading/trailing slashes or wildcard patterns; `static/` and -`favicon.ico` are reserved. Nested paths such as `auth/login` are supported. +`favicon.ico` and `healthz` are reserved. Nested paths such as `auth/login` are supported. +`AUTH_SERVICE_SERVICE_URL_ADMIN='admin'` selects the administrator page, API, and +asset base path. Existing files that omit it retain `/admin`; for example, +`AUTH_SERVICE_SERVICE_URL_ADMIN='ops/classroom'` moves the interface to +`/ops/classroom`. It must not overlap the other configured routes. Docker subnet allocation checks all existing Docker IPAM ranges to avoid conflicts with other projects. Trusted proxy CIDRs support both IPv4 and IPv6. @@ -412,5 +416,6 @@ it deletes the images and their contents. See [Operations](OPERATIONS.md) for `config-check`, `doctor`, systemd recovery, account locks and password changes, backups, capacity settings, class templates, -and the administrator interface at `/admin`. New optional `.env` keys may be +and the administrator interface at the configured `AUTH_SERVICE_SERVICE_URL_ADMIN` +path (default `/admin`). New optional `.env` keys may be added to an existing file; rerunning `init` does not overwrite existing settings. diff --git a/scripts/test_admin_ui.mjs b/scripts/test_admin_ui.mjs new file mode 100644 index 0000000..d3d26bf --- /dev/null +++ b/scripts/test_admin_ui.mjs @@ -0,0 +1,439 @@ +// Run with Node 20+ and Playwright installed outside the application runtime. +// PLAYWRIGHT_MODULE may point to an existing Playwright installation. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFile, mkdir } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const root = fileURLToPath(new URL('..', import.meta.url)); +const assets = path.join(root, 'src/internal/auth/handler'); +const base = '/ops/classroom'; +// Render the actual Go templates so shared layout changes are covered too. +const pages = JSON.parse(execFileSync('go', ['run', '-mod=readonly', './internal/auth/page/testdata/render'], { + cwd: path.join(root, 'src'), encoding: 'utf8', timeout: 60000, + env: { ...process.env, GOTOOLCHAIN: 'local' } +})); +const css = await readFile(path.join(assets, 'admin.css'), 'utf8'); +const js = await readFile(path.join(assets, 'admin.js'), 'utf8'); +const sharedCSS = await readFile(path.join(root, 'src/static/ui.css'), 'utf8'); +const workspaceJS = await readFile(path.join(root, 'src/static/workspace.js'), 'utf8'); +const logo = await readFile(path.join(root, 'src/static/favicon.png')); +const browser = await chromium.launch({ headless: true }); +let passed = 0; + +function gate() { + let release; + const promise = new Promise(resolve => { release = resolve; }); + return { promise, release }; +} + +async function waitUntil(predicate, message) { + const deadline = Date.now() + 5000; + while (!await predicate()) { + assert.ok(Date.now() < deadline, message); + await new Promise(resolve => setTimeout(resolve, 10)); + } +} + +async function fixture(viewport = { width: 1440, height: 1000 }, initial = 'admin', javaScriptEnabled = true) { + const context = await browser.newContext({ viewport, javaScriptEnabled }); + const page = await context.newPage(); + const failures = []; + page.on('pageerror', error => failures.push(error.message)); + const api = { gets: 0, posts: 0, active: 0, maximum: 0, getStatus: 200, postStatus: 200, + holdGet: null, holdPost: null, updated: true, reauthenticate: false, payloads: [], requests: [], terminalLoads: 0 }; + const data = { csrf_token: 'fresh-token', status_available: true, + templates: { linux: {}, c: {}, python: {} }, classes: { 'class-a': 'python' }, users: [ + { id: 'admin', locked: false, state: 'running', sessions: 1, template: 'linux', mounted: true, used_bytes: 536870912, total_bytes: 2147483648 }, + { id: 'alice', locked: false, state: 'running', sessions: 2, class: 'class-a', mounted: true, used_bytes: 2147483648, total_bytes: 8589934592 }, + { id: 'bob', locked: true, state: 'stopped', sessions: 0, mounted: true, used_bytes: 429496730, total_bytes: 2147483648 }, + { id: 'carol', locked: true, maintenance: true, state: 'stopped', sessions: 0, mounted: false } + ] }; + await page.route('http://linuxus.test/**', async route => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + api.requests.push(pathname); + try { + if (pathname === base || pathname === base + '/') return await route.fulfill({ contentType: 'text/html', body: pages.admin }); + if (pathname === base + '/assets/admin.css') return await route.fulfill({ contentType: 'text/css', body: css }); + if (pathname === base + '/assets/admin.js') return await route.fulfill({ contentType: 'text/javascript', body: js }); + if (pathname === '/workspace/') return await route.fulfill({ contentType: 'text/html', body: initial === 'user' ? pages.user : pages.workspace }); + if (pathname === '/auth/login') return await route.fulfill({ contentType: 'text/html', body: pages.login }); + if (pathname === '/auth/signup') return await route.fulfill({ contentType: 'text/html', body: pages.signup }); + if (pathname === '/error') return await route.fulfill({ status: 503, contentType: 'text/html', body: pages.error }); + if (pathname === '/static/ui.css') return await route.fulfill({ contentType: 'text/css', body: sharedCSS }); + if (pathname === '/static/workspace.js') return await route.fulfill({ contentType: 'text/javascript', body: workspaceJS }); + if (pathname === '/shell/') { + api.terminalLoads++; + return await route.fulfill({ contentType: 'text/html', body: '
' }); + } + if (pathname === '/static/favicon.png') return await route.fulfill({ contentType: 'image/png', body: logo }); + assert.equal(pathname, base + '/api/users', 'unexpected or hardcoded public route'); + if (request.method() === 'GET') { + api.gets++; api.active++; api.maximum = Math.max(api.maximum, api.active); + const status = api.getStatus; + const body = status === 200 ? JSON.stringify(data) : 'Status service unavailable'; + const held = api.holdGet; api.holdGet = null; + try { + if (held) await held.promise; + return await route.fulfill({ status, contentType: status === 200 ? 'application/json' : 'text/plain', body }); + } finally { api.active--; } + } + assert.equal(request.method(), 'POST'); + assert.equal(request.headers()['x-csrf-token'], 'fresh-token', 'mutation did not use the latest CSRF token'); + api.posts++; + const payload = request.postDataJSON(); api.payloads.push(payload); + const held = api.holdPost; api.holdPost = null; + if (held) await held.promise; + const user = data.users.find(user => user.id === payload.user_id); + if (api.updated) { + if (payload.action === 'lock') user.locked = true; + if (payload.action === 'unlock') user.locked = false; + if (payload.action === 'template') user.template = payload.value; + if (payload.action === 'class') { user.class = payload.value; user.template = ''; } + } + const headers = {}; + if (api.updated) headers['X-Linuxus-Account-Updated'] = 'true'; + if (api.reauthenticate) headers['X-Linuxus-Reauthenticate'] = 'true'; + return await route.fulfill({ status: api.postStatus, headers, + contentType: api.postStatus === 200 ? 'application/json' : 'text/plain', + body: api.postStatus === 200 ? '{"ok":true}' : 'Account updated; Manager operation failed (HTTP 503)' }); + } catch (error) { + // Aborted reads deliberately finish after editing or navigation has begun. + if (!/already handled|closed|disposed|intercepted/i.test(error.message)) failures.push(error.stack); + } + }); + if (javaScriptEnabled) await page.clock.install(); + const initialPath = { admin: base, workspace: '/workspace/', user: '/workspace/', login: '/auth/login', signup: '/auth/signup', error: '/error' }[initial]; + await page.goto('http://linuxus.test' + initialPath); + if (initial === 'admin' && javaScriptEnabled) { + await waitUntil(async () => await page.locator('#total-users').textContent() === '4', 'initial dashboard did not load'); + await waitUntil(async () => await page.locator('#refresh').isEnabled(), 'initial refresh did not finish'); + } + return { page, api, data, failures, async close() { await context.close(); assert.deepEqual(failures, [], 'browser errors'); } }; +} + +async function refreshAndWait(f) { + const before = f.api.gets; + await f.page.locator('#refresh').click(); + await waitUntil(() => f.api.gets > before, 'manual refresh did not request status'); + await waitUntil(async () => await f.page.locator('#refresh').isEnabled(), 'manual refresh did not finish'); +} + +async function run(name, test) { + const f = await fixture(); + try { await test(f); passed++; console.log('PASS:', name); } + finally { await f.close(); } +} + +try { + if (process.env.LINUXUS_UI_SCREENSHOTS) await mkdir(process.env.LINUXUS_UI_SCREENSHOTS, { recursive: true }); + await run('custom paths, summary, search, filters, and maintenance controls', async ({ page, api }) => { + assert.equal(await page.locator('#running-users').textContent(), '2'); + assert.equal(await page.locator('#active-sessions').textContent(), '3'); + assert.equal(await page.locator('#locked-users').textContent(), '2'); + assert.ok(await page.getByRole('button', { name: 'Manage carol', exact: true }).isDisabled()); + await page.locator('#search').fill('class-a'); + assert.equal(await page.locator('#users tr:visible').count(), 1); + await page.locator('#search').fill(''); + await page.locator('#filter').selectOption('locked'); + assert.equal(await page.locator('#users tr:visible').count(), 2); + assert.ok(!api.requests.some(value => value.startsWith('/admin'))); + }); + + await run('refresh preserves row identity, search text, focus, and filters', async f => { + const { page, data } = f; + await page.locator('#search').fill('alice'); + await page.locator('#filter').selectOption('running'); + await page.locator('#search').focus(); + await page.evaluate(() => { document.querySelector('[data-user-id="alice"]').testIdentity = true; }); + data.users[1].sessions = 4; + await page.clock.runFor(15500); + await waitUntil(async () => (await page.locator('[data-user-id="alice"] .row-note').allTextContents()).includes('4 active sessions'), 'automatic refresh did not update sessions'); + assert.equal(await page.locator('#search').inputValue(), 'alice'); + assert.equal(await page.locator('#filter').inputValue(), 'running'); + assert.equal(await page.evaluate(() => document.activeElement.id), 'search'); + assert.ok(await page.evaluate(() => document.querySelector('[data-user-id="alice"]').testIdentity)); + }); + + await run('overlapping manual refreshes share one pending request', async f => { + const held = gate(); f.api.holdGet = held; + const before = f.api.gets; + await f.page.locator('#refresh').click(); + await waitUntil(() => f.api.gets === before + 1, 'held status request did not start'); + await f.page.evaluate(() => { for (let i = 0; i < 5; i++) document.getElementById('refresh').dispatchEvent(new Event('click')); }); + await f.page.clock.runFor(5000); + assert.equal(f.api.gets, before + 1); + assert.ok(await f.page.locator('#refresh').isDisabled()); + held.release(); + await waitUntil(async () => await f.page.locator('#refresh').isEnabled(), 'refresh did not recover after pending request'); + assert.equal(f.api.maximum, 1); + }); + + await run('dialogs preserve password input and pause polling', async ({ page, api }) => { + await page.getByRole('button', { name: 'Manage alice', exact: true }).click(); + await page.locator('#operation').selectOption('password'); + await page.locator('#new-password').fill('new-password'); + await page.locator('#confirm-password').fill('new-password'); + const before = api.gets; + await page.clock.runFor(45000); + assert.equal(api.gets, before); + assert.equal(await page.locator('#new-password').inputValue(), 'new-password'); + assert.equal(await page.locator('#operation').inputValue(), 'password'); + assert.ok(await page.locator('#refresh').isDisabled()); + await page.locator('#cancel-dialog').click(); + await waitUntil(() => api.gets > before, 'closing the dialog did not resume updates'); + assert.equal(await page.locator('#new-password').inputValue(), ''); + }); + + await run('mutations are single-flight, block closing, and refresh once complete', async ({ page, api }) => { + await page.getByRole('button', { name: 'Manage alice', exact: true }).click(); + await page.locator('#operation').selectOption('password'); + await page.locator('#new-password').fill('confirmed-password'); + await page.locator('#confirm-password').fill('confirmed-password'); + const held = gate(); api.holdPost = held; + await page.locator('#apply-action').click(); + await waitUntil(() => api.posts === 1, 'mutation did not begin'); + await page.evaluate(() => document.getElementById('account-form').dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))); + const before = api.gets; + await page.keyboard.press('Escape'); + await page.clock.runFor(20000); + assert.equal(api.posts, 1); assert.equal(api.gets, before); + assert.ok(await page.locator('#account-dialog').isVisible()); + assert.ok(await page.locator('#apply-action').isDisabled()); + held.release(); + await waitUntil(async () => !await page.locator('#account-dialog').isVisible(), 'successful mutation left the dialog open'); + await waitUntil(() => api.gets > before, 'mutation did not refresh status'); + assert.equal(await page.locator('#new-password').inputValue(), ''); + assert.match(await page.locator('#action-notice').textContent(), /Reset password completed/); + await page.clock.runFor(100); + assert.equal(api.gets, before + 1, 'dialog close duplicated the post-action refresh'); + }); + + await run('password confirmation prevents requests with mismatched input', async ({ page, api }) => { + await page.getByRole('button', { name: 'Manage alice', exact: true }).click(); + await page.locator('#operation').selectOption('password'); + await page.locator('#new-password').fill('first-password'); + await page.locator('#confirm-password').fill('second-password'); + await page.locator('#apply-action').click(); + assert.equal(api.posts, 0); + assert.equal(await page.locator('#confirm-password').evaluate(element => element.validationMessage), 'Passwords do not match.'); + }); + + await run('failed refresh retains data and can recover', async f => { + f.api.getStatus = 503; + await refreshAndWait(f); + assert.equal(await f.page.locator('#users tr').count(), 4); + assert.match(await f.page.locator('#load-error').textContent(), /last successful update/); + assert.equal(await f.page.locator('#connection-status').textContent(), 'Update failed'); + f.api.getStatus = 200; + await refreshAndWait(f); + assert.ok(await f.page.locator('#load-error').isHidden()); + }); + + await run('partial mutations remain visible after status refresh', async ({ page, api }) => { + api.postStatus = 503; + await page.getByRole('button', { name: 'Manage alice', exact: true }).click(); + await page.locator('#apply-action').click(); + await waitUntil(async () => !await page.locator('#account-dialog').isVisible(), 'partial mutation left an unsafe retry form open'); + await waitUntil(async () => await page.locator('[data-user-id="alice"] .badge').first().textContent() === 'Locked', 'partial mutation did not update account status'); + assert.match(await page.locator('#action-notice').textContent(), /Account updated; Manager operation failed/); + assert.equal(api.posts, 1); + }); + + await run('expired sessions stop polling and offer the configured login route', async f => { + f.api.getStatus = 401; + await f.page.locator('#refresh').click(); + await f.page.locator('#session-notice').waitFor({ state: 'visible' }); + const before = f.api.gets; + await f.page.clock.runFor(60000); + assert.equal(f.api.gets, before); + assert.ok(await f.page.getByRole('button', { name: 'Manage alice', exact: true }).isDisabled()); + assert.equal(await f.page.locator('#session-notice a').getAttribute('href'), '/auth/login'); + }); + + await run('changing the current administrator handles revoked sessions without retry', async ({ page, api }) => { + await page.getByRole('link', { name: 'Workspace', exact: true }).click(); + await page.frameLocator('#terminal-frame').locator('#terminal-input').fill('active terminal'); + await page.getByRole('link', { name: 'Administration', exact: true }).click(); + await waitUntil(async () => await page.locator('#refresh').isEnabled(), 'return to administration did not finish'); + api.reauthenticate = true; api.postStatus = 503; + await page.getByRole('button', { name: 'Manage admin', exact: true }).click(); + assert.equal(await page.locator('#operation option[value="lock"]').count(), 0); + await page.locator('#operation').selectOption('disconnect'); + const before = api.gets; + await page.locator('#apply-action').click(); + await page.locator('#session-notice').waitFor({ state: 'visible' }); + await page.clock.runFor(60000); + assert.equal(api.gets, before); + assert.match(await page.locator('#action-notice').textContent(), /Account updated/); + assert.equal(await page.locator('#terminal-frame').getAttribute('src'), null, 'revoked terminal remained connected'); + const workspaceLink = page.getByRole('link', { name: 'Workspace', exact: true }); + assert.equal(await workspaceLink.getAttribute('aria-disabled'), 'true'); + await workspaceLink.dispatchEvent('click', { button: 0 }); + assert.equal(new URL(page.url()).pathname, base, 'expired shell still allowed view changes'); + assert.equal(api.terminalLoads, 1); + }); + + await run('a cancelled old response cannot overwrite a completed account action', async ({ page, api }) => { + const held = gate(); api.holdGet = held; + const before = api.gets; + await page.locator('#refresh').click(); + await waitUntil(() => api.gets > before, 'old request did not begin'); + await page.getByRole('button', { name: 'Manage alice', exact: true }).click(); + await page.locator('#apply-action').click(); + await waitUntil(async () => await page.locator('[data-user-id="alice"] .badge').first().textContent() === 'Locked', 'latest account action was not rendered'); + held.release(); + await waitUntil(() => api.active === 0, 'cancelled request did not finish'); + assert.equal(await page.locator('[data-user-id="alice"] .badge').first().textContent(), 'Locked'); + }); + + await run('automatic refresh pauses in hidden tabs and supports manual control', async ({ page, api }) => { + const before = api.gets; + await page.evaluate(() => { Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }); document.dispatchEvent(new Event('visibilitychange')); }); + await page.clock.runFor(45000); + assert.equal(api.gets, before); + await page.evaluate(() => { delete document.hidden; document.dispatchEvent(new Event('visibilitychange')); }); + await waitUntil(() => api.gets > before, 'visible tab did not refresh'); + await waitUntil(async () => await page.locator('#refresh').isEnabled(), 'visible tab refresh did not finish'); + await page.locator('#auto-refresh').uncheck(); + const paused = api.gets; + await page.clock.runFor(45000); + assert.equal(api.gets, paused); + await page.locator('#refresh').click(); + await waitUntil(() => api.gets > paused, 'manual refresh did not work with automatic refresh disabled'); + }); + + await run('workspace switching preserves the terminal and dashboard state', async ({ page, api }) => { + assert.equal(api.terminalLoads, 0, 'opening administration started a personal environment'); + await page.locator('#search').fill('alice'); + await page.getByRole('link', { name: 'Workspace', exact: true }).click(); + await page.frameLocator('#terminal-frame').locator('#terminal-input').fill('echo preserved'); + assert.equal(new URL(page.url()).pathname, '/workspace/'); + assert.equal(await page.title(), 'Workspace | Linuxus'); + const before = api.gets; + await page.clock.runFor(45000); + assert.equal(api.gets, before, 'dashboard polling continued in Workspace'); + await page.getByRole('link', { name: 'Administration', exact: true }).click(); + await waitUntil(() => api.gets === before + 1, 'returning to administration did not refresh'); + assert.equal(await page.locator('#search').inputValue(), 'alice'); + await page.getByRole('link', { name: 'Linuxus home', exact: true }).click(); + assert.equal(await page.frameLocator('#terminal-frame').locator('#terminal-input').inputValue(), 'echo preserved'); + assert.equal(api.terminalLoads, 1, 'switching views reloaded the terminal'); + }); + + await run('leaving administration cancels an old response', async ({ page, api, data }) => { + data.users[1].locked = true; + const held = gate(); api.holdGet = held; + const before = api.gets; + await page.locator('#refresh').click(); + await waitUntil(() => api.gets > before, 'old status request did not begin'); + await page.getByRole('link', { name: 'Workspace', exact: true }).click(); + data.users[1].locked = false; + held.release(); + await waitUntil(() => api.active === 0, 'cancelled status request did not finish'); + assert.equal(await page.locator('[data-user-id="alice"] .badge').first().textContent(), 'Enabled'); + await page.getByRole('link', { name: 'Administration', exact: true }).click(); + await waitUntil(async () => await page.locator('#refresh').isEnabled(), 'fresh status request did not finish'); + assert.equal(await page.locator('[data-user-id="alice"] .badge').first().textContent(), 'Enabled'); + }); + + const workspace = await fixture(undefined, 'workspace'); + try { + const { page, api } = workspace; + await page.frameLocator('#terminal-frame').locator('#terminal-input').fill('history state'); + assert.equal(api.gets, 0, 'hidden administrator panel requested status'); + await page.getByRole('link', { name: 'Administration', exact: true }).click(); + await waitUntil(async () => await page.locator('#total-users').textContent() === '4', 'administrator panel did not load on first visit'); + await page.goBack(); + await page.locator('#workspace-panel').waitFor({ state: 'visible' }); + assert.equal(new URL(page.url()).pathname, '/workspace/'); + assert.equal(await page.locator('[data-view-link="workspace"]').getAttribute('aria-current'), 'page'); + assert.equal(await page.frameLocator('#terminal-frame').locator('#terminal-input').inputValue(), 'history state'); + await page.goForward(); + await page.locator('#admin-panel').waitFor({ state: 'visible' }); + assert.equal(new URL(page.url()).pathname, base); + assert.equal(api.terminalLoads, 1); + await page.getByRole('link', { name: 'Workspace', exact: true }).click(); + await page.locator('#reconnect-terminal').click(); + await waitUntil(() => api.terminalLoads === 2, 'explicit reconnect did not reload the terminal'); + const screenshotDir = process.env.LINUXUS_UI_SCREENSHOTS; + if (screenshotDir) await page.screenshot({ path: path.join(screenshotDir, 'workspace-desktop.png'), fullPage: true }); + await page.setViewportSize({ width: 390, height: 844 }); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'mobile workspace overflows'); + const dimensions = await page.locator('#terminal-frame').boundingBox(); + assert.ok(dimensions.height >= 300, 'terminal height collapsed'); + if (screenshotDir) await page.screenshot({ path: path.join(screenshotDir, 'workspace-mobile.png'), fullPage: true }); + passed++; console.log('PASS: workspace entry, browser history, reconnect, and mobile layout'); + } finally { await workspace.close(); } + + const user = await fixture(undefined, 'user'); + try { + assert.equal(await user.page.locator('#admin-panel').count(), 0); + assert.equal(await user.page.locator('[data-view-link="admin"]').count(), 0); + assert.equal(await user.page.locator('script[src$="/assets/admin.js"]').count(), 0); + await user.page.clock.runFor(45000); + assert.equal(user.api.gets, 0); + assert.equal(user.api.terminalLoads, 1); + passed++; console.log('PASS: regular user workspace excludes administrator resources'); + } finally { await user.close(); } + + const noScript = await fixture(undefined, 'admin', false); + try { + assert.equal(noScript.api.terminalLoads, 0); + await noScript.page.locator('#admin-panel noscript .notice').waitFor({ state: 'visible' }); + await noScript.page.getByRole('link', { name: 'Workspace', exact: true }).click(); + await noScript.page.frameLocator('#terminal-frame').locator('#terminal-input').waitFor({ state: 'visible' }); + assert.equal(noScript.api.terminalLoads, 1); + assert.equal(noScript.api.gets, 0); + passed++; console.log('PASS: server navigation and terminal remain usable without JavaScript'); + } finally { await noScript.close(); } + + for (const name of ['login', 'signup', 'error']) { + const f = await fixture({ width: 390, height: 844 }, name); + try { + assert.ok(await f.page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), name + ' overflows on mobile'); + assert.equal(await f.page.locator('link[href="/static/ui.css"]').count(), 1); + assert.equal(await f.page.locator('.brand').count(), 1); + if (name === 'login') { + assert.equal(await f.page.locator('form').getAttribute('action'), '/auth/login'); + assert.equal(await f.page.getByRole('link', { name: 'Create an account' }).getAttribute('href'), '/auth/signup'); + } + if (name === 'signup') { + assert.equal(await f.page.locator('form').getAttribute('action'), '/auth/signup'); + assert.equal(await f.page.locator('input[name="confirm_password"]').count(), 1); + } + if (name === 'error') assert.equal(await f.page.getByRole('link', { name: 'Back to sign in' }).getAttribute('href'), '/auth/login'); + const screenshotDir = process.env.LINUXUS_UI_SCREENSHOTS; + if (screenshotDir) await f.page.screenshot({ path: path.join(screenshotDir, name + '-mobile.png'), fullPage: true }); + assert.equal(f.api.gets, 0); + passed++; console.log('PASS:', name, 'uses shared responsive layout and configured routes'); + } finally { await f.close(); } + } + + const f = await fixture(); + try { + const screenshotDir = process.env.LINUXUS_UI_SCREENSHOTS; + if (screenshotDir) { + await mkdir(screenshotDir, { recursive: true }); + await f.page.screenshot({ path: path.join(screenshotDir, 'admin-desktop.png'), fullPage: true }); + } + await f.page.setViewportSize({ width: 390, height: 844 }); + const overflow = await f.page.evaluate(() => ({ width: innerWidth, document: document.documentElement.scrollWidth, + elements: Array.from(document.querySelectorAll('body *')).filter(element => !element.closest('.table-scroll') && element.getBoundingClientRect().right > innerWidth + 1).map(element => ({ tag: element.tagName, id: element.id, className: element.className, right: element.getBoundingClientRect().right })) })); + assert.ok(overflow.document <= overflow.width, 'mobile page overflows horizontally: ' + JSON.stringify(overflow)); + if (screenshotDir) await f.page.screenshot({ path: path.join(screenshotDir, 'admin-mobile.png'), fullPage: true }); + await f.page.getByRole('button', { name: 'Manage alice', exact: true }).click(); + await f.page.locator('#operation').selectOption('template'); + assert.equal(await f.page.locator('#assignment').inputValue(), 'python', 'inherited template was not selected'); + if (screenshotDir) await f.page.screenshot({ path: path.join(screenshotDir, 'admin-dialog.png'), fullPage: true }); + passed++; console.log('PASS: responsive layout and inherited template selection'); + } finally { await f.close(); } + console.log(`PASS: ${passed} browser scenarios; no live services or user data were accessed.`); +} finally { + await browser.close(); +} diff --git a/src/internal/auth/handler/admin.css b/src/internal/auth/handler/admin.css new file mode 100644 index 0000000..6c541df --- /dev/null +++ b/src/internal/auth/handler/admin.css @@ -0,0 +1,53 @@ +.page-heading { display: flex; justify-content: space-between; align-items: center; gap: 24px; margin-bottom: 28px; } +.status-pill { display: inline-flex; gap: 8px; align-items: center; white-space: nowrap; color: var(--muted); font-size: 12px; padding: 7px 12px; border: 1px solid var(--line); background: var(--surface); border-radius: 24px; } +.status-pill::before { content: ""; height: 7px; width: 7px; border-radius: 50%; background: var(--muted); } +.status-pill[data-state="live"]::before { background: var(--accent); } +.status-pill[data-state="error"]::before { background: var(--danger-ink); } +.metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin-bottom: 28px; } +.metrics article { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 21px 24px; display: flex; flex-direction: column; } +.metrics article > span { color: var(--muted); font-weight: 550; } +.metrics strong { font-size: 34px; font-weight: 650; line-height: 1.4; font-variant-numeric: tabular-nums; letter-spacing: -1px; margin: 5px 0; } +.metrics small { color: var(--muted); font-size: 12px; } +.panel { border: 1px solid var(--line); background: var(--surface); border-radius: 12px; } +.panel-heading { padding: 24px; display: flex; justify-content: space-between; align-items: center; gap: 20px; } +.panel-heading p { font-size: 12px; margin-top: 5px; } +.refresh-controls { display: flex; align-items: center; gap: 20px; } +.toggle { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; white-space: nowrap; } +.button.manage { padding: 6px 13px; color: var(--accent); } +.toolbar { display: flex; align-items: center; gap: 12px; padding: 0 24px 20px; } +.search-field { flex: 1; max-width: 380px; } +input[type="search"] { width: 100%; } +#result-count { margin-left: auto; } +.table-scroll { position: relative; overflow-x: auto; } +table { width: 100%; border-collapse: collapse; white-space: nowrap; } +th { font-size: 10px; letter-spacing: 1px; text-transform: uppercase; font-weight: 650; color: var(--muted); background: var(--surface-raised); padding: 12px 24px; text-align: left; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +td { padding: 17px 24px; border-bottom: 1px solid var(--line); vertical-align: middle; } +tr:last-child td { border-bottom: 0; } +tbody tr:hover { background: var(--surface-hover); } +.user-name { font-weight: 650; } +.row-note { display: block; color: var(--muted); font-size: 11px; margin-top: 4px; } +.badge { display: inline-block; padding: 3px 9px; font-size: 11px; font-weight: 600; border-radius: 5px; color: var(--muted); background: var(--surface-hover); } +.badge[data-kind="enabled"], .badge[data-kind="running"] { color: var(--success-ink); background: var(--success-surface); } +.badge[data-kind="locked"] { color: #f2bc92; background: #3c2a20; } +.badge[data-kind="maintenance"], .badge[data-kind="unknown"] { color: var(--warning-ink); background: var(--warning-surface); } +.storage-size { font-size: 12px; font-variant-numeric: tabular-nums; } +.storage-meter { display: block; margin-top: 7px; width: 145px; height: 5px; border-radius: 3px; overflow: hidden; border: 0; appearance: none; } +.storage-meter::-webkit-progress-bar { background: var(--surface-hover); border-radius: 3px; } +.storage-meter::-webkit-progress-value { background: var(--accent); border-radius: 3px; } +.storage-meter::-moz-progress-bar { background: var(--accent); border-radius: 3px; } +.storage-meter.high::-webkit-progress-value { background: var(--warning-ink); } +.storage-meter.high::-moz-progress-bar { background: var(--warning-ink); } +.panel-footer { border-top: 1px solid var(--line); padding: 13px 24px; color: var(--muted); display: flex; justify-content: space-between; gap: 12px; font-size: 11px; } +.empty-state { padding: 48px 24px; text-align: center; } +.empty-state strong { font-size: 16px; font-weight: 600; } +.empty-state p { margin: 7px 0 0; color: var(--muted); } +.page-footnote { text-align: center; color: var(--muted); font-size: 11px; margin: 22px 0; } +dialog { border: 1px solid var(--line); padding: 26px; border-radius: 14px; width: min(460px, calc(100vw - 32px)); color: var(--ink); background: var(--surface-raised); box-shadow: 0 24px 80px #0009; max-height: calc(100dvh - 32px); overflow-y: auto; } +dialog::backdrop { background: #030a11bb; } +.dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 22px; } +.dialog-heading h2 { overflow-wrap: anywhere; } +.icon-button { border: 0; background: transparent; color: var(--muted); font-size: 24px; padding: 0 5px; line-height: 1; } +.dialog-description { color: var(--muted); font-size: 13px; min-height: 40px; } +.dialog-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; } +@media (max-width: 900px) { .metrics article { padding: 18px; } .panel-heading { align-items: flex-start; } .refresh-controls { flex-direction: column-reverse; align-items: flex-end; gap: 10px; } th, td { padding-left: 18px; padding-right: 18px; } } +@media (max-width: 600px) { .page-heading { align-items: flex-start; flex-direction: column; gap: 14px; } .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .metrics article { padding: 14px; } .metrics article > span { font-size: 12px; } .metrics strong { font-size: 28px; } .metrics small { font-size: 10px; } .panel-heading { padding: 18px 16px; flex-direction: column; } .refresh-controls { width: 100%; flex-direction: row; justify-content: space-between; align-items: center; } .toolbar { padding: 0 16px 16px; flex-wrap: wrap; gap: 10px; } .search-field { flex-basis: 100%; max-width: none; } #filter { max-width: 190px; } .panel-footer { padding: 12px 16px; flex-direction: column; gap: 4px; } } diff --git a/src/internal/auth/handler/admin.go b/src/internal/auth/handler/admin.go index 8cfa0c8..b673ce5 100644 --- a/src/internal/auth/handler/admin.go +++ b/src/internal/auth/handler/admin.go @@ -6,7 +6,6 @@ import ( "crypto/hmac" "encoding/json" "fmt" - "html/template" "io" "net/http" "sort" @@ -15,17 +14,21 @@ import ( "github.com/elecbug/linuxus/src/internal/common/user" ) -func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool { +func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request, redirectToLogin bool) bool { + w.Header().Set("Cache-Control", "no-store") id, ok := a.getSessionID(r) if !ok { - http.Error(w, "authentication required", 401) + if redirectToLogin { + http.Redirect(w, r, "/"+a.loginPath, http.StatusSeeOther) + } else { + http.Error(w, "authentication required", http.StatusUnauthorized) + } return false } if a.adminID == "" || id != a.adminID { http.Error(w, "administrator access required", 403) return false } - w.Header().Set("Cache-Control", "no-store") return true } @@ -72,7 +75,7 @@ func (a *App) managerOperation(ctx context.Context, path, method string, payload } func (a *App) handleAdmin(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/admin" && r.URL.Path != "/admin/" { + if r.URL.Path != "/"+a.adminPath && r.URL.Path != "/"+a.adminPath+"/" { http.NotFound(w, r) return } @@ -80,18 +83,15 @@ func (a *App) handleAdmin(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", 405) return } - if !a.requireAdmin(w, r) { + if !a.requireAdmin(w, r, true) { return } - w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("X-Frame-Options", "DENY") - if err := adminTemplate.Execute(w, map[string]string{"Token": a.adminToken(r)}); err != nil { - return - } + a.renderPage(w, "app", a.applicationData(r, a.adminID, "admin"), http.StatusOK) } func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { - if !a.requireAdmin(w, r) { + if !a.requireAdmin(w, r, false) { return } if r.Method == http.MethodGet { @@ -100,10 +100,13 @@ func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { http.Error(w, "cannot read accounts", 500) return } - states := map[string]map[string]any{} + var states map[string]map[string]any ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() stateErr := a.managerOperation(ctx, "/admin/status", http.MethodGet, nil, &states) + if states == nil && stateErr == nil { + stateErr = fmt.Errorf("Manager returned an invalid status response") + } ids := make([]string, 0, len(accounts)) for id := range accounts { ids = append(ids, id) @@ -120,6 +123,12 @@ func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { if row == nil { row = map[string]any{} } + if row["state"] == nil { + row["state"] = "stopped" + if states == nil { + row["state"] = "unknown" + } + } row["id"] = id row["locked"] = account.Locked || account.Maintenance row["maintenance"] = account.Maintenance @@ -127,7 +136,7 @@ func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { row["template"] = account.Template rows = append(rows, row) } - result := map[string]any{"users": rows, "templates": a.templates, "classes": a.classes} + result := map[string]any{"users": rows, "templates": a.templates, "classes": a.classes, "csrf_token": a.adminToken(r), "status_available": states != nil} if stateErr != nil { result["warning"] = stateErr.Error() } @@ -181,6 +190,10 @@ func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), 400) return } + w.Header().Set("X-Linuxus-Account-Updated", "true") + if request.UserID == a.adminID { + w.Header().Set("X-Linuxus-Reauthenticate", "true") + } if action != "unlock" { ctx, cancel := context.WithTimeout(r.Context(), 50*time.Second) defer cancel() @@ -196,20 +209,3 @@ func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") io.WriteString(w, `{"ok":true}`) } - -var adminTemplate = template.Must(template.New("admin").Parse(` -Locking accounts or disconnecting sessions preserves exercise data.
| User | Account and sessions | Disk usage | Classroom environment | Actions |
|---|
CLASSROOM OPERATIONS
Keep accounts, sessions, and classroom access in one place.
Account changes preserve home files and exercise data.
| User | Account | Environment | Home storage | Classroom | Actions |
|---|
Getting the latest account and environment status.
Lock accounts to suspend access. User data stays in place.
+ +{{end}} +{{define "admin-dialog"}} + +{{end}} diff --git a/src/internal/auth/handler/admin.js b/src/internal/auth/handler/admin.js new file mode 100644 index 0000000..801ed47 --- /dev/null +++ b/src/internal/auth/handler/admin.js @@ -0,0 +1,364 @@ +(() => { + 'use strict'; + + const byID = id => document.getElementById(id); + const ui = Object.fromEntries(['users', 'refresh', 'auto-refresh', 'search', 'filter', 'result-count', + 'updated-at', 'refresh-note', 'connection-status', 'empty-state', 'table-region', 'load-error', + 'service-warning', 'action-notice', 'session-notice', 'account-dialog', 'account-form', 'dialog-title', + 'operation', 'operation-description', 'password-fields', 'new-password', 'confirm-password', + 'assignment-field', 'assignment-label', 'assignment', 'self-notice', 'dialog-error', 'apply-action', + 'close-dialog', 'cancel-dialog'].map(id => [id, byID(id)])); + const config = document.body.dataset; + const state = { data: null, rows: new Map(), read: null, sequence: 0, timer: null, + writing: false, expired: false, disposed: false, target: null, updated: null, + refreshOnClose: true, csrf: config.csrfToken, lastError: '' }; + const descriptions = { + lock: 'Block new logins and stop the environment. Home files are preserved.', + unlock: 'Allow this user to sign in and start their environment again.', + disconnect: 'Close existing sessions and stop the environment. The user can sign in again.', + restart: 'Disconnect current sessions and recreate the environment. Home files are preserved.', + password: 'Set a new password, revoke existing sessions, and stop the environment.', + template: 'Choose the image and starter files for the next connection. Existing home files are preserved.', + class: 'Assign a classroom and use its template. This clears the individual template override.' + }; + const labels = { lock: 'Lock account', unlock: 'Unlock account', disconnect: 'Disconnect sessions', + restart: 'Restart environment', password: 'Reset password', template: 'Assign template', class: 'Assign class' }; + + function notice(element, text, kind) { + element.textContent = text; + element.hidden = !text; + if (kind) element.className = 'notice ' + kind; + } + + function isActive() { + return config.view === 'admin'; + } + + function canPoll() { + return !state.disposed && !state.expired && !state.writing && !ui['account-dialog'].open && + isActive() && !document.hidden && ui['auto-refresh'].checked; + } + + function stopTimer() { + clearTimeout(state.timer); + state.timer = null; + } + + function schedule() { + stopTimer(); + if (canPoll()) state.timer = setTimeout(refresh, 15000); + updateControls(); + } + + function updateControls() { + ui.refresh.disabled = state.expired || state.writing || !!state.read || ui['account-dialog'].open; + ui.refresh.textContent = state.read ? 'Refreshing…' : 'Refresh'; + ui['auto-refresh'].disabled = state.expired; + ui['table-region'].setAttribute('aria-busy', String(!!state.read)); + let note = 'Updates every 15 seconds'; + if (state.expired) note = 'Sign in again to resume updates'; + else if (state.writing) note = 'Updates paused while applying changes'; + else if (ui['account-dialog'].open) note = 'Updates paused while editing'; + else if (!isActive()) note = 'Updates paused while viewing your workspace'; + else if (document.hidden) note = 'Updates paused while this tab is hidden'; + else if (!ui['auto-refresh'].checked) note = 'Automatic updates paused'; + ui['refresh-note'].textContent = note; + const status = ui['connection-status']; + status.dataset.state = state.expired || state.lastError ? 'error' : state.updated ? 'live' : 'pending'; + status.textContent = state.expired ? 'Sign-in required' : state.writing ? 'Applying changes' : + state.read ? 'Refreshing' : state.lastError ? 'Update failed' : !state.updated ? 'Connecting' : + ui['account-dialog'].open ? 'Editing' : canPoll() ? 'Live updates' : 'Updates paused'; + for (const row of state.rows.values()) row.manage.disabled = state.expired || state.writing || row.maintenance; + } + + function cancelRead() { + state.sequence++; + if (state.read) state.read.controller.abort(); + state.read = null; + } + + function sessionExpired() { + state.expired = true; + stopTimer(); + cancelRead(); + ui['session-notice'].hidden = false; + document.dispatchEvent(new Event('linuxus:sessionexpired')); + notice(ui['load-error'], ''); + updateControls(); + } + + async function refresh() { + if (state.disposed || state.expired || state.writing || ui['account-dialog'].open || !isActive()) return; + if (state.read) return state.read.promise; + stopTimer(); + const job = { id: ++state.sequence, controller: new AbortController(), promise: null }; + state.read = job; + updateControls(); + job.promise = (async () => { + let timedOut = false; + const timeout = setTimeout(() => { timedOut = true; job.controller.abort(); }, 15000); + try { + const response = await fetch(config.apiUrl, { cache: 'no-store', credentials: 'same-origin', signal: job.controller.signal }); + if (job.id !== state.sequence) return; + if (response.status === 401 || response.status === 403) { sessionExpired(); return; } + if (!response.ok) throw new Error((await response.text()).trim() || 'Unable to load account status.'); + const data = await response.json(); + if (!data || !Array.isArray(data.users)) throw new Error('The server returned an invalid account list.'); + if (job.id !== state.sequence || state.writing || ui['account-dialog'].open || state.disposed) return; + if (data.csrf_token) state.csrf = data.csrf_token; + state.lastError = ''; + notice(ui['load-error'], ''); + notice(ui['service-warning'], data.warning || '', 'warning'); + render(data); + state.updated = new Date(); + ui['updated-at'].textContent = 'Last updated at ' + state.updated.toLocaleTimeString(); + } catch (error) { + if (job.id !== state.sequence || state.disposed || (error.name === 'AbortError' && !timedOut)) return; + state.lastError = timedOut ? 'The status request timed out.' : error.message; + notice(ui['load-error'], state.lastError + (state.data ? ' Showing the last successful update.' : ' Use Refresh to try again.')); + if (!state.data) emptyState('Unable to load users', 'Check the connection, then try Refresh.'); + } finally { + clearTimeout(timeout); + if (state.read === job) { state.read = null; schedule(); } + } + })(); + return job.promise; + } + + function node(tag, className, text) { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; + } + + function createRow(id) { + const row = { root: node('tr'), maintenance: false }; + row.root.dataset.userId = id; + const cells = Array.from({ length: 6 }, () => { const cell = node('td'); row.root.append(cell); return cell; }); + row.name = node('span', 'user-name', id); + cells[0].append(row.name); + if (id === config.adminId) cells[0].append(node('span', 'row-note', 'You · Administrator')); + row.account = node('span', 'badge'); cells[1].append(row.account); + row.runtime = node('span', 'badge'); row.sessions = node('span', 'row-note'); cells[2].append(row.runtime, row.sessions); + row.storage = node('span', 'storage-size'); row.meter = node('progress', 'storage-meter'); + row.meter.max = 100; row.meter.setAttribute('aria-label', 'Home storage used by ' + id); cells[3].append(row.storage, row.meter); + row.template = node('span'); row.className = node('span', 'row-note'); cells[4].append(row.template, row.className); + row.manage = node('button', 'button manage', 'Manage'); row.manage.type = 'button'; + row.manage.setAttribute('aria-label', 'Manage ' + id); row.manage.addEventListener('click', () => openDialog(id)); cells[5].append(row.manage); + return row; + } + + function size(bytes) { + if (!Number.isFinite(bytes) || bytes < 0) return '—'; + if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + ' MiB'; + return (bytes / 1024 ** 3).toFixed(2) + ' GiB'; + } + + function templateFor(user) { + return user.template || (user.class ? state.data.classes?.[user.class] : 'default') || 'Unavailable'; + } + + function render(data) { + state.data = data; + const ids = new Set(data.users.map(user => user.id)); + for (const [id, row] of state.rows) if (!ids.has(id)) { row.root.remove(); state.rows.delete(id); } + let position = ui.users.firstElementChild; + for (const user of data.users) { + let row = state.rows.get(user.id); + if (!row) { row = createRow(user.id); state.rows.set(user.id, row); } + if (row.root !== position) ui.users.insertBefore(row.root, position); + position = row.root.nextElementSibling; + row.maintenance = !!user.maintenance; + const account = user.maintenance ? 'maintenance' : user.locked ? 'locked' : 'enabled'; + row.account.dataset.kind = account; + row.account.textContent = { maintenance: 'Maintenance', locked: 'Locked', enabled: 'Enabled' }[account]; + const runtime = user.state || (data.status_available === false ? 'unknown' : 'stopped'); + row.runtime.dataset.kind = runtime; + row.runtime.textContent = runtime.charAt(0).toUpperCase() + runtime.slice(1); + row.sessions.textContent = data.status_available === false ? 'Session status unavailable' : (user.sessions || 0) + ' active sessions'; + row.storage.textContent = user.disk_error || (user.mounted ? size(user.used_bytes) + ' / ' + size(user.total_bytes) : data.status_available === false ? 'Unavailable' : 'Not mounted'); + row.meter.hidden = !!user.disk_error || !user.mounted || !Number.isFinite(user.used_bytes) || !(user.total_bytes > 0); + if (!row.meter.hidden) { + row.meter.value = Math.max(0, Math.min(100, user.used_bytes / user.total_bytes * 100)); + row.meter.classList.toggle('high', row.meter.value >= 90); + } + row.template.textContent = templateFor(user); + row.className.textContent = user.class ? 'Class: ' + user.class : 'No class assigned'; + row.manage.title = user.maintenance ? 'Complete disk maintenance before changing this account.' : 'Manage ' + user.id; + } + byID('total-users').textContent = data.users.length; + byID('running-users').textContent = data.status_available === false ? '—' : data.users.filter(user => user.state === 'running').length; + byID('active-sessions').textContent = data.status_available === false ? '—' : data.users.reduce((total, user) => total + (user.sessions || 0), 0); + byID('locked-users').textContent = data.users.filter(user => user.locked || user.maintenance).length; + filterRows(); + updateControls(); + } + + function emptyState(title, description) { + ui['empty-state'].hidden = false; + ui['empty-state'].querySelector('strong').textContent = title; + ui['empty-state'].querySelector('p').textContent = description; + } + + function filterRows() { + if (!state.data) return; + const query = ui.search.value.trim().toLowerCase(); + const filter = ui.filter.value; + let visible = 0; + for (const user of state.data.users) { + const matchesText = [user.id, user.class, templateFor(user)].filter(Boolean).join(' ').toLowerCase().includes(query); + const matchesFilter = filter === 'all' || (filter === 'running' && user.state === 'running') || + (filter === 'locked' && user.locked) || (filter === 'maintenance' && user.maintenance); + const show = matchesText && matchesFilter; + state.rows.get(user.id).root.hidden = !show; + if (show) visible++; + } + ui['result-count'].textContent = visible + ' of ' + state.data.users.length + ' users'; + ui['empty-state'].hidden = visible > 0; + if (!visible) emptyState(state.data.users.length ? 'No matching users' : 'No users yet', + state.data.users.length ? 'Try another search or status filter.' : 'Add an account to begin managing classroom environments.'); + } + + function option(value, text) { + const item = node('option', '', text); item.value = value; return item; + } + + function openDialog(id) { + if (state.writing || state.expired || !state.data || !isActive()) return; + const user = state.data.users.find(user => user.id === id); + if (!user || user.maintenance) return; + stopTimer(); cancelRead(); + state.target = user; + state.refreshOnClose = true; + ui['dialog-title'].textContent = 'Manage ' + user.id; + ui.operation.replaceChildren(); + for (const action of [user.locked ? 'unlock' : 'lock', 'disconnect', 'restart', 'password', 'template', 'class']) { + if (action === 'lock' && user.id === config.adminId) continue; + const item = option(action, labels[action]); + item.disabled = (action === 'class' && !Object.keys(state.data.classes || {}).length) || (action === 'restart' && user.locked); + if (action === 'restart' && user.locked) item.textContent += ' (unlock first)'; + ui.operation.append(item); + } + updateOperation(); + ui['account-dialog'].showModal(); + updateControls(); + } + + function updateOperation() { + const action = ui.operation.value; + notice(ui['dialog-error'], ''); + ui['operation-description'].textContent = descriptions[action]; + ui['password-fields'].hidden = action !== 'password'; + for (const id of ['new-password', 'confirm-password']) { + ui[id].value = ''; ui[id].required = action === 'password'; ui[id].setCustomValidity(''); + } + ui['assignment-field'].hidden = action !== 'template' && action !== 'class'; + ui['assignment-label'].textContent = action === 'class' ? 'Class' : 'Template'; + ui.assignment.replaceChildren(); + if (action === 'class' || action === 'template') { + const names = action === 'class' ? Object.keys(state.data.classes || {}).sort() : ['default', ...Object.keys(state.data.templates || {}).sort()]; + ui.assignment.append(option('', action === 'class' ? 'Choose a class' : 'Choose a template')); + for (const name of names) ui.assignment.append(option(name, action === 'class' ? name + ' · ' + state.data.classes[name] : name)); + ui.assignment.value = action === 'class' ? state.target.class || '' : templateFor(state.target); + if (!ui.assignment.value) ui.assignment.value = ''; + } + ui.assignment.required = action === 'class' || action === 'template'; + ui['self-notice'].hidden = state.target.id !== config.adminId; + ui['apply-action'].textContent = labels[action]; + } + + function clearPasswords() { + for (const id of ['new-password', 'confirm-password']) { + ui[id].value = ''; + ui[id].setCustomValidity(''); + } + } + + function setWriting(value) { + state.writing = value; + for (const control of ui['account-form'].querySelectorAll('button, input, select')) control.disabled = value; + ui['apply-action'].textContent = value ? 'Applying…' : labels[ui.operation.value]; + updateControls(); + } + + async function submitAction(event) { + event.preventDefault(); + if (state.writing || state.expired || !state.target) return; + const action = ui.operation.value; + if (action === 'password') { + ui['confirm-password'].setCustomValidity(ui['new-password'].value === ui['confirm-password'].value ? '' : 'Passwords do not match.'); + ui['new-password'].setCustomValidity(new TextEncoder().encode(ui['new-password'].value).length <= 72 ? '' : 'Use a password of at most 72 UTF-8 bytes.'); + } + if (!ui['account-form'].reportValidity()) return; + const id = state.target.id; + const value = action === 'password' ? ui['new-password'].value : action === 'class' || action === 'template' ? ui.assignment.value : ''; + stopTimer(); cancelRead(); setWriting(true); + notice(ui['dialog-error'], ''); notice(ui['action-notice'], ''); + const controller = new AbortController(); + const closeAfterAction = () => { state.refreshOnClose = false; clearPasswords(); ui['account-dialog'].close(); }; + const timeout = setTimeout(() => controller.abort(), 60000); + let refreshAfter = false; + try { + const response = await fetch(config.apiUrl, { method: 'POST', credentials: 'same-origin', signal: controller.signal, + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': state.csrf }, body: JSON.stringify({ user_id: id, action, value }) }); + if (response.status === 401 || response.status === 403 || response.headers.get('X-Linuxus-Reauthenticate') === 'true') { + if (response.headers.get('X-Linuxus-Account-Updated') === 'true') notice(ui['action-notice'], + response.ok ? labels[action] + ' completed for ' + id + '.' : (await response.text()).trim(), response.ok ? 'success' : 'warning'); + sessionExpired(); closeAfterAction(); return; + } + if (!response.ok) { + const error = (await response.text()).trim() || 'The action could not be completed.'; + if (response.headers.get('X-Linuxus-Account-Updated') === 'true') { + notice(ui['action-notice'], error, 'warning'); refreshAfter = true; closeAfterAction(); + } else throw new Error(error); + } else { + notice(ui['action-notice'], labels[action] + ' completed for ' + id + '.', 'success'); + refreshAfter = true; closeAfterAction(); + } + } catch (error) { + notice(ui['dialog-error'], error.name === 'AbortError' ? 'The request timed out. Close this dialog and refresh to check whether the change completed before retrying.' : error.message); + } finally { + clearTimeout(timeout); + setWriting(false); + if (refreshAfter && !state.expired) await refresh(); + else schedule(); + } + } + + ui.refresh.addEventListener('click', refresh); + ui.search.addEventListener('input', filterRows); + ui.filter.addEventListener('change', filterRows); + ui['auto-refresh'].addEventListener('change', () => { stopTimer(); if (canPoll()) refresh(); else updateControls(); }); + ui.operation.addEventListener('change', updateOperation); + for (const id of ['new-password', 'confirm-password']) ui[id].addEventListener('input', () => { + ui['new-password'].setCustomValidity(''); ui['confirm-password'].setCustomValidity(''); + }); + ui['account-form'].addEventListener('submit', submitAction); + for (const id of ['close-dialog', 'cancel-dialog']) ui[id].addEventListener('click', () => { + if (!state.writing) { clearPasswords(); ui['account-dialog'].close(); } + }); + ui['account-dialog'].addEventListener('cancel', event => { + if (state.writing) event.preventDefault(); + else clearPasswords(); + }); + ui['account-dialog'].addEventListener('close', () => { + clearPasswords(); state.target = null; + if (canPoll() && state.refreshOnClose) refresh(); else schedule(); + }); + document.addEventListener('linuxus:viewchange', () => { + stopTimer(); + if (!isActive()) cancelRead(); + else if (!state.data || canPoll()) refresh(); + updateControls(); + }); + document.addEventListener('visibilitychange', () => { + stopTimer(); + if (document.hidden) cancelRead(); + else if (canPoll()) refresh(); + updateControls(); + }); + window.addEventListener('pagehide', () => { state.disposed = true; stopTimer(); cancelRead(); }); + window.addEventListener('pageshow', event => { if (event.persisted) { state.disposed = false; if (canPoll()) refresh(); } }); + refresh(); +})(); diff --git a/src/internal/auth/handler/admin_test.go b/src/internal/auth/handler/admin_test.go index 6d06328..f25eb66 100644 --- a/src/internal/auth/handler/admin_test.go +++ b/src/internal/auth/handler/admin_test.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "net/http" "net/http/httptest" "os" @@ -13,12 +14,15 @@ import ( "github.com/elecbug/linuxus/src/internal/common/user" ) -func adminFixture(t *testing.T) (*App, *http.Cookie, *http.Cookie) { +func adminFixture(t *testing.T, adminPaths ...string) (*App, *http.Cookie, *http.Cookie) { t.Helper() path := filepath.Join(t.TempDir(), "AUTH_LIST") os.WriteFile(path, []byte("admin:admin-hash\nalice:alice-hash\n"), 0600) a := NewApp(&AppConfig{AuthListFile: path, AdminID: "admin", SessionKey: []byte("test-secret"), LoginPath: "login", LogoutPath: "logout", ServicePath: "service", TerminalPath: "terminal", SignupPath: "signup"}) t.Cleanup(a.Stop) + if len(adminPaths) != 0 { + a.adminPath = adminPaths[0] + } a.RegisterRoutes() cookies := []*http.Cookie{} for _, id := range []string{"admin", "alice"} { @@ -35,7 +39,7 @@ func TestAdminRequiresPrivilegeAndCSRF(t *testing.T) { cookie *http.Cookie method, token string want int - }{{nil, "GET", "", 401}, {alice, "GET", "", 403}, {admin, "GET", "", 200}, {admin, "POST", "", 403}, {admin, "POST", "wrong", 403}} { + }{{nil, "GET", "", 303}, {alice, "GET", "", 403}, {admin, "GET", "", 200}, {admin, "POST", "", 403}, {admin, "POST", "wrong", 403}} { r := httptest.NewRequest(tc.method, "/admin", strings.NewReader(`{"user_id":"alice","action":"lock"}`)) if tc.cookie != nil { r.AddCookie(tc.cookie) @@ -144,3 +148,141 @@ func TestAdminKeepsPartialStatusAndShowsDiskWarning(t *testing.T) { t.Fatalf("partial status or warning lost: %d %s", w.Code, w.Body) } } + +func TestConfiguredAdminRouteConnectsPageAssetsAPIAndServiceLink(t *testing.T) { + a, admin, alice := adminFixture(t, "ops/classroom") + manager := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/admin/status" { + t.Errorf("internal Manager route unexpectedly changed: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{}`)) + })) + defer manager.Close() + a.managerBaseURL = manager.URL + a.managerClient = manager.Client() + for _, tc := range []struct { + path, contains string + cookie *http.Cookie + status int + }{ + {"/ops/classroom", `data-api-url="/ops/classroom/api/users"`, admin, 200}, + {"/ops/classroom/", `href="/ops/classroom/assets/admin.css"`, admin, 200}, + {"/ops/classroom", `src="/ops/classroom/assets/admin.js"`, admin, 200}, + {"/ops/classroom/assets/admin.js", "config.apiUrl", admin, 200}, + {"/ops/classroom/assets/admin.css", ".metrics", admin, 200}, + {"/ops/classroom/api/users", `"csrf_token":`, admin, 200}, + {"/service/", `href="/ops/classroom"`, admin, 200}, + {"/ops/classroom/api/users", "authentication required", nil, 401}, + {"/ops/classroom/assets/admin.js", "administrator access required", alice, 403}, + {"/ops/classroom/missing", "404", admin, 404}, + } { + r := httptest.NewRequest(http.MethodGet, tc.path, nil) + if tc.cookie != nil { + r.AddCookie(tc.cookie) + } + w := httptest.NewRecorder() + a.mux.ServeHTTP(w, r) + if w.Code != tc.status || !strings.Contains(w.Body.String(), tc.contains) { + t.Fatalf("%s: status=%d body=%s", tc.path, w.Code, w.Body) + } + if tc.status == 200 && w.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("administrator page or asset can be stale: %s", tc.path) + } + } + r := httptest.NewRequest(http.MethodGet, "/admin/api/users", nil) + r.AddCookie(admin) + w := httptest.NewRecorder() + a.mux.ServeHTTP(w, r) + if w.Code == 200 { + t.Fatal("default API remained active after route change") + } + r = httptest.NewRequest(http.MethodPost, "/ops/classroom/api/users", strings.NewReader(`{"user_id":"alice","action":"unlock"}`)) + r.AddCookie(admin) + r.Header.Set("X-CSRF-Token", a.adminToken(r)) + w = httptest.NewRecorder() + a.mux.ServeHTTP(w, r) + if w.Code != 200 || w.Header().Get("X-Linuxus-Account-Updated") != "true" { + t.Fatalf("custom API mutation failed: %d %s", w.Code, w.Body) + } +} + +func TestManagerFailureShowsUnknownStateAndFreshCSRFToken(t *testing.T) { + a, cookie, _ := adminFixture(t) + manager := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "unavailable", 503) + })) + defer manager.Close() + a.managerBaseURL = manager.URL + a.managerClient = manager.Client() + r := httptest.NewRequest(http.MethodGet, "/admin/api/users", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + a.mux.ServeHTTP(w, r) + var result struct { + Available bool `json:"status_available"` + Token string `json:"csrf_token"` + Users []struct { + State string `json:"state"` + } `json:"users"` + } + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if w.Code != 200 || result.Available || result.Token != a.adminToken(r) || len(result.Users) != 2 { + t.Fatalf("incorrect partial status: %d %+v", w.Code, result) + } + for _, row := range result.Users { + if row.State != "unknown" { + t.Fatal("failed status lookup reported a stopped environment") + } + } +} + +func TestOwnAccountChangeSignalsReauthenticationEvenOnPartialFailure(t *testing.T) { + for _, status := range []int{200, 503} { + a, cookie, _ := adminFixture(t) + manager := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + w.Write([]byte(`{}`)) + })) + a.managerBaseURL = manager.URL + a.managerClient = manager.Client() + r := httptest.NewRequest(http.MethodPost, "/admin/api/users", strings.NewReader(`{"user_id":"admin","action":"disconnect"}`)) + r.AddCookie(cookie) + r.Header.Set("X-CSRF-Token", a.adminToken(r)) + w := httptest.NewRecorder() + a.mux.ServeHTTP(w, r) + manager.Close() + if w.Code != status || w.Header().Get("X-Linuxus-Reauthenticate") != "true" || w.Header().Get("X-Linuxus-Account-Updated") != "true" { + t.Fatalf("partial account change lost session guidance: %d %v", w.Code, w.Header()) + } + if _, ok := a.getSessionID(r); ok { + t.Fatal("own account change did not revoke the old session") + } + } +} + +func TestExpiredAdminPageRedirectsToConfiguredLoginWhileAPIStaysUnauthorized(t *testing.T) { + a, cookie, _ := adminFixture(t, "ops/classroom") + a.loginPath = "auth/login" + if err := user.UpdateAccount(a.authListFile, "admin", "disconnect", ""); err != nil { + t.Fatal(err) + } + for _, path := range []string{"/ops/classroom", "/ops/classroom/api/users"} { + r := httptest.NewRequest(http.MethodGet, path, nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + a.mux.ServeHTTP(w, r) + if path == "/ops/classroom" { + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/auth/login" { + t.Fatalf("expired page did not reach configured login: %d %v", w.Code, w.Header()) + } + } else if w.Code != http.StatusUnauthorized { + t.Fatalf("API returned a login page instead of an authorization error: %d", w.Code) + } + if w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("expired response may be cached") + } + } +} diff --git a/src/internal/auth/handler/admin_view.go b/src/internal/auth/handler/admin_view.go new file mode 100644 index 0000000..e0f02e0 --- /dev/null +++ b/src/internal/auth/handler/admin_view.go @@ -0,0 +1,39 @@ +package handler + +import ( + "bytes" + "embed" + "html/template" + "net/http" + "strings" + "time" + + "github.com/elecbug/linuxus/src/internal/auth/page" +) + +//go:embed admin.html admin.css admin.js +var adminAssets embed.FS + +var webTemplates = template.Must(template.Must(page.NewTemplates()).ParseFS(adminAssets, "admin.html")) + +func (a *App) handleAdminAsset(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !a.requireAdmin(w, r, false) { + return + } + name := strings.TrimPrefix(r.URL.Path, "/"+a.adminPath+"/assets/") + if name != "admin.css" && name != "admin.js" { + http.NotFound(w, r) + return + } + data, err := adminAssets.ReadFile(name) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("X-Content-Type-Options", "nosniff") + http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data)) +} diff --git a/src/internal/auth/handler/app.go b/src/internal/auth/handler/app.go index a1695dd..056bc07 100644 --- a/src/internal/auth/handler/app.go +++ b/src/internal/auth/handler/app.go @@ -2,7 +2,6 @@ package handler import ( "context" - "html/template" "log" "net" "net/http" @@ -10,12 +9,12 @@ import ( "sync" "time" - "github.com/elecbug/linuxus/src/internal/auth/page" commonconfig "github.com/elecbug/linuxus/src/internal/common/config" ) // App holds auth server state, templates, and manager integration clients. type App struct { + adminPath string adminID string templates map[string]commonconfig.Template classes map[string]string @@ -27,14 +26,6 @@ type App struct { authListFile string // sessionKey is used to sign session cookie payloads. sessionKey []byte - // loginTmpl renders the login page. - loginTmpl *template.Template - // serviceTmpl renders the service landing page. - serviceTmpl *template.Template - // errorTmpl renders error pages. - errorTmpl *template.Template - // signupTmpl renders the user registration page. - signupTmpl *template.Template // loginPath is the configured login endpoint path. loginPath string @@ -87,6 +78,8 @@ type App struct { // AppConfig defines all configuration values required to initialize an App. type AppConfig struct { + // AdminPath is the base path for the administrator page, API, and assets. + AdminPath string AdminID string Templates map[string]commonconfig.Template Classes map[string]string @@ -154,8 +147,13 @@ func NewApp(config *AppConfig) *App { if config.Users == nil { config.Users = make(map[string]string) } + adminPath := config.AdminPath + if adminPath == "" { + adminPath = "admin" + } app := &App{ - adminID: config.AdminID, templates: config.Templates, classes: config.Classes, + adminPath: adminPath, + adminID: config.AdminID, templates: config.Templates, classes: config.Classes, users: config.Users, authListFile: config.AuthListFile, sessionKey: config.SessionKey, @@ -240,37 +238,14 @@ func (a *App) Stop() { a.stopOnce.Do(func() { close(a.done) }) } -// RegisterRoutes compiles templates and binds HTTP handlers. +// RegisterRoutes binds HTTP handlers to the configured routes. func (a *App) RegisterRoutes() { - loginTmpl, err := template.New(a.loginPath).Parse(page.GetLoginPage(a.loginPath, a.allowSignup, a.signupPath)) - if err != nil { - log.Fatalf("failed to parse login template: %v", err) - } - - serviceTmpl, err := template.New(a.servicePath).Parse(page.GetServicePage(a.terminalPath, a.logoutPath)) - if err != nil { - log.Fatalf("failed to parse service template: %v", err) - } - - errorTmpl, err := template.New("error").Parse(page.GetErrorPage()) - if err != nil { - log.Fatalf("failed to parse error template: %v", err) - } - - signupTmpl, err := template.New(a.signupPath).Parse(page.GetSignupPage(a.signupPath, a.loginPath)) - if err != nil { - log.Fatalf("failed to parse signup template: %v", err) - } - - a.loginTmpl = loginTmpl - a.serviceTmpl = serviceTmpl - a.errorTmpl = errorTmpl - a.signupTmpl = signupTmpl - a.mux.HandleFunc("/", a.handleRoot) - a.mux.HandleFunc("/admin", a.handleAdmin) - a.mux.HandleFunc("/admin/", a.handleAdmin) - a.mux.HandleFunc("/admin/api/users", a.handleAdminUsers) + a.mux.HandleFunc("/"+a.adminPath, a.handleAdmin) + a.mux.HandleFunc("/"+a.adminPath+"/", a.handleAdmin) + a.mux.HandleFunc("/"+a.adminPath+"/api/users", a.handleAdminUsers) + a.mux.HandleFunc("/"+a.adminPath+"/assets/admin.css", a.handleAdminAsset) + a.mux.HandleFunc("/"+a.adminPath+"/assets/admin.js", a.handleAdminAsset) a.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) a.mux.HandleFunc("/"+a.loginPath, a.handleLogin) a.mux.HandleFunc("/"+a.logoutPath, a.handleLogout) diff --git a/src/internal/auth/handler/assets_test.go b/src/internal/auth/handler/assets_test.go index 722a13c..6e1eaa8 100644 --- a/src/internal/auth/handler/assets_test.go +++ b/src/internal/auth/handler/assets_test.go @@ -4,6 +4,7 @@ import ( "bytes" "net/http" "net/http/httptest" + "strings" "testing" "github.com/elecbug/linuxus/src/static" @@ -13,15 +14,26 @@ func TestStaticAssetsWithoutSourceDirectory(t *testing.T) { t.Chdir(t.TempDir()) a := &App{mux: http.NewServeMux()} a.handleFavicon() - want, err := static.Files.ReadFile("favicon.png") - if err != nil { - t.Fatal(err) - } - for _, path := range []string{"/favicon.ico", "/static/favicon.png"} { + for _, tc := range []struct{ path, name, contentType string }{ + {"/favicon.ico", "favicon.png", "image/png"}, + {"/static/favicon.png", "favicon.png", "image/png"}, + {"/static/ui.css", "ui.css", "text/css"}, + {"/static/workspace.js", "workspace.js", "javascript"}, + } { + want, err := static.Files.ReadFile(tc.name) + if err != nil { + t.Fatal(err) + } rr := httptest.NewRecorder() - a.mux.ServeHTTP(rr, httptest.NewRequest("GET", path, nil)) + a.mux.ServeHTTP(rr, httptest.NewRequest("GET", tc.path, nil)) if rr.Code != http.StatusOK || !bytes.Equal(rr.Body.Bytes(), want) { - t.Fatalf("asset %s not served: %d", path, rr.Code) + t.Fatalf("asset %s not served: %d", tc.path, rr.Code) + } + if !strings.Contains(rr.Header().Get("Content-Type"), tc.contentType) { + t.Fatalf("incorrect asset content type: %s", tc.path) + } + if strings.HasPrefix(tc.path, "/static/") && rr.Header().Get("Cache-Control") != "no-cache" { + t.Fatalf("UI asset can remain stale: %s", tc.path) } } } diff --git a/src/internal/auth/handler/handle_login.go b/src/internal/auth/handler/handle_login.go index 77c4919..e7ff604 100644 --- a/src/internal/auth/handler/handle_login.go +++ b/src/internal/auth/handler/handle_login.go @@ -1,7 +1,6 @@ package handler import ( - "bytes" "net" "net/http" "strconv" @@ -102,33 +101,16 @@ func (a *App) isBlocked(ip, id string) (bool, time.Time) { // renderLogin renders the login page with an optional error message. func (a *App) renderLogin(w http.ResponseWriter, errMsg string) { - data := struct { - Error string - }{ - Error: errMsg, - } - - if err := a.loginTmpl.Execute(w, data); err != nil { - http.Error(w, "Template error", http.StatusInternalServerError) - } + data := a.pageData("Sign in") + data.Error = errMsg + a.renderPage(w, "login", data, http.StatusOK) } // renderError renders a formatted error page with a specific status code. func (a *App) renderError(w http.ResponseWriter, errMsg string, statusCode int) { - data := struct { - Error string - }{ - Error: errMsg, - } - - var buf bytes.Buffer - if err := a.errorTmpl.Execute(&buf, data); err != nil { - http.Error(w, "Template error", http.StatusInternalServerError) - return - } - - w.WriteHeader(statusCode) - _, _ = buf.WriteTo(w) + data := a.pageData("Unable to continue") + data.Error = errMsg + a.renderPage(w, "error", data, statusCode) } // recordFail updates per-IP and optional per-user failure counters and lock states. diff --git a/src/internal/auth/handler/handle_root.go b/src/internal/auth/handler/handle_root.go index d0da6cb..8b1f5c9 100644 --- a/src/internal/auth/handler/handle_root.go +++ b/src/internal/auth/handler/handle_root.go @@ -19,7 +19,12 @@ func (a *App) handleRoot(w http.ResponseWriter, r *http.Request) { // handleFavicon serves embedded assets, independent of the working directory. func (a *App) handleFavicon() { - a.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static.Files)))) + files := http.StripPrefix("/static/", http.FileServer(http.FS(static.Files))) + a.mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Content-Type-Options", "nosniff") + files.ServeHTTP(w, r) + })) a.mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { data, _ := static.Files.ReadFile("favicon.png") http.ServeContent(w, r, "favicon.png", time.Time{}, bytes.NewReader(data)) diff --git a/src/internal/auth/handler/handle_service.go b/src/internal/auth/handler/handle_service.go index 268bcbe..d6106df 100644 --- a/src/internal/auth/handler/handle_service.go +++ b/src/internal/auth/handler/handle_service.go @@ -20,18 +20,6 @@ func (a *App) handleServicePage(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("Pragma", "no-cache") - - data := struct { - ID string - IsAdmin bool - }{ - ID: id, - IsAdmin: a.adminID != "" && id == a.adminID, - } - - if err := a.serviceTmpl.Execute(w, data); err != nil { - http.Error(w, "Template error", http.StatusInternalServerError) - } + w.Header().Set("X-Frame-Options", "DENY") + a.renderPage(w, "app", a.applicationData(r, id, "workspace"), http.StatusOK) } diff --git a/src/internal/auth/handler/handle_signup.go b/src/internal/auth/handler/handle_signup.go index d7bb2db..3dd7b5e 100644 --- a/src/internal/auth/handler/handle_signup.go +++ b/src/internal/auth/handler/handle_signup.go @@ -1,11 +1,9 @@ package handler import ( - "html/template" "net/http" "strings" - "github.com/elecbug/linuxus/src/internal/auth/page" "github.com/elecbug/linuxus/src/internal/common/ruleset" "github.com/elecbug/linuxus/src/internal/common/user" ) @@ -83,24 +81,7 @@ func (a *App) handleSignup(w http.ResponseWriter, r *http.Request) { // renderSignup renders the signup page with an optional error message. func (a *App) renderSignup(w http.ResponseWriter, errMsg string) { - tmpl, err := template.New("signup").Parse( - page.GetSignupPage(a.signupPath, a.loginPath), - ) - if err != nil { - http.Error(w, "Template parsing error", http.StatusInternalServerError) - return - } - - data := struct { - Error string - }{ - Error: errMsg, - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - - if err := tmpl.Execute(w, data); err != nil { - http.Error(w, "Template execution error", http.StatusInternalServerError) - return - } + data := a.pageData("Create account") + data.Error = errMsg + a.renderPage(w, "signup", data, http.StatusOK) } diff --git a/src/internal/auth/handler/views.go b/src/internal/auth/handler/views.go new file mode 100644 index 0000000..5599f6d --- /dev/null +++ b/src/internal/auth/handler/views.go @@ -0,0 +1,49 @@ +package handler + +import ( + "bytes" + "log" + "net/http" + + "github.com/elecbug/linuxus/src/internal/auth/page" +) + +func (a *App) pageData(title string) page.Data { + base := "/" + a.adminPath + return page.Data{ + Title: title, AllowSignup: a.allowSignup, + LoginURL: "/" + a.loginPath, SignupURL: "/" + a.signupPath, + ServiceURL: "/" + a.servicePath + "/", TerminalURL: "/" + a.terminalPath + "/", + LogoutURL: "/" + a.logoutPath, AdminURL: base, + BaseURL: base, APIURL: base + "/api/users", AdminID: a.adminID, + } +} + +func (a *App) applicationData(r *http.Request, id, view string) page.Data { + title := "Workspace" + if view == "admin" { + title = "Administration" + } + data := a.pageData(title) + data.ID, data.View = id, view + data.IsAdmin = a.adminID != "" && id == a.adminID + if data.IsAdmin { + data.Token = a.adminToken(r) + } + return data +} + +// renderPage buffers output so template failures cannot leave a partial response. +func (a *App) renderPage(w http.ResponseWriter, name string, data page.Data, status int) { + var buf bytes.Buffer + if err := webTemplates.ExecuteTemplate(&buf, name, data); err != nil { + log.Printf("failed to render %s page: %v", name, err) + http.Error(w, "Template error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(status) + _, _ = buf.WriteTo(w) +} diff --git a/src/internal/auth/handler/views_test.go b/src/internal/auth/handler/views_test.go new file mode 100644 index 0000000..1908a86 --- /dev/null +++ b/src/internal/auth/handler/views_test.go @@ -0,0 +1,88 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestUnifiedApplicationPreservesRoleBoundary(t *testing.T) { + a, admin, alice := adminFixture(t, "ops/classroom") + for _, tc := range []struct { + name string + cookie *http.Cookie + administrator bool + }{{"administrator", admin, true}, {"user", alice, false}} { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/service/", nil) + r.AddCookie(tc.cookie) + w := httptest.NewRecorder() + a.mux.ServeHTTP(w, r) + body := w.Body.String() + for _, text := range []string{`data-view="workspace"`, `href="/static/ui.css"`, `src="/static/workspace.js"`, `src="/terminal/"`, `href="/logout"`} { + if w.Code != 200 || !strings.Contains(body, text) { + t.Fatalf("missing workspace element %q: %d %s", text, w.Code, body) + } + } + for _, text := range []string{`id="admin-panel"`, `data-csrf-token="`, `/ops/classroom/assets/admin.js`, `data-view-link="admin"`} { + if strings.Contains(body, text) != tc.administrator { + t.Fatalf("role boundary failed for %q", text) + } + } + if w.Header().Get("Cache-Control") != "no-store" || w.Header().Get("Content-Type") != "text/html; charset=utf-8" { + t.Fatal("session page response headers missing") + } + if strings.Contains(body, "admin-hash") || strings.Contains(body, "alice-hash") { + t.Fatal("credentials leaked into the page") + } + }) + } +} + +func TestDirectAdministrationDoesNotStartTerminal(t *testing.T) { + a, cookie, _ := adminFixture(t, "ops/classroom") + r := httptest.NewRequest(http.MethodGet, "/ops/classroom/", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + a.mux.ServeHTTP(w, r) + body := w.Body.String() + if w.Code != 200 || !strings.Contains(body, `data-view="admin"`) || !strings.Contains(body, `data-terminal-url="/terminal/"`) || strings.Contains(body, `src="/terminal/"`) { + t.Fatalf("direct administration started a terminal or failed: %d %s", w.Code, body) + } + if !strings.Contains(body, `aria-current="page">Administration`) || !strings.Contains(body, `data-api-url="/ops/classroom/api/users"`) { + t.Fatal("configured navigation or API missing") + } +} + +func TestSharedAuthenticationPagesEscapeMessagesAndUseConfiguredRoutes(t *testing.T) { + a, _, _ := adminFixture(t) + a.loginPath, a.signupPath, a.servicePath = "auth/login", "auth/signup", "workspace" + a.allowSignup = true + message := `` + for _, tc := range []struct { + name string + render func(http.ResponseWriter) + want string + status int + }{ + {"login", func(w http.ResponseWriter) { a.renderLogin(w, message) }, `action="/auth/login"`, 200}, + {"signup", func(w http.ResponseWriter) { a.renderSignup(w, message) }, `action="/auth/signup"`, 200}, + {"error", func(w http.ResponseWriter) { a.renderError(w, message, 503) }, `href="/auth/login"`, 503}, + } { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + tc.render(w) + body := w.Body.String() + if w.Code != tc.status || !strings.Contains(body, tc.want) || !strings.Contains(body, `href="/static/ui.css"`) { + t.Fatalf("shared page failed: %d %s", w.Code, body) + } + if strings.Contains(body, message) || !strings.Contains(body, "<script>") { + t.Fatal("error message was not escaped") + } + if w.Header().Get("Cache-Control") != "no-store" || w.Header().Get("Content-Type") != "text/html; charset=utf-8" { + t.Fatal("authentication page response headers missing") + } + }) + } +} diff --git a/src/internal/auth/page/app.html b/src/internal/auth/page/app.html new file mode 100644 index 0000000..8e513aa --- /dev/null +++ b/src/internal/auth/page/app.html @@ -0,0 +1,30 @@ +{{define "app"}} + + + + {{template "head" .}} + + {{if .IsAdmin}}{{end}} + + +Your session expired or your account permissions changed.
YOUR ENVIRONMENT
Your personal Linux terminal.
WELCOME TO LINUXUS
Your terminal and classroom tools, in one place.
+ {{if .Error}}{{.Error}}
{{end}} + + {{if .AllowSignup}}New to Linuxus? Create an account
{{else}}Contact your administrator to request an account.
{{end}} +JOIN YOUR CLASSROOM
Get a personal Linux workspace for your exercises.
+ {{if .Error}}{{.Error}}
{{end}} + +Already registered? Sign in
+SOMETHING WENT WRONG
{{.Error}}
+Try again, or contact your administrator if the issue continues.
+ Back to sign in +