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(` -Linuxus Administration - -

Linuxus Administration

Locking accounts or disconnecting sessions preserves exercise data.

UserAccount and sessionsDisk usageClassroom environmentActions
-

Reset password

`)) diff --git a/src/internal/auth/handler/admin.html b/src/internal/auth/handler/admin.html new file mode 100644 index 0000000..595610d --- /dev/null +++ b/src/internal/auth/handler/admin.html @@ -0,0 +1,40 @@ +{{define "admin-panel"}} +
+

CLASSROOM OPERATIONS

Manage your environments.

Keep accounts, sessions, and classroom access in one place.

+ Connecting +
+
+
Total users—Registered accounts
+
Running environments—Currently started
+
Active sessions—Connected terminals
+
Restricted accounts—Locked or under maintenance
+
+ + + +
+

Users

Account changes preserve home files and exercise data.

+
+
+
UserAccountEnvironmentHome storageClassroomActions
+
Loading users…

Getting the latest account and environment status.

+
+ +
+

Lock accounts to suspend access. User data stays in place.

+ +{{end}} +{{define "admin-dialog"}} + +
+

ACCOUNT ACTION

Manage user

+ +

+ + + + + +
+
+{{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}} + + + + {{if .IsAdmin}}{{end}} +
+
+

YOUR ENVIRONMENT

Workspace

Your personal Linux terminal.

+
+
+ {{if .IsAdmin}}
{{template "admin-panel" .}}
{{end}} +
+ {{template "footer" .}} + {{if .IsAdmin}}{{template "admin-dialog" .}}{{end}} + + +{{end}} diff --git a/src/internal/auth/page/auth.html b/src/internal/auth/page/auth.html new file mode 100644 index 0000000..3a236c2 --- /dev/null +++ b/src/internal/auth/page/auth.html @@ -0,0 +1,68 @@ +{{define "login"}} + + +{{template "head" .}} + +{{template "auth-header" .}} +
+
+ {{template "auth-logo" .}} +

WELCOME TO LINUXUS

Sign in to your workspace.

+

Your terminal and classroom tools, in one place.

+ {{if .Error}}{{end}} +
+ + + +
+ {{if .AllowSignup}}

New to Linuxus? Create an account

{{else}}

Contact your administrator to request an account.

{{end}} +
+
+{{template "footer" .}} + + +{{end}} +{{define "signup"}} + + +{{template "head" .}} + +{{template "auth-header" .}} +
+
+ {{template "auth-logo" .}} +

JOIN YOUR CLASSROOM

Create your account.

+

Get a personal Linux workspace for your exercises.

+ {{if .Error}}{{end}} +
+ + + + +
+

Already registered? Sign in

+
+
+{{template "footer" .}} + + +{{end}} +{{define "error"}} + + +{{template "head" .}} + +{{template "auth-header" .}} +
+
+ {{template "auth-logo" .}} +

SOMETHING WENT WRONG

Unable to continue.

+ +

Try again, or contact your administrator if the issue continues.

