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>
|
<button id="btn-create-invite" class="btn-sm btn-primary" type="button"><i class="fas fa-plus"></i> Create</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="admin-invite-new" style="margin-top:10px;"></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>
|
||||||
|
|
||||||
<div id="admin-invites-list" style="display:flex;flex-direction:column;gap:4px;max-height:340px;overflow-y:auto;"></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
|
// 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
|
// records a decision somebody took, and it is not cluttering anything the way
|
||||||
// a pile of expired codes does.
|
// 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.
|
// 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.
|
// Confirmed first: it is a delete, even if everything it removes is finished.
|
||||||
function clearUsedInvites() {
|
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() })
|
fetch('/api/admin/invites/spent', { method: 'DELETE', headers: getAuthHeaders() })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
|
|
|
||||||
|
|
@ -137,16 +137,16 @@ async function revoke(id, adminUserId) {
|
||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spent: used, or run out of time without being used. The one thing never
|
// Spent: used, revoked, or run out of time. The one thing never deletable is a
|
||||||
// deletable is a code that could still be redeemed — deleting that takes it off
|
// code that could still be redeemed — deleting that takes it off the list
|
||||||
// the list without taking it out of anybody's inbox, so the holder keeps
|
// without taking it out of anybody's inbox, so the holder keeps something that
|
||||||
// something that looks valid, it quietly stops working, and nothing is left to
|
// looks valid, it quietly stops working, and nothing is left to say who had it.
|
||||||
// say who had it. Revoke is what stops a live code, and it leaves the row.
|
|
||||||
//
|
//
|
||||||
// Written to match the status the list shows rather than as
|
// Revoked codes were held back from this at first, on the reasoning that revoke
|
||||||
// "used OR expires_at <= NOW()", which would also catch a revoked code whose
|
// stops a code and leaves the row. But a revoked code is already dead: it
|
||||||
// date had passed — one the screen still labels revoked, with no delete on it.
|
// cannot be redeemed, and keeping it only fills the list. Revoke and delete are
|
||||||
var SPENT = '(used_at IS NOT NULL OR (revoked_at IS NULL AND expires_at <= NOW()))';
|
// 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.
|
* 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.
|
// 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
|
// 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.
|
// hands: it quietly stops working and nothing is left to say who held it.
|
||||||
// Revoke does that job and leaves the row behind, marked.
|
// Revoke is what stops such a code — and a revoked one is then dead, so it
|
||||||
assert.match(src, /var SPENT = '\(used_at IS NOT NULL OR \(revoked_at IS NULL AND expires_at <= NOW\(\)\)\)';/);
|
// 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 id = \$1 AND ' \+ SPENT/);
|
||||||
assert.match(src, /DELETE FROM registration_invites WHERE ' \+ SPENT/, 'and in bulk');
|
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
|
// Scoped to the rule itself. revoked_at IS NULL appears elsewhere and
|
||||||
// catch a revoked code past its date, which the screen still calls revoked
|
// belongs there: revoke() will not re-revoke, and claim() will not redeem a
|
||||||
// and offers no delete on.
|
// revoked code. It is only in *this* rule that it meant "keep it forever".
|
||||||
assert.match(src, /revoked_at IS NULL AND expires_at <= NOW\(\)/);
|
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.
|
// 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\./);
|
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.
|
// And the button is only offered where it can work.
|
||||||
const js = read('public/js/admin.js');
|
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, /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');
|
assert.match(js, /clear\.hidden = spent === 0;/, 'and hidden when there are none');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue