diff --git a/public/components/admin.html b/public/components/admin.html index 0fa5bc9a..fdc5c5e6 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -48,6 +48,10 @@
+ +
+ +
diff --git a/public/js/admin.js b/public/js/admin.js index f59fd9c6..622710c6 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1688,6 +1688,7 @@ initImageSettings(); if (revoke) inviteAction(revoke.dataset.id, 'revoke'); var del = e.target.closest('.admin-invite-delete'); if (del) inviteAction(del.dataset.id, 'delete'); + if (e.target.closest('#btn-clear-used-invites')) clearUsedInvites(); var copy = e.target.closest('.admin-invite-copy'); if (copy && navigator.clipboard) { navigator.clipboard.writeText(copy.dataset.code) @@ -1764,6 +1765,21 @@ initImageSettings(); .catch(function(err) { showToast(err.message, 'error'); }); } + // Spent invitations in one go, which is what a cluttered list actually wants. + // Confirmed first: it is a delete, even if everything it removes is finished. + function clearUsedInvites() { + showConfirm('Delete every used invitation? Unused and revoked ones are kept.', function() { + fetch('/api/admin/invites/used', { method: 'DELETE', headers: getAuthHeaders() }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (!data.success) throw new Error(data.error || 'Could not clear them'); + showToast('Removed ' + data.removed + ' used invitation' + (data.removed === 1 ? '' : 's'), 'success'); + loadInvites(); + }) + .catch(function(err) { showToast(err.message, 'error'); }); + }, { danger: true, confirmText: 'Delete' }); + } + function renderInvites(rows) { var container = document.getElementById('admin-invites-list'); if (!container) return; @@ -1783,9 +1799,22 @@ initImageSettings(); '' + esc(row.note || '') + '' + '' + esc(when) + who + '' + (row.status === 'active' ? '' : '') + - '' + + // Delete is offered on spent codes only. One that has not been used may + // still be in somebody's inbox: taking it off this list would not take + // it out of their hands, and nothing would then say who held it. + // Revoking is what stops a live code, and it leaves the row behind. + (row.status === 'used' + ? '' + : '') + ''; }).join(''); + + var used = rows.filter(function(row) { return row.status === 'used'; }).length; + var clear = document.getElementById('btn-clear-used-invites'); + if (clear) { + clear.hidden = used === 0; + clear.textContent = 'Clear ' + used + ' used'; + } } } diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 815dcaa4..57b67dad 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -739,9 +739,25 @@ router.post('/invites/:id/revoke', async function(req, res) { } catch (e) { return serverError(res, 'Invite revoke', e, 'Could not revoke the invitation'); } }); +// Clearing away spent invitations. Used ones only — an unused code may still be +// in somebody's inbox, and deleting the row takes it off the list without taking +// it out of their hands, leaving nothing to say who held it. Revoke is what +// stops a live code, and it leaves the row behind, marked. +router.delete('/invites/used', async function(req, res) { + try { + var removed = await invites.removeUsed(); + logger.audit(req.user.id, 'invite_delete', 'Cleared ' + removed + ' used invitations', req, { category: 'admin' }); + res.json({ success: true, removed: removed }); + } catch (e) { return serverError(res, 'Invite clear', e, 'Could not clear used invitations'); } +}); + router.delete('/invites/:id', async function(req, res) { try { - if (!await invites.remove(req.params.id)) return res.status(404).json({ error: 'Not found' }); + if (!await invites.remove(req.params.id)) { + // Said plainly rather than as "not found": the row is very likely there, + // and the reason it cannot go is worth knowing. + return res.status(409).json({ error: 'Only a used invitation can be deleted. Revoke it instead.' }); + } logger.audit(req.user.id, 'invite_delete', 'Deleted invitation ' + req.params.id, req, { category: 'admin' }); res.json({ success: true }); } catch (e) { return serverError(res, 'Invite delete', e, 'Could not delete the invitation'); } diff --git a/src/utils/registrationInvites.js b/src/utils/registrationInvites.js index 03c65acd..1fe20ed2 100644 --- a/src/utils/registrationInvites.js +++ b/src/utils/registrationInvites.js @@ -89,11 +89,31 @@ async function revoke(id, adminUserId) { return result.changes > 0; } +/** + * Delete a spent invitation. + * + * Used codes only. A code that has not been used yet is one somebody may still + * be holding: deleting it takes it out of the list without taking it out of + * their inbox, and there is then no record of who it went to or why it stopped + * working. Revoke does that job — it leaves the row, marked. This is only for + * clearing away codes whose whole story is already told. + * + * Returns false for a code that is not spent, which the caller reports rather + * than treating as a missing row. + */ async function remove(id) { - var result = await db().run('DELETE FROM registration_invites WHERE id = $1', [id]); + var result = await db().run( + 'DELETE FROM registration_invites WHERE id = $1 AND used_at IS NOT NULL', [id]); return result.changes > 0; } +// Every spent invitation at once, which is what "they clutter the list" asks +// for. Same rule: nothing unused is touched. +async function removeUsed() { + var result = await db().run('DELETE FROM registration_invites WHERE used_at IS NOT NULL'); + return result.changes || 0; +} + /** * Claim a code for a registration, atomically. * @@ -133,6 +153,7 @@ module.exports = { list, revoke, remove, + removeUsed, claim, inviteOnly }; diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js index e44adfe2..8d57d02d 100644 --- a/test/backend-hardening.test.js +++ b/test/backend-hardening.test.js @@ -153,6 +153,30 @@ test('.env.example documents every variable the app reads', () => { // registration_enabled is open-or-closed. Invite-only is the middle setting, // and it has to hold up against someone probing codes. +test('an invitation can only be deleted once it has been used', () => { + const src = read('src/utils/registrationInvites.js'); + const route = read('src/routes/adminConfig.js'); + + // An unused code may still be in somebody's inbox. Deleting the row takes it + // off the list without taking it out of their hands, and nothing is then left + // to say who held it or why it stopped working. Revoke does that job and + // leaves the row behind, marked. + assert.match(src, /DELETE FROM registration_invites WHERE id = \$1 AND used_at IS NOT NULL/); + assert.match(src, /DELETE FROM registration_invites WHERE used_at IS NOT NULL/, 'and in bulk'); + + // Refused with the reason, not as a missing row: the row is very likely there. + assert.match(route, /Only a used invitation can be deleted\. Revoke it instead\./); + assert.match(route, /res\.status\(409\)/); + // The bulk route is declared before /invites/:id, or "used" is read as an id. + assert.ok(route.indexOf("router.delete('/invites/used'") < route.indexOf("router.delete('/invites/:id'")); + + // And the button is only offered where it can work. + const js = read('public/js/admin.js'); + assert.match(js, /row\.status === 'used'\s*\n?\s*\? '