+ Back to sign in +
+
+{{template "footer" .}} + + +{{end}} diff --git a/src/internal/auth/page/css.go b/src/internal/auth/page/css.go deleted file mode 100644 index b7aab99..0000000 --- a/src/internal/auth/page/css.go +++ /dev/null @@ -1,325 +0,0 @@ -package page - -import ( - "fmt" - - "github.com/elecbug/linuxus/src/internal/common/html" -) - -// getLoginCSS builds style rules for the login page. -func getLoginCSS() *html.CSS { - return html.NewCSS().AddContents( - loginBodyCSS(), - loginErrorCSS(), - ).AddContents( - loginTooltipCSS()..., - ).AddContents( - footerCSS(40)..., - ).AddContents( - loginFormCSS()..., - ).AddContents( - loginButtonCSS()..., - ) -} - -// getErrorCSS builds style rules for the error page. -func getErrorCSS() *html.CSS { - return html.NewCSS().AddContents( - loginBodyCSS(), - loginErrorCSS(), - ).AddContents( - loginTooltipCSS()..., - ).AddContents( - footerCSS(40)..., - ) -} - -// getServiceCSS builds style rules for the service page. -func getServiceCSS() *html.CSS { - return html.NewCSS().AddContents( - serviceBodyCSS(), - ).AddContents( - serviceToolBarCSS()..., - ).AddContents( - serviceIframeCSS()..., - ).AddContents( - serviceButtonCSS()..., - ).AddContents( - footerCSS(20)..., - ) - -} - -// footerCSS returns footer style rules with configurable top margin. -func footerCSS(marginTop int) []*html.CSSContent { - return []*html.CSSContent{ - html.NewCSSContent("footer", - html.NewAttributes( - "margin-top", fmt.Sprintf("%dpx", marginTop), - "margin-bottom", "20px", - "font-size", "0.9em", - "text-align", "center", - "color", FOOTER_COLOR, - )..., - ), - html.NewCSSContent("footer a", - html.NewAttributes( - "color", FOOTER_LINK_COLOR, - "text-decoration", "none", - )..., - ), - html.NewCSSContent("footer a:hover", - html.NewAttributes( - "color", FOOTER_LINK_HOVER_COLOR, - "text-decoration", "underline", - )..., - ), - html.NewCSSContent("footer a:active", - html.NewAttributes( - "color", FOOTER_LINK_ACTIVE_COLOR, - "text-decoration", "underline", - )..., - ), - html.NewCSSContent("footer a:visited", - html.NewAttributes( - "color", FOOTER_LINK_VISITED_COLOR, - "text-decoration", "underline", - )..., - ), - } -} - -// loginBodyCSS returns base body styling for login and error pages. -func loginBodyCSS() *html.CSSContent { - return html.NewCSSContent("body", - html.NewAttributes( - "font-family", BASE_FONT_FAMILY, - "max-width", "420px", - "margin", "60px auto", - "background", BASE_BACKGROUND, - "color", BASE_COLOR, - )..., - ) -} - -// loginFormCSS returns styling for the login form container. -func loginFormCSS() []*html.CSSContent { - return []*html.CSSContent{ - html.NewCSSContent("form.login-form", - html.NewAttributes( - "display", "flex", - "flex-direction", "column", - "gap", "12px", - )..., - ), - } -} - -// loginErrorCSS returns styling for login error messages. -func loginErrorCSS() *html.CSSContent { - return html.NewCSSContent("p.error", - html.NewAttributes( - "color", DANGER_COLOR, - )..., - ) -} - -// loginTooltipCSS returns styling for helper tooltip text. -func loginTooltipCSS() []*html.CSSContent { - return []*html.CSSContent{ - html.NewCSSContent("p.tooltip", - html.NewAttributes( - "color", TOOLTIP_COLOR, - "font-size", "0.9em", - "font-style", "italic", - )..., - ), - html.NewCSSContent("a.signup-link", - html.NewAttributes( - "color", FOOTER_LINK_COLOR, - "text-decoration", "none", - )..., - ), - html.NewCSSContent("a.signup-link:hover", - html.NewAttributes( - "color", FOOTER_LINK_HOVER_COLOR, - "text-decoration", "underline", - )..., - ), - html.NewCSSContent("a.signup-link:active", - html.NewAttributes( - "color", FOOTER_LINK_ACTIVE_COLOR, - "text-decoration", "underline", - )..., - ), - html.NewCSSContent("a.signup-link:visited", - html.NewAttributes( - "color", FOOTER_LINK_COLOR, - "text-decoration", "underline", - )..., - ), - } -} - -// loginButtonCSS returns styling for login form inputs and button. -func loginButtonCSS() []*html.CSSContent { - return []*html.CSSContent{ - box("input", false, false), - box("button", true, false), - boxHoverEffect("button:hover", true), - } -} - -// serviceBodyCSS returns base layout styling for service page. -func serviceBodyCSS() *html.CSSContent { - return html.NewCSSContent("html, body", - html.NewAttributes( - "margin", "0", - "padding", "0", - "height", "100%", - "display", "flex", - "flex-direction", "column", - "font-family", BASE_FONT_FAMILY, - "background", BASE_BACKGROUND, - "color", BASE_COLOR, - )..., - ) -} - -// serviceToolBarCSS returns style rules for service top toolbar. -func serviceToolBarCSS() []*html.CSSContent { - return []*html.CSSContent{ - html.NewCSSContent("div.topbar", - html.NewAttributes( - "height", "56px", - "flex-shrink", "0", - "display", "flex", - "align-items", "center", - "justify-content", "space-between", - "padding", "0 16px", - "box-sizing", "border-box", - "border-bottom", DARK_BORDER, - )..., - ), - html.NewCSSContent("div.left", - html.NewAttributes( - "font-size", "1.2em", - "font-weight", "bold", - )..., - ), - html.NewCSSContent("div.right", - html.NewAttributes( - "display", "flex", - "gap", "10px", - )..., - ), - } -} - -// serviceIframeCSS returns style rules for terminal iframe area. -func serviceIframeCSS() []*html.CSSContent { - return []*html.CSSContent{ - html.NewCSSContent("div.frame-wrap", - html.NewAttributes( - "flex", "1", - "min-height", "0", - )..., - ), - html.NewCSSContent("iframe", - html.NewAttributes( - "width", "100%", - "height", "100%", - "border", "0", - "display", "block", - )..., - ), - } -} - -// serviceButtonCSS returns style rules for service action buttons. -func serviceButtonCSS() []*html.CSSContent { - return []*html.CSSContent{ - box("a.btn", true, false), - boxHoverEffect("a.btn:hover", true), - box("a.btn-danger", true, true), - boxHoverEffect("a.btn-danger:hover", true), - } -} - -// box creates a reusable button/input style variant. -func box(tag string, isDark, isDangerOpt bool) *html.CSSContent { - if isDark && !isDangerOpt { - return html.NewCSSContent(tag, - html.NewAttributes( - "display", BOX_DISPLAY, - "padding", BOX_PADDING, - "text-decoration", BOX_TEXT_DECORATION, - "border", DARK_BORDER, - "border-radius", BOX_BORDER_RADIUS, - "font-size", BOX_FONT_SIZE, - "color", DARK_COLOR, - "background", DARK_BACKGROUND, - )..., - ) - } else if !isDark && !isDangerOpt { - return html.NewCSSContent(tag, - html.NewAttributes( - "display", BOX_DISPLAY, - "padding", BOX_PADDING, - "text-decoration", BOX_TEXT_DECORATION, - "border", LIGHT_BORDER, - "border-radius", BOX_BORDER_RADIUS, - "font-size", BOX_FONT_SIZE, - "color", LIGHT_COLOR, - "background", LIGHT_BACKGROUND, - )..., - ) - } else if isDark && isDangerOpt { - return html.NewCSSContent(tag, - html.NewAttributes( - "display", BOX_DISPLAY, - "padding", BOX_PADDING, - "text-decoration", BOX_TEXT_DECORATION, - "border", DANGER_BORDER, - "border-radius", BOX_BORDER_RADIUS, - "font-size", BOX_FONT_SIZE, - "color", DANGER_COLOR, - "background", DARK_BACKGROUND, - )..., - ) - } else if !isDark && isDangerOpt { - return html.NewCSSContent(tag, - html.NewAttributes( - "display", BOX_DISPLAY, - "padding", BOX_PADDING, - "text-decoration", BOX_TEXT_DECORATION, - "border", DANGER_BORDER, - "border-radius", BOX_BORDER_RADIUS, - "font-size", BOX_FONT_SIZE, - "color", DANGER_COLOR, - "background", LIGHT_BACKGROUND, - )..., - ) - } - - return nil -} - -// boxHoverEffect creates hover background style for a selector. -func boxHoverEffect(tag string, isDark bool) *html.CSSContent { - if isDark { - return html.NewCSSContent(tag, - html.NewAttributes( - "background", DARK_BACKGROUND_HOVER, - )..., - ) - } else if !isDark { - return html.NewCSSContent(tag, - html.NewAttributes( - "background", LIGHT_BACKGROUND_HOVER, - )..., - ) - } - - return nil -} diff --git a/src/internal/auth/page/html.go b/src/internal/auth/page/html.go index 19ddf46..f8960da 100644 --- a/src/internal/auth/page/html.go +++ b/src/internal/auth/page/html.go @@ -1,288 +1,23 @@ +// Package page contains the shared authentication and application templates. package page import ( - "github.com/elecbug/linuxus/src/internal/common/html" + "embed" + "html/template" ) -// GetLoginPage renders the login HTML template. -func GetLoginPage(loginPath string, allowSignup bool, signupPath string) string { - htmlpage := html.NewHTMLPage( - "Linuxus | Login", - getBaseMeta(), - getFaviconLink(), - getLoginCSS(), - html.NewHTML( - "h2", - html.NewAttributes(), - "Linuxus Login", - ), - html.NewHTML( - "p", - html.NewAttributes("class", "error"), - "{{.Error}}", - ).AddPrefix("{{if .Error}}").AddSuffix("{{end}}"), - html.NewHTML( - "form", - html.NewAttributes( - "class", "login-form", - "method", "post", - "action", "/"+loginPath, - ), - html.NewHTML( - "input", - html.NewAttributes( - "type", "text", - "name", "id", - "placeholder", "ID", - "required", "true", - ), - ), - html.NewHTML( - "input", - html.NewAttributes( - "type", "password", - "name", "password", - "placeholder", "Password", - "required", "true", - ), - ), - html.NewHTML( - "button", - html.NewAttributes("type", "submit"), - "Login", - ), - ), - getAllowSignupHTML(allowSignup, signupPath), - linuxusFooterHTML(), - ) +//go:embed *.html +var templates embed.FS - return htmlpage.Render() +// Data supplies configured routes and session details to the shared interface. +type Data struct { + Title, Error, ID, View string + LoginURL, SignupURL, ServiceURL, TerminalURL, LogoutURL, AdminURL string + BaseURL, APIURL, Token, AdminID string + IsAdmin, AllowSignup bool } -func getAllowSignupHTML(allowSignup bool, signupPath string) *html.HTML { - if allowSignup { - return html.NewHTML( - "p", - html.NewAttributes( - "class", "tooltip", - ), - "Don't have an account? ", - html.NewHTML( - "a", - html.NewAttributes( - "class", "signup-link", - "href", "/"+signupPath, - ), - "Sign up", - ), - " here.", - ) - } else { - return html.NewHTML( - "p", - html.NewAttributes( - "class", "tooltip", - ), - "New user sign ups are currently disabled.", - ) - } -} - -// GetServicePage renders the post-login service HTML template. -func GetServicePage(terminalPath, logoutPath string) string { - htmlpage := html.NewHTMLPage( - "Linuxus | {{.ID}}", - getBaseMeta(), - getFaviconLink(), - getServiceCSS(), - html.NewHTML( - "div", - html.NewAttributes("class", "topbar"), - html.NewHTML( - "div", - html.NewAttributes("class", "left"), - html.NewHTML("p", - html.NewAttributes(), - "Linuxus | {{.ID}}", - ), - ), - html.NewHTML( - "div", - html.NewAttributes("class", "right"), - html.NewHTML("a", html.NewAttributes("class", "btn", "href", "/admin"), "Admin").AddPrefix("{{if .IsAdmin}}").AddSuffix("{{end}}"), - html.NewHTML( - "a", - html.NewAttributes( - "class", "btn", - "href", "/"+terminalPath+"/", - "target", "shellframe", - ), - "Open Shell", - ), - html.NewHTML( - "a", - html.NewAttributes( - "class", "btn btn-danger", - "href", "/"+logoutPath, - ), - "Logout", - ), - ), - ), - html.NewHTML( - "div", - html.NewAttributes("class", "frame-wrap"), - html.NewHTML( - "iframe", - html.NewAttributes( - "name", "shellframe", - "src", "/"+terminalPath+"/", - ), - "", // The iframe content will be loaded from the terminal path - ), - ), - linuxusFooterHTML(), - ) - - return htmlpage.Render() -} - -// GetErrorPage renders a generic error HTML template. -func GetErrorPage() string { - htmlpage := html.NewHTMLPage( - "Linuxus | Error", - getBaseMeta(), - getFaviconLink(), - getErrorCSS(), - html.NewHTML( - "h2", - html.NewAttributes(), - "An Error Occurred", - ), - html.NewHTML( - "p", - html.NewAttributes("class", "error"), - "{{.Error}}", - ), - html.NewHTML( - "p", - html.NewAttributes("class", "tooltip"), - "Please try again or contact the administrator.", - ), - linuxusFooterHTML(), - ) - - return htmlpage.Render() -} - -// GetSignupPage renders the user registration HTML template. -func GetSignupPage(signupPath, loginPath string) string { - htmlpage := html.NewHTMLPage( - "Linuxus | Sign Up", - getBaseMeta(), - getFaviconLink(), - getLoginCSS(), - html.NewHTML( - "h2", - html.NewAttributes(), - "Linuxus Sign Up", - ), - html.NewHTML( - "p", - html.NewAttributes("class", "error"), - "{{.Error}}", - ).AddPrefix("{{if .Error}}").AddSuffix("{{end}}"), - html.NewHTML( - "form", - html.NewAttributes( - "class", "login-form", - "method", "post", - "action", "/"+signupPath, - ), - html.NewHTML( - "input", - html.NewAttributes( - "type", "text", - "name", "id", - "placeholder", "ID", - "required", "true", - ), - ), - html.NewHTML( - "input", - html.NewAttributes( - "type", "password", - "name", "password", - "placeholder", "Password", - "required", "true", - ), - ), - html.NewHTML( - "input", - html.NewAttributes( - "type", "password", - "name", "confirm_password", - "placeholder", "Confirm Password", - "required", "true", - ), - ), - html.NewHTML( - "button", - html.NewAttributes("type", "submit"), - "Sign Up", - ), - ), - html.NewHTML( - "p", - html.NewAttributes("class", "tooltip"), - "Already have an account? ", - html.NewHTML( - "a", - html.NewAttributes( - "class", "signup-link", - "href", "/"+loginPath, - ), - "Login", - ), - " here.", - ), - linuxusFooterHTML(), - ) - - return htmlpage.Render() -} - -// getBaseMeta returns base head meta entries used by all pages. -func getBaseMeta() []html.Attribute { - return html.NewAttributes( - "charset", "UTF-8", - "name", "viewport", - "content", "width=device-width, initial-scale=1.0", - ) -} - -// getFaviconLink returns the link tag for the favicon. -func getFaviconLink() map[string][]html.Attribute { - return map[string][]html.Attribute{ - "icon": html.NewAttributes( - "rel", "icon", - "type", "image/png", - "href", "/static/favicon.png", - ), - } -} - -// linuxusFooterHTML builds the shared footer element. -func linuxusFooterHTML() *html.HTML { - return html.NewHTML( - "footer", - html.NewAttributes(), - "© 2026 ", - html.NewHTML( - "a", - html.NewAttributes("href", "https://github.com/elecbug/linuxus"), - "Linuxus", - ), - ". All rights reserved.", - ) +// NewTemplates loads the shared layouts. Callers add the administrator panel. +func NewTemplates() (*template.Template, error) { + return template.New("linuxus").ParseFS(templates, "*.html") } diff --git a/src/internal/auth/page/layout.html b/src/internal/auth/page/layout.html new file mode 100644 index 0000000..ebc37b5 --- /dev/null +++ b/src/internal/auth/page/layout.html @@ -0,0 +1,14 @@ +{{define "head"}} + + + +{{.Title}} | Linuxus + + +{{end}} +{{define "brand"}}Linuxus{{end}} +{{define "footer"}}{{end}} +{{define "auth-header"}} + +{{end}} +{{define "auth-logo"}}{{end}} diff --git a/src/internal/auth/page/testdata/render/main.go b/src/internal/auth/page/testdata/render/main.go new file mode 100644 index 0000000..6ca374a --- /dev/null +++ b/src/internal/auth/page/testdata/render/main.go @@ -0,0 +1,49 @@ +// Render the production templates with fixed, non-sensitive browser test data. +package main + +import ( + "bytes" + "encoding/json" + "html/template" + "os" + + "github.com/elecbug/linuxus/src/internal/auth/page" +) + +func main() { + templates := template.Must(template.Must(page.NewTemplates()).ParseFS(os.DirFS("internal/auth/handler"), "admin.html")) + data := page.Data{ + ID: "admin", AdminID: "admin", IsAdmin: true, AllowSignup: true, + LoginURL: "/auth/login", SignupURL: "/auth/signup", LogoutURL: "/auth/logout", + ServiceURL: "/workspace/", TerminalURL: "/shell/", AdminURL: "/ops/classroom", + BaseURL: "/ops/classroom", APIURL: "/ops/classroom/api/users", Token: "initial-token", + } + rendered := map[string]string{} + for _, name := range []string{"admin", "workspace", "user", "login", "signup", "error"} { + value := data + templateName := name + switch name { + case "admin": + templateName, value.Title, value.View = "app", "Administration", "admin" + case "workspace", "user": + templateName, value.Title, value.View = "app", "Workspace", "workspace" + if name == "user" { + value.ID, value.IsAdmin, value.Token = "alice", false, "" + } + case "login": + value.Title = "Sign in" + case "signup": + value.Title = "Create account" + case "error": + value.Title, value.Error = "Unable to continue", "The environment is currently unavailable." + } + var out bytes.Buffer + if err := templates.ExecuteTemplate(&out, templateName, value); err != nil { + panic(err) + } + rendered[name] = out.String() + } + if err := json.NewEncoder(os.Stdout).Encode(rendered); err != nil { + panic(err) + } +} diff --git a/src/internal/auth/page/value.go b/src/internal/auth/page/value.go deleted file mode 100644 index 4070789..0000000 --- a/src/internal/auth/page/value.go +++ /dev/null @@ -1,33 +0,0 @@ -package page - -const BOX_DISPLAY = "inline-block" -const BOX_PADDING = "8px 12px" -const BOX_FONT_SIZE = "1em" -const BOX_TEXT_DECORATION = "none" -const BOX_BORDER_RADIUS = "6px" - -const LIGHT_COLOR = "#303030" -const LIGHT_BACKGROUND = DARK_COLOR -const LIGHT_BACKGROUND_HOVER = "#d0d0d0" -const LIGHT_BORDER = "1px solid " + LIGHT_COLOR - -const DARK_COLOR = "#e0e0e0" -const DARK_BACKGROUND = LIGHT_COLOR -const DARK_BACKGROUND_HOVER = "#404040" -const DARK_BORDER = "1px solid " + DARK_COLOR - -const DANGER_COLOR = "#ff0000" -const DANGER_BORDER = "1px solid " + DANGER_COLOR - -const BASE_FONT_FAMILY = "sans-serif" -const BASE_COLOR = "#f0f0f0" -const BASE_BACKGROUND = "#202020" -const BASE_BORDER = "1px solid " + BASE_COLOR - -const TOOLTIP_COLOR = "#cccccc" - -const FOOTER_COLOR = "#888888" -const FOOTER_LINK_COLOR = "#1e90ff" -const FOOTER_LINK_HOVER_COLOR = "#1c86ee" -const FOOTER_LINK_ACTIVE_COLOR = "#1874cd" -const FOOTER_LINK_VISITED_COLOR = "#551a8b" diff --git a/src/internal/auth/server.go b/src/internal/auth/server.go index 30843d0..7618867 100644 --- a/src/internal/auth/server.go +++ b/src/internal/auth/server.go @@ -65,6 +65,7 @@ func parseConfig() (*handler.AppConfig, error) { Users: users, AuthListFile: cfg.AuthService.Mounts.ContainerAuthListPath, SessionKey: []byte(cfg.AuthService.Security.SessionSecret), + AdminPath: cfg.AdminRoute(), LoginPath: cfg.AuthService.ServiceURL.Login, LogoutPath: cfg.AuthService.ServiceURL.Logout, ServicePath: cfg.AuthService.ServiceURL.Service, diff --git a/src/internal/auth/server_test.go b/src/internal/auth/server_test.go new file mode 100644 index 0000000..8351741 --- /dev/null +++ b/src/internal/auth/server_test.go @@ -0,0 +1,58 @@ +package auth + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/elecbug/linuxus/src/internal/common/config" +) + +func TestAdminRouteSurvivesEnvAndContainerConfiguration(t *testing.T) { + for _, admin := range []string{"admin", "ops/classroom", ""} { + contents := strings.Replace(config.DefaultEnv, "AUTH_SERVICE_SERVICE_URL_ADMIN='admin'", "AUTH_SERVICE_SERVICE_URL_ADMIN='"+admin+"'", 1) + cfg, err := config.ParseEnv([]byte(contents)) + if err != nil { + t.Fatal(err) + } + cfg.AuthService.Mounts.ContainerAuthListPath = filepath.Join(t.TempDir(), "AUTH_LIST") + if err := os.WriteFile(cfg.AuthService.Mounts.ContainerAuthListPath, []byte("admin:hash\n"), 0600); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + t.Setenv("ENV", string(data)) + app, err := parseConfig() + if err != nil { + t.Fatal(err) + } + want := admin + if want == "" { + want = "admin" + } + if app.AdminPath != want { + t.Fatalf("route lost in configuration transport: %q", app.AdminPath) + } + } +} + +func TestInvalidAdminRouteRejectedBeforeLoadingAccounts(t *testing.T) { + cfg, err := config.ParseEnv([]byte(config.DefaultEnv)) + if err != nil { + t.Fatal(err) + } + cfg.AuthService.ServiceURL.Admin = "service/admin" + cfg.AuthService.Mounts.ContainerAuthListPath = filepath.Join(t.TempDir(), "missing") + data, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + t.Setenv("ENV", string(data)) + if _, err := parseConfig(); err == nil || !strings.Contains(err.Error(), "administrator route") { + t.Fatalf("invalid route was not rejected before runtime access: %v", err) + } +} diff --git a/src/internal/common/config/config.go b/src/internal/common/config/config.go index 3e4a0a2..13cab8f 100644 --- a/src/internal/common/config/config.go +++ b/src/internal/common/config/config.go @@ -63,6 +63,8 @@ type Config struct { // ServiceURL defines auth endpoint paths. ServiceURL struct { + // Admin is the administrator interface base path; empty uses admin. + Admin string `env:"ADMIN"` // Login is the login route path. Login string `env:"LOGIN"` // Logout is the logout route path. diff --git a/src/internal/common/config/default.env b/src/internal/common/config/default.env index 020f5e6..c44b009 100644 --- a/src/internal/common/config/default.env +++ b/src/internal/common/config/default.env @@ -30,6 +30,7 @@ USER_SERVICE_LIMITS_ADMIN_DISK='512M' AUTH_SERVICE_CONTAINER_NAME='linuxus_auth' AUTH_SERVICE_CONTAINER_EXTERNAL_PORT=8080 AUTH_SERVICE_RUNTIME_TIMEZONE='Asia/Seoul' +AUTH_SERVICE_SERVICE_URL_ADMIN='admin' AUTH_SERVICE_SERVICE_URL_LOGIN='login' AUTH_SERVICE_SERVICE_URL_LOGOUT='logout' AUTH_SERVICE_SERVICE_URL_SERVICE='service' diff --git a/src/internal/common/config/routes.go b/src/internal/common/config/routes.go index b707afa..db4af6c 100644 --- a/src/internal/common/config/routes.go +++ b/src/internal/common/config/routes.go @@ -6,10 +6,19 @@ import ( "strings" ) +// AdminRoute preserves the default route for deployments created before this setting. +func (cfg *Config) AdminRoute() string { + if cfg.AuthService.ServiceURL.Admin == "" { + return "admin" + } + return cfg.AuthService.ServiceURL.Admin +} + // ValidateAuthRoutes rejects patterns that ServeMux would reinterpret, conflict // with, or redirect away from. Nested paths such as auth/login remain supported. func ValidateAuthRoutes(cfg *Config) error { routes := []struct{ name, value string }{ + {"admin", cfg.AdminRoute()}, {"login", cfg.AuthService.ServiceURL.Login}, {"logout", cfg.AuthService.ServiceURL.Logout}, {"service", cfg.AuthService.ServiceURL.Service}, @@ -29,12 +38,18 @@ func ValidateAuthRoutes(cfg *Config) error { if !valid { return fmt.Errorf("AUTH_SERVICE_SERVICE_URL_%s must be a clean URL path without leading/trailing slashes, query, escapes, or wildcard patterns", strings.ToUpper(route.name)) } - if value == "admin" || strings.HasPrefix(value, "admin/") || value == "healthz" || value == "static" || strings.HasPrefix(value, "static/") || value == "favicon.ico" { + if value == "healthz" || value == "static" || strings.HasPrefix(value, "static/") || value == "favicon.ico" { return fmt.Errorf("AUTH_SERVICE_SERVICE_URL_%s conflicts with a reserved asset route", strings.ToUpper(route.name)) } if previous, exists := seen[value]; exists { return fmt.Errorf("AUTH_SERVICE_SERVICE_URL_%s duplicates %s", strings.ToUpper(route.name), strings.ToUpper(previous)) } + for previousPath, previousName := range seen { + if (route.name == "admin" || previousName == "admin") && + (strings.HasPrefix(value, previousPath+"/") || strings.HasPrefix(previousPath, value+"/")) { + return fmt.Errorf("AUTH_SERVICE_SERVICE_URL_%s overlaps the administrator route", strings.ToUpper(route.name)) + } + } seen[value] = route.name } return nil diff --git a/src/internal/common/config/routes_test.go b/src/internal/common/config/routes_test.go index 3980337..59b5f75 100644 --- a/src/internal/common/config/routes_test.go +++ b/src/internal/common/config/routes_test.go @@ -26,3 +26,33 @@ func TestAuthRouteValidation(t *testing.T) { }) } } + +func TestAdminRouteDefaultsAndConflicts(t *testing.T) { + for _, tc := range []struct { + admin, login string + valid bool + }{ + {"", "login", true}, {"admin", "login", true}, {"ops/classroom", "auth/login", true}, + {"ops", "admin", true}, {"/ops", "login", false}, {"ops/", "login", false}, + {"ops//admin", "login", false}, {"ops?mode=admin", "login", false}, + {"ops/{user}", "login", false}, {"static", "login", false}, + {"static/admin", "login", false}, {"healthz", "login", false}, {"favicon.ico", "login", false}, + {"ops", "ops", false}, {"ops", "ops/login", false}, {"ops/admin", "ops", false}, + {"service/manage", "login", false}, {"admin", "admin/api/users", false}, + } { + t.Run(tc.admin+"/"+tc.login, func(t *testing.T) { + cfg, err := ParseEnv([]byte(DefaultEnv)) + if err != nil { + t.Fatal(err) + } + cfg.AuthService.ServiceURL.Admin = tc.admin + cfg.AuthService.ServiceURL.Login = tc.login + if err := ValidateAuthRoutes(&cfg); (err == nil) != tc.valid { + t.Fatalf("admin=%q login=%q error=%v", tc.admin, tc.login, err) + } + if tc.admin == "" && cfg.AdminRoute() != "admin" { + t.Fatal("legacy configuration lost its default administrator route") + } + }) + } +} diff --git a/src/internal/ctl/app/diagnostics.go b/src/internal/ctl/app/diagnostics.go index 3ba193d..8cdf549 100644 --- a/src/internal/ctl/app/diagnostics.go +++ b/src/internal/ctl/app/diagnostics.go @@ -33,10 +33,10 @@ func CheckConfig(configFile string, output io.Writer) error { if err != nil { return err } - _, err = fmt.Fprintf(output, "Configuration valid: %s\nAuth list: %s\nVolumes: %s\nHomes: %s\nShare: %s\nReadonly: %s\nAuto-ensure: %t\n", + _, err = fmt.Fprintf(output, "Configuration valid: %s\nAuth list: %s\nVolumes: %s\nHomes: %s\nShare: %s\nReadonly: %s\nAuto-ensure: %t\nAdmin URL: /%s\n", a.configFile, a.Config.AuthService.Mounts.HostAuthListPath, a.Config.Volumes.Host.Volumes, a.Config.Volumes.Host.Homes, - a.Config.Volumes.Host.Share, a.Config.Volumes.Host.Readonly, a.Config.Volumes.AutoEnsure) + a.Config.Volumes.Host.Share, a.Config.Volumes.Host.Readonly, a.Config.Volumes.AutoEnsure, a.Config.AdminRoute()) return err } diff --git a/src/static/assets.go b/src/static/assets.go index a9ad3d5..054d54a 100644 --- a/src/static/assets.go +++ b/src/static/assets.go @@ -5,5 +5,5 @@ import "embed" // Files contains the authentication server's static assets. // -//go:embed favicon.png +//go:embed favicon.png ui.css workspace.js var Files embed.FS diff --git a/src/static/ui.css b/src/static/ui.css new file mode 100644 index 0000000..7299ed0 --- /dev/null +++ b/src/static/ui.css @@ -0,0 +1,113 @@ +:root { + color-scheme: dark; + --canvas: #0e161e; + --surface: #15222d; + --surface-raised: #1b2b38; + --surface-inset: #101c26; + --surface-hover: #203341; + --ink: #e5eef4; + --muted: #9eb1c0; + --line: #344b5c; + --control-line: #637e8f; + --accent: #70d5d0; + --accent-hover: #91e6df; + --accent-surface: #183d42; + --on-accent: #082b2b; + --focus: #86d5ff; + --success-surface: #16382f; + --success-line: #326a54; + --success-ink: #a0e7c4; + --warning-surface: #3a301b; + --warning-line: #80662f; + --warning-ink: #f0cd85; + --danger-surface: #3b232b; + --danger-line: #8a4b55; + --danger-ink: #ffb6ba; +} +* { box-sizing: border-box; } +[hidden] { display: none !important; } +body { margin: 0; background: var(--canvas); color: var(--ink); font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +button, input, select { font: inherit; } +button, select, input[type="checkbox"] { cursor: pointer; } +button:disabled, input:disabled, select:disabled { cursor: not-allowed; opacity: .55; } +button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, .table-scroll:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; } +.brand { display: flex; align-items: center; gap: 12px; font-size: 19px; font-weight: 750; letter-spacing: -.5px; } +.brand-logo { display: block; width: 40px; height: 40px; flex: none; object-fit: contain; } +.current-user { color: var(--muted); } +.eyebrow { margin: 0 0 8px; color: var(--accent); font-size: 11px; letter-spacing: 1.8px; font-weight: 750; } +h1 { font-size: clamp(25px, 3vw, 34px); line-height: 1.2; letter-spacing: -1.1px; margin: 0 0 10px; font-weight: 650; } +h2 { font-size: 19px; letter-spacing: -.4px; line-height: 1.3; margin: 0; } +.subtitle, .panel-heading p { color: var(--muted); margin: 0; } +input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; } +.button { display: inline-flex; justify-content: center; align-items: center; border: 1px solid var(--control-line); border-radius: 7px; padding: 8px 14px; font-size: 13px; font-weight: 600; background: var(--surface-raised); color: var(--ink); line-height: 1.5; white-space: nowrap; } +.button:hover:not(:disabled) { background: var(--surface-hover); text-decoration: none; } +.button.primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); } +.button.primary:hover:not(:disabled) { background: var(--accent-hover); } +input[type="search"], .field input, select { border: 1px solid var(--control-line); border-radius: 7px; padding: 9px 12px; background: var(--surface-inset); color: var(--ink); min-height: 40px; } +input::placeholder { color: var(--muted); opacity: 1; } +input:autofill { box-shadow: 0 0 0 1000px var(--surface-inset) inset; -webkit-text-fill-color: var(--ink); caret-color: var(--ink); } +::selection { background: var(--accent); color: var(--on-accent); } +.muted { color: var(--muted); font-size: 12px; } +.notice { border: 1px solid; border-radius: 8px; padding: 13px 16px; font-size: 13px; margin: 0 0 16px; overflow-wrap: anywhere; } +.notice p { margin: 3px 0 0; } +.notice.success { background: var(--success-surface); border-color: var(--success-line); color: var(--success-ink); } +.notice.warning { background: var(--warning-surface); border-color: var(--warning-line); color: var(--warning-ink); } +.notice.danger { background: var(--danger-surface); border-color: var(--danger-line); color: var(--danger-ink); } +#session-notice { display: flex; justify-content: space-between; align-items: center; gap: 16px; } +.field { display: flex; flex-direction: column; gap: 7px; font-size: 12px; font-weight: 600; margin: 16px 0; } +.field input, .field select { width: 100%; font-size: 14px; font-weight: 400; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } } +.site-header { min-height: 76px; padding: 14px max(24px, calc((100vw - 1320px) / 2)); background: var(--surface); border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 24px; flex: none; } +.brand { color: var(--ink); flex: none; } +.brand:hover { text-decoration: none; } +.brand-section { color: var(--muted); font-size: 13px; } +.view-navigation, .account-navigation { display: flex; align-items: center; gap: 10px; font-size: 13px; min-width: 0; } +.view-navigation a { border-radius: 7px; padding: 9px 15px; color: var(--muted); font-weight: 600; } +.view-navigation a:hover { background: var(--surface-hover); text-decoration: none; } +.view-navigation a[aria-current="page"] { background: var(--accent-surface); color: var(--accent); } +.view-navigation a[aria-disabled="true"] { opacity: .55; cursor: not-allowed; } +.account-navigation { gap: 18px; } +.current-user { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; } +.site-footer { text-align: center; color: var(--muted); font-size: 11px; padding: 14px 24px; flex: none; } +.auth-page { display: flex; flex-direction: column; min-height: 100dvh; } +.auth-main { flex: 1; display: grid; place-items: center; padding: 48px 24px; } +.auth-card { background: var(--surface); border: 1px solid var(--line); border-radius: 16px; width: min(440px, 100%); padding: 36px; } +.auth-logo { display: block; width: 64px; height: 64px; margin: 0 0 24px; object-fit: contain; } +.auth-card h1 { font-size: 30px; } +.auth-card .subtitle { margin-bottom: 24px; } +.auth-card .field { margin: 20px 0; } +.full-width { width: 100%; } +.auth-switch { margin: 24px 0 0; font-size: 13px; color: var(--muted); text-align: center; } +.app-page { min-height: 100dvh; display: flex; flex-direction: column; } +.app-main { flex: 1; width: 100%; min-height: 0; } +.admin-panel { max-width: 1368px; padding: 38px 24px 0; margin: auto; } +.workspace-panel { max-width: 1368px; padding: 26px 24px 0; margin: auto; display: flex; flex-direction: column; min-height: 0; width: 100%; height: 100%; } +.workspace-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 18px; flex: none; } +.workspace-heading h1 { font-size: 26px; margin-bottom: 6px; } +.workspace-heading .eyebrow { margin-bottom: 5px; } +.terminal-frame { flex: 1; min-height: 300px; border: 1px solid var(--line); background: var(--surface-inset); border-radius: 12px; overflow: hidden; } +.terminal-frame iframe { display: block; border: 0; height: 100%; width: 100%; } +.app-page[data-view="workspace"] { height: 100dvh; } +.app-page[data-view="workspace"] .app-main { display: flex; } +.app-session-notice { margin: 16px 24px 0; flex: none; } +@media (max-width: 600px) { + .site-header { padding: 14px 16px; flex-wrap: wrap; gap: 12px; } + .brand { font-size: 18px; } + .view-navigation { order: 3; width: 100%; border-top: 1px solid var(--line); padding-top: 10px; } + .view-navigation a { flex: 1; text-align: center; padding: 8px 10px; } + .account-navigation { margin-left: auto; gap: 12px; } + .current-user { max-width: 100px; font-size: 12px; } + .auth-header .brand-section { font-size: 11px; } + .auth-main { padding: 28px 16px; } + .auth-card { padding: 26px; } + .auth-card h1 { font-size: 27px; } + .admin-panel { padding: 26px 16px 0; } + .workspace-panel { padding: 20px 16px 0; } + .workspace-heading { align-items: flex-start; flex-wrap: wrap; gap: 12px; } + .workspace-heading h1 { font-size: 24px; } + .app-session-notice { margin: 16px 16px 0; flex-direction: column; align-items: flex-start; } + .site-footer { padding: 12px 16px; } +} diff --git a/src/static/workspace.js b/src/static/workspace.js new file mode 100644 index 0000000..3b2ed7e --- /dev/null +++ b/src/static/workspace.js @@ -0,0 +1,68 @@ +(() => { + 'use strict'; + + const config = document.body.dataset; + const frame = document.getElementById('terminal-frame'); + const reconnect = document.getElementById('reconnect-terminal'); + const links = Array.from(document.querySelectorAll('[data-view-link], [data-open-view]')); + const panels = { workspace: document.getElementById('workspace-panel'), admin: document.getElementById('admin-panel') }; + let expired = false; + + function startTerminal() { + if (!expired && !frame.hasAttribute('src')) frame.src = config.terminalUrl; + } + + function routeView() { + const pathname = location.pathname; + if (panels.admin && (pathname === config.adminUrl || pathname === config.adminUrl + '/')) return 'admin'; + if (pathname === config.serviceUrl) return 'workspace'; + return null; + } + + function show(view, focus = false) { + if (!panels[view]) return; + config.view = view; + for (const [name, panel] of Object.entries(panels)) if (panel) panel.hidden = name !== view; + for (const link of links) { + if (!link.dataset.viewLink) continue; + if (link.dataset.viewLink === view) link.setAttribute('aria-current', 'page'); + else link.removeAttribute('aria-current'); + } + document.title = (view === 'admin' ? 'Administration' : 'Workspace') + ' | Linuxus'; + if (view === 'workspace') startTerminal(); + document.dispatchEvent(new CustomEvent('linuxus:viewchange', { detail: { view } })); + if (focus && !document.getElementById('account-dialog')?.open) { + document.getElementById(view === 'admin' ? 'page-title' : 'workspace-title').focus({ preventScroll: true }); + } + } + + for (const link of links) link.addEventListener('click', event => { + if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; + if (expired) { event.preventDefault(); return; } + const view = link.dataset.viewLink || link.dataset.openView; + // The server-rendered links remain usable without JavaScript. + if (!panels[view]) return; + event.preventDefault(); + if (view === config.view) return; + history.pushState(null, '', link.href); + show(view, true); + }); + + reconnect.hidden = false; + reconnect.addEventListener('click', () => { if (!expired) frame.src = config.terminalUrl; }); + window.addEventListener('popstate', () => { + const view = routeView(); + if (view) show(view, true); + }); + window.addEventListener('pageshow', event => { + if (event.persisted) show(routeView() || config.view); + }); + document.addEventListener('linuxus:sessionexpired', () => { + expired = true; + reconnect.disabled = true; + for (const link of links) link.setAttribute('aria-disabled', 'true'); + // Removing the source closes a terminal belonging to a revoked session. + frame.removeAttribute('src'); + }); + show(routeView() || config.view); +})();