Skip to content

feat(console): make the consent roster a list somebody can work through - #143

Merged
TheMeinerLP merged 1 commit into
mainfrom
feat/console-consents-list
Aug 23, 2026
Merged

feat(console): make the consent roster a list somebody can work through#143
TheMeinerLP merged 1 commit into
mainfrom
feat/console-consents-list

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Turns the consent roster into a paginated UiDisclosureList with a bulk withdrawal, a name in front of every snowflake, and a page number in the address bar.


The problem

pages/admin/consents.vue rendered one <article> per person from GET /api/guilds/{id}/consents, which returned every consent record a guild had ever held. It sorted them in the browser with orderConsents, and offered exactly one action per card behind its own confirmation.

Three costs, and the third is the one that matters.

A guild with four hundred participants sent four hundred records to draw the first ten. orderConsents then re-sorted them on the server and in the browser, with a hand-rolled compareNames written specifically so the two runtimes could not disagree about where "Ö" goes — an amount of care that only existed because the sort was in the wrong place.

Withdrawing consent for four people was four confirmations, each of which had to be read, because each one named a different person and a different count of recordings. Nobody reads the fourth.

And this is the page where a person exercises a right. The prose on it is load-bearing — the role stays, the recordings stay, it is in the audit log — and none of it may be lost while the list around it is rebuilt.

What a row is now

Collapsed: who, and where they stand. The person's name, and the state badge — Consent in force, Withdrawn, Policy version superseded. Nothing else. An administrator arrives at this page having been asked about a person and scans for a name; a row that also carried the policy version and two dates would be a row nobody scans.

Expanded: everything the card used to carry, and the action. The badge's long form, the note about the name where there is one, the policy version, the grant, the scope, the withdrawal date, the recordings held, the Discord user id, and the withdrawal itself with its confirmation. The <dl> is the one the cards had, moved inside the disclosure.

The id is the one thing that moved down. It is still there — it is what somebody quotes in a ticket — but it is a row of the detail list rather than the second line of the heading.

Paging

limit/offset at PAGE_SIZE, UiPagination below the list, and ?page= in the address, because a page somebody has scrolled to is a place and a place needs an address. Page one carries no ?page=1: two URLs for one page is two entries in a history that render identically.

total counts people, not consent rows, and that changes a sentence. The page used to say 40 people have a consent record here; 6 of them are in force right now — one sentence about one set, which it had all of. It now says two:

137 people have a consent record in this server.
16 of the 20 on this page are in force right now.

The total is the API's and describes the server; "in force" can only be counted over the rows in hand. A single sentence carrying both would read as "16 of 137" while counting 16 of 20 — the kind of wrong number nobody checks because it is arithmetic.

isPastTheEnd handles the bookmark to a page that has since emptied. It says the list has changed and offers the first page, rather than saying nobody has consented — telling somebody their server has no consents because their link went stale is the sort of wrong answer that gets reported as data loss.

The bulk withdrawal

The dangerous part, and the reason most of utils/consentRoster.ts exists.

Selection is a list of ids, so it outlives the page. That is UiDisclosureList's decision from #133 and it is the right one — but it means the confirmation has to name people whose rows are no longer in hand. So every person the reader has seen is remembered by id as they go past, and the confirmation lists them by name:

Withdraw consent for 9 people?
It applies to these people and to nobody else:
Ada Lovelace · Grace Hopper · Member 42 · …

Then the three limits, in the plural, kept as three sentences for the reason the single-row confirmation keeps its three: a paragraph carrying all of them is skimmed exactly where the reader most needs to notice that the roles and the recordings are not part of this.

An already-withdrawn consent is not selectable. revocability decides, the same function that withholds the single-row button. A header checkbox that ticked one would promise a withdrawal the API answers already_revoked to, and it would also make the selection count a lie about the request.

There is no "select all matching". The API caps a batch at MAX_REVOCATIONS_PER_REQUEST, which is deliberately exactly one page of the roster, so no such request exists to build. Selection still grows a page at a time, so it can reach the bound gradually — and when it does, batchVerdict refuses out loud with the number in it rather than letting the reader discover the bound as a 400.

The answer is per person. This is what the endpoint's 200-for-a-mixed-result is for:

1 of the 3 named was withdrawn.
What happened to each of them:
Ada Lovelace — Withdrawn. It takes effect 2026-03-01 12:00 UTC. 2 recordings containing their audio fall on or after that moment. None of them were deleted.
Grace Hopper — Already withdrawn — by somebody else, or in another tab. Nothing changed just now, and nothing needed to.
Member 42 — Sturnus holds no consent record for this person at all, so there was nothing to withdraw. They are not being recorded.

A tally heads it and never replaces it. "Some were refused" is an administrator who has to withdraw all ten again one at a time to find out which; "already withdrawn" against a name is something they can act on, and "no record at all" against another sends them somewhere different.

Outcomes are matched to people by id, not by position. The API does answer index-for-index, and relying on that means one dropped entry renames every outcome after it — the one error here that is invisible on screen, because the wrong sentence against the wrong name still reads perfectly.

The names

