Files
qrcode-generator/src/app/api/admin/users/[id]/route.ts
T
Florian Wilde f1cdb3da13 Phase B1: Anmeldung, Benutzer, Gruppen und Rollen
Umschaltbar ueber AUTH_ENABLED in der .env. Steht sie auf false, verhaelt
sich alles wie bisher - geprueft mit den 200 bestehenden Pruefungen.

Kern ist eine Actor- und Policy-Schicht: getActor() liefert bei
ausgeschalteter Anmeldung einen system-Actor mit Vollzugriff, sonst den
angemeldeten Benutzer. Dadurch braucht keine einzige Route ein
'if (anmeldungAktiv)' - sie fragen alle nur can() bzw. requirePermission().

- Datenmodell: User, Session, Group, Membership; Codes bekommen Gruppe und
  Ersteller. Die Migration legt eine Standardgruppe an und ordnet ihr
  bestehende Codes zu, bevor die Spalte verpflichtend wird - sonst
  scheiterte sie auf jeder bereits benutzten Installation.
- Passwoerter mit scrypt aus der Node-Standardbibliothek. Bewusst kein
  Argon2: das braeuchte ein natives Modul passend zur Container-Plattform.
- Sitzungen in der Datenbank, im Cookie nur ein Zufallstoken; gespeichert
  wird dessen SHA-256. Deaktivieren oder ein neues Passwort beendet alle
  offenen Sitzungen sofort.
- Aussperrschutz: der letzte aktive Administrator kann sich nicht selbst
  entmachten, deaktivieren oder loeschen. Der Schalter liegt in der .env,
  nicht in der Oberflaeche - so bleibt der Weg zurueck immer offen.
- Ersteinrichtung legt den ersten Administrator an und schliesst sich danach.
- Die Weiterleitung /r/{slug} bleibt oeffentlich, sonst liesse sich kein
  gedruckter Code mehr scannen.

Zwei Fehler dabei gefunden und behoben:

- 'return handleLogin(...)' innerhalb eines try-Blocks gibt das Promise
  zurueck, bevor es sich aufloest. Die Ablehnung kam nie im catch an und
  wurde zu einem nackten 500er statt einer 401. Jetzt mit await.
- Die Fehlerklassen wurden ueber instanceof erkannt. type-module.ts landet
  aber sowohl im Server- als auch im Client-Buendel, es gibt also zwei
  Klassenidentitaeten - saubere 400er wurden dadurch still zu 500ern. Die
  Erkennung laeuft jetzt ueber eine Eigenschaft.

Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
2026-08-14 12:31:16 +02:00

172 lines
4.9 KiB
TypeScript

import type { NextRequest } from "next/server";
import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import {
describePasswordRules,
hashPassword,
MIN_PASSWORD_LENGTH,
} from "@/lib/auth/password";
import { requirePermission } from "@/lib/auth/policy";
import { destroyAllSessions } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
const updateSchema = z.object({
name: z.string().trim().max(120).nullable().optional(),
isActive: z.boolean().optional(),
isInstanceAdmin: z.boolean().optional(),
password: z
.string()
.min(MIN_PASSWORD_LENGTH, {
error: `Das Passwort ist zu kurz. ${describePasswordRules()}`,
})
.optional(),
/** Sperre nach Fehlversuchen aufheben. */
unlock: z.boolean().optional(),
});
/**
* Verhindert, dass sich die Installation selbst aussperrt: es muss immer
* mindestens ein aktiver Administrator übrig bleiben.
*/
async function assertNotLastAdmin(userId: string, action: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { isInstanceAdmin: true, isActive: true },
});
if (!user?.isInstanceAdmin || !user.isActive) return;
const others = await prisma.user.count({
where: { isInstanceAdmin: true, isActive: true, id: { not: userId } },
});
if (others === 0) {
throw new QrRequestError(
`Das ist der letzte aktive Administrator — ${action} würde die Installation unbedienbar machen. Lege zuerst einen weiteren Administrator an.`,
);
}
}
export async function PATCH(
request: NextRequest,
context: Context,
): Promise<Response> {
try {
const actor = await getActor();
requirePermission(actor, "instance:manage");
const { id } = await context.params;
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) {
throw new QrRequestError("Dieser Benutzer existiert nicht (mehr).", 404);
}
const data: {
name?: string | null;
isActive?: boolean;
isInstanceAdmin?: boolean;
passwordHash?: string;
failedLogins?: number;
lockedUntil?: Date | null;
} = {};
if (parsed.data.name !== undefined) {
data.name = parsed.data.name || null;
}
if (parsed.data.isActive === false) {
await assertNotLastAdmin(id, "das Deaktivieren");
data.isActive = false;
} else if (parsed.data.isActive === true) {
data.isActive = true;
}
if (parsed.data.isInstanceAdmin === false) {
await assertNotLastAdmin(id, "der Entzug der Administratorrechte");
data.isInstanceAdmin = false;
} else if (parsed.data.isInstanceAdmin === true) {
data.isInstanceAdmin = true;
}
if (parsed.data.password) {
data.passwordHash = await hashPassword(parsed.data.password);
data.failedLogins = 0;
data.lockedUntil = null;
}
if (parsed.data.unlock) {
data.failedLogins = 0;
data.lockedUntil = null;
}
if (Object.keys(data).length === 0) {
return jsonError("Es wurden keine Änderungen übermittelt.");
}
const updated = await prisma.user.update({ where: { id }, data });
// Deaktivieren oder ein neues Passwort beenden laufende Sitzungen —
// sonst bliebe der Betroffene angemeldet.
if (data.isActive === false || data.passwordHash) {
await destroyAllSessions(id);
}
return Response.json({
user: {
id: updated.id,
email: updated.email,
isActive: updated.isActive,
isInstanceAdmin: updated.isInstanceAdmin,
},
});
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(
_request: NextRequest,
context: Context,
): Promise<Response> {
try {
const actor = await getActor();
requirePermission(actor, "instance:manage");
const { id } = await context.params;
if (actor.kind === "user" && actor.user.id === id) {
throw new QrRequestError(
"Du kannst dein eigenes Konto nicht löschen.",
);
}
const target = await prisma.user.findUnique({
where: { id },
select: { id: true },
});
if (!target) {
throw new QrRequestError("Dieser Benutzer existiert nicht (mehr).", 404);
}
await assertNotLastAdmin(id, "das Löschen");
// Die Codes des Benutzers bleiben bestehen; nur der Ersteller entfällt
// (onDelete: SetNull). Gedruckte Codes dürfen nicht daran hängen, wer
// sie einmal angelegt hat.
await prisma.user.delete({ where: { id } });
return new Response(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}