feat(selbstauskunft): Mitarbeiter sehen ihre eigenen Geraete (#55)
„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:
co-authored by
Claude Opus 5
parent
a417426f43
commit
0158d0292f
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Building2,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
Laptop,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
Menu,
|
||||
@@ -33,6 +34,7 @@ const SYMBOLE: Record<string, LucideIcon> = {
|
||||
Building2,
|
||||
Archive,
|
||||
Settings,
|
||||
Laptop,
|
||||
};
|
||||
|
||||
function NavListe({ modus, aktiv, rolle }: { modus: Betriebsmodus; aktiv: string; rolle?: Rolle }) {
|
||||
@@ -184,6 +186,15 @@ export function Huelle({
|
||||
aktiv?: string;
|
||||
benutzer?: AngemeldeterBenutzer;
|
||||
}) {
|
||||
/*
|
||||
* Scannen setzt voraus, dass man mit dem Gefundenen etwas tun darf. Ein
|
||||
* `MITARBEITER` darf das nicht (#035) — ihm den Knopf zu zeigen hiesse, ihn
|
||||
* auf eine Seite zu schicken, die ihn abweist. `lesen` ist dabei das richtige
|
||||
* Recht und nicht `geraeteBearbeiten`: ein LESER darf das gescannte Geraet
|
||||
* ansehen, nur nichts aendern.
|
||||
*/
|
||||
const darfScannen = benutzer ? darf(benutzer.rolle, "lesen") : false;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh md:flex">
|
||||
{/* Handy: Kopfzeile mit Menue */}
|
||||
@@ -191,7 +202,7 @@ export function Huelle({
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Bildmarke className="size-6 shrink-0 text-akzent" />
|
||||
<Wortmarke className="text-lg" />
|
||||
<ScannenKnopf className="ml-auto" />
|
||||
{darfScannen ? <ScannenKnopf className="ml-auto" /> : null}
|
||||
<details className="relative">
|
||||
<summary
|
||||
className="flex size-11 cursor-pointer list-none items-center justify-center rounded-pille text-nebentext"
|
||||
@@ -217,7 +228,7 @@ export function Huelle({
|
||||
<Bildmarke className="size-7 shrink-0 text-akzent" />
|
||||
<Wortmarke className="text-xl" />
|
||||
</Link>
|
||||
<ScannenKnopf className="mb-6" />
|
||||
{darfScannen ? <ScannenKnopf className="mb-6" /> : null}
|
||||
<nav aria-label="Hauptnavigation">
|
||||
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
|
||||
</nav>
|
||||
|
||||
+28
-4
@@ -30,15 +30,26 @@ export type NavEintrag = {
|
||||
readonly ausIssue?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* **Jeder Eintrag nennt sein Recht.**
|
||||
*
|
||||
* Bis #035 stand `recht` nur am Admin-Bereich, und das genuegte: jede Rolle
|
||||
* durfte `lesen`, also war alles andere fuer jeden sichtbar. Mit `MITARBEITER`
|
||||
* stimmt das nicht mehr — diese Rolle darf **nicht** `lesen`, und ohne das Recht
|
||||
* an jedem Eintrag haette die Seitenleiste ihr sechs Links angeboten, die alle
|
||||
* auf `/kein-zugriff` fuehren. Ein Eintrag ohne `recht` ist seitdem die Ausnahme
|
||||
* und nicht die Regel.
|
||||
*/
|
||||
export const NAVIGATION: readonly NavEintrag[] = [
|
||||
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", verfuegbar: true },
|
||||
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", verfuegbar: true },
|
||||
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", verfuegbar: true },
|
||||
{ pfad: "/", beschriftung: "Übersicht", symbol: "LayoutDashboard", recht: "lesen", verfuegbar: true },
|
||||
{ pfad: "/laeufe", beschriftung: "Läufe", symbol: "ListChecks", recht: "lesen", verfuegbar: true },
|
||||
{ pfad: "/vorlagen", beschriftung: "Vorlagen", symbol: "FileText", recht: "lesen", verfuegbar: true },
|
||||
{
|
||||
pfad: "/mitarbeiter",
|
||||
beschriftung: "Mitarbeiter & Geräte",
|
||||
beschriftungDienstleistung: "Personen & Geräte",
|
||||
symbol: "Users",
|
||||
recht: "lesen",
|
||||
verfuegbar: true,
|
||||
},
|
||||
{
|
||||
@@ -46,10 +57,23 @@ export const NAVIGATION: readonly NavEintrag[] = [
|
||||
beschriftung: "Kunden",
|
||||
nurModus: "DIENSTLEISTUNG",
|
||||
symbol: "Building2",
|
||||
recht: "lesen",
|
||||
verfuegbar: true,
|
||||
},
|
||||
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", verfuegbar: true },
|
||||
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", recht: "lesen", verfuegbar: true },
|
||||
{ pfad: "/admin", beschriftung: "Admin", symbol: "Settings", recht: "verwaltung", verfuegbar: true },
|
||||
/*
|
||||
* Die eine Seite der Selbstauskunft (#035). Sie steht am Ende, weil die
|
||||
* Reihenfolge fuer alle anderen Rollen gilt — und fuer einen `MITARBEITER` ist
|
||||
* sie der einzige Eintrag, da ist die Position gleichgueltig.
|
||||
*/
|
||||
{
|
||||
pfad: "/meine-geraete",
|
||||
beschriftung: "Meine Geräte",
|
||||
symbol: "Laptop",
|
||||
recht: "selbstauskunft",
|
||||
verfuegbar: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ export const ROLLEN_TEXT: Record<Rolle, string> = {
|
||||
[Rolle.REDAKTEUR]: "Redakteur",
|
||||
[Rolle.TECHNIKER]: "Techniker",
|
||||
[Rolle.LESER]: "Leser",
|
||||
[Rolle.MITARBEITER]: "Mitarbeiter (Selbstauskunft)",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -24,4 +25,11 @@ export const ROLLEN_RANG: Record<Rolle, number> = {
|
||||
[Rolle.REDAKTEUR]: 30,
|
||||
[Rolle.TECHNIKER]: 20,
|
||||
[Rolle.LESER]: 10,
|
||||
/*
|
||||
* **Unter LESER, und zwar deutlich.** Ein MITARBEITER sieht weniger als jede
|
||||
* andere Rolle: nur sich selbst. Der Rang entscheidet in `staerksteRolle`, wer
|
||||
* gewinnt, wenn jemand in mehreren abgebildeten Gruppen steht — und dort darf
|
||||
* die Selbstauskunft nie eine echte Rolle verdraengen.
|
||||
*/
|
||||
[Rolle.MITARBEITER]: 5,
|
||||
};
|
||||
|
||||
+125
-5
@@ -5,7 +5,7 @@ import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
kundenkuerzelAusGruppen,
|
||||
rolleAusGruppen,
|
||||
rolleAusGruppenGenau,
|
||||
rollenZuordnungAktiv,
|
||||
type OidcAnspruecke,
|
||||
} from "@/server/auth/oidc";
|
||||
@@ -17,10 +17,23 @@ import {
|
||||
* Verzeichnis. Hier geht es um die Menschen, die Laufzettel bedienen.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gruende, die der Aufrufer **unterscheiden** muss.
|
||||
*
|
||||
* Nicht am Meldungstext festmachen: der ist fuer Menschen und aendert sich. Die
|
||||
* beiden Gruende aus #035 fuehren auf eigene Hinweise an der Anmeldeseite, denn
|
||||
* „fehlgeschlagen“ liesse jemanden es dreimal versuchen, obwohl sich ohne Zutun
|
||||
* eines Administrators nichts aendert.
|
||||
*/
|
||||
export type AnmeldeGrund = "keine-person" | "upn-mehrdeutig";
|
||||
|
||||
export class AnmeldeFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
readonly grund: AnmeldeGrund | undefined;
|
||||
|
||||
constructor(nachricht: string, grund?: AnmeldeGrund) {
|
||||
super(nachricht);
|
||||
this.name = "AnmeldeFehler";
|
||||
this.grund = grund;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +115,112 @@ export async function passwortHashen(passwort: string): Promise<string> {
|
||||
* Aenderung im Verzeichnis greift damit ab der naechsten Anmeldung, ohne dass
|
||||
* jemand hier nachpflegen muss.
|
||||
*/
|
||||
/**
|
||||
* Die Person im Verzeichnis zu einem UPN – oder ein Fehler, wenn es mehrere sind.
|
||||
*
|
||||
* Verglichen wird **ohne** Ruecksicht auf Gross- und Kleinschreibung: ein UPN
|
||||
* ist technisch nicht case-sensitiv, und ein Anbieter schickt ihn nicht immer so,
|
||||
* wie er im Verzeichnis steht.
|
||||
*
|
||||
* **Mehrdeutig ist ein Fehler, kein Zufallstreffer.** Zwei Kunden koennen
|
||||
* dieselbe Person fuehren (dieselbe Firma, zwei Mandanten), und ein `findFirst`
|
||||
* entschiede dann nach Sortierung darueber, wessen Geraete jemand sieht. Lieber
|
||||
* kein Zugang als der falsche.
|
||||
*/
|
||||
async function personZuUpn(
|
||||
upn: string,
|
||||
): Promise<
|
||||
| { readonly treffer: "einer"; readonly id: string; readonly kundeId: string }
|
||||
| { readonly treffer: "keiner" }
|
||||
| { readonly treffer: "mehrere"; readonly anzahl: number }
|
||||
> {
|
||||
const gesucht = upn.trim();
|
||||
if (!gesucht) return { treffer: "keiner" };
|
||||
|
||||
const passende = await prisma.mitarbeiter.findMany({
|
||||
where: { upn: { equals: gesucht, mode: "insensitive" }, aktiv: true },
|
||||
select: { id: true, kundeId: true },
|
||||
take: 2,
|
||||
});
|
||||
|
||||
const erster = passende[0];
|
||||
if (!erster) return { treffer: "keiner" };
|
||||
if (passende.length > 1) return { treffer: "mehrere", anzahl: passende.length };
|
||||
return { treffer: "einer", id: erster.id, kundeId: erster.kundeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Rolle bekommt diese Anmeldung, und auf welche Person zeigt sie?
|
||||
*
|
||||
* Drei Faelle, in dieser Reihenfolge:
|
||||
*
|
||||
* 1. **Keine Gruppen-Zuordnung eingerichtet** — alles bleibt wie bisher. Die
|
||||
* Selbstauskunft greift dann **gar nicht**: ohne abgebildete Gruppen kann die
|
||||
* Anwendung nicht unterscheiden, ob jemand Techniker oder Mitarbeiter ist, und
|
||||
* ein Update duerfte niemandem seinen Zugang nehmen.
|
||||
* 2. **Eine Gruppe hat getroffen** — diese Rolle, und keine Person. Wer eine
|
||||
* echte Rolle hat, sieht ueber `lesen` ohnehin alles.
|
||||
* 3. **Zuordnung aktiv, aber keine Gruppe getroffen** — der Fall, um den es in
|
||||
* #035 geht: ueber den UPN wird eine Person gesucht. Gibt es sie, wird daraus
|
||||
* ein `MITARBEITER`; gibt es sie nicht, gibt es **keinen Zugang**.
|
||||
*
|
||||
* Fall 3 ist eine Verschaerfung gegenueber dem Stand vor #035, wo dieselbe Lage
|
||||
* `LESER` ergab — also Lesezugriff auf alle Laeufe des Hauses. Das war fuer eine
|
||||
* Person ohne jede Rollengruppe zu viel, und genau deshalb steht es im Changelog
|
||||
* als Aenderung und nicht als Ergaenzung.
|
||||
*/
|
||||
async function rolleUndPersonBestimmen(
|
||||
anspruecke: OidcAnspruecke,
|
||||
vorhanden: Benutzer | null,
|
||||
): Promise<{ readonly rolle: Rolle; readonly mitarbeiterId: string | null }> {
|
||||
if (!rollenZuordnungAktiv()) {
|
||||
// Wie bisher – und eine bestehende Zuordnung bleibt, wie sie ist.
|
||||
return {
|
||||
rolle: vorhanden?.rolle ?? "LESER",
|
||||
mitarbeiterId: vorhanden?.mitarbeiterId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const ausGruppen = rolleAusGruppenGenau(anspruecke.gruppen);
|
||||
if (ausGruppen) return { rolle: ausGruppen, mitarbeiterId: null };
|
||||
|
||||
const person = await personZuUpn(anspruecke.upn);
|
||||
|
||||
if (person.treffer === "mehrere") {
|
||||
/*
|
||||
* Protokolliert, **bevor** geworfen wird: wer das in der Oberflaeche sieht,
|
||||
* soll nicht raten muessen, warum sich jemand nicht anmelden kann. Ohne
|
||||
* Benutzerbezug, denn das Konto gibt es an dieser Stelle vielleicht noch
|
||||
* nicht — der Eintrag erscheint im Protokoll als Systemlauf.
|
||||
*/
|
||||
await prisma.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Benutzer",
|
||||
entitaetId: vorhanden?.id ?? anspruecke.sub,
|
||||
aktion: "Anmeldung abgewiesen: UPN mehrdeutig",
|
||||
nachher: { upn: anspruecke.upn, treffer: person.anzahl },
|
||||
},
|
||||
});
|
||||
throw new AnmeldeFehler(
|
||||
`Der Anmeldename ${anspruecke.upn} gehoert zu mehr als einer Person im Verzeichnis. ` +
|
||||
"Ein Administrator muss das aufloesen – welche Person gemeint ist, laesst sich hier nicht raten.",
|
||||
"upn-mehrdeutig",
|
||||
);
|
||||
}
|
||||
|
||||
if (person.treffer === "keiner") {
|
||||
throw new AnmeldeFehler(
|
||||
`Zu dem Anmeldenamen ${anspruecke.upn} gibt es keine aktive Person im Verzeichnis, und ` +
|
||||
"dieses Konto gehoert keiner Rollengruppe an. Damit gibt es nichts, was angezeigt werden " +
|
||||
"koennte – ein Administrator muss entweder eine Rolle zuweisen oder die Person im " +
|
||||
"Verzeichnis pruefen.",
|
||||
"keine-person",
|
||||
);
|
||||
}
|
||||
|
||||
return { rolle: "MITARBEITER", mitarbeiterId: person.id };
|
||||
}
|
||||
|
||||
export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Promise<Benutzer> {
|
||||
// Zuerst ueber sub suchen: die Adresse eines Kontos kann sich aendern, die
|
||||
// Kennung beim Anbieter nicht.
|
||||
@@ -133,9 +252,8 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr
|
||||
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
|
||||
}
|
||||
|
||||
// Ohne eingerichtete Gruppen-Zuordnung bleibt die Rolle, wie sie ist – sonst
|
||||
// waere jede Anmeldung eine stille Herabstufung auf LESER.
|
||||
const rolle = rollenZuordnungAktiv() ? rolleAusGruppen(anspruecke.gruppen) : (vorhanden?.rolle ?? "LESER");
|
||||
const zuweisung = await rolleUndPersonBestimmen(anspruecke, vorhanden);
|
||||
const { rolle, mitarbeiterId } = zuweisung;
|
||||
|
||||
const benutzer = vorhanden
|
||||
? await prisma.benutzer.update({
|
||||
@@ -145,6 +263,7 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr
|
||||
email: anspruecke.email,
|
||||
name: anspruecke.name,
|
||||
rolle,
|
||||
mitarbeiterId,
|
||||
letzteAnmeldungAm: new Date(),
|
||||
},
|
||||
})
|
||||
@@ -154,6 +273,7 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr
|
||||
email: anspruecke.email,
|
||||
name: anspruecke.name,
|
||||
rolle,
|
||||
mitarbeiterId,
|
||||
aktiv: true,
|
||||
letzteAnmeldungAm: new Date(),
|
||||
},
|
||||
|
||||
+26
-3
@@ -1,5 +1,5 @@
|
||||
import * as oidc from "openid-client";
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
import { Rolle } from "@/generated/prisma/enums";
|
||||
import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
|
||||
import { staerksteRolle } from "@/server/auth/rollen";
|
||||
|
||||
@@ -139,6 +139,16 @@ export type OidcAnspruecke = {
|
||||
readonly emailBestaetigt: boolean;
|
||||
readonly name: string;
|
||||
readonly gruppen: readonly string[];
|
||||
/**
|
||||
* Der UPN, an dem die Selbstauskunft haengt (#035).
|
||||
*
|
||||
* Reihenfolge mit Absicht: `upn` (so heisst der Anspruch bei Entra ID),
|
||||
* sonst `preferred_username` (authentik und Keycloak schicken dort den
|
||||
* Anmeldenamen), sonst die Mailadresse. Die Mailadresse zuletzt, weil sie im
|
||||
* AD oft **nicht** der UPN ist — `vorname.nachname@firma.de` gegen
|
||||
* `vnachname@firma.local`.
|
||||
*/
|
||||
readonly upn: string;
|
||||
/** Ziel, das vor der Anmeldung angefordert wurde. */
|
||||
readonly ziel: string;
|
||||
};
|
||||
@@ -199,6 +209,7 @@ export async function anmeldungAbschliessen(
|
||||
emailBestaetigt: anspruecke["email_verified"] === true,
|
||||
name: textAus(anspruecke["name"]) ?? textAus(anspruecke["preferred_username"]) ?? email,
|
||||
gruppen: gruppenAus(anspruecke["groups"]),
|
||||
upn: textAus(anspruecke["upn"]) ?? textAus(anspruecke["preferred_username"]) ?? email,
|
||||
ziel: fluss["ziel"] ?? "/",
|
||||
};
|
||||
}
|
||||
@@ -223,7 +234,15 @@ export function rollenZuordnungAktiv(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function rolleAusGruppen(gruppen: readonly string[]): Rolle {
|
||||
/**
|
||||
* Die abgebildete Rolle – oder `undefined`, wenn **keine** Gruppe getroffen hat.
|
||||
*
|
||||
* Die Unterscheidung ist der Angelpunkt der Selbstauskunft (#035): „keine
|
||||
* Gruppe getroffen“ und „die Gruppe fuer LESER getroffen“ sahen bis dahin
|
||||
* gleich aus, weil beides `LESER` ergab. Erst wer sie trennt, kann aus dem
|
||||
* ersten Fall eine Selbstauskunft machen, ohne dem zweiten etwas zu nehmen.
|
||||
*/
|
||||
export function rolleAusGruppenGenau(gruppen: readonly string[]): Rolle | undefined {
|
||||
const zuordnung: readonly (readonly [string | undefined, Rolle])[] = [
|
||||
[umgebung("OIDC_GROUP_ADMIN"), "ADMIN"],
|
||||
[umgebung("OIDC_GROUP_EDITOR"), "REDAKTEUR"],
|
||||
@@ -234,7 +253,11 @@ export function rolleAusGruppen(gruppen: readonly string[]): Rolle {
|
||||
const getroffen = zuordnung
|
||||
.filter(([gruppe]) => gruppe !== undefined && vorhanden.has(gruppe))
|
||||
.map(([, rolle]) => rolle);
|
||||
return staerksteRolle(getroffen);
|
||||
return getroffen.length === 0 ? undefined : staerksteRolle(getroffen);
|
||||
}
|
||||
|
||||
export function rolleAusGruppen(gruppen: readonly string[]): Rolle {
|
||||
return rolleAusGruppenGenau(gruppen) ?? Rolle.LESER;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,8 +20,24 @@ import { ROLLEN_RANG, ROLLEN_TEXT } from "@/lib/rollen-text";
|
||||
* des Cookies.
|
||||
*/
|
||||
export const RECHTE = {
|
||||
/** Vorlagen, Laeufe, Geraete und Verzeichnis ansehen. Jede Rolle darf das. */
|
||||
/**
|
||||
* Vorlagen, Laeufe, Geraete und Verzeichnis ansehen.
|
||||
*
|
||||
* **Nicht mehr „jede Rolle“** (#035): `MITARBEITER` fehlt hier, und das ist
|
||||
* der ganze Punkt dieser Rolle. Wer sich selbst auskunftsberechtigt anmeldet,
|
||||
* darf nicht die Laeufe des Hauses lesen — dafuer gibt es `selbstauskunft`.
|
||||
*/
|
||||
lesen: ["ADMIN", "REDAKTEUR", "TECHNIKER", "LESER"],
|
||||
/**
|
||||
* Die eigenen Geraete und die Laeufe dazu ansehen (#035).
|
||||
*
|
||||
* **Nur `MITARBEITER`.** Die anderen Rollen sehen ueber `lesen` ohnehin alles;
|
||||
* sie hier mit aufzunehmen waere ein zweiter Weg zu denselben Daten und damit
|
||||
* eine zweite Stelle, an der jemand die Einschraenkung auf die eigene Person
|
||||
* vergessen kann. Ein Techniker, der seine eigenen Geraete sehen will, nimmt
|
||||
* die gewoehnliche Geraeteliste.
|
||||
*/
|
||||
selbstauskunft: ["MITARBEITER"],
|
||||
/** Laeufe starten, abarbeiten, abschliessen und archivieren. */
|
||||
laeufeBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
|
||||
/** Geraete und deren Stammdaten pflegen, Tags registrieren. */
|
||||
@@ -57,18 +73,59 @@ export function darf(rolle: Rolle, recht: Recht): boolean {
|
||||
* Die staerkste der uebergebenen Rollen.
|
||||
*
|
||||
* Der OIDC-Login braucht das: ist jemand in mehreren abgebildeten Gruppen, gewinnt die
|
||||
* staerkere Rolle. Eine leere Liste ergibt `LESER` -- nie ein Fehler und nie `ADMIN`. Wer
|
||||
* sich anmelden darf, aber keiner abgebildeten Gruppe angehoert, soll hineinsehen koennen
|
||||
* und nichts aendern; ein Fehler an dieser Stelle wuerde stattdessen den Login blockieren.
|
||||
* staerkere Rolle. Eine leere Liste ergibt `LESER` -- nie ein Fehler und nie `ADMIN`.
|
||||
*
|
||||
* **Das gilt weiter fuer eine eingerichtete Zuordnung ohne Treffer in dieser
|
||||
* Funktion.** Ob aus „keine Gruppe getroffen“ eine Selbstauskunft wird, entscheidet
|
||||
* seit #035 der Login ueber `rolleAusGruppenGenau` — hier bleibt der Standard
|
||||
* `LESER`, damit ein Aufrufer ohne diese Unterscheidung nie versehentlich mehr
|
||||
* bekommt als vorher.
|
||||
*/
|
||||
export function staerksteRolle(rollen: readonly Rolle[]): Rolle {
|
||||
let staerkste: Rolle = Rolle.LESER;
|
||||
/*
|
||||
* `undefined` als Startwert, nicht `LESER`.
|
||||
*
|
||||
* Mit `LESER` als Startwert war der Wert zugleich **Untergrenze**: eine
|
||||
* uebergebene Rolle unterhalb davon kam nie heraus. Solange alle Rollen
|
||||
* mindestens `LESER` waren, fiel das nicht auf — mit `MITARBEITER` (#035) gab
|
||||
* `staerksteRolle(["MITARBEITER"])` ploetzlich `LESER`, also **mehr** Rechte
|
||||
* als uebergeben. Der Test „gibt bei nur <Rolle> diese Rolle zurueck“ stand
|
||||
* seit #007 da und hat es sofort gezeigt.
|
||||
*
|
||||
* Der Rueckfall auf `LESER` gilt jetzt nur noch fuer die **leere** Liste, und
|
||||
* genau so steht es auch im Kommentar darueber.
|
||||
*/
|
||||
let staerkste: Rolle | undefined;
|
||||
for (const rolle of rollen) {
|
||||
if (ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) {
|
||||
if (staerkste === undefined || ROLLEN_RANG[rolle] > ROLLEN_RANG[staerkste]) {
|
||||
staerkste = rolle;
|
||||
}
|
||||
}
|
||||
return staerkste;
|
||||
return staerkste ?? Rolle.LESER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wo diese Rolle nach der Anmeldung landet (#035).
|
||||
*
|
||||
* **Gefunden beim Durchklicken:** ein `MITARBEITER` meldete sich an und sah
|
||||
* „Dafuer fehlt dir die Berechtigung“ — das Standardziel ist `/`, und die
|
||||
* Uebersicht verlangt `lesen`. Genau die leere Seite, die wie ein Fehler
|
||||
* aussieht und die #035 vermeiden will.
|
||||
*
|
||||
* Eine reine Funktion und nicht ein `if` in der Seite: dieselbe Antwort braucht
|
||||
* die Uebersicht (zum Umleiten), `/kein-zugriff` (fuer seinen Knopf — der auf
|
||||
* `/` zeigte und damit im Kreis) und spaeter jede weitere Rolle mit eigenem
|
||||
* Einstieg.
|
||||
*/
|
||||
export function startseite(rolle: Rolle): string {
|
||||
if (darf(rolle, "lesen")) return "/";
|
||||
if (darf(rolle, "selbstauskunft")) return "/meine-geraete";
|
||||
/*
|
||||
* Kein Recht fuer irgendeinen Einstieg. Gibt es heute nicht — jede Rolle hat
|
||||
* entweder `lesen` oder `selbstauskunft` —, und `/` ist hier die ehrlichere
|
||||
* Antwort als ein Wurf: dort erklaert die Seite, was los ist.
|
||||
*/
|
||||
return "/";
|
||||
}
|
||||
|
||||
// Beschriftung und Rang liegen in src/lib, damit Client-Komponenten sie ohne
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { LaufStatus, type GeraeteTyp } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Was ein Mitarbeiter über sich selbst sehen darf (#035).
|
||||
*
|
||||
* **Die wichtigste Entscheidung dieses Meilensteins steht hier im Code und nicht
|
||||
* in der Seite:** eine Selbstauskunft ist **keine Akteneinsicht**. Zurück kommen
|
||||
* Titel, Status, Zeitraum und das Gerät eines Laufs — und sonst nichts.
|
||||
*
|
||||
* Nicht dabei, und zwar ausdrücklich:
|
||||
*
|
||||
* - **Schritte.** In ihnen stehen Befehle mit Platzhaltern für Zugangsdaten.
|
||||
* - **Notizen.** „Gerät kam beschädigt zurück, Nutzer unkooperativ“ ist eine
|
||||
* Arbeitsnotiz über einen Menschen, nicht eine Auskunft an ihn.
|
||||
* - **Kommentare.** Dasselbe, nur länger.
|
||||
* - **Anhänge.** Ein Foto vom Typenschild ist harmlos, eines vom Schreibtisch
|
||||
* nicht — und welches es ist, weiß hier niemand.
|
||||
*
|
||||
* Der Typ `MeinLauf` trägt deshalb nichts davon, und das `select` unten holt es
|
||||
* gar nicht erst. Wer es ändern wollte, müsste beides anfassen und stünde vor
|
||||
* diesem Kommentar.
|
||||
*
|
||||
* **Jede Abfrage schränkt selbst ein** (CLAUDE.md): die `mitarbeiterId` kommt
|
||||
* als Parameter, und ohne sie gibt es keine Abfrage. Eine Seite, die filtert,
|
||||
* wäre eine Seite, die es vergessen kann.
|
||||
*/
|
||||
|
||||
export type MeinLauf = {
|
||||
readonly id: string;
|
||||
readonly titel: string;
|
||||
readonly status: LaufStatus;
|
||||
readonly vorlagenName: string;
|
||||
readonly begonnenAm: Date | null;
|
||||
readonly abgeschlossenAm: Date | null;
|
||||
readonly erstelltAm: Date;
|
||||
};
|
||||
|
||||
export type MeinGeraet = {
|
||||
readonly id: string;
|
||||
readonly bezeichnung: string;
|
||||
readonly typ: GeraeteTyp;
|
||||
readonly tagCode: string;
|
||||
readonly seriennummer: string | null;
|
||||
readonly archiviert: boolean;
|
||||
readonly laeufe: readonly MeinLauf[];
|
||||
};
|
||||
|
||||
/** Nur die Felder, die eine Selbstauskunft tragen darf. */
|
||||
const LAUF_AUSWAHL = {
|
||||
id: true,
|
||||
titel: true,
|
||||
status: true,
|
||||
gestartetAm: true,
|
||||
abgeschlossenAm: true,
|
||||
erstelltAm: true,
|
||||
vorlagenVersion: { select: { vorlage: { select: { name: true } } } },
|
||||
} as const;
|
||||
|
||||
type RoherLauf = {
|
||||
readonly id: string;
|
||||
readonly titel: string;
|
||||
readonly status: LaufStatus;
|
||||
readonly gestartetAm: Date | null;
|
||||
readonly abgeschlossenAm: Date | null;
|
||||
readonly erstelltAm: Date;
|
||||
readonly vorlagenVersion: { readonly vorlage: { readonly name: string } };
|
||||
};
|
||||
|
||||
function laufBauen(zeile: RoherLauf): MeinLauf {
|
||||
return {
|
||||
id: zeile.id,
|
||||
titel: zeile.titel,
|
||||
status: zeile.status,
|
||||
vorlagenName: zeile.vorlagenVersion.vorlage.name,
|
||||
begonnenAm: zeile.gestartetAm,
|
||||
abgeschlossenAm: zeile.abgeschlossenAm,
|
||||
erstelltAm: zeile.erstelltAm,
|
||||
};
|
||||
}
|
||||
|
||||
/** Der Anzeigename eines Geraets, wie ihn auch die Geraeteliste bildet. */
|
||||
function geraetName(geraet: {
|
||||
readonly hostname: string | null;
|
||||
readonly bezeichnung: string | null;
|
||||
readonly tagCode: string;
|
||||
}): string {
|
||||
return geraet.hostname ?? geraet.bezeichnung ?? geraet.tagCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meine Geräte, je Gerät die Läufe chronologisch.
|
||||
*
|
||||
* Archivierte Geräte bleiben dabei: „was ist mit meinem alten Notebook
|
||||
* passiert?“ ist genau die Frage, um die es geht, und das alte Notebook ist
|
||||
* archiviert.
|
||||
*/
|
||||
export async function meineGeraete(mitarbeiterId: string): Promise<readonly MeinGeraet[]> {
|
||||
const zeilen = await prisma.geraet.findMany({
|
||||
where: { mitarbeiterId },
|
||||
orderBy: [{ archiviert: "asc" }, { erstelltAm: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
bezeichnung: true,
|
||||
tagCode: true,
|
||||
typ: true,
|
||||
seriennummer: true,
|
||||
archiviert: true,
|
||||
laeufe: { select: LAUF_AUSWAHL, orderBy: { erstelltAm: "desc" } },
|
||||
},
|
||||
});
|
||||
|
||||
return zeilen.map((zeile) => ({
|
||||
id: zeile.id,
|
||||
bezeichnung: geraetName(zeile),
|
||||
typ: zeile.typ,
|
||||
tagCode: zeile.tagCode,
|
||||
seriennummer: zeile.seriennummer,
|
||||
archiviert: zeile.archiviert,
|
||||
laeufe: zeile.laeufe.map(laufBauen),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Läufe, die an **mir** hängen und nicht an einem Gerät.
|
||||
*
|
||||
* Das Onboarding ist der Fall: es läuft auf die Person, nicht auf ein Notebook.
|
||||
* Läufe mit Gerät stehen schon unter dem Gerät — hier noch einmal wären sie
|
||||
* doppelt.
|
||||
*/
|
||||
export async function meineLaeufeOhneGeraet(mitarbeiterId: string): Promise<readonly MeinLauf[]> {
|
||||
const zeilen = await prisma.lauf.findMany({
|
||||
where: { mitarbeiterId, geraetId: null },
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
select: LAUF_AUSWAHL,
|
||||
});
|
||||
return zeilen.map(laufBauen);
|
||||
}
|
||||
|
||||
export type MeineDaten = {
|
||||
readonly anzeigename: string;
|
||||
readonly abteilung: string | null;
|
||||
readonly titel: string | null;
|
||||
readonly mail: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Die eigenen Stammdaten, wie das Verzeichnis sie führt.
|
||||
*
|
||||
* Dabei, weil die häufigste Rückfrage nach einem Abgleich „steht meine Abteilung
|
||||
* richtig drin?“ ist — und weil jemand sehen soll, auf welche Person dieses
|
||||
* Konto zeigt. `undefined`, wenn die Zeile verschwunden ist; die Seite macht
|
||||
* daraus einen Hinweis und keine leere Fläche.
|
||||
*/
|
||||
export async function meineDaten(mitarbeiterId: string): Promise<MeineDaten | undefined> {
|
||||
const zeile = await prisma.mitarbeiter.findUnique({
|
||||
where: { id: mitarbeiterId },
|
||||
select: { anzeigename: true, abteilung: true, titel: true, mail: true },
|
||||
});
|
||||
return zeile ?? undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user