Compare commits

..

10 commits

Author SHA1 Message Date
Zac Gaetano
8ede44ae87 docs(auth): flip AUTH_ENABLED default + document setup + recovery 2026-05-27 15:25:29 -04:00
Zac Gaetano
2aec4636cb feat(web-ui): Settings → Account (change password) + API Tokens sections 2026-05-27 15:20:57 -04:00
Zac Gaetano
cfe21e315e feat(web-ui): LoginScreen + SetupScreen (layout B from brainstorm) 2026-05-27 15:17:33 -04:00
Zac Gaetano
7e240d86c8 feat(web-ui): AuthGate orchestration + replace 401 bounce with in-SPA re-render 2026-05-27 15:08:14 -04:00
Zac Gaetano
96effaaa3c fix(mam-api): TRUST_PROXY boot warning + CSRF integration tests + bounded rate-limit map
Fixes three issues in the authentication system:

C1: Add boot-time warning when AUTH_ENABLED=true but TRUST_PROXY!=true.
    Without TRUST_PROXY=true behind nginx, req.ip becomes the proxy IP for all
    clients, collapsing per-IP rate limiting into a shared pool. Operators must
    explicitly set TRUST_PROXY=true to make per-IP rate limiting effective.

C2: Mount requireUiHeader middleware in test helpers (auth.test.js,
    users.test.js, tokens.test.js). The CSRF header validation was not being
    exercised in the test suite. Tests now send X-Requested-With: dragonflight-ui
    headers that are actually validated by the middleware.

I1: Implement bounded rate-limit Map with MAX_ENTRIES=10000 and LRU eviction.
    Unbounded Maps are vulnerable to spray attacks: attackers can force memory
    exhaustion by requesting with distinct IPs. Now we evict the oldest entry
    (by insertion order) when the map reaches capacity.
2026-05-27 15:03:35 -04:00
Zac Gaetano
d209a192c3 feat(mam-api): login rate limit + X-Requested-With CSRF header check 2026-05-27 14:58:02 -04:00
Zac Gaetano
56b661ef65 feat(mam-api): API token CRUD — show raw once, bearer-authenticate via SHA-256 lookup 2026-05-27 14:52:07 -04:00
Zac Gaetano
b7f5a84d2d feat(mam-api): user CRUD + admin password reset + last-user delete guard 2026-05-27 14:47:03 -04:00
Zac Gaetano
0bbaf80d2a feat(mam-api): GET /auth/me + POST /auth/password 2026-05-27 14:42:53 -04:00
Zac Gaetano
d75a0241eb feat(mam-api): POST /auth/logout 2026-05-27 14:38:05 -04:00
21 changed files with 3998 additions and 26 deletions

View file

@ -22,5 +22,17 @@ SESSION_SECRET=changeme
# MAM API Configuration
MAM_API_URL=http://mam-api:3000
# Auth (set to 'true' to require login; false for open/dev mode)
AUTH_ENABLED=false
# Auth — default to ON in production. Setting to 'false' is a dev-only escape
# hatch that disables all auth checks and attaches a synthetic 'dev' user to
# every request. Never run with AUTH_ENABLED=false on a network you don't control.
AUTH_ENABLED=true
# CORS allowlist — comma-separated origins that may carry credentials to the API.
# Same-origin requests via the nginx reverse proxy do not need to be listed here.
# Leave empty to allow any origin (DEV ONLY).
ALLOWED_ORIGINS=
# Reverse-proxy trust — set 'true' when the API sits behind nginx terminating HTTPS,
# so secure-cookie + X-Forwarded-Proto behave correctly. ALSO required for accurate
# per-IP login rate-limiting (otherwise req.ip is always the nginx IP).
TRUST_PROXY=false

View file

@ -205,6 +205,54 @@ Total time from end of capture to relinked master: ~2 minutes.
- `deploy/test-cluster.sh` — primary↔worker connectivity smoke test
- `docs/GROWING_FILES_QUICKSTART.md` — Premiere CEP panel install + growing-file flow
## Authentication
Dragonflight uses local username/password authentication with two transports:
- **Browser:** session cookie (`dragonflight.sid`), 8 hour absolute + 1 hour idle timeout.
- **Premiere panel / scripts:** SHA-256-hashed bearer tokens issued from `Settings → API Tokens`.
### First-run setup
On a fresh install with `AUTH_ENABLED=true`, navigate to the web UI in a browser.
With no users in the database, the login screen renders a "First-run setup" form
instead — fill it in to create the first admin and you are logged in immediately.
Subsequent users are created from `Settings → Users` (any signed-in user can
create others — flat access).
### Dev mode
Setting `AUTH_ENABLED=false` disables all auth checks; a synthetic `dev` user
is attached to every request. **Never deploy this way.** The dev user row is
seeded with a hash that no real password can match, so flipping
`AUTH_ENABLED=true` later does not expose the dev account.
### Recovering a forgotten admin password
Any signed-in user can reset another user's password from `Settings → Users`.
If no one can sign in (all admins forgot their passwords), reset directly in
Postgres:
```sql
-- generate a fresh bcrypt hash with:
-- node -e "import('bcrypt').then(b => b.default.hash(process.argv[1], 12).then(h => console.log(h)))" 'new-passphrase-here'
UPDATE users SET password_hash = '<bcrypt-hash>', password_updated_at = NOW()
WHERE username = 'admin';
```
### `AUTH_ENABLED` transition
When flipping `AUTH_ENABLED=false``true` on an existing install:
1. Ensure `SESSION_SECRET` is set to a stable value (rotating it logs everyone out).
2. Set `ALLOWED_ORIGINS` to the public origin(s) of the web UI.
3. Set `TRUST_PROXY=true` when behind nginx (required for rate-limit accuracy).
4. Restart `mam-api`.
5. Visit the UI — first-run setup will appear if no real users exist yet.
---
## License
Proprietary — Wild Dragon LLC, all rights reserved.

