Initial commit: Kalendartool (Next.js, Prisma, Docker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
95
app/api/users/[id]/route.ts
Normal file
95
app/api/users/[id]/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* PATCH /api/users/[id] - Rolle eines Benutzers aendern (nur ADMIN).
|
||||
*
|
||||
* Sicherheitsregeln:
|
||||
* - Nur ADMIN darf Rollen veraendern.
|
||||
* - Ein Admin kann sich nicht selbst degradieren (verhindert, dass
|
||||
* das letzte Admin-Konto versehentlich die Rechte verliert).
|
||||
* - Der letzte verbleibende Admin kann nicht degradiert werden.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db/client';
|
||||
import { requireDbUser } from '@/lib/api/context';
|
||||
import { updateRoleSchema } from '@/lib/validation';
|
||||
import {
|
||||
errorResponse,
|
||||
forbiddenResponse,
|
||||
notFoundResponse,
|
||||
validationErrorResponse,
|
||||
} from '@/lib/api/responses';
|
||||
import { withSerializableTx } from '@/lib/db/tx';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const actingUser = await requireDbUser();
|
||||
if (actingUser.role !== 'ADMIN') {
|
||||
return forbiddenResponse();
|
||||
}
|
||||
|
||||
const body = updateRoleSchema.parse(await request.json());
|
||||
|
||||
// Selbst-Degradierung blockieren: sonst sperrt sich der Admin
|
||||
// moeglicherweise selbst aus der Verwaltung aus.
|
||||
if (id === actingUser.id && body.role !== 'ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Die eigene Rolle kann nicht geaendert werden.' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// Letzten Admin schuetzen: Pruefung und Update laufen ATOMAR in
|
||||
// einer SERIALIZABLE-Transaktion. Zwei parallele Degradierungen
|
||||
// koennen sonst beide die Count-Pruefung passieren (TOCTOU) und
|
||||
// die App ohne Admin zuruecklassen.
|
||||
const updated = await withSerializableTx(async (tx) => {
|
||||
const target = await tx.user.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
if (!target) {
|
||||
throw new Error('NOT_FOUND');
|
||||
}
|
||||
if (target.role === 'ADMIN' && body.role === 'USER') {
|
||||
const adminCount = await tx.user.count({ where: { role: 'ADMIN' } });
|
||||
if (adminCount <= 1) {
|
||||
throw new Error('LAST_ADMIN');
|
||||
}
|
||||
}
|
||||
return tx.user.update({
|
||||
where: { id },
|
||||
data: { role: body.role },
|
||||
select: { id: true, email: true, username: true, role: true },
|
||||
});
|
||||
}).catch((error: unknown) => {
|
||||
if (error instanceof Error && error.message === 'NOT_FOUND') {
|
||||
return null;
|
||||
}
|
||||
if (error instanceof Error && error.message === 'LAST_ADMIN') {
|
||||
return 'LAST_ADMIN' as const;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (updated === null) {
|
||||
return notFoundResponse('Benutzer nicht gefunden.');
|
||||
}
|
||||
if (updated === 'LAST_ADMIN') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Der letzte Administrator kann nicht degradiert werden.' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ user: updated });
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return validationErrorResponse(error);
|
||||
}
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user