feat(selbstauskunft): Mitarbeiter sehen ihre eigenen Geraete (#55)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

„Was ist eigentlich mit meinem Notebook passiert?“ musste bisher ein
Techniker nachsehen. Ein Mitarbeiter kann die Arbeiten an **seinen**
Geraeten jetzt selbst ansehen — mit der fuenften Rolle MITARBEITER,
zugeordnet beim Anmelden ueber den UPN.

Was er sieht, sind Titel, Zeitraum und Status. Keine Schritte, keine
Notizen, keine Kommentare, keine Anhaenge: in einem Lauf stehen
Arbeitsnotizen ueber Menschen und Befehle mit Platzhaltern fuer
Zugangsdaten. Eine Selbstauskunft ist keine Akteneinsicht, und das haelt
nicht die Seite fest, sondern das Lesemodell — eine Seite, die filtert,
ist eine Seite, die es vergessen kann.

`selbstauskunft` ist ein **Ersatz** fuer `lesen` und kein Zusatz; auch
ein ADMIN hat es nicht. Ein zweiter Weg zu denselben Daten waere eine
zweite Stelle, an der jemand die Einschraenkung auf die eigene Person
vergisst. Die Datenbank verlangt das sogar: ein CHECK haelt fest, dass
Rolle und Zuordnung zusammengehoeren, in beide Richtungen.

Ein mehrdeutiger UPN wird abgewiesen und protokolliert. Zwei Kunden
koennen dieselbe Person fuehren; ein findFirst entschiede nach
Sortierung darueber, wessen Geraete jemand sieht.

**Verhaltensaenderung:** wer sich anmeldet und in keiner Rollengruppe
steht, bekommt nicht mehr automatisch LESER — also nicht mehr
Lesezugriff auf alle Laeufe des Hauses. Das greift nur mit
konfigurierten OIDC_GROUP_*; ohne sie bleibt alles wie vorher, denn ohne
abgebildete Gruppen laesst sich Techniker nicht von Mitarbeiter
unterscheiden.

Nebenbei behoben: `staerksteRolle` benutzte LESER als Startwert und
damit als Untergrenze — mit einer Rolle darunter lieferte sie mehr
Rechte als uebergeben. Gefunden von einem Test, der seit #007
unveraendert dasteht.

Closes #55

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-06 07:32:54 +02:00
co-authored by Claude Opus 5
parent a417426f43
commit 0158d0292f
28 changed files with 1806 additions and 76 deletions
+9 -2
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { ShieldAlert } from "lucide-react";
import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { startseite } from "@/server/auth/rollen";
/**
* „Dafür fehlt dir die Berechtigung“ — für jemanden, der **angemeldet** ist (#018).
@@ -30,6 +31,7 @@ export default async function KeinZugriffSeite({
// Nur eigene Pfade anzeigen: ein fremder Wert in der Adresse waere sonst ein
// Text, den jemand anderes auf diese Seite schreibt.
const gezeigt = bereich?.startsWith("/") && !bereich.startsWith("//") ? bereich : undefined;
const einstieg = startseite(benutzer.rolle);
return (
<div className="mx-auto flex max-w-prose flex-col items-start gap-5 py-10">
@@ -56,11 +58,16 @@ export default async function KeinZugriffSeite({
Anmeldung übernommen.
</p>
{/*
* Der Knopf fuehrt zum **eigenen** Einstieg und nicht stur auf `/`. Fuer
* einen `MITARBEITER` war das eine Schleife: `/` verlangt `lesen`, also
* landete er wieder hier (#035).
*/}
<Link
href="/"
href={einstieg}
className="flex min-h-11 w-fit items-center rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
>
Zur Übersicht
{einstieg === "/" ? "Zur Übersicht" : "Zu meinen Geräten"}
</Link>
</div>
);
+221
View File
@@ -0,0 +1,221 @@
import { Info, Laptop } from "lucide-react";
import { LaufStatus } from "@/generated/prisma/enums";
import { GERAETE_TYP_TEXT } from "@/lib/geraete-text";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import {
meineDaten,
meineGeraete,
meineLaeufeOhneGeraet,
type MeinLauf,
} from "@/server/queries/selbstauskunft";
/**
* Selbstauskunft: meine Geräte und was mit ihnen geschehen ist (#035).
*
* **Diese Seite zeigt keine Schritte, Notizen, Kommentare oder Anhänge** — und
* zwar nicht, weil sie sie weglässt, sondern weil das Lesemodell sie nicht
* liefert. Die Einschränkung sitzt in `src/server/queries/selbstauskunft.ts`;
* eine Seite, die filtert, wäre eine Seite, die es vergessen kann (CLAUDE.md).
*
* Jede Seite prüft selbst — die Hülle ist das zweite Netz, nicht das erste.
*/
const STATUS_TEXT: Record<LaufStatus, string> = {
OFFEN: "offen",
IN_ARBEIT: "in Arbeit",
ABGESCHLOSSEN: "abgeschlossen",
ARCHIVIERT: "abgeschlossen",
};
/*
* `ARCHIVIERT` heisst hier „abgeschlossen“, nicht „archiviert“.
*
* Der Unterschied ist eine Frage der Aktenfuehrung und interessiert den
* Mitarbeiter nicht — fuer ihn ist beides „fertig“. „Archiviert“ klaenge
* ausserdem, als waere etwas weggelegt worden.
*/
const STATUS_FARBE: Record<LaufStatus, string> = {
OFFEN: "text-nebentext",
IN_ARBEIT: "text-faellig",
ABGESCHLOSSEN: "text-erledigt",
ARCHIVIERT: "text-erledigt",
};
function datum(wert: Date): string {
return wert.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "Europe/Berlin",
});
}
/** Der Zeitraum eines Laufs, so kurz wie möglich. */
function zeitraum(lauf: MeinLauf): string {
const beginn = lauf.begonnenAm ?? lauf.erstelltAm;
if (!lauf.abgeschlossenAm) return `seit ${datum(beginn)}`;
const anfang = datum(beginn);
const ende = datum(lauf.abgeschlossenAm);
return anfang === ende ? anfang : `${anfang} – ${ende}`;
}
function LaufZeile({ lauf }: { lauf: MeinLauf }) {
return (
<li className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-t border-linie py-3 first:border-t-0">
<span className="min-w-0 flex-1 break-words">{lauf.titel}</span>
<span className="text-xs text-nebentext">{zeitraum(lauf)}</span>
<span className={`text-xs font-semibold ${STATUS_FARBE[lauf.status]}`}>{STATUS_TEXT[lauf.status]}</span>
</li>
);
}
export default async function MeineGeraeteSeite() {
const { benutzer } = await sitzungErforderlich("selbstauskunft", "/meine-geraete");
/*
* Ohne Zuordnung gibt es die Rolle nicht — das hält die Datenbank fest
* (CHECK `Benutzer_mitarbeiter_nur_bei_rolle`). Der Fall kann hier also nicht
* eintreten; die Prüfung steht trotzdem da, weil ein `!` an dieser Stelle eine
* Behauptung wäre und kein Beweis.
*/
if (!benutzer.mitarbeiterId) {
return (
<>
<h1 className="text-2xl font-bold">Meine Geräte</h1>
<p role="alert" className="mt-6 max-w-prose rounded-karte border border-fehler/40 bg-flaeche p-4">
Dieses Konto ist keiner Person im Verzeichnis zugeordnet. Ein Administrator muss das einrichten —
bis dahin gibt es hier nichts zu zeigen.
</p>
</>
);
}
const [geraete, ohneGeraet, daten] = await Promise.all([
meineGeraete(benutzer.mitarbeiterId),
meineLaeufeOhneGeraet(benutzer.mitarbeiterId),
meineDaten(benutzer.mitarbeiterId),
]);
const nichts = geraete.length === 0 && ohneGeraet.length === 0;
return (
<>
<h1 className="text-2xl font-bold">Meine Geräte</h1>
<p className="mt-2 max-w-prose text-sm text-nebentext">
Die Geräte, die auf {daten?.anzeigename ?? "dich"} laufen, und was die IT daran gemacht hat.
</p>
<p className="mt-4 flex max-w-prose items-start gap-2 rounded-karte border border-linie bg-flaeche p-3 text-sm text-nebentext">
<Info className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
<span>
Hier stehen <strong>Titel, Zeitraum und Status</strong> einer Arbeit — nicht die einzelnen Schritte,
Notizen oder Fotos. Wenn du wissen willst, was genau gemacht wurde, frag die IT.
</span>
</p>
{daten ? (
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Meine Daten</h2>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
So steht es im Verzeichnis. Stimmt etwas nicht, meldet das die IT dem Personalbüro — ändern lässt
es sich hier nicht.
</p>
<dl className="grid gap-x-8 gap-y-2 text-sm sm:grid-cols-[auto_1fr]">
<dt className="text-nebentext">Name</dt>
<dd>{daten.anzeigename}</dd>
{daten.titel ? (
<>
<dt className="text-nebentext">Funktion</dt>
<dd>{daten.titel}</dd>
</>
) : null}
{daten.abteilung ? (
<>
<dt className="text-nebentext">Abteilung</dt>
<dd>{daten.abteilung}</dd>
</>
) : null}
{daten.mail ? (
<>
<dt className="text-nebentext">E-Mail</dt>
<dd className="break-all">{daten.mail}</dd>
</>
) : null}
</dl>
</section>
) : null}
{nichts ? (
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Auf dich läuft gerade kein Gerät, und es ist auch keine Arbeit zu dir vermerkt. Das ist kein Fehler
— es heißt nur, dass die IT nichts zu dir festgehalten hat.
</p>
) : null}
{geraete.length > 0 ? (
<ul className="mt-6 flex flex-col gap-4">
{geraete.map((geraet) => (
<li key={geraet.id} className="rounded-karte border border-linie bg-flaeche p-5">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<Laptop
className="size-[18px] shrink-0 self-center text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<h2 className="text-lg font-semibold">{geraet.bezeichnung}</h2>
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
{GERAETE_TYP_TEXT[geraet.typ]}
</span>
{geraet.archiviert ? (
<span className="rounded-pille border border-linie px-2 py-0.5 text-xs text-nebentext">
nicht mehr in Benutzung
</span>
) : null}
</div>
<dl className="mt-2 flex flex-wrap gap-x-6 gap-y-1 text-xs text-nebentext">
{geraet.seriennummer ? (
<div>
<dt className="inline">Seriennummer: </dt>
<dd className="mono inline">{geraet.seriennummer}</dd>
</div>
) : null}
<div>
<dt className="inline">Aufkleber: </dt>
<dd className="mono inline">{geraet.tagCode}</dd>
</div>
</dl>
{geraet.laeufe.length === 0 ? (
<p className="mt-4 text-sm text-nebentext">
Zu diesem Gerät ist noch keine Arbeit festgehalten.
</p>
) : (
<ul className="mt-4 text-sm">
{geraet.laeufe.map((lauf) => (
<LaufZeile key={lauf.id} lauf={lauf} />
))}
</ul>
)}
</li>
))}
</ul>
) : null}
{ohneGeraet.length > 0 ? (
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Zu mir persönlich</h2>
<p className="mt-1 mb-2 max-w-prose text-sm text-nebentext">
Arbeiten, die nicht an einem Gerät hängen — etwa das Einrichten deines Kontos.
</p>
<ul className="text-sm">
{ohneGeraet.map((lauf) => (
<LaufZeile key={lauf.id} lauf={lauf} />
))}
</ul>
</section>
) : null}
</>
);
}
+13 -1
View File
@@ -2,7 +2,8 @@ import Link from "next/link";
import { ArrowRight, Clock, ListChecks, Rocket, TriangleAlert } from "lucide-react";
import { LaufStatus } from "@/generated/prisma/enums";
import { fristVerstrichen } from "@/lib/fristen";
import { darf } from "@/server/auth/rollen";
import { darf, startseite } from "@/server/auth/rollen";
import { redirect } from "next/navigation";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import {
UEBERSICHT_JE_BLOCK,
@@ -223,6 +224,17 @@ function Schnellstart({ vorlagen }: { vorlagen: readonly HaeufigeVorlage[] }) {
}
export default async function Uebersichtsseite() {
/*
* **Erst nur die Anmeldung, dann das Recht.** Wer hier kein `lesen` hat, aber
* einen eigenen Einstieg (seit #035 der `MITARBEITER`), wird dorthin
* geschickt — statt auf „Dafuer fehlt dir die Berechtigung“ zu landen, obwohl
* er gerade nur die Anwendung geoeffnet hat.
*/
const angemeldeter = await sitzungErforderlich(undefined, "/");
const einstieg = startseite(angemeldeter.benutzer.rolle);
if (einstieg !== "/") redirect(einstieg);
// Und jetzt das Recht, mit derselben Wirkung wie vorher.
const { benutzer } = await sitzungErforderlich("lesen", "/");
const darfArbeiten = darf(benutzer.rolle, "laeufeBearbeiten");
+17
View File
@@ -22,6 +22,23 @@ const MELDUNGEN = new Map<string, string>(
"oidc-aus": "Die Anmeldung über den Anbieter ist nicht eingerichtet.",
"oidc-start": "Der Anbieter war nicht erreichbar. Bitte später erneut versuchen.",
"oidc-rueckruf": "Die Anmeldung beim Anbieter ist fehlgeschlagen.",
/*
* Zwei eigene Codes fuer die Selbstauskunft (#035). Nicht `oidc-rueckruf`:
* „fehlgeschlagen“ laesst jemanden es noch dreimal versuchen, obwohl sich
* ohne Zutun eines Administrators nichts aendert.
*
* **Nicht auf `/kein-zugriff`**, obwohl das Issue es so vorsah: diese Seite
* verlangt eine Sitzung, und bei gescheiterter Anmeldung gibt es keine — sie
* wuerde auf die Anmeldeseite zurueckleiten, also im Kreis. Die Begruendung
* gehoert deshalb hierhin, und genau das ist der Punkt des Kriteriums: keine
* leere Seite, die wie ein Fehler aussieht.
*/
"keine-person":
"Dein Konto ist keiner Person im Verzeichnis zugeordnet und gehört keiner Rollengruppe an. " +
"Damit gibt es nichts, was angezeigt werden könnte. Bitte wende dich an die IT.",
"upn-mehrdeutig":
"Dein Anmeldename gehört zu mehr als einer Person im Verzeichnis. Das muss die IT auflösen — " +
"welche Person gemeint ist, lässt sich hier nicht raten.",
}),
);
+21 -2
View File
@@ -1,7 +1,7 @@
import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung";
import { benutzerAusOidcUebernehmen } from "@/server/auth/benutzer";
import { AnmeldeFehler, benutzerAusOidcUebernehmen } from "@/server/auth/benutzer";
import { OIDC_FLUSS_COOKIE, anmeldungAbschliessen } from "@/server/auth/oidc";
/**
@@ -35,8 +35,27 @@ export async function GET(anfrage: NextRequest): Promise<Response> {
`OIDC-Rueckruf fehlgeschlagen:`,
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
);
/*
* Ein `AnmeldeFehler` ist etwas anderes als ein kaputter OIDC-Fluss: am
* Anbieter hat alles funktioniert, nur darf oder kann dieses Konto hier
* nichts sehen (#035). „Die Anmeldung ist fehlgeschlagen“ liesse jemanden es
* dreimal versuchen, obwohl sich ohne Zutun eines Administrators nichts
* aendert — deshalb ein eigener Code mit einer Begruendung, die weiterhilft.
*
* Unterschieden wird am **Grund**, nicht an der Meldung: der Text wird hier
* nie durchgereicht, sonst stuende eine Dienstmeldung in einer Adresse.
*/
const schluessel =
fehler instanceof AnmeldeFehler
? fehler.grund === "upn-mehrdeutig"
? "upn-mehrdeutig"
: fehler.grund === "keine-person"
? "keine-person"
: "oidc-rueckruf"
: "oidc-rueckruf";
const antwort = NextResponse.redirect(
oeffentlicheAdresse("/anmelden?fehler=oidc-rueckruf", anfrage.nextUrl.origin),
oeffentlicheAdresse(`/anmelden?fehler=${schluessel}`, anfrage.nextUrl.origin),
);
antwort.cookies.delete(OIDC_FLUSS_COOKIE);
return antwort;