diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 2523170..dcd6cad 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -30,13 +30,13 @@ export default async function SettingsPage() { {(hasPermission(session, "user:manage") || hasPermission(session, "role:manage")) && ( -
+

Benutzer & Rollen

Benutzer anlegen/verwalten, Rollen und Berechtigungen pflegen (mandantenintern).

- -
+ Öffnen → + )}
diff --git a/src/app/(app)/settings/users/page.tsx b/src/app/(app)/settings/users/page.tsx index e6b332f..fbe7fad 100644 --- a/src/app/(app)/settings/users/page.tsx +++ b/src/app/(app)/settings/users/page.tsx @@ -4,24 +4,33 @@ import { ArrowLeft } from "lucide-react"; import { requireSession } from "@/server/auth"; import { dbForTenant } from "@/server/db"; import { hasPermission, PERMISSIONS, ROLE_DEFS } from "@/server/rbac"; +import { createUser, updateUser, setUserRoles, setUserStatus, resetUserPassword } from "@/server/actions/tenant-users"; import { PageHead } from "@/components/mockup-ui"; -import { TenantUsersManager } from "@/components/tenant-users-manager"; +import { Modal } from "@/components/modal"; +import { UserTable } from "@/components/user-table"; +import { UserCreateForm, UserEditForm } from "@/components/user-forms"; import { RoleManager } from "@/components/role-manager"; /** * Benutzer- & Rollenverwaltung im Mandanten (Paket B). Sichtbar nur mit user:manage * bzw. role:manage; strikt auf den eigenen Mandanten begrenzt (dbForTenant). + * Benutzer werden über Popups (?new/?edit) angelegt/bearbeitet, die Tabelle zeigt nur. */ -export default async function UsersRolesPage() { +export default async function UsersRolesPage({ + searchParams, +}: { + searchParams: Promise<{ new?: string; edit?: string }>; +}) { const session = await requireSession(); const canUsers = hasPermission(session, "user:manage"); const canRoles = hasPermission(session, "role:manage"); if (!canUsers && !canRoles) redirect("/dashboard"); const db = dbForTenant(session.user.tenantId); + const sp = await searchParams; const [users, roles] = await Promise.all([ db.user.findMany({ - select: { id: true, name: true, email: true, status: true, userRoles: { select: { roleId: true } } }, + select: { id: true, name: true, email: true, status: true, userRoles: { select: { role: { select: { id: true, name: true } } } } }, orderBy: { createdAt: "asc" }, }), db.role.findMany({ @@ -31,16 +40,19 @@ export default async function UsersRolesPage() { ]); const standardKeys = new Set(Object.keys(ROLE_DEFS)); - const roleOptions = roles.map((r) => ({ id: r.id, key: r.key, name: r.name })); - const managedUsers = users.map((u) => ({ + const roleOptions = roles.map((r) => ({ id: r.id, name: r.name })); + const tableUsers = users.map((u) => ({ id: u.id, name: u.name, email: u.email, status: u.status, - roleIds: u.userRoles.map((ur) => ur.roleId), isSelf: u.id === session.user.id, + roleNames: u.userRoles.map((ur) => ur.role.name), isSelf: u.id === session.user.id, })); const managedRoles = roles.map((r) => ({ id: r.id, key: r.key, name: r.name, isStandard: standardKeys.has(r.key), permKeys: r.rolePermissions.map((rp) => rp.permission.key), userCount: r._count.userRoles, })); + const editUser = sp.edit ? users.find((u) => u.id === sp.edit) : null; + const base = "/settings/users"; + return (
@@ -53,7 +65,7 @@ export default async function UsersRolesPage() { {canUsers && (

Benutzer

- + `${base}?edit=${id}`} />
)} @@ -63,6 +75,28 @@ export default async function UsersRolesPage() { )} + + {canUsers && sp.new && ( + + + + )} + + {canUsers && editUser && ( + + ur.role.id), isSelf: editUser.id === session.user.id, + }} + roles={roleOptions} + updateAction={updateUser.bind(null, editUser.id)} + rolesAction={setUserRoles.bind(null, editUser.id)} + statusAction={setUserStatus.bind(null, editUser.id, editUser.status === "ACTIVE" ? "DEACTIVATED" : "ACTIVE")} + resetAction={resetUserPassword.bind(null, editUser.id)} + /> + + )}
); } diff --git a/src/app/(platform)/admin/[id]/page.tsx b/src/app/(platform)/admin/[id]/page.tsx index 161c708..11345a5 100644 --- a/src/app/(platform)/admin/[id]/page.tsx +++ b/src/app/(platform)/admin/[id]/page.tsx @@ -6,14 +6,24 @@ import { Button } from "@/components/ui/button"; import { PageHead, Pill } from "@/components/mockup-ui"; import { MODULES } from "@/lib/modules"; import { setTenantStatus, toggleTenantModule, setTenantTisaxLevel } from "@/server/actions/admin"; -import { PlatformTenantUsers } from "@/components/platform-tenant-users"; +import { createTenantUser, updateTenantUser, setTenantUserRoles, setTenantUserStatus, resetTenantUserPassword } from "@/server/actions/platform-users"; +import { Modal } from "@/components/modal"; +import { UserTable } from "@/components/user-table"; +import { UserCreateForm, UserEditForm } from "@/components/user-forms"; const STATUS_TONE: Record = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" }; const STATUS_LABEL: Record = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" }; -export default async function AdminTenantPage({ params }: { params: Promise<{ id: string }> }) { +export default async function AdminTenantPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams: Promise<{ new?: string; edit?: string }>; +}) { // Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen. const { id } = await params; + const sp = await searchParams; const tenant = await prisma.tenant.findUnique({ where: { id }, @@ -22,16 +32,19 @@ export default async function AdminTenantPage({ params }: { params: Promise<{ id modules: true, roles: { select: { id: true, key: true, name: true }, orderBy: { name: "asc" } }, users: { - select: { id: true, name: true, email: true, status: true, userRoles: { select: { roleId: true } } }, + select: { id: true, name: true, email: true, status: true, userRoles: { select: { role: { select: { id: true, name: true } } } } }, orderBy: { createdAt: "asc" }, }, }, }); if (!tenant) notFound(); - const managedUsers = tenant.users.map((u) => ({ - id: u.id, name: u.name, email: u.email, status: u.status, roleIds: u.userRoles.map((ur) => ur.roleId), + const roleOptions = tenant.roles.map((r) => ({ id: r.id, name: r.name })); + const tableUsers = tenant.users.map((u) => ({ + id: u.id, name: u.name, email: u.email, status: u.status, roleNames: u.userRoles.map((ur) => ur.role.name), })); + const editUser = sp.edit ? tenant.users.find((u) => u.id === sp.edit) : null; + const base = `/admin/${tenant.id}`; const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled])); const isOn = (key: string) => moduleState.get(key) ?? true; @@ -115,11 +128,33 @@ export default async function AdminTenantPage({ params }: { params: Promise<{ id - {/* Benutzerverwaltung (Paket A) */} + {/* Benutzerverwaltung (Paket A) — Tabelle + Popups */}
-

Benutzer & Rollen — {tenant.name}

- +

Benutzer — {tenant.name}

+ `${base}?edit=${uid}`} />
+ + {sp.new && ( + + + + )} + + {editUser && ( + + ur.role.id), + }} + roles={roleOptions} + updateAction={updateTenantUser.bind(null, tenant.id, editUser.id)} + rolesAction={setTenantUserRoles.bind(null, tenant.id, editUser.id)} + statusAction={setTenantUserStatus.bind(null, tenant.id, editUser.id, editUser.status === "ACTIVE" ? "DEACTIVATED" : "ACTIVE")} + resetAction={resetTenantUserPassword.bind(null, tenant.id, editUser.id)} + /> + + )} ); } diff --git a/src/components/platform-tenant-users.tsx b/src/components/platform-tenant-users.tsx deleted file mode 100644 index 2d8f0cf..0000000 --- a/src/components/platform-tenant-users.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"use client"; - -import { useActionState } from "react"; -import { - createTenantUser, updateTenantUser, resetTenantUserPassword, setTenantUserStatus, setTenantUserRoles, - type CreateUserState, type ResetPwState, type EditUserState, -} from "@/server/actions/platform-users"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Pill } from "@/components/mockup-ui"; - -export interface RoleOption { id: string; key: string; name: string } -export interface ManagedUser { id: string; name: string; email: string; status: string; roleIds: string[] } - -const createInit: CreateUserState = { status: "idle" }; -const resetInit: ResetPwState = { status: "idle" }; -const editInit: EditUserState = { status: "idle" }; - -function OneTimeSecret({ label, value }: { label: string; value: string }) { - return ( -
- {label}: - {value} -

Einmalig sichtbar — dem Nutzer sicher mitteilen. Wechsel beim ersten Login erforderlich.

-
- ); -} - -function RoleChecks({ roles, selected, idPrefix }: { roles: RoleOption[]; selected: Set; idPrefix: string }) { - return ( -
- {roles.map((r) => ( - - ))} -
- ); -} - -function CreateUserForm({ tenantId, roles }: { tenantId: string; roles: RoleOption[] }) { - const [state, action, pending] = useActionState(createTenantUser.bind(null, tenantId), createInit); - return ( - -
- - -
-
- - -
-
- -
-
-
- - - - -
- {state.status === "error" && ( -

{state.message}

- )} - {state.status === "done" && ( -
-

Nutzer {state.email} angelegt.

- {state.generatedPassword && } -
- )} -
- -
- - ); -} - -function ResetPasswordButton({ tenantId, userId }: { tenantId: string; userId: string }) { - const [state, action, pending] = useActionState(resetTenantUserPassword.bind(null, tenantId, userId), resetInit); - return ( -
- - {state.status === "done" && } - {state.status === "error" &&

{state.message}

} - - ); -} - -function EditUserForm({ tenantId, user }: { tenantId: string; user: ManagedUser }) { - const [state, action, pending] = useActionState(updateTenantUser.bind(null, tenantId, user.id), editInit); - return ( -
-
- - -
-
- - -
- - {state.status === "ok" && Gespeichert} - {state.status === "error" && {state.message}} -
- ); -} - -function UserRow({ tenantId, user, roles }: { tenantId: string; user: ManagedUser; roles: RoleOption[] }) { - const active = user.status === "ACTIVE"; - return ( -
-
-
- {active ? "Aktiv" : "Deaktiviert"} -
- -
- -
- -
- -
-
- -
- -
-
- ); -} - -export function PlatformTenantUsers({ tenantId, users, roles }: { tenantId: string; users: ManagedUser[]; roles: RoleOption[] }) { - return ( -
-
-

Benutzer anlegen

- -
-
- {users.map((u) => ( - - ))} - {users.length === 0 &&

Noch keine Benutzer in diesem Mandanten.

} -
-
- ); -} diff --git a/src/components/tenant-users-manager.tsx b/src/components/tenant-users-manager.tsx deleted file mode 100644 index 8de41cb..0000000 --- a/src/components/tenant-users-manager.tsx +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; - -import { useActionState } from "react"; -import { - createUser, updateUser, resetUserPassword, setUserStatus, setUserRoles, - type CreateUserState, type ResetPwState, type EditUserState, -} from "@/server/actions/tenant-users"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Pill } from "@/components/mockup-ui"; - -export interface RoleOption { id: string; key: string; name: string } -export interface ManagedUser { id: string; name: string; email: string; status: string; roleIds: string[]; isSelf: boolean } - -const createInit: CreateUserState = { status: "idle" }; -const resetInit: ResetPwState = { status: "idle" }; -const editInit: EditUserState = { status: "idle" }; - -function OneTimeSecret({ label, value }: { label: string; value: string }) { - return ( -
- {label}: - {value} -

Einmalig sichtbar — dem Nutzer sicher mitteilen. Wechsel beim ersten Login erforderlich.

-
- ); -} - -function RoleChecks({ roles, selected, idPrefix }: { roles: RoleOption[]; selected: Set; idPrefix: string }) { - return ( -
- {roles.map((r) => ( - - ))} -
- ); -} - -function CreateUserForm({ roles }: { roles: RoleOption[] }) { - const [state, action, pending] = useActionState(createUser, createInit); - return ( -
-
- - -
-
- - -
-
- -
-
-
- - - - -
- {state.status === "error" && ( -

{state.message}

- )} - {state.status === "done" && ( -
-

Nutzer {state.email} angelegt.

- {state.generatedPassword && } -
- )} -
-
- ); -} - -function ResetPasswordButton({ userId }: { userId: string }) { - const [state, action, pending] = useActionState(resetUserPassword.bind(null, userId), resetInit); - return ( -
- - {state.status === "done" && } - {state.status === "error" &&

{state.message}

} - - ); -} - -function EditUserForm({ user }: { user: ManagedUser }) { - const [state, action, pending] = useActionState(updateUser.bind(null, user.id), editInit); - return ( -
-
- - -
-
- - -
- - {state.status === "ok" && Gespeichert} - {state.status === "error" && {state.message}} -
- ); -} - -function UserRow({ user, roles }: { user: ManagedUser; roles: RoleOption[] }) { - const active = user.status === "ACTIVE"; - return ( -
-
-
- {active ? "Aktiv" : "Deaktiviert"} -
- -
- -
- -
- -
-
- -
- -
-
- ); -} - -export function TenantUsersManager({ users, roles }: { users: ManagedUser[]; roles: RoleOption[] }) { - return ( -
-
-

Benutzer anlegen

- -
-
- {users.map((u) => )} -
-
- ); -} diff --git a/src/components/user-forms.tsx b/src/components/user-forms.tsx new file mode 100644 index 0000000..70400b9 --- /dev/null +++ b/src/components/user-forms.tsx @@ -0,0 +1,145 @@ +"use client"; + +import Link from "next/link"; +import { useActionState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +/** + * Generische Benutzer-Formulare für Popups (Anlegen/Bearbeiten). Die konkreten + * Server-Actions werden als (bereits gebundene) Props übergeben — dieselben + * Komponenten dienen der Plattform-Admin- und der Mandanten-Admin-Verwaltung. + * Die Zustandstypen sind in beiden Action-Dateien strukturgleich. + */ + +export type CreateResult = + | { status: "idle" } + | { status: "error"; message: string } + | { status: "done"; email: string; generatedPassword: string | null }; +export type EditResult = { status: "idle" } | { status: "error"; message: string } | { status: "ok" }; +export type ResetResult = { status: "idle" } | { status: "error"; message: string } | { status: "done"; generatedPassword: string }; + +export interface RoleOption { id: string; name: string } + +type CreateAction = (prev: CreateResult, formData: FormData) => Promise; +type EditAction = (prev: EditResult, formData: FormData) => Promise; +type ResetAction = (prev: ResetResult, formData: FormData) => Promise; +type PlainAction = (formData: FormData) => void | Promise; + +function OneTimeSecret({ label, value }: { label: string; value: string }) { + return ( +
+ {label}: + {value} +

Einmalig sichtbar — dem Nutzer sicher mitteilen. Wechsel beim ersten Login erforderlich.

+
+ ); +} + +function RoleChecks({ roles, selected, idPrefix }: { roles: RoleOption[]; selected: Set; idPrefix: string }) { + return ( +
+ {roles.map((r) => ( + + ))} +
+ ); +} + +export function UserCreateForm({ action, roles, closeHref }: { action: CreateAction; roles: RoleOption[]; closeHref: string }) { + const [state, formAction, pending] = useActionState(action, { status: "idle" } as CreateResult); + + if (state.status === "done") { + return ( +
+

Nutzer {state.email} angelegt.

+ {state.generatedPassword + ? + :

Das gesetzte Initialpasswort ist dem Nutzer bereits bekannt.

} + +
+ ); + } + + return ( +
+
+ + +
+
+ + +
+
+ +
+
+
+ + + + +
+ {state.status === "error" && ( +

{state.message}

+ )} +
+ + +
+
+ ); +} + +export function UserEditForm({ + user, roles, updateAction, rolesAction, statusAction, resetAction, +}: { + user: { id: string; name: string; email: string; status: string; roleIds: string[]; isSelf?: boolean }; + roles: RoleOption[]; + updateAction: EditAction; + rolesAction: PlainAction; + statusAction: PlainAction; + resetAction: ResetAction; +}) { + const [editState, editFormAction, editPending] = useActionState(updateAction, { status: "idle" } as EditResult); + const [resetState, resetFormAction, resetPending] = useActionState(resetAction, { status: "idle" } as ResetResult); + const active = user.status === "ACTIVE"; + + return ( +
+
+
+ + +
+
+ + +
+
+ + {editState.status === "ok" && Gespeichert} + {editState.status === "error" && {editState.message}} +
+
+ +
+ +
+ +
+ +
+
+
+
+ {resetState.status === "done" && } + {resetState.status === "error" &&

{resetState.message}

} +
+ ); +} diff --git a/src/components/user-table.tsx b/src/components/user-table.tsx new file mode 100644 index 0000000..07f8118 --- /dev/null +++ b/src/components/user-table.tsx @@ -0,0 +1,63 @@ +import Link from "next/link"; +import { Plus } from "lucide-react"; +import { Pill } from "@/components/mockup-ui"; +import { Button } from "@/components/ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; + +export interface UserTableRow { + id: string; + name: string; + email: string; + status: string; + roleNames: string[]; + isSelf?: boolean; +} + +/** + * Reine Anzeige-Tabelle der Benutzer. Zeilen verlinken auf das Bearbeiten-Popup + * (?edit=), der Button auf das Anlegen-Popup (?new=1). Keine Inline-Bearbeitung. + */ +export function UserTable({ + users, newHref, editHref, +}: { + users: UserTableRow[]; + newHref: string; + editHref: (id: string) => string; +}) { + return ( +
+
+ +
+
+ + + + Name + E-Mail + Rollen + Status + + + + {users.map((u) => ( + + + + {u.name}{u.isSelf && (Sie)} + + + {u.email} + {u.roleNames.join(", ") || "—"} + {u.status === "ACTIVE" ? "Aktiv" : "Deaktiviert"} + + ))} + {users.length === 0 && ( + Noch keine Benutzer. + )} + +
+
+
+ ); +}