From 3b840c92f7683df02c32fe9bbf1945a25b80c81f Mon Sep 17 00:00:00 2001 From: ragnarok-clanker Date: Sun, 25 Oct 2026 20:27:24 +0100 Subject: [PATCH] Preserve persistent admin registration approval setting --- src/server/accounts.js | 27 ++++++++++++++++++++---- src/server/app.js | 9 ++++++++ src/server/db.js | 6 +++--- src/server/web/app.js | 29 +++++++++++++++++++++++--- src/server/web/index.html | 14 +++++++++---- src/server/web/style.css | 15 +++++++++++++ test/server.test.js | 44 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 130 insertions(+), 14 deletions(-) diff --git a/src/server/accounts.js b/src/server/accounts.js index adbeeb2..043ca4e 100644 --- a/src/server/accounts.js +++ b/src/server/accounts.js @@ -1,6 +1,6 @@ // 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 // 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; } + 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 async register(name, password) { @@ -73,9 +86,15 @@ export class Accounts { checkPassword(password); if (this.byName(name)) throw new AccountError(409, 'username is taken'); const hash = await hashPassword(password); - this.store.run(`INSERT INTO users (name, password_hash, role, status, created_at) VALUES (?, ?, 'contributor', 'pending', ?)`, name, hash, now()); - this.store.event('register', null, { name }); - return { name, status: 'pending' }; + return this.store.tx(() => { + const { autoApprove } = this.registrationSettings(); + 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) { diff --git a/src/server/app.js b/src/server/app.js index 5f07b2e..8afea5d 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -982,6 +982,15 @@ export function createApp(ctx) { }); 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 }) => { auth(req, { role: 'admin', session: true }); return { roles: ROLES, users: accounts.listUsers(query.get('status') ?? undefined), work: claimedWork(), reviewers: reviewerStats(store) }; diff --git a/src/server/db.js b/src/server/db.js index c064755..5a5d3a1 100644 --- a/src/server/db.js +++ b/src/server/db.js @@ -4,9 +4,9 @@ import { DatabaseSync } from 'node:sqlite'; import crypto from 'node:crypto'; import { formatSymbolId } from '../shared/annotations.js'; -// Accounts. People register with a password and stay 'pending' until an admin -// approves them. Approved users create API tokens for their agents; the browser -// uses short-lived sessions. Both are stored as SHA-256 hashes only. +// Accounts. Registrations stay 'pending' until approved unless auto-approval is on. +// Approved users create API tokens for their agents; the browser uses short-lived +// sessions. Both are stored as SHA-256 hashes only. const USERS_TABLES = ` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, diff --git a/src/server/web/app.js b/src/server/web/app.js index f417584..d1da6c6 100644 --- a/src/server/web/app.js +++ b/src/server/web/app.js @@ -226,9 +226,11 @@ $('login-form').addEventListener('submit', async (e) => { $('register-form').addEventListener('submit', async (e) => { e.preventDefault(); try { - await post('/api/register', formData(e.target)); + const registration = await post('/api/register', formData(e.target)); 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) { $('register-msg').textContent = err.message; } @@ -859,11 +861,32 @@ async function adminAction(name, action, body) { 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; 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; + $('auto-approve').checked = registration.autoApprove; + $('auto-approve').disabled = false; renderAdmin(); renderAuthors(authors.authors); } diff --git a/src/server/web/index.html b/src/server/web/index.html index 5445011..373431b 100644 --- a/src/server/web/index.html +++ b/src/server/web/index.html @@ -6,7 +6,7 @@ Clank Together - +
@@ -122,8 +122,8 @@
  1. 1
    Register an account

    Register here.

  2. -
  3. 2
    Wait for approval -

    An admin checks every new account. Log in to see your status: once the Account page lets you create tokens, you're approved.

  4. +
  5. 2
    Check your approval +

    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.

  6. 3
    Create an API token

    On your Account page, give the token a label (for example "laptop") and press Create token. The page then shows your personal install line with the token already filled in. Copy it; it's shown only once.

  7. 4
    Run the install line @@ -359,6 +359,12 @@

    Accounts

    + +

    When on, new accounts can log in immediately. Existing pending accounts still need manual approval.

    +

    @@ -383,6 +389,6 @@

    Clank Together · source is open: git clone https://clank.rodecomp.online/git/clank/game-decomp.git

    - + diff --git a/src/server/web/style.css b/src/server/web/style.css index 0a27731..f3953ab 100644 --- a/src/server/web/style.css +++ b/src/server/web/style.css @@ -308,6 +308,21 @@ input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus- outline-offset: 1px; } 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 { font: 600 14px var(--font-display); diff --git a/test/server.test.js b/test/server.test.js index 66478da..aaa9450 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -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'); }); +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 () => { const erin = await login('erin'); const t = await request('POST', '/api/me/tokens', { label: 'laptop' }, { cookie: erin });