diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts index 99987b6..88853d6 100644 --- a/src/app/api/auth/route.ts +++ b/src/app/api/auth/route.ts @@ -8,7 +8,11 @@ import { MIN_PASSWORD_LENGTH, verifyPassword, } from "@/lib/auth/password"; -import { createSession, destroySession } from "@/lib/auth/session"; +import { + createSession, + destroyAllSessions, + destroySession, +} from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { QrRequestError } from "@/lib/qr/type-module"; @@ -46,10 +50,21 @@ const setupSchema = z.object({ const logoutSchema = z.object({ action: z.literal("logout") }); +const changePasswordSchema = z.object({ + action: z.literal("change-password"), + currentPassword: z.string().min(1, { + error: "Bitte gib dein bisheriges Passwort ein.", + }), + newPassword: z.string().min(MIN_PASSWORD_LENGTH, { + error: `Das neue Passwort ist zu kurz. ${describePasswordRules()}`, + }), +}); + const bodySchema = z.discriminatedUnion("action", [ loginSchema, setupSchema, logoutSchema, + changePasswordSchema, ]); /** Wer ist angemeldet? Von der Oberfläche für den Kopfbereich genutzt. */ @@ -96,6 +111,9 @@ export async function POST(request: NextRequest): Promise { // Das `await` ist hier nicht optional: ohne es verlässt das Promise den // try-Block, bevor es sich auflöst — eine Ablehnung käme dann nie im // catch an und würde als nackter 500er beim Aufrufer landen. + if (parsed.data.action === "change-password") { + return await handleChangePassword(parsed.data); + } if (parsed.data.action === "setup") { return await handleSetup(parsed.data); } @@ -105,6 +123,51 @@ export async function POST(request: NextRequest): Promise { } } +/** + * Eigenes Passwort ändern. Das bisherige muss stimmen — sonst könnte jemand + * an einem unbeaufsichtigten Rechner das Konto übernehmen. + */ +async function handleChangePassword( + input: z.infer, +): Promise { + const actor = await getActor(); + if (actor.kind !== "user") { + throw new QrRequestError("Bitte melde dich an.", 401); + } + + const user = await prisma.user.findUnique({ where: { id: actor.user.id } }); + if (!user) { + throw new QrRequestError("Dieses Konto existiert nicht mehr.", 404); + } + + if (!(await verifyPassword(input.currentPassword, user.passwordHash))) { + throw new QrRequestError("Das bisherige Passwort stimmt nicht.", 403); + } + + if (input.currentPassword === input.newPassword) { + throw new QrRequestError( + "Das neue Passwort ist mit dem bisherigen identisch.", + ); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash: await hashPassword(input.newPassword), + failedLogins: 0, + lockedUntil: null, + }, + }); + + // Alle Sitzungen beenden — auch die eigene — und sofort eine neue + // aufmachen. So fliegen andere Geräte raus, ohne dass man sich hier neu + // anmelden muss. + await destroyAllSessions(user.id); + await createSession(user.id); + + return Response.json({ ok: true }); +} + /** Legt den ersten Benutzer an — nur solange es noch keinen gibt. */ async function handleSetup( input: z.infer, diff --git a/src/app/konto/page.tsx b/src/app/konto/page.tsx new file mode 100644 index 0000000..f43b8c1 --- /dev/null +++ b/src/app/konto/page.tsx @@ -0,0 +1,133 @@ +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"; + +export const metadata: Metadata = { title: "Konto" }; +export const dynamic = "force-dynamic"; + +export default async function AccountPage() { + const actor = await requirePageActor(); + + // 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 ( +
+

+ Konto +

+
+ + Die Anmeldung ist ausgeschaltet.{" "} + Es gibt deshalb kein Konto und niemanden zum Abmelden — wer die + Anwendung erreicht, darf alles. Zum Einschalten{" "} + + AUTH_ENABLED=true + {" "} + in der .env setzen und{" "} + + docker compose up -d + {" "} + ausführen. Beim nächsten Aufruf legst du den ersten Administrator + an. + +
+ + Zur Typ-Auswahl + +
+ ); + } + + const { user } = actor; + + return ( +
+
+

+ Konto +

+

+ Deine Anmeldedaten und die Gruppen, in denen du mitarbeitest. +

+
+ +
+ + Angemeldet als +
+
+
Name:
+
{user.name?.trim() || "—"}
+
+
+
E-Mail:
+
{user.email}
+
+
+
Rolle:
+
+ {user.isInstanceAdmin ? ( + Administrator der Installation + ) : ( + Benutzer + )} +
+
+
+
+ + + + Meine Gruppen + + {user.memberships.length === 0 ? ( +

+ Du bist in keiner Gruppe. Bitte einen Administrator, dich einer + zuzuordnen — sonst kannst du keine Codes anlegen. +

+ ) : ( +
    + {user.memberships.map((membership) => ( +
  • + + {membership.groupName} + + {ROLE_LABELS[membership.role]} + + {ROLE_DESCRIPTIONS[membership.role]} + +
  • + ))} +
+ )} +
+ + {isAuthEnabled() ? ( + <> + + + + ) : null} +
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a9533a7..1fb47f2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -65,11 +65,21 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { {isAuthEnabled() && actor.kind === "user" ? ( <> + + {actorLabel(actor).slice(0, 2)} + + + {actorLabel(actor)} + + ) : null} @@ -79,6 +89,24 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { Anmelden ) : null} + + {/* Ohne Anmeldung gibt es kein Konto. Das gehört sichtbar + gesagt — sonst sucht man den Abmelden-Knopf vergeblich. */} + {!isAuthEnabled() ? ( + <> +