fix(widerruf): Massenwiderruf nimmt erzeugte Schluessel an (#032)

Der neue E2E-Test (zwei im Browser erzeugte Schluessel mit Public Key
auf Ziel B, Auswahl in der Liste, frische Bestaetigung ueber den
Mock-OIDC mit auth_time, gemeinsamer Dry-Run, Anwenden) fand zwei
Fehler im Produkt:

- Die Massenwiderruf-Seite filterte die IDs aus ?id= mit
  /^[a-z0-9]{1,64}$/. Vom Worker erzeugte Schluessel tragen aber eine
  UUID mit Bindestrichen, nur Prisma-Defaults sind cuids. Damit fiel
  jeder erzeugte Schluessel still aus der Auswahl, die Seite meldete
  "Keine Schluessel gewaehlt". Der Filter steckt jetzt als
  idsAusSuchparameter in src/lib/schluessel.ts, erlaubt Bindestriche
  und hat einen eigenen Test.
- Der Hinweis nach verweigerter Bestaetigung sagte "erneut Widerruf
  vorbereiten waehlen", der Knopf heisst beim Massenwiderruf aber
  "2 Schluessel widerrufen". Er nennt jetzt den Knopf, den es gibt.

Der Test prueft danach, dass beide Keys aus authorized_keys auf B
verschwunden und WIDERRUFEN sind. datenbank() und oidcBestaetigen()
wandern dafuer aus schluessel.spec.ts nach tests/e2e/hilfen.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:44:46 +02:00
co-authored by Claude Opus 5.5
parent be9837ba50
commit fb56fd36c5
7 changed files with 144 additions and 41 deletions
+3 -10
View File
@@ -9,6 +9,7 @@ import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import { WiderrufFormular } from "@/components/widerruf/WiderrufFormular";
import { idsAusSuchparameter } from "@/lib/schluessel";
import { darf } from "@/server/auth/rechte";
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { widerrufUebersicht } from "@/server/queries/widerruf";
@@ -17,14 +18,6 @@ export const metadata: Metadata = { title: "Massenwiderruf" };
type Suchparameter = Promise<{ id?: string | string[]; zielkonten?: string | string[] }>;
function liste(wert: string | string[] | undefined): string[] {
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
return [...new Set(roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9]{1,64}$/i.test(w)))].slice(
0,
200,
);
}
/**
* Massenwiderruf (#024): mehrere Schluessel – etwa alle einer Person nach Projektende – in einem
* Durchgang mit gemeinsamem Dry-Run. Nur Admins; das Vormerken verlangt eine frische
@@ -32,7 +25,7 @@ function liste(wert: string | string[] | undefined): string[] {
*/
export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) {
const parameter = await searchParams;
const ids = liste(parameter.id);
const ids = idsAusSuchparameter(parameter.id);
const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`;
const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst);
@@ -61,7 +54,7 @@ export default async function MassenwiderrufSeite({ searchParams }: { searchPara
const offeneStati = (s: { status: string }) => s.status === "AKTIV" || s.status === "ABGELAUFEN";
const nochNichtEingeleitet = uebersicht.schluessel.filter((s) => offeneStati(s) && !s.eingeleitet);
const laufend = uebersicht.schluessel.filter((s) => offeneStati(s) && s.eingeleitet);
const vorauswahl = liste(parameter.zielkonten);
const vorauswahl = idsAusSuchparameter(parameter.zielkonten);
return (
<div className="flex flex-col gap-6 sm:gap-7">
+4 -6
View File
@@ -32,6 +32,7 @@ export function WiderrufFormular({
const [laeuft, starten] = useTransition();
const gesperrt = uebersicht.schluessel.filter((s) => s.gesperrt);
const mehrere = uebersicht.schluessel.length > 1;
const knopfText = mehrere ? `${uebersicht.schluessel.length} Schlüssel widerrufen` : "Widerruf vorbereiten";
const ziel = (() => {
const url = new URL(ruecksprung, "http://x");
@@ -100,7 +101,8 @@ export function WiderrufFormular({
href={`/bestaetigen?ziel=${encodeURIComponent(ruecksprung)}`}
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium"
>
Anmeldung bestätigen und danach erneut „Widerruf vorbereiten“ wählen
{/* Nennt den Knopf, den es hier wirklich gibt – beim Massenwiderruf heisst er anders (#032). */}
Anmeldung bestätigen und danach erneut „{knopfText}“ wählen
</a>
)}
<div className="flex flex-wrap justify-end gap-2.5">
@@ -124,11 +126,7 @@ export function WiderrufFormular({
})
}
>
{laeuft
? "Wird vorgemerkt …"
: mehrere
? `${uebersicht.schluessel.length} Schlüssel widerrufen`
: "Widerruf vorbereiten"}
{laeuft ? "Wird vorgemerkt …" : knopfText}
</Button>
</div>
</div>
+11
View File
@@ -293,3 +293,14 @@ export function publicKeyZeileMitKommentar(zeile: string, kommentar: string): st
const [typ, base64] = zeile.trim().split(/\s+/);
return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`;
}
/**
* IDs aus einem Suchparameter (`?id=a&id=b` oder `?id=a,b`), etwa fuer den Massenwiderruf (#024).
* Erlaubt Bindestriche: Vom Worker erzeugte Schluessel tragen eine UUID, nur Prisma-Defaults sind
* cuids – ohne Bindestrich fielen alle erzeugten Schluessel still aus der Auswahl (#032).
*/
export function idsAusSuchparameter(wert: string | string[] | undefined, hoechstens = 200): string[] {
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
const gueltig = roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9-]{1,64}$/i.test(w));
return [...new Set(gueltig)].slice(0, hoechstens);
}