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*\? '