In der Nutzerverwaltung kannst du jetzt pro Konto die Rolle, die AG-Zuordnung und die Freigaberollen bearbeiten. Die feste 3er-Freigabelogik bleibt Vorstand A / Vorstand B / Finanz-AG, aber jetzt legst du über die Nutzer fest, wer diese Schritte autorisieren darf. Zusätzlich gibt es unter Nutzer anlegen eine eigene Insel für die Freigabe-Schwelle, und diese Schwelle wird jetzt auch wirklich überall verwendet: in der Erfassungslogik, in den Budgetkarten, im CSV-Backup/-Import und im Audit-Restore. Die Hauptänderungen sitzen in dashboard-shell.tsx, budget-column.tsx, route.ts, schema.prisma und route.ts.
All checks were successful
CI / build-and-deploy (push) Successful in 1m22s

Den Zeitraum-Bereich habe ich dabei gleich mit aufgeräumt: die Auswahl des aktuellen Haushalts ist breiter und sauberer angeordnet, und die Desktop-Nutzerverwaltung ist jetzt wirklich links Anlegen + Schwelle und rechts die Nutzerliste. Seed und Backup/Restore kennen die neuen Felder ebenfalls in seed.ts, route.ts und route.ts.
This commit is contained in:
Jan
2026-04-12 20:09:46 +02:00
parent 92d96ffa27
commit b202fc6c26
20 changed files with 1018 additions and 365 deletions

View File

@@ -1,17 +1,121 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { snapshotUser } from "@/lib/audit-snapshots";
import { createAuditLog } from "@/lib/audit-log";
import { canManageUsers } from "@/lib/domain";
import {
APPROVAL_FLOW,
canManageUsers,
getLegacyApprovalPreference,
normalizeApprovalPermissions
} from "@/lib/domain";
import prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session";
const userRoleSchema = z.enum(["ADMIN", "FINANCE", "MEMBER"]);
const approvalPermissionSchema = z.enum(APPROVAL_FLOW);
const updateUserSchema = z.object({
role: userRoleSchema,
workingGroupId: z.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()]),
approvalPermissions: z.array(approvalPermissionSchema).default([])
});
type Context = {
params: {
id: string;
};
};
export async function PATCH(request: Request, { params }: Context) {
const viewer = await getCurrentViewer();
if (!viewer) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
if (!canManageUsers(viewer.role)) {
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Nutzer bearbeiten." }, { status: 403 });
}
const body = await request.json().catch(() => null);
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Bitte Rolle, AG und Freigaberollen korrekt angeben." }, { status: 400 });
}
const user = await prisma.user.findUnique({
where: { id: params.id }
});
if (!user) {
return NextResponse.json({ error: "Nutzer nicht gefunden." }, { status: 404 });
}
const workingGroupId = typeof parsed.data.workingGroupId === "string" && parsed.data.workingGroupId.length > 0
? parsed.data.workingGroupId
: null;
if (parsed.data.role === "MEMBER" && !workingGroupId) {
return NextResponse.json({ error: "AG-Mitglieder brauchen eine AG-Zuordnung." }, { status: 400 });
}
if (workingGroupId) {
const workingGroup = await prisma.workingGroup.findUnique({
where: { id: workingGroupId }
});
if (!workingGroup) {
return NextResponse.json({ error: "Die ausgewaehlte AG wurde nicht gefunden." }, { status: 404 });
}
}
if (user.role === "ADMIN" && parsed.data.role !== "ADMIN") {
const adminCount = await prisma.user.count({
where: { role: "ADMIN" }
});
if (adminCount <= 1) {
return NextResponse.json({ error: "Mindestens ein Vorstandskonto muss erhalten bleiben." }, { status: 400 });
}
}
const approvalPermissions = normalizeApprovalPermissions(parsed.data.role, parsed.data.approvalPermissions, null);
const approvalPreference = getLegacyApprovalPreference(approvalPermissions);
const previousSnapshot = snapshotUser(user);
const updatedUser = await prisma.user.update({
where: { id: params.id },
data: {
role: parsed.data.role,
workingGroupId,
approvalPreference,
approvalPermissions
}
});
await createAuditLog(prisma, {
actorId: viewer.id,
action: "user.update",
entityType: "user",
entityId: updatedUser.id,
entityLabel: updatedUser.username,
summary: `Nutzer ${updatedUser.username} wurde aktualisiert.`,
metadata: {
workingGroupId: updatedUser.workingGroupId,
role: updatedUser.role,
approvalPermissions: updatedUser.approvalPermissions,
rollback: {
kind: "user.update",
previous: previousSnapshot
}
}
});
return NextResponse.json({ ok: true });
}
export async function DELETE(_: Request, { params }: Context) {
const viewer = await getCurrentViewer();