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);
|
||||
}
|
||||
}
|
||||
42
app/api/users/route.ts
Normal file
42
app/api/users/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* GET /api/users - Benutzerverwaltung (nur ADMIN).
|
||||
*
|
||||
* Liefert alle Benutzer mit Rolle und Kalender-Beziehungen, damit
|
||||
* ein Admin Rollen vergeben und Zuordnungen ueberblicken kann.
|
||||
*/
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db/client';
|
||||
import { requireDbUser } from '@/lib/api/context';
|
||||
import { errorResponse, forbiddenResponse } from '@/lib/api/responses';
|
||||
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
try {
|
||||
const user = await requireDbUser();
|
||||
if (user.role !== 'ADMIN') {
|
||||
return forbiddenResponse();
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
memberships: {
|
||||
select: {
|
||||
calendar: { select: { id: true, title: true, isActive: true } },
|
||||
},
|
||||
},
|
||||
ownedCalendars: {
|
||||
select: { id: true, title: true, isActive: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ users });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
44
app/api/users/search/route.ts
Normal file
44
app/api/users/search/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* GET /api/users/search?q=... - Benutzer-Suche fuer die
|
||||
* Mitgliederverwaltung (nur ADMIN).
|
||||
*
|
||||
* DB-basierte Suche per E-Mail (case-insensitive, Teilstring).
|
||||
* Liefert maximal 8 Treffer mit E-Mail und ID; Passwort-Hashes und
|
||||
* andere sensible Felder werden niemals zurueckgegeben.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db/client';
|
||||
import { requireDbUser } from '@/lib/api/context';
|
||||
import { errorResponse, forbiddenResponse } from '@/lib/api/responses';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const user = await requireDbUser();
|
||||
if (user.role !== 'ADMIN') {
|
||||
return forbiddenResponse();
|
||||
}
|
||||
|
||||
const query = request.nextUrl.searchParams.get('q')?.trim() ?? '';
|
||||
if (query.length < 2) {
|
||||
return NextResponse.json({ users: [] });
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: { contains: query, mode: 'insensitive' } },
|
||||
{ username: { contains: query, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
orderBy: { email: 'asc' },
|
||||
take: 8,
|
||||
select: { id: true, email: true, username: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ users });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user