Benutzerverwaltung als Popup (Einstellungen + Admin), Tabelle nur Anzeige

Anlegen/Bearbeiten von Nutzern erfolgt jetzt wie bei Assets über URL-gesteuerte
Popups (?new/?edit, modal.tsx) statt Inline in der Tabelle.

- Neue generische Komponenten: user-table.tsx (reine Anzeige-Tabelle, Zeilen
  verlinken auf ?edit, Button auf ?new) und user-forms.tsx (UserCreateForm /
  UserEditForm; Name/E-Mail + Rollen + Status + Passwort-Reset; Einmal-Passwort
  im Popup). Actions werden als gebundene Props übergeben — dieselben Formulare
  für Plattform- und Mandanten-Verwaltung.
- /settings/users und /admin/[id] rendern die Tabelle + Modals; die alten
  Inline-Komponenten (platform-tenant-users, tenant-users-manager) entfernt.
- Einstellungen: „Benutzer & Rollen"-Kachel ist als Ganzes klickbar (Link).

Browser-verifiziert: Anlegen im Popup (Einmal-Passwort + Fertig), Zeilenklick
öffnet Bearbeiten-Popup vorbefüllt; Admin-Portal analog. tsc + lint + build +
Guard-Check grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 11:24:37 +02:00
co-authored by Claude Opus 4.8
parent 959d8162d6
commit 73c98137ea
7 changed files with 295 additions and 315 deletions
+3 -3
View File
@@ -30,13 +30,13 @@ export default async function SettingsPage() {
<PageHead crumb="Organisation" title="Einstellungen" sub="Unternehmensdaten, Branding, Sicherheits-Policy — Stammdaten speisen die ISMS-Variablen" />
{(hasPermission(session, "user:manage") || hasPermission(session, "role:manage")) && (
<div className="shadow-card mt-4 flex items-center justify-between rounded-xl border bg-card p-5">
<Link href="/settings/users" className="shadow-card mt-4 flex items-center justify-between rounded-xl border bg-card p-5 transition-colors hover:bg-muted/40">
<div>
<p className="font-heading text-sm font-semibold">Benutzer &amp; Rollen</p>
<p className="text-[12px] text-muted-foreground">Benutzer anlegen/verwalten, Rollen und Berechtigungen pflegen (mandantenintern).</p>
</div>
<Button nativeButton={false} render={<Link href="/settings/users" />}>Öffnen</Button>
</div>
<span className="text-[13px] font-semibold text-[var(--primary)]">Öffnen </span>
</Link>
)}
<form action={updateTenantSettings} className="mt-4 grid gap-5 lg:grid-cols-[1fr_320px]">
+41 -7
View File
@@ -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 (
<main className="flex-1 p-6">
<Link href="/settings" className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
@@ -53,7 +65,7 @@ export default async function UsersRolesPage() {
{canUsers && (
<section className="mt-5">
<h2 className="mb-3 font-heading text-sm font-semibold">Benutzer</h2>
<TenantUsersManager users={managedUsers} roles={roleOptions} />
<UserTable users={tableUsers} newHref={`${base}?new=1`} editHref={(id) => `${base}?edit=${id}`} />
</section>
)}
@@ -63,6 +75,28 @@ export default async function UsersRolesPage() {
<RoleManager roles={managedRoles} allPermissions={[...PERMISSIONS]} />
</section>
)}
{canUsers && sp.new && (
<Modal title="Benutzer anlegen" sub="Neuen Nutzer mit Rollen und Initialpasswort anlegen" closeHref={base} closeLabel="Schließen">
<UserCreateForm action={createUser} roles={roleOptions} closeHref={base} />
</Modal>
)}
{canUsers && editUser && (
<Modal title={`Benutzer bearbeiten — ${editUser.name}`} sub={editUser.email} closeHref={base} closeLabel="Schließen">
<UserEditForm
user={{
id: editUser.id, name: editUser.name, email: editUser.email, status: editUser.status,
roleIds: editUser.userRoles.map((ur) => 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)}
/>
</Modal>
)}
</main>
);
}
+43 -8
View File
@@ -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<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
const STATUS_LABEL: Record<string, string> = { 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
</div>
</div>
{/* Benutzerverwaltung (Paket A) */}
{/* Benutzerverwaltung (Paket A) — Tabelle + Popups */}
<div className="mt-5">
<p className="mb-3 font-heading text-sm font-semibold">Benutzer &amp; Rollen {tenant.name}</p>
<PlatformTenantUsers tenantId={tenant.id} users={managedUsers} roles={tenant.roles} />
<p className="mb-3 font-heading text-sm font-semibold">Benutzer {tenant.name}</p>
<UserTable users={tableUsers} newHref={`${base}?new=1`} editHref={(uid) => `${base}?edit=${uid}`} />
</div>
{sp.new && (
<Modal title="Benutzer anlegen" sub={`Neuer Nutzer für ${tenant.name}`} closeHref={base} closeLabel="Schließen">
<UserCreateForm action={createTenantUser.bind(null, tenant.id)} roles={roleOptions} closeHref={base} />
</Modal>
)}
{editUser && (
<Modal title={`Benutzer bearbeiten — ${editUser.name}`} sub={editUser.email} closeHref={base} closeLabel="Schließen">
<UserEditForm
user={{
id: editUser.id, name: editUser.name, email: editUser.email, status: editUser.status,
roleIds: editUser.userRoles.map((ur) => 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)}
/>
</Modal>
)}
</main>
);
}
-151
View File
@@ -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 (
<div className="mt-2 rounded-lg bg-[rgba(46,204,113,0.12)] px-3 py-2 text-sm">
<span className="text-muted-foreground">{label}: </span>
<code className="select-all font-mono font-semibold tracking-wider">{value}</code>
<p className="mt-0.5 text-[11px] text-muted-foreground">Einmalig sichtbar dem Nutzer sicher mitteilen. Wechsel beim ersten Login erforderlich.</p>
</div>
);
}
function RoleChecks({ roles, selected, idPrefix }: { roles: RoleOption[]; selected: Set<string>; idPrefix: string }) {
return (
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
{roles.map((r) => (
<label key={r.id} htmlFor={`${idPrefix}-${r.id}`} className="flex items-center gap-1.5 text-[13px]">
<input id={`${idPrefix}-${r.id}`} type="checkbox" name="roles" value={r.id} defaultChecked={selected.has(r.id)} />
{r.name}
</label>
))}
</div>
);
}
function CreateUserForm({ tenantId, roles }: { tenantId: string; roles: RoleOption[] }) {
const [state, action, pending] = useActionState(createTenantUser.bind(null, tenantId), createInit);
return (
<form action={action} className="grid gap-3 md:grid-cols-2">
<div>
<Label htmlFor="cu-name">Name *</Label>
<Input id="cu-name" name="name" required className="mt-1" placeholder="Vor- und Nachname" />
</div>
<div>
<Label htmlFor="cu-email">E-Mail *</Label>
<Input id="cu-email" name="email" type="email" required className="mt-1" placeholder="person@kunde.example" />
</div>
<div className="md:col-span-2">
<Label>Rollen *</Label>
<div className="mt-1"><RoleChecks roles={roles} selected={new Set()} idPrefix="cu" /></div>
</div>
<fieldset className="md:col-span-2 space-y-2">
<Label>Initialpasswort</Label>
<label className="flex items-center gap-2 text-[13px]"><input type="radio" name="pwMode" value="generate" defaultChecked /> Einmal-Passwort automatisch erzeugen (empfohlen, einmalig angezeigt)</label>
<label className="flex items-center gap-2 text-[13px]"><input type="radio" name="pwMode" value="set" /> Passwort selbst setzen:</label>
<Input name="password" type="text" className="mt-1" placeholder={"nur bei „selbst setzen“"} autoComplete="off" />
</fieldset>
{state.status === "error" && (
<p role="alert" className="md:col-span-2 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{state.message}</p>
)}
{state.status === "done" && (
<div className="md:col-span-2">
<p className="text-sm text-[var(--ok)]">Nutzer {state.email} angelegt.</p>
{state.generatedPassword && <OneTimeSecret label="Einmal-Passwort" value={state.generatedPassword} />}
</div>
)}
<div className="md:col-span-2">
<Button type="submit" disabled={pending}>{pending ? "Lege an…" : "Benutzer anlegen"}</Button>
</div>
</form>
);
}
function ResetPasswordButton({ tenantId, userId }: { tenantId: string; userId: string }) {
const [state, action, pending] = useActionState(resetTenantUserPassword.bind(null, tenantId, userId), resetInit);
return (
<form action={action} className="inline">
<Button type="submit" variant="outline" size="sm" disabled={pending}>{pending ? "…" : "Passwort zurücksetzen"}</Button>
{state.status === "done" && <OneTimeSecret label="Neues Einmal-Passwort" value={state.generatedPassword} />}
{state.status === "error" && <p className="mt-1 text-xs text-[var(--risk)]">{state.message}</p>}
</form>
);
}
function EditUserForm({ tenantId, user }: { tenantId: string; user: ManagedUser }) {
const [state, action, pending] = useActionState(updateTenantUser.bind(null, tenantId, user.id), editInit);
return (
<form action={action} className="flex flex-wrap items-end gap-2">
<div className="min-w-40 flex-1">
<Label htmlFor={`en-${user.id}`} className="text-[11px] text-muted-foreground">Name</Label>
<Input id={`en-${user.id}`} name="name" defaultValue={user.name} className="mt-1 h-8" />
</div>
<div className="min-w-52 flex-1">
<Label htmlFor={`ee-${user.id}`} className="text-[11px] text-muted-foreground">E-Mail</Label>
<Input id={`ee-${user.id}`} name="email" type="email" defaultValue={user.email} className="mt-1 h-8" />
</div>
<Button type="submit" variant="outline" size="sm" disabled={pending}>{pending ? "…" : "Speichern"}</Button>
{state.status === "ok" && <span className="text-[11px] text-[var(--ok)]">Gespeichert</span>}
{state.status === "error" && <span className="text-[11px] text-[var(--risk)]">{state.message}</span>}
</form>
);
}
function UserRow({ tenantId, user, roles }: { tenantId: string; user: ManagedUser; roles: RoleOption[] }) {
const active = user.status === "ACTIVE";
return (
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex-1"><EditUserForm tenantId={tenantId} user={user} /></div>
<Pill tone={active ? "ok" : "mut"}>{active ? "Aktiv" : "Deaktiviert"}</Pill>
</div>
<form action={setTenantUserRoles.bind(null, tenantId, user.id)} className="mt-3">
<Label className="text-[12px] text-muted-foreground">Rollen</Label>
<div className="mt-1"><RoleChecks roles={roles} selected={new Set(user.roleIds)} idPrefix={`u-${user.id}`} /></div>
<Button type="submit" variant="outline" size="sm" className="mt-2">Rollen speichern</Button>
</form>
<div className="mt-3 flex flex-wrap items-center gap-2">
<form action={setTenantUserStatus.bind(null, tenantId, user.id, active ? "DEACTIVATED" : "ACTIVE")} className="inline">
<Button type="submit" variant="outline" size="sm">{active ? "Deaktivieren" : "Reaktivieren"}</Button>
</form>
<ResetPasswordButton tenantId={tenantId} userId={user.id} />
</div>
</div>
);
}
export function PlatformTenantUsers({ tenantId, users, roles }: { tenantId: string; users: ManagedUser[]; roles: RoleOption[] }) {
return (
<div className="space-y-4">
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-3 font-heading text-sm font-semibold">Benutzer anlegen</p>
<CreateUserForm tenantId={tenantId} roles={roles} />
</div>
<div className="space-y-3">
{users.map((u) => (
<UserRow key={u.id} tenantId={tenantId} user={u} roles={roles} />
))}
{users.length === 0 && <p className="text-sm text-muted-foreground">Noch keine Benutzer in diesem Mandanten.</p>}
</div>
</div>
);
}
-146
View File
@@ -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 (
<div className="mt-2 rounded-lg bg-[rgba(46,204,113,0.12)] px-3 py-2 text-sm">
<span className="text-muted-foreground">{label}: </span>
<code className="select-all font-mono font-semibold tracking-wider">{value}</code>
<p className="mt-0.5 text-[11px] text-muted-foreground">Einmalig sichtbar dem Nutzer sicher mitteilen. Wechsel beim ersten Login erforderlich.</p>
</div>
);
}
function RoleChecks({ roles, selected, idPrefix }: { roles: RoleOption[]; selected: Set<string>; idPrefix: string }) {
return (
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
{roles.map((r) => (
<label key={r.id} htmlFor={`${idPrefix}-${r.id}`} className="flex items-center gap-1.5 text-[13px]">
<input id={`${idPrefix}-${r.id}`} type="checkbox" name="roles" value={r.id} defaultChecked={selected.has(r.id)} />
{r.name}
</label>
))}
</div>
);
}
function CreateUserForm({ roles }: { roles: RoleOption[] }) {
const [state, action, pending] = useActionState(createUser, createInit);
return (
<form action={action} className="grid gap-3 md:grid-cols-2">
<div>
<Label htmlFor="nu-name">Name *</Label>
<Input id="nu-name" name="name" required className="mt-1" placeholder="Vor- und Nachname" />
</div>
<div>
<Label htmlFor="nu-email">E-Mail *</Label>
<Input id="nu-email" name="email" type="email" required className="mt-1" placeholder="person@firma.example" />
</div>
<div className="md:col-span-2">
<Label>Rollen *</Label>
<div className="mt-1"><RoleChecks roles={roles} selected={new Set()} idPrefix="nu" /></div>
</div>
<fieldset className="md:col-span-2 space-y-2">
<Label>Initialpasswort</Label>
<label className="flex items-center gap-2 text-[13px]"><input type="radio" name="pwMode" value="generate" defaultChecked /> Einmal-Passwort automatisch erzeugen (empfohlen, einmalig angezeigt)</label>
<label className="flex items-center gap-2 text-[13px]"><input type="radio" name="pwMode" value="set" /> Passwort selbst setzen:</label>
<Input name="password" type="text" className="mt-1" placeholder={"nur bei „selbst setzen“"} autoComplete="off" />
</fieldset>
{state.status === "error" && (
<p role="alert" className="md:col-span-2 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{state.message}</p>
)}
{state.status === "done" && (
<div className="md:col-span-2">
<p className="text-sm text-[var(--ok)]">Nutzer {state.email} angelegt.</p>
{state.generatedPassword && <OneTimeSecret label="Einmal-Passwort" value={state.generatedPassword} />}
</div>
)}
<div className="md:col-span-2"><Button type="submit" disabled={pending}>{pending ? "Lege an…" : "Benutzer anlegen"}</Button></div>
</form>
);
}
function ResetPasswordButton({ userId }: { userId: string }) {
const [state, action, pending] = useActionState(resetUserPassword.bind(null, userId), resetInit);
return (
<form action={action} className="inline">
<Button type="submit" variant="outline" size="sm" disabled={pending}>{pending ? "…" : "Passwort zurücksetzen"}</Button>
{state.status === "done" && <OneTimeSecret label="Neues Einmal-Passwort" value={state.generatedPassword} />}
{state.status === "error" && <p className="mt-1 text-xs text-[var(--risk)]">{state.message}</p>}
</form>
);
}
function EditUserForm({ user }: { user: ManagedUser }) {
const [state, action, pending] = useActionState(updateUser.bind(null, user.id), editInit);
return (
<form action={action} className="flex flex-wrap items-end gap-2">
<div className="min-w-40 flex-1">
<Label htmlFor={`ten-${user.id}`} className="text-[11px] text-muted-foreground">Name {user.isSelf && <span>(Sie)</span>}</Label>
<Input id={`ten-${user.id}`} name="name" defaultValue={user.name} className="mt-1 h-8" />
</div>
<div className="min-w-52 flex-1">
<Label htmlFor={`tee-${user.id}`} className="text-[11px] text-muted-foreground">E-Mail</Label>
<Input id={`tee-${user.id}`} name="email" type="email" defaultValue={user.email} className="mt-1 h-8" />
</div>
<Button type="submit" variant="outline" size="sm" disabled={pending}>{pending ? "…" : "Speichern"}</Button>
{state.status === "ok" && <span className="text-[11px] text-[var(--ok)]">Gespeichert</span>}
{state.status === "error" && <span className="text-[11px] text-[var(--risk)]">{state.message}</span>}
</form>
);
}
function UserRow({ user, roles }: { user: ManagedUser; roles: RoleOption[] }) {
const active = user.status === "ACTIVE";
return (
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex-1"><EditUserForm user={user} /></div>
<Pill tone={active ? "ok" : "mut"}>{active ? "Aktiv" : "Deaktiviert"}</Pill>
</div>
<form action={setUserRoles.bind(null, user.id)} className="mt-3">
<Label className="text-[12px] text-muted-foreground">Rollen</Label>
<div className="mt-1"><RoleChecks roles={roles} selected={new Set(user.roleIds)} idPrefix={`ru-${user.id}`} /></div>
<Button type="submit" variant="outline" size="sm" className="mt-2">Rollen speichern</Button>
</form>
<div className="mt-3 flex flex-wrap items-center gap-2">
<form action={setUserStatus.bind(null, user.id, active ? "DEACTIVATED" : "ACTIVE")} className="inline">
<Button type="submit" variant="outline" size="sm">{active ? "Deaktivieren" : "Reaktivieren"}</Button>
</form>
<ResetPasswordButton userId={user.id} />
</div>
</div>
);
}
export function TenantUsersManager({ users, roles }: { users: ManagedUser[]; roles: RoleOption[] }) {
return (
<div className="space-y-4">
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-3 font-heading text-sm font-semibold">Benutzer anlegen</p>
<CreateUserForm roles={roles} />
</div>
<div className="space-y-3">
{users.map((u) => <UserRow key={u.id} user={u} roles={roles} />)}
</div>
</div>
);
}
+145
View File
@@ -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<CreateResult>;
type EditAction = (prev: EditResult, formData: FormData) => Promise<EditResult>;
type ResetAction = (prev: ResetResult, formData: FormData) => Promise<ResetResult>;
type PlainAction = (formData: FormData) => void | Promise<void>;
function OneTimeSecret({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg bg-[rgba(46,204,113,0.12)] px-3 py-2 text-sm">
<span className="text-muted-foreground">{label}: </span>
<code className="select-all font-mono font-semibold tracking-wider">{value}</code>
<p className="mt-0.5 text-[11px] text-muted-foreground">Einmalig sichtbar dem Nutzer sicher mitteilen. Wechsel beim ersten Login erforderlich.</p>
</div>
);
}
function RoleChecks({ roles, selected, idPrefix }: { roles: RoleOption[]; selected: Set<string>; idPrefix: string }) {
return (
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
{roles.map((r) => (
<label key={r.id} htmlFor={`${idPrefix}-${r.id}`} className="flex items-center gap-1.5 text-[13px]">
<input id={`${idPrefix}-${r.id}`} type="checkbox" name="roles" value={r.id} defaultChecked={selected.has(r.id)} />
{r.name}
</label>
))}
</div>
);
}
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 (
<div className="space-y-4 p-5">
<p className="text-sm text-[var(--ok)]">Nutzer {state.email} angelegt.</p>
{state.generatedPassword
? <OneTimeSecret label="Einmal-Passwort" value={state.generatedPassword} />
: <p className="text-[12.5px] text-muted-foreground">Das gesetzte Initialpasswort ist dem Nutzer bereits bekannt.</p>}
<Button nativeButton={false} render={<Link href={closeHref} />}>Fertig</Button>
</div>
);
}
return (
<form action={formAction} className="grid gap-3 p-5 md:grid-cols-2">
<div>
<Label htmlFor="cu-name">Name *</Label>
<Input id="cu-name" name="name" required className="mt-1" placeholder="Vor- und Nachname" />
</div>
<div>
<Label htmlFor="cu-email">E-Mail *</Label>
<Input id="cu-email" name="email" type="email" required className="mt-1" placeholder="person@firma.example" />
</div>
<div className="md:col-span-2">
<Label>Rollen *</Label>
<div className="mt-1"><RoleChecks roles={roles} selected={new Set()} idPrefix="cu" /></div>
</div>
<fieldset className="space-y-2 md:col-span-2">
<Label>Initialpasswort</Label>
<label className="flex items-center gap-2 text-[13px]"><input type="radio" name="pwMode" value="generate" defaultChecked /> Einmal-Passwort automatisch erzeugen (empfohlen, einmalig angezeigt)</label>
<label className="flex items-center gap-2 text-[13px]"><input type="radio" name="pwMode" value="set" /> Passwort selbst setzen:</label>
<Input name="password" type="text" className="mt-1" placeholder={"nur bei „selbst setzen“"} autoComplete="off" />
</fieldset>
{state.status === "error" && (
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)] md:col-span-2">{state.message}</p>
)}
<div className="flex gap-2 md:col-span-2">
<Button type="submit" disabled={pending}>{pending ? "Lege an…" : "Benutzer anlegen"}</Button>
<Button variant="outline" nativeButton={false} render={<Link href={closeHref} />}>Abbrechen</Button>
</div>
</form>
);
}
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 (
<div className="space-y-5 p-5">
<form action={editFormAction} className="grid gap-3 sm:grid-cols-2">
<div>
<Label htmlFor="eu-name">Name {user.isSelf && <span className="text-[11px] text-muted-foreground">(Sie)</span>}</Label>
<Input id="eu-name" name="name" defaultValue={user.name} className="mt-1" />
</div>
<div>
<Label htmlFor="eu-email">E-Mail</Label>
<Input id="eu-email" name="email" type="email" defaultValue={user.email} className="mt-1" />
</div>
<div className="flex items-center gap-3 sm:col-span-2">
<Button type="submit" size="sm" disabled={editPending}>{editPending ? "…" : "Stammdaten speichern"}</Button>
{editState.status === "ok" && <span className="text-[12px] text-[var(--ok)]">Gespeichert</span>}
{editState.status === "error" && <span className="text-[12px] text-[var(--risk)]">{editState.message}</span>}
</div>
</form>
<form action={rolesAction} className="border-t pt-4">
<Label className="text-[12px] text-muted-foreground">Rollen</Label>
<div className="mt-1"><RoleChecks roles={roles} selected={new Set(user.roleIds)} idPrefix="eu" /></div>
<Button type="submit" variant="outline" size="sm" className="mt-2">Rollen speichern</Button>
</form>
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
<form action={statusAction}><Button type="submit" variant="outline" size="sm">{active ? "Deaktivieren" : "Reaktivieren"}</Button></form>
<form action={resetFormAction}><Button type="submit" variant="outline" size="sm" disabled={resetPending}>{resetPending ? "…" : "Passwort zurücksetzen"}</Button></form>
</div>
{resetState.status === "done" && <OneTimeSecret label="Neues Einmal-Passwort" value={resetState.generatedPassword} />}
{resetState.status === "error" && <p className="text-xs text-[var(--risk)]">{resetState.message}</p>}
</div>
);
}
+63
View File
@@ -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=<id>), 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 (
<div>
<div className="mb-3 flex justify-end">
<Button nativeButton={false} render={<Link href={newHref} />}><Plus className="size-4" /> Neuer Benutzer</Button>
</div>
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Rollen</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => (
<TableRow key={u.id} className="cursor-pointer hover:bg-muted/40">
<TableCell className="p-0">
<Link href={editHref(u.id)} className="block px-4 py-3 font-medium">
{u.name}{u.isSelf && <span className="text-[11px] text-muted-foreground"> (Sie)</span>}
</Link>
</TableCell>
<TableCell className="p-0"><Link href={editHref(u.id)} className="block px-4 py-3 text-muted-foreground">{u.email}</Link></TableCell>
<TableCell className="p-0"><Link href={editHref(u.id)} className="block px-4 py-3 text-[12.5px]">{u.roleNames.join(", ") || "—"}</Link></TableCell>
<TableCell><Pill tone={u.status === "ACTIVE" ? "ok" : "mut"}>{u.status === "ACTIVE" ? "Aktiv" : "Deaktiviert"}</Pill></TableCell>
</TableRow>
))}
{users.length === 0 && (
<TableRow><TableCell colSpan={4} className="text-muted-foreground">Noch keine Benutzer.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}