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:
@@ -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 & 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]">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user