Preserve persistent admin registration approval setting
This commit is contained in:
parent
3def972933
commit
3b840c92f7
7 changed files with 130 additions and 14 deletions
|
|
@ -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());
|
||||
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 });
|
||||
return { name, status: 'pending' };
|
||||
if (autoApprove) this.store.event('user_approved', null, { name, automatic: true });
|
||||
return { name, status };
|
||||
});
|
||||
}
|
||||
|
||||
async login(name, password) {
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<meta name="color-scheme" content="dark light">
|
||||
<title>Clank Together</title>
|
||||
<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>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
|
|
@ -122,8 +122,8 @@
|
|||
<ol class="steps">
|
||||
<li><span class="bolt">1</span><div><strong>Register an account</strong>
|
||||
<p><a href="#/login">Register here</a>.</p></div></li>
|
||||
<li><span class="bolt">2</span><div><strong>Wait for 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>
|
||||
<li><span class="bolt">2</span><div><strong>Check your approval</strong>
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -359,6 +359,12 @@
|
|||
<h2>Accounts</h2>
|
||||
<input id="user-filter" type="search" placeholder="Find an account…" aria-label="Find an account">
|
||||
</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="users"></div>
|
||||
<p class="msg" id="admin-msg" role="status"></p>
|
||||
|
|
@ -383,6 +389,6 @@
|
|||
<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>
|
||||
</footer>
|
||||
<script src="/app.js?v=feed-20261024"></script>
|
||||
<script src="/app.js?v=auto-approve-20261025"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue