Files
qrcode-generator/src/app/(app)/konto/page.tsx
T
Florian Wilde b828f38ca5 Dark Mode: folgt dem System, mit Umschalter
Drei Zustaende in der Kopfzeile — System, Hell, Dunkel. Drei statt zwei, weil
ein reiner Ein/Aus-Schalter nach dem ersten Klick fuer immer eine feste Wahl
bedeutet; wer "System" waehlt, bekommt abends dunkel und morgens hell.

Umgesetzt ueber `light-dark()` im CSS, nicht ueber `dark:`-Klassen. Die
Oberflaeche benutzt an rund 440 Stellen feste Farbklassen; jede davon zu
verdoppeln waere eine riesige Fleissarbeit, und jede kuenftige Zeile muesste
daran denken. Stattdessen entscheiden die Farbvariablen selbst, welchen Wert
sie tragen — gesteuert ueber `color-scheme`. Deshalb genuegt fuer den
Umschalter ein Attribut am html-Element, und die Systemwahl funktioniert
sogar ohne JavaScript. Das Skript im Kopf setzt nur eine ausdrueckliche Wahl,
damit die helle Fassung nicht kurz aufblitzt.

Zwei Faelle liessen sich nicht ueber Variablen loesen: `bg-white` muss im
Dunkeln eine dunkle Flaeche sein, `text-white` dagegen weiss bleiben — beide
teilen sich aber dieselbe Variable. Fuer Flaechen gibt es deshalb jetzt
`bg-surface`. Ebenso brauchen Akzent-Links im Dunkeln eine hellere Stufe als
Akzent-Knoepfe: dafuer `text-accent-text` und `bg-accent-soft`.

Bewusst unveraendert bleiben die QR-Codes selbst — deren Farben legt die
Gestaltung fest.

Die dunklen Werte sind keine Spiegelung: Reines Schwarz unter weisser Schrift
flimmert. Alle Textkontraste sind gemessen und liegen ueber 4,5:1, der
niedrigste bei 5,9:1.
2026-08-16 09:09:10 +02:00

154 lines
5.5 KiB
TypeScript

import type { Metadata } from "next";
import Link from "next/link";
import { ChangePasswordForm, SignOutCard } from "@/components/account";
import { Badge, Card, CardTitle, Notice } from "@/components/ui";
import { isAuthEnabled } from "@/lib/auth/actor";
import { requirePageActor } from "@/lib/auth/guard";
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
import { ROLE_DESCRIPTIONS, ROLE_LABELS } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Konto" };
export const dynamic = "force-dynamic";
export default async function AccountPage() {
const actor = await requirePageActor();
// Konten aus dem externen Verzeichnis haben hier kein Passwort.
const ausVerzeichnis =
actor.kind === "user"
? ((await prisma.user.findUnique({
where: { id: actor.user.id },
select: { passwordHash: true },
}))?.passwordHash ?? null) === null
: false;
// Bei ausgeschalteter Anmeldung gibt es kein Konto — dann erklärt die Seite,
// wie sich das umstellen lässt, statt einfach leer zu sein.
if (actor.kind !== "user") {
return (
<div className="mx-auto max-w-2xl px-4 py-10 sm:px-6">
<h1 className="text-2xl font-bold tracking-tight text-slate-900">
Konto
</h1>
<div className="mt-5">
<Notice tone="info">
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
Es gibt deshalb kein Konto und niemanden zum Abmelden — wer die
Anwendung erreicht, darf alles. Zum Einschalten{" "}
<code className="rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
AUTH_ENABLED=true
</code>{" "}
in der <code className="font-mono text-xs">.env</code> setzen und{" "}
<code className="rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
docker compose up -d
</code>{" "}
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
an.
</Notice>
</div>
<Link
href="/"
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
Zur Typ-Auswahl
</Link>
</div>
);
}
const { user } = actor;
return (
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Konto
</h1>
<p className="mt-2 text-slate-600">
Deine Anmeldedaten und die Gruppen, in denen du mitarbeitest.
</p>
</div>
<div className="space-y-4">
<Card>
<CardTitle>Angemeldet als</CardTitle>
<dl className="space-y-2 text-sm">
<div className="flex flex-wrap gap-x-2">
<dt className="text-slate-500">Name:</dt>
<dd className="text-slate-900">{user.name?.trim() || "—"}</dd>
</div>
<div className="flex flex-wrap gap-x-2">
<dt className="text-slate-500">E-Mail:</dt>
<dd className="text-slate-900">{user.email}</dd>
</div>
<div className="flex flex-wrap items-center gap-x-2">
<dt className="text-slate-500">Rolle:</dt>
<dd>
{user.isInstanceAdmin ? (
<Badge tone="accent">Administrator der Installation</Badge>
) : (
<span className="text-slate-900">Benutzer</span>
)}
</dd>
</div>
</dl>
</Card>
<Card>
<CardTitle
hint={
user.isInstanceAdmin
? "Als Administrator der Installation siehst du ohnehin alle Gruppen."
: undefined
}
>
Meine Gruppen
</CardTitle>
{user.memberships.length === 0 ? (
<p className="text-sm text-slate-600">
Du bist in keiner Gruppe. Bitte einen Administrator, dich einer
zuzuordnen — sonst kannst du keine Codes anlegen.
</p>
) : (
<ul className="space-y-2">
{user.memberships.map((membership) => (
<li
key={membership.groupId}
className="flex flex-wrap items-center gap-2 rounded-lg border border-slate-200 p-3"
>
<span className="font-medium text-slate-900">
{membership.groupName}
</span>
<Badge tone="neutral">{ROLE_LABELS[membership.role]}</Badge>
<span className="w-full text-sm text-slate-500">
{ROLE_DESCRIPTIONS[membership.role]}
</span>
</li>
))}
</ul>
)}
</Card>
{isAuthEnabled() ? (
<>
{ausVerzeichnis ? (
<Card>
<CardTitle>Passwort</CardTitle>
<p className="mt-2 text-sm text-slate-600">
Dieses Konto meldet sich über den externen Anbieter an. Ein
Passwort gibt es hier nicht — geändert wird es beim Anbieter.
</p>
</Card>
) : (
<ChangePasswordForm minLength={MIN_PASSWORD_LENGTH} />
)}
<SignOutCard />
</>
) : null}
</div>
</div>
);
}