2992
services/mam-api/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,24 @@
// Per-IP exponential backoff for /auth/login. Single-instance — fine for
// Dragonflight's deployment shape (one mam-api per node). Documented limitation.
const failures = new Map(); // ip -> count
const STEPS = [1000, 2000, 4000, 8000, 16000, 30000];
const MAX_ENTRIES = 10000; // bounded to prevent unbounded growth under spray attacks
export const ipBackoff = {
delayMs(ip) {
const n = failures.get(ip) || 0;
if (n === 0) return 0;
return STEPS[Math.min(n - 1, STEPS.length - 1)];
},
recordFailure(ip) {
// Evict the oldest entry if we're at the cap. Map preserves insertion order,
// so .keys().next().value is the oldest.
if (failures.size >= MAX_ENTRIES && !failures.has(ip)) {
failures.delete(failures.keys().next().value);
}
failures.set(ip, (failures.get(ip) || 0) + 1);
},
recordSuccess(ip) { failures.delete(ip); },
reset(ip) { failures.delete(ip); },
};

View file

@ -8,10 +8,12 @@ import os from 'node:os';
import { exec } from 'node:child_process';
import pool from './db/pool.js';
import { errorHandler } from './middleware/errors.js';
import { requireAuth } from './middleware/auth.js';
import { requireAuth, requireUiHeader } from './middleware/auth.js';
import { loadS3ConfigFromDb } from './s3/client.js';
import authRouter from './routes/auth.js';
import tokensRouter from './routes/tokens.js';
import usersRouter from './routes/users.js';
// Routes
import assetsRouter from './routes/assets.js';
import projectsRouter from './routes/projects.js';
@ -98,6 +100,8 @@ const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-require
// to require auth. If node-agent grows another endpoint, add it here.
// TODO: long-term, issue node-agent a real bound api_token and drop this carve-out.
const SERVICE_PATHS = new Set(['/cluster/heartbeat']);
app.use('/api/v1', requireUiHeader);
// then the existing gate:
app.use('/api/v1', (req, res, next) => {
if (UNAUTH_PATHS.has(req.path)) return next();
if (SERVICE_PATHS.has(req.path)) return next();
@ -106,6 +110,8 @@ app.use('/api/v1', (req, res, next) => {
// ── API Routes ────────────────────────────────────────────────────────────────
app.use('/api/v1/auth', authRouter);
app.use('/api/v1/auth/users', usersRouter);
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
app.use('/api/v1/assets', assetsRouter);
app.use('/api/v1/projects', projectsRouter);
app.use('/api/v1/bins', binsRouter);
@ -267,9 +273,12 @@ setInterval(selfHeartbeat, 30_000);
selfHeartbeat();
const server = app.listen(PORT, () => {
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED — dev mode (dev user attached to every request)';
console.log(`MAM API listening on port ${PORT}`);
console.log(`Authentication: ${authMode}`);
if (process.env.AUTH_ENABLED === 'true' && process.env.TRUST_PROXY !== 'true') {
console.warn('[auth] WARNING: AUTH_ENABLED=true but TRUST_PROXY=false — req.ip will be the proxy IP, login rate-limit will throttle all clients together. Set TRUST_PROXY=true when behind nginx/HTTPS.');
}
// Boot the recorder scheduler tick loop after the HTTP server is live so
// the loop's self-calls to /recorders/:id/start|stop reach a ready socket.
startSchedulerLoop();

View file

@ -62,3 +62,18 @@ export async function requireAuth(req, res, next) {
// 3. Nothing matched
return res.status(401).json({ error: 'unauthorized' });
}
// Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site
// cookie sends, but a custom header that no <form> can produce hardens
// against the edge cases. Applied to mutating verbs only.
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const REQUIRED_HEADER = 'dragonflight-ui';
export function requireUiHeader(req, res, next) {
if (!MUTATING.has(req.method)) return next();
// Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
// browsers and can't be drive-by'd from another origin.
if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next();
return res.status(403).json({ error: 'missing X-Requested-With header' });
}

View file

@ -1,7 +1,8 @@
import express from 'express';
import pool from '../db/pool.js';
import { DEV_USER_ID } from '../middleware/auth.js';
import { DEV_USER_ID, requireAuth } from '../middleware/auth.js';
import { hashPassword, comparePassword } from '../auth/passwords.js';
import { ipBackoff } from '../auth/rate-limit.js';
const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K';
@ -64,8 +65,15 @@ router.post('/setup', async (req, res, next) => {
// POST /api/v1/auth/login — authenticate an existing user by username + password.
router.post('/login', async (req, res, next) => {
try {
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
const delay = ipBackoff.delayMs(ip);
if (delay > 0) await new Promise(r => setTimeout(r, delay));
const { username, password } = req.body || {};
if (!username || !password) return res.status(401).json({ error: 'invalid credentials' });
if (!username || !password) {
ipBackoff.recordFailure(ip);
return res.status(401).json({ error: 'invalid credentials' });
}
const { rows } = await pool.query(
`SELECT id, username, display_name, password_hash FROM users WHERE username = $1 AND id <> $2`,
@ -76,10 +84,12 @@ router.post('/login', async (req, res, next) => {
// Used to keep the user-not-found response time uniform with the wrong-password
// path (~180ms at cost 12) so user enumeration via timing isn't possible.
await comparePassword(password, DUMMY_PASSWORD_HASH);
ipBackoff.recordFailure(ip);
return res.status(401).json({ error: 'invalid credentials' });
}
const user = rows[0];
if (!(await comparePassword(password, user.password_hash))) {
ipBackoff.recordFailure(ip);
return res.status(401).json({ error: 'invalid credentials' });
}
@ -94,7 +104,43 @@ router.post('/login', async (req, res, next) => {
pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
ipBackoff.recordSuccess(ip);
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); } catch (err) { next(err); }
});
// POST /api/v1/auth/logout — destroys the session and clears the cookie.
router.post('/logout', (req, res) => {
if (!req.session) return res.status(204).end();
req.session.destroy(err => {
if (err) console.error('[auth] session destroy failed:', err.message);
res.clearCookie('dragonflight.sid', { path: '/' });
res.status(204).end();
});
});
// GET /api/v1/auth/me
router.get('/me', requireAuth, (req, res) => {
res.json({ id: req.user.id, username: req.user.username, display_name: req.user.display_name });
});
// POST /api/v1/auth/password { current_password, new_password }
router.post('/password', requireAuth, async (req, res, next) => {
try {
const { current_password, new_password } = req.body || {};
if (!current_password || !new_password) return badRequest(res, 'current_password and new_password required');
if (new_password.length < MIN_PASSWORD_LEN) return badRequest(res, 'new password must be at least ' + MIN_PASSWORD_LEN + ' characters');
const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]);
if (!rows.length) return res.status(401).json({ error: 'unauthorized' });
if (!(await comparePassword(current_password, rows[0].password_hash))) {
return badRequest(res, 'current password is incorrect');
}
const newHash = await hashPassword(new_password);
await pool.query(
`UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2`,
[newHash, req.user.id]
);
res.status(204).end();
} catch (err) { next(err); }
});

View file

@ -0,0 +1,46 @@
// Current-user API token CRUD. The raw token is returned exactly once at
// creation time; only the SHA-256 hash and an 8-char display prefix are stored.
import express from 'express';
import pool from '../db/pool.js';
import { generateToken, hashToken, tokenDisplayPrefix } from '../auth/tokens.js';
const router = express.Router();
// GET / — list current user's tokens (prefix only, never the raw token)
router.get('/', async (req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT id, name, token_prefix AS prefix, last_used_at, expires_at, created_at
FROM api_tokens WHERE user_id = $1 ORDER BY created_at DESC`,
[req.user.id]);
res.json(rows);
} catch (err) { next(err); }
});
// POST / — create a new token
router.post('/', async (req, res, next) => {
try {
const { name, expires_at } = req.body || {};
if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' });
const raw = generateToken();
const { rows } = await pool.query(
`INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, token_prefix AS prefix, expires_at, created_at`,
[req.user.id, name.trim(), hashToken(raw), tokenDisplayPrefix(raw), expires_at || null]);
res.status(201).json({ ...rows[0], token: raw }); // raw token shown exactly once
} catch (err) { next(err); }
});
// DELETE /:id — revoke; only the owner can revoke
router.delete('/:id', async (req, res, next) => {
try {
const { rowCount } = await pool.query(
`DELETE FROM api_tokens WHERE id = $1 AND user_id = $2`,
[req.params.id, req.user.id]);
if (rowCount === 0) return res.status(404).json({ error: 'token not found' });
res.status(204).end();
} catch (err) { next(err); }
});
export default router;

View file

@ -0,0 +1,72 @@
// User CRUD. Mounted at /api/v1/auth/users by index.js (behind the auth gate).
// Flat access: any logged-in user can manage other users (spec).
import express from 'express';
import pool from '../db/pool.js';
import { hashPassword } from '../auth/passwords.js';
import { DEV_USER_ID } from '../middleware/auth.js';
const router = express.Router();
const MIN_PASSWORD_LEN = 12;
function bad(res, msg) { return res.status(400).json({ error: msg }); }
// GET / — list users (real ones; dev seed hidden)
router.get('/', async (_req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT id, username, display_name, role, last_login_at, created_at
FROM users WHERE id <> $1 ORDER BY username`, [DEV_USER_ID]);
res.json(rows);
} catch (err) { next(err); }
});
// POST / — create user
router.post('/', async (req, res, next) => {
try {
const { username, password, display_name, role } = req.body || {};
if (!username || typeof username !== 'string') return bad(res, 'username required');
if (!password || password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
const hash = await hashPassword(password);
const { rows } = await pool.query(
`INSERT INTO users (username, password_hash, display_name, role)
VALUES ($1, $2, $3, $4)
RETURNING id, username, display_name, role, created_at`,
[username.trim(), hash, display_name || username.trim(), role || 'admin']
);
res.status(201).json(rows[0]);
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'username already exists' });
next(err);
}
});
// POST /:id/password — admin reset another user's password
router.post('/:id/password', async (req, res, next) => {
try {
const { new_password } = req.body || {};
if (!new_password || new_password.length < MIN_PASSWORD_LEN) {
return bad(res, 'new password must be at least ' + MIN_PASSWORD_LEN + ' chars');
}
const hash = await hashPassword(new_password);
const { rowCount } = await pool.query(
`UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2 AND id <> $3`,
[hash, req.params.id, DEV_USER_ID]);
if (rowCount === 0) return res.status(404).json({ error: 'user not found' });
res.status(204).end();
} catch (err) { next(err); }
});
// DELETE /:id — delete a user, except the last real user
router.delete('/:id', async (req, res, next) => {
try {
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot delete dev user' });
const { rows } = await pool.query(
`SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]);
if (rows[0].n <= 1) return res.status(409).json({ error: 'cannot delete last user' });
const { rowCount } = await pool.query(`DELETE FROM users WHERE id = $1`, [req.params.id]);
if (rowCount === 0) return res.status(404).json({ error: 'user not found' });
res.status(204).end();
} catch (err) { next(err); }
});
export default router;

View file

@ -0,0 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ipBackoff } from '../../src/auth/rate-limit.js';
test('first failure → small delay; repeated failures → exponential up to 30s', () => {
ipBackoff.reset('1.2.3.4');
assert.equal(ipBackoff.delayMs('1.2.3.4'), 0);
ipBackoff.recordFailure('1.2.3.4');
assert.equal(ipBackoff.delayMs('1.2.3.4'), 1000);
ipBackoff.recordFailure('1.2.3.4');
assert.equal(ipBackoff.delayMs('1.2.3.4'), 2000);
ipBackoff.recordFailure('1.2.3.4');
assert.equal(ipBackoff.delayMs('1.2.3.4'), 4000);
for (let i = 0; i < 10; i++) ipBackoff.recordFailure('1.2.3.4');
assert.equal(ipBackoff.delayMs('1.2.3.4'), 30000);
});
test('successful login resets the counter', () => {
ipBackoff.reset('5.6.7.8');
ipBackoff.recordFailure('5.6.7.8');
ipBackoff.recordFailure('5.6.7.8');
ipBackoff.recordSuccess('5.6.7.8');
assert.equal(ipBackoff.delayMs('5.6.7.8'), 0);
});

View file

@ -147,3 +147,29 @@ test('bearer token whose user was deleted → 401', { skip: !isTestDbConfigured(
assert.equal(res.statusCode, 401);
} finally { await pool.end(); }
});
import { requireUiHeader } from '../../src/middleware/auth.js';
test('requireUiHeader: GET → next (any header)', () => {
let called = false;
requireUiHeader({ method: 'GET', headers: {} }, { status: () => ({ json: () => {} }) }, () => { called = true; });
assert.equal(called, true);
});
test('requireUiHeader: POST without header → 403', () => {
const res = { status(n) { this.statusCode = n; return this; }, json(o) { this.body = o; } };
requireUiHeader({ method: 'POST', headers: {} }, res, () => {});
assert.equal(res.statusCode, 403);
});
test('requireUiHeader: POST with correct header → next', () => {
let called = false;
requireUiHeader({ method: 'POST', headers: { 'x-requested-with': 'dragonflight-ui' } }, {}, () => { called = true; });
assert.equal(called, true);
});
test('requireUiHeader: POST with bearer auth → next (exempt)', () => {
let called = false;
requireUiHeader({ method: 'POST', headers: { authorization: 'Bearer dfl_xxx' } }, {}, () => { called = true; });
assert.equal(called, true);
});

View file

@ -5,7 +5,7 @@ import express from 'express';
import session from 'express-session';
import authRouter from '../../src/routes/auth.js';
import { hashPassword } from '../../src/auth/passwords.js';
import { requireAuth } from '../../src/middleware/auth.js';
import { requireAuth, requireUiHeader } from '../../src/middleware/auth.js';
async function appWithAuth(pool) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
@ -52,6 +52,7 @@ async function appWithSession(pool) {
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
rolling: false, resave: false, saveUninitialized: false,
}));
app.use('/api/v1', requireUiHeader);
app.use('/api/v1/auth', authRouter);
return new Promise(r => {
const srv = app.listen(0, '127.0.0.1', () => {
@ -66,7 +67,7 @@ test('POST /auth/setup creates the first admin and returns a session cookie', {
try {
const res = await fetch(baseUrl + '/api/v1/auth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }),
});
assert.equal(res.status, 200);
@ -85,7 +86,7 @@ test('POST /auth/setup is 409 once a real user exists', { skip: !isTestDbConfigu
try {
const res = await fetch(baseUrl + '/api/v1/auth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }),
});
assert.equal(res.status, 409);
@ -98,7 +99,7 @@ test('POST /auth/setup rejects passwords shorter than 12 chars', { skip: !isTest
const { baseUrl, close } = await appWithSession(pool);
try {
const res = await fetch(baseUrl + '/api/v1/auth/setup', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ username: 'admin', password: 'short' }),
});
assert.equal(res.status, 400);
@ -119,6 +120,7 @@ async function appWithSessionAndMe(pool) {
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
rolling: false, resave: false, saveUninitialized: false,
}));
app.use('/api/v1', requireUiHeader);
app.use('/api/v1/auth', authRouter);
app.get('/api/v1/protected/me', requireAuth, (req, res) => res.json({ user: req.user }));
return new Promise(r => {
@ -137,7 +139,7 @@ test('POST /auth/login with valid creds → 200 + cookie, and the cookie unlocks
// 1. Login.
const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
});
assert.equal(loginRes.status, 200);
@ -162,11 +164,11 @@ test('POST /auth/login with wrong password → 401 + generic message (no enumera
const { baseUrl, close } = await appWithSessionAndMe(pool);
try {
const r1 = await fetch(baseUrl + '/api/v1/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ username: 'alice', password: 'wrong' }),
});
const r2 = await fetch(baseUrl + '/api/v1/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ username: 'nobody', password: 'whatever-long-enough' }),
});
assert.equal(r1.status, 401);
@ -176,3 +178,69 @@ test('POST /auth/login with wrong password → 401 + generic message (no enumera
assert.equal(e2, 'invalid credentials'); // identical message — no enumeration
} finally { await close(); await pool.end(); }
});
test('POST /auth/logout destroys the session row and the cookie no longer unlocks /me', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
const pool = await setupTestDb();
const hash = await hashPassword('correct-horse-battery');
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [hash]);
const { baseUrl, close } = await appWithSessionAndMe(pool);
try {
const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
});
const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0];
const logoutRes = await fetch(baseUrl + '/api/v1/auth/logout', {
method: 'POST', headers: { cookie },
});
assert.equal(logoutRes.status, 204);
const meRes = await fetch(baseUrl + '/api/v1/protected/me', { headers: { cookie } });
assert.equal(meRes.status, 401);
} finally { await close(); await pool.end(); }
});
test('GET /auth/me returns the authed user', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
const pool = await setupTestDb();
const hash = await hashPassword('correct-horse-battery');
await pool.query(`INSERT INTO users (username, password_hash, display_name) VALUES ('alice', $1, 'Alice')`, [hash]);
const { baseUrl, close } = await appWithSessionAndMe(pool);
try {
const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
});
const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0];
const me = await fetch(baseUrl + '/api/v1/auth/me', { headers: { cookie } });
assert.equal(me.status, 200);
const body = await me.json();
assert.equal(body.username, 'alice');
assert.equal(body.display_name, 'Alice');
} finally { await close(); await pool.end(); }
});
test('POST /auth/password rotates the password when current is correct', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
const pool = await setupTestDb();
const hash = await hashPassword('correct-horse-battery');
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [hash]);
const { baseUrl, close } = await appWithSessionAndMe(pool);
try {
const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
});
const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0];
const change = await fetch(baseUrl + '/api/v1/auth/password', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie },
body: JSON.stringify({ current_password: 'correct-horse-battery', new_password: 'brand-new-passphrase' }),
});
assert.equal(change.status, 204);
// Wrong current → 400
const wrong = await fetch(baseUrl + '/api/v1/auth/password', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie },
body: JSON.stringify({ current_password: 'no', new_password: 'another-good-passphrase' }),
});
assert.equal(wrong.status, 400);
} finally { await close(); await pool.end(); }
});

View file

@ -0,0 +1,103 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import session from 'express-session';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import tokensRouter from '../../src/routes/tokens.js';
import authRouter from '../../src/routes/auth.js';
import { requireAuth, requireUiHeader } from '../../src/middleware/auth.js';
import { hashPassword } from '../../src/auth/passwords.js';
async function app(pool) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
const ConnectPg = (await import('connect-pg-simple')).default(session);
const a = express();
a.use(express.json());
a.use(session({
store: new ConnectPg({ pool, tableName: 'sessions' }),
secret: 'test', name: 'dragonflight.sid',
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
rolling: false, resave: false, saveUninitialized: false,
}));
a.use('/api/v1', requireUiHeader);
a.use('/api/v1/auth', authRouter);
a.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
a.get('/api/v1/protected/ping', requireAuth, (req, res) => res.json({ user: req.user.username }));
return new Promise(r => {
const srv = a.listen(0, '127.0.0.1', () => {
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) });
});
});
}
async function loginCookie(baseUrl, u, p) {
const r = await fetch(baseUrl + '/api/v1/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ username: u, password: p }),
});
return (r.headers.get('set-cookie') || '').split(';')[0];
}
test('tokens: create returns the raw token exactly once; bearer of that token works; revoke 401s subsequent calls', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
const pool = await setupTestDb();
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]);
const { baseUrl, close } = await app(pool);
try {
const cookie = await loginCookie(baseUrl, 'alice', 'correct-horse-battery');
// Create
const create = await fetch(baseUrl + '/api/v1/auth/tokens', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie },
body: JSON.stringify({ name: 'Premiere panel' }),
});
assert.equal(create.status, 201);
const created = await create.json();
assert.match(created.token, /^dfl_[0-9a-f]{64}$/);
assert.equal(created.prefix, created.token.slice(0, 8));
// List — should NOT include the raw token, only the prefix.
const list = await fetch(baseUrl + '/api/v1/auth/tokens', { headers: { cookie } });
const rows = await list.json();
assert.equal(rows.length, 1);
assert.equal(rows[0].prefix, created.prefix);
assert.equal(rows[0].token, undefined);
// The raw token authenticates as a bearer.
const ping = await fetch(baseUrl + '/api/v1/protected/ping', {
headers: { authorization: 'Bearer ' + created.token },
});
assert.equal(ping.status, 200);
// Revoke.
const rev = await fetch(baseUrl + '/api/v1/auth/tokens/' + created.id, {
method: 'DELETE', headers: { cookie },
});
assert.equal(rev.status, 204);
// Same bearer now 401s.
const ping2 = await fetch(baseUrl + '/api/v1/protected/ping', {
headers: { authorization: 'Bearer ' + created.token },
});
assert.equal(ping2.status, 401);
} finally { await close(); await pool.end(); }
});
test('tokens: cannot revoke another users token', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
const pool = await setupTestDb();
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]);
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('bob', $1)`, [await hashPassword('bob-passphrase-12')]);
const { baseUrl, close } = await app(pool);
try {
const aliceCookie = await loginCookie(baseUrl, 'alice', 'correct-horse-battery');
const bobCookie = await loginCookie(baseUrl, 'bob', 'bob-passphrase-12');
const aliceTok = await (await fetch(baseUrl + '/api/v1/auth/tokens', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie: aliceCookie },
body: JSON.stringify({ name: 'alice token' }),
})).json();
const r = await fetch(baseUrl + '/api/v1/auth/tokens/' + aliceTok.id, {
method: 'DELETE', headers: { cookie: bobCookie },
});
assert.equal(r.status, 404); // not found from bob's perspective
} finally { await close(); await pool.end(); }
});

View file

@ -0,0 +1,90 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import session from 'express-session';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import usersRouter from '../../src/routes/users.js';
import authRouter from '../../src/routes/auth.js';
import { requireAuth, requireUiHeader } from '../../src/middleware/auth.js';
import { hashPassword } from '../../src/auth/passwords.js';
async function app(pool) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
process.env.AUTH_ENABLED = 'true';
const ConnectPg = (await import('connect-pg-simple')).default(session);
const a = express();
a.use(express.json());
a.use(session({
store: new ConnectPg({ pool, tableName: 'sessions' }),
secret: 'test', name: 'dragonflight.sid',
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
rolling: false, resave: false, saveUninitialized: false,
}));
a.use('/api/v1', requireUiHeader);
a.use('/api/v1/auth', authRouter);
a.use('/api/v1/auth/users', requireAuth, usersRouter);
return new Promise(r => {
const srv = a.listen(0, '127.0.0.1', () => {
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) });
});
});
}
async function login(baseUrl, username, password) {
const r = await fetch(baseUrl + '/api/v1/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ username, password }),
});
assert.equal(r.status, 200, 'login failed: ' + JSON.stringify(await r.json()));
return (r.headers.get('set-cookie') || '').split(';')[0];
}
test('users: list + create + delete + admin reset password', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
const pool = await setupTestDb();
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('admin', $1)`, [await hashPassword('admin-passphrase!!')]);
const { baseUrl, close } = await app(pool);
try {
const cookie = await login(baseUrl, 'admin', 'admin-passphrase!!');
// List
const list = await fetch(baseUrl + '/api/v1/auth/users', { headers: { cookie } });
assert.equal(list.status, 200);
const users0 = await list.json();
assert.ok(users0.find(u => u.username === 'admin'));
// Create
const created = await fetch(baseUrl + '/api/v1/auth/users', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie },
body: JSON.stringify({ username: 'bob', password: 'bob-passphrase!', display_name: 'Bob' }),
});
assert.equal(created.status, 201);
const bob = await created.json();
assert.equal(bob.username, 'bob');
// Admin reset password
const reset = await fetch(baseUrl + '/api/v1/auth/users/' + bob.id + '/password', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie },
body: JSON.stringify({ new_password: 'a-fresh-passphrase' }),
});
assert.equal(reset.status, 204);
// Delete
const del = await fetch(baseUrl + '/api/v1/auth/users/' + bob.id, {
method: 'DELETE', headers: { cookie },
});
assert.equal(del.status, 204);
} finally { await close(); await pool.end(); }
});
test('users: cannot delete the last real user', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
const pool = await setupTestDb();
await pool.query(`INSERT INTO users (id, username, password_hash) VALUES (uuid_generate_v4(), 'solo', $1)`, [await hashPassword('only-user-here-12')]);
const { baseUrl, close } = await app(pool);
try {
const cookie = await login(baseUrl, 'solo', 'only-user-here-12');
const me = await (await fetch(baseUrl + '/api/v1/auth/me', { headers: { cookie } })).json();
const r = await fetch(baseUrl + '/api/v1/auth/users/' + me.id, { method: 'DELETE', headers: { cookie } });
assert.equal(r.status, 409);
assert.equal((await r.json()).error, 'cannot delete last user');
} finally { await close(); await pool.end(); }
});

View file

@ -154,4 +154,5 @@ function lighten(hex, amt) {
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
const AuthGate = window.AuthGateComponent;
root.render(<AuthGate><App /></AuthGate>);

View file

@ -0,0 +1,77 @@
// auth-gate.jsx owns the "logged in or not" state.
//
// The SPA boots into <AuthGate>, which calls GET /auth/me. On 401 it then
// calls GET /auth/setup-required and renders <SetupScreen> or <LoginScreen>
// (defined in screens-auth.jsx, Task 16). On 200 it renders the real <App>.
//
// This component is the SINGLE source of truth for the auth check no other
// component should redirect to a login page or wipe data on 401. Other code
// surfaces auth failure by calling window.AuthGate.bounce(), which re-mounts
// the gate so the next /auth/me request decides what to do.
(function () {
const API = '/api/v1';
const LAST_PATH_KEY = 'df.auth.last_path';
async function authFetch(path, opts) {
return fetch(API + path, {
credentials: 'include',
...opts,
headers: {
...(opts && opts.headers),
'Content-Type': 'application/json',
...((opts && opts.method && opts.method !== 'GET') ? { 'X-Requested-With': 'dragonflight-ui' } : {}),
},
});
}
function AuthGate({ children }) {
const [state, setState] = React.useState({ kind: 'loading' });
const check = React.useCallback(async () => {
setState({ kind: 'loading' });
try {
const r = await authFetch('/auth/me');
if (r.status === 200) {
const me = await r.json();
window.ZAMPP_DATA = window.ZAMPP_DATA || {};
window.ZAMPP_DATA.ME = me;
setState({ kind: 'authed' });
return;
}
} catch (_) { /* fall through to setup/login decision */ }
const setup = await authFetch('/auth/setup-required').then(r => r.json()).catch(() => ({ required: false }));
setState({ kind: setup.required ? 'setup' : 'login' });
}, []);
React.useEffect(() => { check(); }, [check]);
// Global hook: anything else (e.g. data.jsx 401 handler) can re-trigger
// the gate after auth state changes server-side. Saves current path so
// the user is restored to where they were after login.
React.useEffect(() => {
window.AuthGate = {
bounce(reason) {
try { sessionStorage.setItem(LAST_PATH_KEY, location.pathname + location.search); } catch {}
if (reason) console.warn('[AuthGate] bounce:', reason);
check();
},
signedIn() { check(); },
};
}, [check]);
if (state.kind === 'loading') {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: 'var(--bg-0)' }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-3)' }}>Loading</div>
</div>
);
}
if (state.kind === 'setup') return React.createElement(window.SetupScreen, { onDone: () => window.AuthGate.signedIn() });
if (state.kind === 'login') return React.createElement(window.LoginScreen, { onDone: () => window.AuthGate.signedIn() });
return children;
}
window.AuthGate = window.AuthGate || {};
window.AuthGateComponent = AuthGate;
})();

View file

@ -62,19 +62,24 @@ window.ZAMPP_DATA = {
};
async function apiFetch(path, opts = {}) {
const method = (opts.method || 'GET').toUpperCase();
const headers = {
...(opts.headers || {}),
'Content-Type': 'application/json',
};
if (method !== 'GET' && method !== 'HEAD') headers['X-Requested-With'] = 'dragonflight-ui';
const res = await fetch(API + path, {
credentials: 'include',
...opts,
headers: { ...(opts.headers || {}), 'Content-Type': 'application/json' },
headers,
});
// 401 from any API call means there's no live session. Bounce to the
// login screen instead of leaving the app in a half-loaded state.
// While AUTH_ENABLED=false the server returns a synthetic /auth/me with
// 200 so this branch never fires; flipping AUTH_ENABLED=true is what
// activates the redirect end-to-end.
if (res.status === 401 && !location.pathname.endsWith('/login.html')) {
location.replace('/login.html');
throw new Error('Unauthenticated — redirecting to login');
// 401: hand off to AuthGate, which will re-render Login (no full-page reload).
if (res.status === 401) {
if (window.AuthGate && typeof window.AuthGate.bounce === 'function') {
window.AuthGate.bounce('apiFetch saw 401 on ' + path);
}
throw new Error('Unauthenticated');
}
if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
return res.json();

View file

@ -25,6 +25,8 @@
<script src="dist/icons.js"></script>
<script src="dist/visuals.js"></script>
<script src="dist/shell.js"></script>
<script src="dist/auth-gate.js"></script>
<script src="dist/screens-auth.js"></script>
<script src="dist/screens-home.js"></script>
<script src="dist/screens-library.js"></script>
<script src="dist/screens-asset.js"></script>

View file

@ -1406,10 +1406,137 @@ function DetailRow({ k, v, mono }) {
);
}
function AccountSection() {
const [current, setCurrent] = React.useState('');
const [next, setNext] = React.useState('');
const [confirm, setConfirm] = React.useState('');
const [msg, setMsg] = React.useState(null); // { kind: 'ok'|'err', text }
const [busy, setBusy] = React.useState(false);
const submit = async () => {
setMsg(null);
if (next !== confirm) { setMsg({ kind: 'err', text: 'Passwords do not match' }); return; }
if (next.length < 12) { setMsg({ kind: 'err', text: 'New password must be at least 12 characters' }); return; }
setBusy(true);
try {
const r = await fetch('/api/v1/auth/password', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ current_password: current, new_password: next }),
});
if (r.status === 204) {
setMsg({ kind: 'ok', text: 'Password updated' });
setCurrent(''); setNext(''); setConfirm('');
} else {
const body = await r.json().catch(() => ({}));
setMsg({ kind: 'err', text: body.error || 'Failed (' + r.status + ')' });
}
} finally { setBusy(false); }
};
return (
<section className="panel" style={{ padding: 16, marginBottom: 16 }}>
<h3 style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>Account</h3>
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 10, alignItems: 'center', maxWidth: 480 }}>
<label>Current password</label>
<input type="password" autoComplete="current-password" className="field-input" value={current} onChange={e => setCurrent(e.target.value)} />
<label>New password</label>
<input type="password" autoComplete="new-password" className="field-input" value={next} onChange={e => setNext(e.target.value)} />
<label>Confirm new password</label>
<input type="password" autoComplete="new-password" className="field-input" value={confirm} onChange={e => setConfirm(e.target.value)} />
</div>
{msg && (
<div style={{ marginTop: 10, fontSize: 11.5, color: msg.kind === 'ok' ? 'var(--success)' : 'var(--danger)' }}>{msg.text}</div>
)}
<button className="btn primary sm" style={{ marginTop: 12 }} disabled={busy || !current || !next || !confirm} onClick={submit}>
Change password
</button>
</section>
);
}
function ApiTokensSection() {
const [tokens, setTokens] = React.useState([]);
const [name, setName] = React.useState('');
const [justCreated, setJustCreated] = React.useState(null); // { token, prefix, name }
const [busy, setBusy] = React.useState(false);
const load = React.useCallback(async () => {
const r = await fetch('/api/v1/auth/tokens', { credentials: 'include' });
if (r.status === 200) setTokens(await r.json());
}, []);
React.useEffect(() => { load(); }, [load]);
const create = async () => {
if (!name.trim()) return;
setBusy(true);
try {
const r = await fetch('/api/v1/auth/tokens', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ name: name.trim() }),
});
if (r.status === 201) {
const created = await r.json();
setJustCreated(created);
setName('');
await load();
}
} finally { setBusy(false); }
};
const revoke = async (id) => {
await fetch('/api/v1/auth/tokens/' + id, {
method: 'DELETE',
credentials: 'include',
headers: { 'X-Requested-With': 'dragonflight-ui' },
});
await load();
};
return (
<section className="panel" style={{ padding: 16, marginBottom: 16 }}>
<h3 style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>API Tokens</h3>
{justCreated && (
<div style={{ marginBottom: 12, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)', marginBottom: 6 }}>
Save this token now it will not be shown again
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-1)', wordBreak: 'break-all', marginBottom: 6 }}>{justCreated.token}</div>
<button className="btn sm" onClick={() => navigator.clipboard.writeText(justCreated.token)}>Copy</button>
<button className="btn sm" style={{ marginLeft: 8 }} onClick={() => setJustCreated(null)}>Dismiss</button>
</div>
)}
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input className="field-input" placeholder="Token name (e.g. Premiere panel)" value={name} onChange={e => setName(e.target.value)} style={{ flex: 1 }} />
<button className="btn primary sm" disabled={busy || !name.trim()} onClick={create}>New token</button>
</div>
<div className="token-list">
{tokens.length === 0 && <div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>No tokens yet.</div>}
{tokens.map(t => (
<div key={t.id} className="token-row" style={{ display: 'grid', gridTemplateColumns: '1fr 120px 140px 80px', gap: 10, alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
<div>{t.name}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-2)' }}>{t.prefix}</div>
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : 'never used'}</div>
<button className="btn sm" onClick={() => revoke(t.id)}>Revoke</button>
</div>
))}
</div>
</section>
);
}
function Settings() {
const [section, setSection] = React.useState('storage');
const [section, setSection] = React.useState('account');
const SECTIONS = [
{ id: 'account', label: 'Account', icon: 'user' },
{ id: 'storage', label: 'Storage', icon: 'hdd' },
{ id: 'proxy', label: 'Proxy encoding', icon: 'gpu' },
{ id: 'sdk', label: 'Capture SDKs', icon: 'video' },
@ -1435,6 +1562,12 @@ function Settings() {
))}
</nav>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{section === 'account' && (
<>
<AccountSection />
<ApiTokensSection />
</>
)}
{section === 'storage' && <StorageSection />}
{section === 'proxy' && <GpuSettingsCard />}
{section === 'sdk' && <SdkSettingsCard />}

View file

@ -0,0 +1,179 @@
// LoginScreen + SetupScreen layout B from the auth brainstorm spec:
// 22px wordmark + "WILD DRAGON BROADCAST" tagline above a --bg-1 card.
// Matches DESIGN.md tokens; no decoration, dense, ops register.
(function () {
const API_BASE = '/api/v1';
async function postJson(path, body) {
return fetch(API_BASE + path, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'dragonflight-ui',
},
body: JSON.stringify(body),
});
}
function Brand() {
return (
<div style={{ textAlign: 'center', marginBottom: 22 }}>
<div style={{ fontSize: 22, fontWeight: 600, color: 'var(--text-1)', letterSpacing: '-0.01em' }}>Dragonflight</div>
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', marginTop: 6, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
Wild Dragon Broadcast
</div>
</div>
);
}
function Card({ children }) {
return (
<div style={{
background: 'var(--bg-1)',
border: '1px solid var(--border)',
borderRadius: 12,
padding: 22,
}}>{children}</div>
);
}
function Field({ label, type = 'text', value, onChange, autoComplete, autoFocus }) {
return (
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 10.5, fontWeight: 600, color: 'var(--text-2)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 6 }}>
{label}
</label>
<input
type={type}
autoComplete={autoComplete}
autoFocus={autoFocus}
value={value}
onChange={e => onChange(e.target.value)}
style={{
width: '100%',
background: 'var(--bg-3)',
border: '1px solid var(--border)',
borderRadius: 4,
padding: '8px 10px',
fontSize: 12.5,
color: 'var(--text-1)',
fontFamily: 'var(--font-mono)',
boxSizing: 'border-box',
}}
/>
</div>
);
}
function Button({ children, disabled, onClick, type = 'button' }) {
return (
<button
type={type}
disabled={disabled}
onClick={onClick}
style={{
width: '100%',
background: disabled ? 'var(--bg-3)' : 'var(--accent)',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '9px',
fontSize: 13,
fontWeight: 600,
fontFamily: 'inherit',
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.6 : 1,
}}
>{children}</button>
);
}
function ErrorRow({ text }) {
if (!text) return null;
return (
<div style={{
fontSize: 11.5,
color: 'var(--danger)',
marginBottom: 12,
padding: '6px 10px',
background: 'var(--danger-soft)',
border: '1px solid var(--danger)',
borderRadius: 4,
}}>{text}</div>
);
}
function Screen({ children }) {
return (
<div style={{ minHeight: '100vh', background: 'var(--bg-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<form onSubmit={e => e.preventDefault()} style={{ width: 300 }}>
<Brand />
<Card>{children}</Card>
</form>
</div>
);
}
function LoginScreen({ onDone }) {
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
const submit = async () => {
setError(''); setBusy(true);
try {
const r = await postJson('/auth/login', { username, password });
if (r.status === 200) { onDone(); return; }
const body = await r.json().catch(() => ({}));
setError(body.error || ('Login failed: ' + r.status));
} catch (e) { setError(e.message || 'Login failed'); }
finally { setBusy(false); }
};
return (
<Screen>
<ErrorRow text={error} />
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
<Button type="submit" disabled={busy || !username || !password} onClick={submit}>Sign in</Button>
</Screen>
);
}
function SetupScreen({ onDone }) {
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [confirm, setConfirm] = React.useState('');
const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
const submit = async () => {
setError('');
if (password !== confirm) { setError('Passwords do not match'); return; }
if (password.length < 12) { setError('Password must be at least 12 characters'); return; }
setBusy(true);
try {
const r = await postJson('/auth/setup', { username, password });
if (r.status === 200) { onDone(); return; }
const body = await r.json().catch(() => ({}));
setError(body.error || ('Setup failed: ' + r.status));
} catch (e) { setError(e.message || 'Setup failed'); }
finally { setBusy(false); }
};
return (
<Screen>
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}>
First-run setup create the first admin
</div>
<ErrorRow text={error} />
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="new-password" />
<Field label="Confirm password" type="password" value={confirm} onChange={setConfirm} autoComplete="new-password" />
<Button type="submit" disabled={busy || !username || !password || !confirm} onClick={submit}>Create admin</Button>
</Screen>
);
}
window.LoginScreen = LoginScreen;
window.SetupScreen = SetupScreen;
})();

View file

@ -215,7 +215,7 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
<button className="icon-btn" aria-label="Sign out" data-tip="Sign out" title="Sign out"
onClick={async () => {
try { await window.ZAMPP_API.fetch('/auth/logout', { method: 'POST' }); } catch (_) {}
window.location.replace('/login.html');
window.AuthGate.bounce('user signed out');
}}>
<Icon name="power" />
</button>