Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions console/app/components/ui/UiDisclosureList.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts" generic="Row extends UiRow">
/**
* A list whose rows open, with checkboxes for the actions that apply to
* more than one of them.
Expand All @@ -20,6 +20,13 @@
* - **A row's summary and its revealed actions are the caller's markup**,
* so they arrive as slots. This component owns the disclosure, not what
* is disclosed.
*
* It is generic in its row type, and that is not decoration. A caller has
* more to say about a row than an id — a consent, a person, a job — and a
* list that narrowed every row to `UiRow` on the way into the slot would
* force the page 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 record the
* first time the two arrays stop being the same length.
*/
import {
type UiRow,
Expand All @@ -35,7 +42,7 @@ import {

const props = withDefaults(
defineProps<{
rows: readonly UiRow[]
rows: readonly Row[]
/** The ids ticked, across every page. Absent means no checkboxes. */
selected?: readonly string[] | null
/** The word for the bulk action, which goes into the sentence saying
Expand Down
925 changes: 685 additions & 240 deletions console/app/pages/admin/consents.vue

Large diffs are not rendered by default.

632 changes: 632 additions & 0 deletions console/app/utils/consentRoster.ts

Large diffs are not rendered by default.

64 changes: 43 additions & 21 deletions console/app/utils/consents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,21 +154,16 @@ export function personLabel(row: ConsentRow): string {
}

/**
* The line under a nameless row, or `null` when there is a name.
* There is no `identityNote` here any more.
*
* A bare snowflake where every other row has a name reads as a fault in the
* console. It is not one: consent is given in a Discord command, and a
* display name is only learned when somebody turns up in a session that was
* recorded. Saying so is the difference between "this row is broken" and
* "this person has consented and has not been in a meeting yet".
* The sentence under a nameless row used to be built from the consent
* record alone, and it therefore claimed Sturnus had "never had a name to
* learn". That is no longer true: the roster consults the guild directory,
* which holds display names for exactly these people, and the three
* outcomes of asking it -- a name, no entry, no directory to ask -- are
* three different facts needing three different sentences. `nameNote` in
* `~/utils/consentRoster` says them, in keys rather than in English.
*/
export function identityNote(row: ConsentRow): string | null {
if (row.display_name) return null
return (
'No display name on record: this is their Discord user id. They gave consent but have not '
+ 'been in a recorded session since, so Sturnus has never had a name to learn.'
)
}

/* -------------------------------------------------------------------- */
/* What state a consent is in */
Expand Down Expand Up @@ -348,13 +343,20 @@ export const ROLE_STAYS_NOTE =

/**
* Nothing already recorded is deleted, and the count says how much that is
* for this person specifically. A general sentence about retention is easy
* for this person specifically.
*
* The name is a parameter with `personLabel` as its default, here and in
* the two functions below it. The roster resolves the nameless against the
* guild directory, and a confirmation that said "Withdraw Discord user
* 100000000000000001's consent?" under a row headed "Ada Lovelace" would be
* a confirmation the reader cannot match to the thing they clicked. The
* default keeps every caller that has no better name unchanged. A general sentence about retention is easy
* to read past; "and the four recordings that already contain their audio
* stay" is not.
*/
export function recordingsKeptNote(row: ConsentRow): string {
export function recordingsKeptNote(row: ConsentRow, label: string = personLabel(row)): string {
const held = row.recordings_with_audio
const who = personLabel(row)
const who = label
if (held === 0) {
return (
`Nothing already recorded is deleted. Sturnus holds no recordings containing ${who}’s audio `
Expand Down Expand Up @@ -394,10 +396,13 @@ export interface RevokeConfirmation {
* cannot be undone from this console: only the person themselves can grant
* consent again, with `/consent grant` in Discord.
*/
export function revokeConfirmation(row: ConsentRow): RevokeConfirmation {
export function revokeConfirmation(
row: ConsentRow,
label: string = personLabel(row),
): RevokeConfirmation {
return {
title: `Withdraw ${personLabel(row)}’s consent?`,
consequences: [ROLE_STAYS_NOTE, recordingsKeptNote(row), AUDIT_LOG_NOTE],
title: `Withdraw ${label}’s consent?`,
consequences: [ROLE_STAYS_NOTE, recordingsKeptNote(row, label), AUDIT_LOG_NOTE],
confirmLabel: 'Yes, withdraw this consent',
}
}
Expand Down Expand Up @@ -497,7 +502,11 @@ export interface RevokeOutcome {
* moment somebody is most likely to believe more happened than did is the
* moment they have just watched a row change state.
*/
export function revokeOutcome(row: ConsentRow, result: RevokeResult): RevokeOutcome {
export function revokeOutcome(
row: ConsentRow,
result: RevokeResult,
label: string = personLabel(row),
): RevokeOutcome {
if (!result.revoked) {
return {
tone: 'refused',
Expand All @@ -513,7 +522,7 @@ export function revokeOutcome(row: ConsentRow, result: RevokeResult): RevokeOutc
+ 'their audio are untouched'
return {
tone: 'done',
headline: `${personLabel(row)}’s consent is withdrawn.`,
headline: `${label}’s consent is withdrawn.`,
detail:
'Recording of them stops within about five seconds, mid-session if a meeting is running. '
+ `Their Discord consent role is unchanged, ${recordings}, and this is in the audit log. `
Expand Down Expand Up @@ -561,6 +570,19 @@ export function isStaleRow(error: unknown): boolean {
*/
export function describeConsentError(error: unknown): string {
switch (statusOf(error)) {
case 400:
// Two requests on this page can earn a 400, and this sentence has to
// be true of both: a page of the roster the API will not serve — a
// hand-edited `?page=`, or a bookmark to a page that has since gone —
// and a withdrawal naming nobody, the same person twice, or more
// people than one request may carry. Both are the console asking for
// something malformed rather than the guild being in a state that
// refuses it, and in neither case did anything change.
return (
'Sturnus will not serve that request: it asks for a page of the roster that does not '
+ 'exist, or names a set of people it cannot accept. Nothing was changed — go back to the '
+ 'first page and start again.'
)
case 401:
return 'Your session has ended. Sign in again, then retry — nothing was withdrawn.'
case 403:
Expand Down
38 changes: 37 additions & 1 deletion console/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,20 @@
"errorStatus": "Sturnus hat mit {status} geantwortet und konnte die Zahlen dieses Servers nicht erstellen. Über den Grund ist nichts bekannt."
},
"consents": {
"roster": {
"server": "Welcher Server",
"list": "Einwilligungsliste",
"showing": "Angezeigt: {from}–{to} von {total}",
"showingOne": "Angezeigt: {from} von {total}",
"total": "{count} Person hat in diesem Server einen Einwilligungseintrag. | {count} Personen haben in diesem Server einen Einwilligungseintrag.",
"inForce": "{count} der {shown} auf dieser Seite ist derzeit gültig. | {count} der {shown} auf dieser Seite sind derzeit gültig.",
"beyondEnd": "Diese Seite der Liste gibt es nicht mehr — die Liste hat sich geändert, seit dieser Link entstanden ist. Es fehlt niemand; alle stehen auf einer früheren Seite.",
"backToFirst": "Zurück zur ersten Seite",
"userId": "Discord-Benutzer-ID",
"fromDirectory": "Name aus dem Discord-Verzeichnis dieses Servers; der Einwilligungseintrag selbst trägt keinen.",
"unresolved": "Nirgends ein Name: Der Einwilligungseintrag trägt keinen, und das Discord-Verzeichnis dieses Servers kennt diese ID nicht — deshalb steht hier die blanke Discord-Benutzer-ID. Eine Einwilligung entsteht per Discord-Befehl, und einen Namen lernt Sturnus erst, wenn jemand in einer aufgezeichneten Sitzung auftaucht.",
"unlisted": "Kein Name im Einwilligungseintrag, und das Discord-Verzeichnis dieses Servers war gerade nicht lesbar — deshalb steht hier die blanke Discord-Benutzer-ID."
},
"scope": {
"label": "Umfang",
"audio": "Nur Audio",
Expand All @@ -446,7 +460,6 @@
"legend": "Wirksam ab",
"nowNote": "Sofort, sofern hier kein anderer Zeitpunkt gewählt wird. Ohne diesen Bereich zu öffnen, verhält sich das Zurücknehmen genau wie bisher.",
"help": "Ein Zeitpunkt in der Vergangenheit ist eine Aussage über bereits vorhandene Aufnahmen. Ein Zeitpunkt in der Zukunft ist ein vorgemerktes Zurücknehmen: bis dahin wird weiter aufgenommen, danach endet es innerhalb von etwa fünf Sekunden.",
"zone": "Gilt als Ortszeit und wird als {offset} gegenüber UTC gesendet. Einen Zeitpunkt ohne Zeitzonenangabe weist Sturnus zurück.",
"reset": "Zurück auf sofort",
"unreadable": "Das ist kein Zeitpunkt, den diese Konsole lesen kann. Datum und Uhrzeit wählen oder zurück auf sofort.",
"beforeGrant": "Diese Einwilligung wurde am {granted} erteilt. Ein Zurücknehmen kann nicht vor der Einwilligung wirken, die es zurücknimmt — diesen oder einen späteren Zeitpunkt wählen.",
Expand All @@ -459,6 +472,29 @@
"fromNone": "Keine Aufnahme mit dieser Stimme fällt auf {when} oder danach.",
"fromOne": "{count} Aufnahme mit dieser Stimme fällt auf {when} oder danach. Gelöscht wurde sie nicht.",
"fromMany": "{count} Aufnahmen mit dieser Stimme fallen auf {when} oder danach. Gelöscht wurde keine davon."
},
"bulk": {
"action": "Einwilligung widerrufen",
"title": "Einwilligung von {count} Person widerrufen? | Einwilligung von {count} Personen widerrufen?",
"lead": "Betrifft genau diese Person und sonst niemanden: | Betrifft genau diese Personen und sonst niemanden:",
"roleStays": "Damit werden die von Sturnus gespeicherten Einwilligungseinträge widerrufen. Die Discord-Rolle für Einwilligung wird niemandem entzogen — die API hält bewusst kein Discord-Token und kann keine Rollen ändern. Die Aufzeichnung jeder dieser Personen endet trotzdem innerhalb von etwa fünf Sekunden, auch mitten in einer laufenden Sitzung, denn bei jedem Frame wird der gespeicherte Eintrag geprüft. Sollen auch die Rollen weg, dann in Discord entfernen.",
"recordingsKept": "Nichts bereits Aufgezeichnetes wird gelöscht. Jede Aufnahme, die deren Ton schon enthält, bleibt genau dort, wo sie ist; das Löschen ist ein eigener Schritt — /audio purge in Discord.",
"audit": "Das geht ins Audit-Log, eine Zeile je Person: das eigene Discord-Konto, wessen Einwilligung widerrufen wurde, und wann.",
"confirm": "Ja, diese Einwilligungen widerrufen",
"cancel": "Abbrechen",
"none": "Nichts ausgewählt, also gibt es nichts zu widerrufen.",
"tooMany": "Sturnus widerruft höchstens {max} Einwilligungen pro Anfrage, ausgewählt sind {count}. Diese Grenze entspricht genau einer Seite dieser Liste — also einen Teil der Auswahl aufheben und die Liste Seite für Seite abarbeiten.",
"effectiveLegend": "Wirksam ab, für alle oben genannten Personen",
"effectiveNote": "Jetzt, sofern hier kein anderer Zeitpunkt gewählt wird. Derselbe Zeitpunkt gilt für jede Person in diesem Widerruf.",
"working": "Wird widerrufen …",
"tally": "{count} der {requested} genannten Personen wurde widerrufen. | {count} der {requested} genannten Personen wurden widerrufen.",
"perPerson": "Was mit jeder einzelnen Person geschehen ist:",
"dismiss": "Ausblenden",
"outcomeWithdrawn": "Widerrufen.",
"outcomeAlready": "Bereits widerrufen — von jemand anderem oder in einem anderen Tab. Gerade hat sich nichts geändert, und es musste sich auch nichts ändern.",
"outcomeNoRecord": "Sturnus hat zu dieser Person überhaupt keinen Einwilligungseintrag, es gab also nichts zu widerrufen. Diese Person wird nicht aufgezeichnet.",
"outcomeBeforeGrant": "Abgelehnt: Der gewählte Zeitpunkt liegt vor der Einwilligung dieser Person, und ein Widerruf kann nicht vor der Einwilligung wirksam werden, die er widerruft.",
"outcomeRefused": "Abgelehnt, ohne dass Sturnus einen Grund genannt hat. Bei dieser Person hat sich nichts geändert."
}
},
"settings": {
Expand Down
38 changes: 37 additions & 1 deletion console/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,20 @@
"errorStatus": "Sturnus answered {status} and could not produce this server's figures. Nothing is known about why."
},
"consents": {
"roster": {
"server": "Which server",
"list": "Consent roster",
"showing": "Showing {from}–{to} of {total}",
"showingOne": "Showing {from} of {total}",
"total": "{count} person has a consent record in this server. | {count} people have a consent record in this server.",
"inForce": "{count} of the {shown} on this page is in force right now. | {count} of the {shown} on this page are in force right now.",
"beyondEnd": "This page of the roster does not exist any more — the list has changed since this link was made. Nobody is missing; everybody is on an earlier page.",
"backToFirst": "Back to the first page",
"userId": "Discord user id",
"fromDirectory": "Name taken from this server's Discord directory; the consent record itself carries none.",
"unresolved": "No name anywhere: the consent record carries none, and this server's Discord directory has no entry for this id — so this row shows the bare Discord user id. Consent is given by a Discord command, and a name is only learned when somebody turns up in a session that was recorded.",
"unlisted": "No name on the consent record, and this server's Discord directory could not be read just now — so this row shows the bare Discord user id."
},
"scope": {
"label": "Scope",
"audio": "Audio only",
Expand All @@ -446,7 +460,6 @@
"legend": "Effective from",
"nowNote": "Now, unless another moment is chosen here. Withdrawing without opening this behaves exactly as it always has.",
"help": "A moment in the past is a statement about recordings that already exist. A moment in the future is a scheduled withdrawal: recording continues until then, and stops within about five seconds of it.",
"zone": "Read as your local time and sent as {offset} from UTC. Sturnus rejects a moment that carries no offset.",
"reset": "Back to now",
"unreadable": "That is not a moment this console can read. Pick a date and a time, or go back to now.",
"beforeGrant": "This consent was granted {granted}. A withdrawal cannot take effect before the consent it withdraws — pick that moment or a later one.",
Expand All @@ -459,6 +472,29 @@
"fromNone": "No recording containing their audio falls on or after {when}.",
"fromOne": "{count} recording containing their audio falls on or after {when}. It was not deleted.",
"fromMany": "{count} recordings containing their audio fall on or after {when}. None of them were deleted."
},
"bulk": {
"action": "Withdraw consent",
"title": "Withdraw consent for {count} person? | Withdraw consent for {count} people?",
"lead": "It applies to this person and to nobody else: | It applies to these people and to nobody else:",
"roleStays": "This withdraws the consent records Sturnus stores. It removes the Discord consent role from nobody — the API holds no Discord token, by design, and cannot change anybody's roles. Recording of each of them still stops within about five seconds, in the middle of a running session if there is one, because the stored record is what is checked on every frame. If the roles should go too, remove them in Discord.",
"recordingsKept": "Nothing already recorded is deleted. Every recording that already contains any of their audio stays exactly where it is; erasing one is a separate act — /audio purge in Discord.",
"audit": "This is written to the audit log, one line per person: your Discord account, whose consent you withdrew, and when.",
"confirm": "Yes, withdraw these consents",
"cancel": "Cancel",
"none": "Nothing is selected, so there is nothing to withdraw.",
"tooMany": "Sturnus withdraws at most {max} consents in one request, and {count} are selected. That bound is one page of this roster, so clear part of the selection and work through it a page at a time.",
"effectiveLegend": "Effective from, for everybody named above",
"effectiveNote": "Now, unless another moment is chosen here. The same moment applies to every person in this withdrawal.",
"working": "Withdrawing…",
"tally": "{count} of the {requested} named was withdrawn. | {count} of the {requested} named were withdrawn.",
"perPerson": "What happened to each of them:",
"dismiss": "Hide this",
"outcomeWithdrawn": "Withdrawn.",
"outcomeAlready": "Already withdrawn — by somebody else, or in another tab. Nothing changed just now, and nothing needed to.",
"outcomeNoRecord": "Sturnus holds no consent record for this person at all, so there was nothing to withdraw. They are not being recorded.",
"outcomeBeforeGrant": "Refused: the moment chosen falls before this person's consent was granted, and a withdrawal cannot take effect before the consent it withdraws.",
"outcomeRefused": "Refused, and Sturnus did not say why. Nothing of theirs was changed."
}
},
"settings": {
Expand Down
Loading
Loading