feat: a revoked invitation can be deleted
Revoked codes had a Revoke button and nothing else — the row stayed on the list for good. The reasoning was that revoke stops a live code and leaves the row behind as a record, which is right about revoke and wrong about what comes after: a revoked code is already dead. It cannot be redeemed by anyone, so keeping it only fills the list. The rule that matters is unchanged, and it is the only one that ever mattered: a code that could still be redeemed is never deletable. Deleting one takes it off the list without taking it out of anybody's inbox — the holder keeps something that looks valid, it quietly stops working, and nothing is left to say who had it. Revoke is what stops such a code; deleting it afterwards is the second half of the same thought, and it was missing. Also removed a note that had been wrong since codes became copyable: "The code is shown once, here. Only its hash is stored, so it cannot be read again afterwards." Both sentences stopped being true when the cipher was added so an invitation could be handed to somebody later than the moment it was made. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
0e1e74882e
commit
3e8972348a
4 changed files with 26 additions and 20 deletions
|
|
@ -52,7 +52,7 @@
|
|||
<button id="btn-create-invite" class="btn-sm btn-primary" type="button"><i class="fas fa-plus"></i> Create</button>
|
||||
</div>
|
||||
<div id="admin-invite-new" style="margin-top:10px;"></div>
|
||||
<p class="admin-note" style="margin-top:6px;">The code is shown once, here. Only its hash is stored, so it cannot be read again afterwards.</p>
|
||||
<p class="admin-note" style="margin-top:6px;">Codes stay readable here so you can copy one again — an invitation usually has to be given to somebody later than the moment it was made. A code that has been used, revoked or expired can be deleted.</p>
|
||||
</div>
|
||||
|
||||
<div id="admin-invites-list" style="display:flex;flex-direction:column;gap:4px;max-height:340px;overflow-y:auto;"></div>
|
||||
|
|
|
|||
|
|
@ -1869,12 +1869,15 @@ initImageSettings();
|
|||
// Used, or expired without being redeemed. A revoked code keeps its row: it
|
||||
// records a decision somebody took, and it is not cluttering anything the way
|
||||
// a pile of expired codes does.
|
||||
var SPENT_STATUS = ['used', 'expired'];
|
||||
// Spent: it can no longer be redeemed, whatever ended it. Revoked was held
|
||||
// back at first — revoke stops a code and leaves the row — but a revoked code
|
||||
// is already dead, so keeping it only fills the list.
|
||||
var SPENT_STATUS = ['used', 'expired', 'revoked'];
|
||||
|
||||
// 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 and expired invitation? Live and revoked ones are kept.', function() {
|
||||
showConfirm('Delete every used, revoked and expired invitation? Ones that can still be redeemed are kept.', function() {
|
||||
fetch('/api/admin/invites/spent', { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
|
|
|
|||
|
|
@ -137,16 +137,16 @@ async function revoke(id, adminUserId) {
|
|||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// Spent: used, or run out of time without being used. The one thing never
|
||||
// deletable is a code that could still be redeemed — deleting that takes it off
|
||||
// the list without taking it out of anybody's inbox, so the holder keeps
|
||||
// something that looks valid, it quietly stops working, and nothing is left to
|
||||
// say who had it. Revoke is what stops a live code, and it leaves the row.
|
||||
// Spent: used, revoked, or run out of time. The one thing never deletable is a
|
||||
// code that could still be redeemed — deleting that takes it off the list
|
||||
// without taking it out of anybody's inbox, so the holder keeps something that
|
||||
// looks valid, it quietly stops working, and nothing is left to say who had it.
|
||||
//
|
||||
// Written to match the status the list shows rather than as
|
||||
// "used OR expires_at <= NOW()", which would also catch a revoked code whose
|
||||
// date had passed — one the screen still labels revoked, with no delete on it.
|
||||
var SPENT = '(used_at IS NOT NULL OR (revoked_at IS NULL AND expires_at <= NOW()))';
|
||||
// Revoked codes were held back from this at first, on the reasoning that revoke
|
||||
// stops a code and leaves the row. But a revoked code is already dead: it
|
||||
// cannot be redeemed, and keeping it only fills the list. Revoke and delete are
|
||||
// two steps of the same thought, and the second was missing.
|
||||
var SPENT = '(used_at IS NOT NULL OR revoked_at IS NOT NULL OR expires_at <= NOW())';
|
||||
|
||||
/**
|
||||
* Delete a spent invitation.
|
||||
|
|
|
|||
|
|
@ -156,14 +156,17 @@ test('an invitation is deletable only once it can no longer be used', () => {
|
|||
// A code that could still be redeemed may be sitting in somebody's inbox.
|
||||
// Deleting the row takes it off the list without taking it out of their
|
||||
// hands: it quietly stops working and nothing is left to say who held it.
|
||||
// Revoke does that job and leaves the row behind, marked.
|
||||
assert.match(src, /var SPENT = '\(used_at IS NOT NULL OR \(revoked_at IS NULL AND expires_at <= NOW\(\)\)\)';/);
|
||||
// Revoke is what stops such a code — and a revoked one is then dead, so it
|
||||
// is deletable like any other spent code.
|
||||
assert.match(src, /var SPENT = '\(used_at IS NOT NULL OR revoked_at IS NOT NULL OR expires_at <= NOW\(\)\)';/);
|
||||
assert.match(src, /DELETE FROM registration_invites WHERE id = \$1 AND ' \+ SPENT/);
|
||||
assert.match(src, /DELETE FROM registration_invites WHERE ' \+ SPENT/, 'and in bulk');
|
||||
// Written to match the status the list shows: "used OR expired" would also
|
||||
// catch a revoked code past its date, which the screen still calls revoked
|
||||
// and offers no delete on.
|
||||
assert.match(src, /revoked_at IS NULL AND expires_at <= NOW\(\)/);
|
||||
// Scoped to the rule itself. revoked_at IS NULL appears elsewhere and
|
||||
// belongs there: revoke() will not re-revoke, and claim() will not redeem a
|
||||
// revoked code. It is only in *this* rule that it meant "keep it forever".
|
||||
const spentRule = src.slice(src.indexOf('var SPENT ='), src.indexOf('var SPENT =') + 120);
|
||||
assert.doesNotMatch(spentRule, /revoked_at IS NULL/, 'a revoked code is spent, not protected');
|
||||
assert.match(spentRule, /revoked_at IS NOT NULL/);
|
||||
|
||||
// Refused with the reason, not as a missing row: the row is very likely there.
|
||||
assert.match(route, /That invitation can still be used\. Revoke it instead\./);
|
||||
|
|
@ -173,9 +176,9 @@ test('an invitation is deletable only once it can no longer be used', () => {
|
|||
|
||||
// And the button is only offered where it can work.
|
||||
const js = read('public/js/admin.js');
|
||||
assert.match(js, /var SPENT_STATUS = \['used', 'expired'\];/);
|
||||
assert.match(js, /var SPENT_STATUS = \['used', 'expired', 'revoked'\];/);
|
||||
assert.match(js, /SPENT_STATUS\.indexOf\(row\.status\) !== -1\s*\n?\s*\? '<button type="button" class="btn-sm btn-ghost admin-invite-delete/);
|
||||
assert.match(js, /Delete every used and expired invitation\? Live and revoked ones are kept\./);
|
||||
assert.match(js, /Delete every used, revoked and expired invitation\? Ones that can still be redeemed are kept\./);
|
||||
assert.match(js, /clear\.hidden = spent === 0;/, 'and hidden when there are none');
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue