Preserve persistent admin registration approval setting

This commit is contained in:
ragnarok-clanker 2026-10-25 20:27:24 +01:00
commit 3b840c92f7
7 changed files with 130 additions and 14 deletions

View file

@ -1,6 +1,6 @@
// Accounts, passwords, browser sessions and API tokens. // Accounts, passwords, browser sessions and API tokens.
// //
// register -> pending --admin approves--> active --admin revokes--> revoked // register -> pending (or active with auto-approval) --admin approves--> active --admin revokes--> revoked
// //
// Only active users can do anything. Every request looks its credential up in // Only active users can do anything. Every request looks its credential up in
// the database, so revoking a user, a token or a session takes effect at once. // the database, so revoking a user, a token or a session takes effect at once.
@ -66,6 +66,19 @@ export class Accounts {
this.store = store; this.store = store;
} }
registrationSettings() {
return { autoApprove: this.store.getMeta('auto_approve_accounts') === 'true' };
}
setRegistrationSettings(autoApprove, adminId) {
if (typeof autoApprove !== 'boolean') throw new AccountError(400, 'autoApprove must be a boolean');
return this.store.tx(() => {
this.store.setMeta('auto_approve_accounts', autoApprove);
this.store.event('registration_settings', adminId, { autoApprove });
return this.registrationSettings();
});
}
// --- self service // --- self service
async register(name, password) { async register(name, password) {
@ -73,9 +86,15 @@ export class Accounts {
checkPassword(password); checkPassword(password);
if (this.byName(name)) throw new AccountError(409, 'username is taken'); if (this.byName(name)) throw new AccountError(409, 'username is taken');
const hash = await hashPassword(password); const hash = await hashPassword(password);
this.store.run(`INSERT INTO users (name, password_hash, role, status, created_at) VALUES (?, ?, 'contributor', 'pending', ?)`, name, hash, now()); return this.store.tx(() => {
this.store.event('register', null, { name }); const { autoApprove } = this.registrationSettings();
return { name, status: 'pending' }; const status = autoApprove ? 'active' : 'pending';
const createdAt = now();
this.store.run(`INSERT INTO users (name, password_hash, role, status, created_at, approved_at) VALUES (?, ?, 'contributor', ?, ?, ?)`, name, hash, status, createdAt, autoApprove ? createdAt : null);
this.store.event('register', null, { name });
if (autoApprove) this.store.event('user_approved', null, { name, automatic: true });
return { name, status };
});
} }
async login(name, password) { async login(name, password) {

View file

@ -982,6 +982,15 @@ export function createApp(ctx) {
}); });
return { authors, limits: LIMITS }; return { authors, limits: LIMITS };
}); });
router.on('GET', '/api/admin/registration', ({ req }) => {
auth(req, { role: 'admin', session: true });
return accounts.registrationSettings();
});
router.on('POST', '/api/admin/registration', async ({ req }) => {
const admin = auth(req, { role: 'admin', session: true });
const body = await readJson(req, 1024);
return accountCall(() => accounts.setRegistrationSettings(body.autoApprove, admin.id));
});
router.on('GET', '/api/admin/users', ({ req, query }) => { router.on('GET', '/api/admin/users', ({ req, query }) => {
auth(req, { role: 'admin', session: true }); auth(req, { role: 'admin', session: true });
return { roles: ROLES, users: accounts.listUsers(query.get('status') ?? undefined), work: claimedWork(), reviewers: reviewerStats(store) }; return { roles: ROLES, users: accounts.listUsers(query.get('status') ?? undefined), work: claimedWork(), reviewers: reviewerStats(store) };

View file

@ -4,9 +4,9 @@ import { DatabaseSync } from 'node:sqlite';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { formatSymbolId } from '../shared/annotations.js'; import { formatSymbolId } from '../shared/annotations.js';
// Accounts. People register with a password and stay 'pending' until an admin // Accounts. Registrations stay 'pending' until approved unless auto-approval is on.
// approves them. Approved users create API tokens for their agents; the browser // Approved users create API tokens for their agents; the browser uses short-lived
// uses short-lived sessions. Both are stored as SHA-256 hashes only. // sessions. Both are stored as SHA-256 hashes only.
const USERS_TABLES = ` const USERS_TABLES = `
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,

View file

@ -226,9 +226,11 @@ $('login-form').addEventListener('submit', async (e) => {
$('register-form').addEventListener('submit', async (e) => { $('register-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
await post('/api/register', formData(e.target)); const registration = await post('/api/register', formData(e.target));
e.target.reset(); e.target.reset();
$('register-msg').textContent = 'Registered. You can log in once an admin approves your account.'; $('register-msg').textContent = registration.status === 'active'
? 'Registered. Your account is approved; you can log in now.'
: 'Registered. You can log in once an admin approves your account.';
} catch (err) { } catch (err) {
$('register-msg').textContent = err.message; $('register-msg').textContent = err.message;
} }
@ -859,11 +861,32 @@ async function adminAction(name, action, body) {
return null; return null;
} }
} }
$('auto-approve').addEventListener('change', async (e) => {
const toggle = e.target;
const previous = !toggle.checked;
toggle.disabled = true;
$('registration-msg').textContent = '';
try {
const settings = await post('/api/admin/registration', { autoApprove: toggle.checked });
toggle.checked = settings.autoApprove;
$('registration-msg').textContent = settings.autoApprove
? 'Auto approval is on. New accounts can log in immediately.'
: 'Auto approval is off. New accounts need manual approval.';
} catch (err) {
toggle.checked = previous;
$('registration-msg').textContent = err.message;
} finally {
toggle.disabled = false;
}
});
let adminData = null; let adminData = null;
async function loadAdmin() { async function loadAdmin() {
const [users, authors] = await Promise.all([api('/api/admin/users'), api('/api/admin/authors').catch(() => ({ authors: [] }))]); const [users, authors, registration] = await Promise.all([api('/api/admin/users'), api('/api/admin/authors').catch(() => ({ authors: [] })), api('/api/admin/registration')]);
adminData = users; adminData = users;
$('auto-approve').checked = registration.autoApprove;
$('auto-approve').disabled = false;
renderAdmin(); renderAdmin();
renderAuthors(authors.authors); renderAuthors(authors.authors);
} }

View file

@ -6,7 +6,7 @@
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>Clank Together</title> <title>Clank Together</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/style.css?v=feed-20261024"> <link rel="stylesheet" href="/style.css?v=auto-approve-20261025">
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@ -122,8 +122,8 @@
<ol class="steps"> <ol class="steps">
<li><span class="bolt">1</span><div><strong>Register an account</strong> <li><span class="bolt">1</span><div><strong>Register an account</strong>
<p><a href="#/login">Register here</a>.</p></div></li> <p><a href="#/login">Register here</a>.</p></div></li>
<li><span class="bolt">2</span><div><strong>Wait for approval</strong> <li><span class="bolt">2</span><div><strong>Check your approval</strong>
<p>An admin checks every new account. Log in to see your status: once the Account page lets you create tokens, you're approved.</p></div></li> <p>When auto approval is on, you can log in immediately after registering. Otherwise, wait for an admin to approve your account. Once the Account page lets you create tokens, you're approved.</p></div></li>
<li><span class="bolt">3</span><div><strong>Create an API token</strong> <li><span class="bolt">3</span><div><strong>Create an API token</strong>
<p>On your <a href="#/account">Account</a> page, give the token a label (for example "laptop") and press Create token. The page then shows <em>your personal install line</em> with the token already filled in. Copy it; it's shown only once.</p></div></li> <p>On your <a href="#/account">Account</a> page, give the token a label (for example "laptop") and press Create token. The page then shows <em>your personal install line</em> with the token already filled in. Copy it; it's shown only once.</p></div></li>
<li><span class="bolt">4</span><div><strong>Run the install line</strong> <li><span class="bolt">4</span><div><strong>Run the install line</strong>
@ -359,6 +359,12 @@
<h2>Accounts</h2> <h2>Accounts</h2>
<input id="user-filter" type="search" placeholder="Find an account…" aria-label="Find an account"> <input id="user-filter" type="search" placeholder="Find an account…" aria-label="Find an account">
</div> </div>
<label class="approval-switch">
<input id="auto-approve" type="checkbox" role="switch" aria-describedby="auto-approve-help" disabled>
Auto approve new accounts
</label>
<p class="hint" id="auto-approve-help">When on, new accounts can log in immediately. Existing pending accounts still need manual approval.</p>
<p class="msg" id="registration-msg" role="status"></p>
<div id="pending"></div> <div id="pending"></div>
<div id="users"></div> <div id="users"></div>
<p class="msg" id="admin-msg" role="status"></p> <p class="msg" id="admin-msg" role="status"></p>
@ -383,6 +389,6 @@
<div class="hazard thin" aria-hidden="true"></div> <div class="hazard thin" aria-hidden="true"></div>
<p>Clank Together · source is open: <code id="clone-cmd">git clone https://clank.rodecomp.online/git/clank/game-decomp.git</code></p> <p>Clank Together · source is open: <code id="clone-cmd">git clone https://clank.rodecomp.online/git/clank/game-decomp.git</code></p>
</footer> </footer>
<script src="/app.js?v=feed-20261024"></script> <script src="/app.js?v=auto-approve-20261025"></script>
</body> </body>
</html> </html>

View file

@ -308,6 +308,21 @@ input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-
outline-offset: 1px; outline-offset: 1px;
} }
textarea { width: 100%; font-family: var(--font-body); } textarea { width: 100%; font-family: var(--font-body); }
.approval-switch { flex-direction: row; align-items: center; gap: 10px; color: var(--fg); font-size: 15px; cursor: pointer; }
.approval-switch input {
appearance: none;
flex: none;
width: 42px;
height: 24px;
margin: 0;
padding: 2px;
border-radius: 12px;
cursor: pointer;
}
.approval-switch input::before { content: ''; display: block; width: 18px; height: 18px; border-radius: 50%; background: var(--muted); }
.approval-switch input:checked { background: var(--accent); border-color: var(--accent); }
.approval-switch input:checked::before { transform: translateX(18px); background: var(--accent-text-on); }
.approval-switch input:disabled { opacity: 0.45; cursor: not-allowed; }
button { button {
font: 600 14px var(--font-display); font: 600 14px var(--font-display);

View file

@ -253,6 +253,50 @@ test('registration needs admin approval before anything works', async () => {
assert.equal((await request('GET', '/api/admin/users', null, { cookie: erin })).status, 403, 'contributors are not admins'); assert.equal((await request('GET', '/api/admin/users', null, { cookie: erin })).status, 403, 'contributors are not admins');
}); });
test('auto approval is admin-controlled and affects only new registrations', async () => {
await ctx.accounts.register('waiting_before_auto', PASSWORD);
const sessionFor = async (name) => `td_session=${(await ctx.accounts.login(name, PASSWORD)).token}`;
const admin = await sessionFor('root');
const contributor = await sessionFor('erin');
const adminToken = ctx.accounts.createApiToken(ctx.accounts.byName('root').id, 'settings-test').token;
const endpoint = '/api/admin/registration';
const enable = { autoApprove: true };
assert.equal((await request('GET', endpoint)).status, 401);
assert.equal((await request('POST', endpoint, enable)).status, 401);
for (const options of [{ cookie: contributor }, { token: adminToken }]) {
assert.equal((await request('GET', endpoint, null, options)).status, 403);
assert.equal((await request('POST', endpoint, enable, options)).status, 403);
}
assert.equal((await request('POST', endpoint, enable, { cookie: admin, csrf: false })).status, 403);
for (const body of [{}, { autoApprove: 'false' }, { autoApprove: 1 }, { autoApprove: null }]) {
assert.equal((await request('POST', endpoint, body, { cookie: admin })).status, 400);
}
assert.equal((await request('GET', endpoint, null, { cookie: admin })).body.autoApprove, false);
try {
assert.equal((await request('POST', endpoint, enable, { cookie: admin })).status, 200);
const registered = await request('POST', '/api/register', { name: 'auto_approved', password: PASSWORD });
assert.equal(registered.status, 200);
assert.equal(registered.body.status, 'active');
const user = ctx.accounts.byName('auto_approved');
assert.equal(user.role, 'contributor');
assert.equal(user.approved_at, user.created_at);
assert.equal(user.approved_by, null);
const session = await sessionFor('auto_approved');
assert.equal((await request('GET', '/api/me', null, { cookie: session })).body.name, 'auto_approved');
assert.equal(ctx.accounts.byName('waiting_before_auto').status, 'pending');
assert.equal((await request('POST', endpoint, { autoApprove: false }, { cookie: admin })).status, 200);
const manual = await request('POST', '/api/register', { name: 'manual_again', password: PASSWORD });
assert.equal(manual.status, 200);
assert.equal(manual.body.status, 'pending');
assert.equal(ctx.accounts.byName('manual_again').approved_at, null);
await assert.rejects(ctx.accounts.login('manual_again', PASSWORD), (e) => e.status === 403);
assert.equal((await request('GET', '/api/me', null, { cookie: session })).body.name, 'auto_approved');
} finally {
ctx.accounts.setRegistrationSettings(false, ctx.accounts.byName('root').id);
}
});
test('API tokens: created in the browser, limited scope, revocable', async () => { test('API tokens: created in the browser, limited scope, revocable', async () => {
const erin = await login('erin'); const erin = await login('erin');
const t = await request('POST', '/api/me/tokens', { label: 'laptop' }, { cookie: erin }); const t = await request('POST', '/api/me/tokens', { label: 'laptop' }, { cookie: erin });