personLabel falls back to Discord user 100000000000000040, which is honest and unreadable. GET .../directory has held display names for exactly these people since the pickers landed, parseDirectory has been parsing them, and nothing has ever rendered them. Now the roster asks.

Three outcomes, three sentences, because they are three different facts:

Source Renders as Note
The consent record the display name none
The guild directory the directory's name "Name taken from this server's Discord directory; the consent record itself carries none."
Neither, directory read the bare id "No name anywhere: the consent record carries none, and this server's Discord directory has no entry for this id…"
Neither, no directory the bare id "…this server's Discord directory could not be read just now…"

The last two are kept apart on purpose. Telling somebody their guild is missing a member when the console simply did not look is a support ticket about Discord that belongs to the console. The directory request fails quietly for the same reason: a directory that cannot be read is a roster with fewer names on it, never a roster that refuses to render.

Nobody is dropped and nothing is blank. A person the directory cannot name still has a row, still has their state badge, and still has the withdrawal — with a short Discord user id marker beside the snowflake so the row is scannable, and the full sentence inside it. A person quietly missing from a consent roster is a roster that is wrong about who may be recorded.

revokeConfirmation, recordingsKeptNote and revokeOutcome now take the name as an optional parameter defaulting to personLabel. Without it, a row headed Ada Lovelace opened a confirmation asking whether to withdraw Discord user 100000000000000040's consent — a confirmation the reader cannot match to the thing they clicked.

identityNote is gone. It claimed Sturnus had "never had a name to learn", which stopped being true the moment the roster consulted the directory.

The effective instant

UiDatePicker, in both confirmations. It emits ISO-8601 with the offset of the chosen moment, and it does so by calling ~/utils/effectiveInstant — the module that worked that out. The page's hand-rolled datetime-local inside a <details>, and the isoFromLocalInput/localOffsetMinutes/offsetLabel calls that went with it, are gone. Three implementations of one piece of daylight-saving arithmetic is two too many, and they would have disagreed first here.

admin.consents.effective.zone goes with them: the picker already quotes the instant it is about to send, offset and all, and two sentences naming one offset are two chances to name a different one.

For a batch, the floor is the latest grant among the people chosen. An instant legal for nine and not the tenth would earn nine withdrawals and one effective_before_grant — a half-applied act explained afterwards, rather than a whole one refused before.

UiDisclosureList is generic in its row type

One change to the control from #133: generic="Row extends UiRow", so :rows narrows the slot props instead of widening them to UiRow.

Without it a page that has more to say about a row than an id — a consent, a person — has to look the row back up by index in a second array. That lookup is exactly how one name ends up beside somebody else's consent record the first time the two arrays stop being the same length, and on this page that is a withdrawal attributed to the wrong person. Runtime behaviour is unchanged; the gallery at /dev/ui and uiComponents.spec.ts are untouched.

Everything the page promised is still on it

The "what withdrawing does, and does not" panel, ROLE_STAYS_NOTE, the recordings-kept note with its per-person count, AUDIT_LOG_NOTE, the three state badges and their long forms, the "nobody has consented yet" empty state, the "you administer nothing" state, the refusal sentences, and the guild switcher's localStorage memory — which now uses UiSelect with the guild's name and its id as the row subtext, per #140.

Checks

lint, test, typecheck and build are green. 1369 → 1421 tests: consentRoster.spec.ts adds 52, consents.spec.ts trades three identityNote tests for three about the supplied name and the 400.

Verified against a stub API serving 137 people — a fifth of them nameless, a tenth of those also absent from the directory — at pages 1, 3 and 99.

The page rendered one card per person, fetched every consent record a
guild ever had in one request, sorted them in the browser, and offered
exactly one action per card behind its own confirmation. A guild with
four hundred participants sent four hundred records to draw the first
ten, and withdrawing consent for four people was four confirmations.

It is now a paginated UiDisclosureList. A row says who and where they
stand; opening it reveals the policy version, the grant, the scope, the
withdrawal date, the recordings held and the withdrawal itself. The
window is the API's limit/offset, the page number is in the address, and
the order is SQL's -- orderConsents is deliberately no longer called,
because a second ordering over a page whose neighbours the reader cannot
see is a list that disagrees with its own pager.

Several people can be withdrawn at once. The confirmation names every
one of them before anything happens to anybody, including the ones whose
rows the reader has paged away from, and the answer is rendered one
person at a time: the endpoint answers 200 for a mixed result on purpose,
so "seven withdrawn, two already withdrawn, one had no record" has to be
legible per person rather than collapsed into a sentence.

Ids are secondary now. The guild directory was parsed and consumed
nowhere; the roster asks it for the people the consent record has no
name for. Somebody neither can name still renders -- as their bare id,
with a sentence saying which of the two reasons applies -- because a
person quietly missing from a consent roster is a roster that is wrong
about who may be recorded.

The effective instant is UiDatePicker, which already calls the module
that decided this question. The hand-rolled datetime-local goes, and
with it the third implementation of that arithmetic.
@TheMeinerLP
TheMeinerLP merged commit 5f3345e into main Aug 23, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant