Files
vereinskalender/components/AdminUserApprovals.tsx
2026-01-18 00:40:01 +01:00

492 lines
17 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
type UserItem = {
id: string;
email: string;
name?: string | null;
status: string;
role: string;
emailVerified: boolean;
createdAt: string;
loginStats?: {
attempts: number;
lastAttempt: string | null;
lockedUntil: string | null;
};
};
type AdminUserApprovalsProps = {
role?: string | null;
};
export default function AdminUserApprovals({ role }: AdminUserApprovalsProps) {
const [users, setUsers] = useState<UserItem[]>([]);
const [allUsers, setAllUsers] = useState<UserItem[]>([]);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [modalError, setModalError] = useState<string | null>(null);
const [modalStatus, setModalStatus] = useState<string | null>(null);
const [editingUser, setEditingUser] = useState<UserItem | null>(null);
const loadPending = async () => {
try {
const response = await fetch("/api/users?status=PENDING");
if (!response.ok) {
throw new Error("Nutzer konnten nicht geladen werden.");
}
setUsers(await response.json());
} catch (err) {
setError((err as Error).message);
}
};
const loadAll = async () => {
if (!role) return;
try {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("Übersicht konnte nicht geladen werden.");
}
setAllUsers(await response.json());
} catch (err) {
setError((err as Error).message);
}
};
useEffect(() => {
loadPending();
loadAll();
}, [role]);
const isSuperAdmin = role === "SUPERADMIN";
const canManageUsers = role === "ADMIN" || role === "SUPERADMIN";
const approveUser = async (userId: string) => {
setError(null);
setStatus(null);
const response = await fetch("/api/users", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, status: "ACTIVE" })
});
if (!response.ok) {
const data = await response.json();
setError(data.error || "Freischaltung fehlgeschlagen.");
return;
}
setUsers((prev) => prev.filter((user) => user.id !== userId));
setStatus("Benutzer freigeschaltet.");
loadAll();
};
const openCreate = () => {
setEditingUser(null);
setModalError(null);
setModalStatus(null);
setModalOpen(true);
};
const openEdit = (user: UserItem) => {
setEditingUser(user);
setModalError(null);
setModalStatus(null);
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditingUser(null);
};
const saveUser = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setModalError(null);
setModalStatus(null);
const formData = new FormData(event.currentTarget);
const payload = {
email: formData.get("email"),
name: formData.get("name"),
role: formData.get("role"),
status: formData.get("status"),
emailVerified: formData.get("emailVerified") === "on",
password: formData.get("password")
};
const response = await fetch("/api/users", {
method: editingUser ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
editingUser ? { ...payload, userId: editingUser.id } : payload
)
});
if (!response.ok) {
const data = await response.json();
setModalError(data.error || "Speichern fehlgeschlagen.");
return;
}
setModalStatus("Gespeichert.");
loadPending();
loadAll();
setModalOpen(false);
setEditingUser(null);
};
const removeUser = async (user: UserItem, mode: "disable" | "delete" = "disable") => {
if (mode === "delete" && !isSuperAdmin) {
setError("Nur Superadmins können Benutzer endgültig löschen.");
return;
}
const ok = window.confirm(
mode === "delete"
? `Benutzer ${user.email} endgültig löschen? Alle Daten werden entfernt.`
: `Benutzer ${user.email} deaktivieren?`
);
if (!ok) return;
const hardParam = mode === "delete" ? "&hard=true" : "";
const response = await fetch(`/api/users?id=${user.id}${hardParam}`, {
method: "DELETE"
});
if (!response.ok) {
const data = await response.json();
setError(data.error || "Löschen fehlgeschlagen.");
return;
}
setStatus(mode === "delete" ? "Benutzer gelöscht." : "Benutzer deaktiviert.");
loadPending();
loadAll();
};
const resetLoginAttempts = async (user: UserItem) => {
setError(null);
setStatus(null);
const response = await fetch("/api/users", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: user.id, resetLoginAttempts: true })
});
if (!response.ok) {
const data = await response.json();
setError(data.error || "Zurücksetzen fehlgeschlagen.");
return;
}
setStatus("Loginversuche zurückgesetzt.");
loadAll();
};
const manageableUsers = isSuperAdmin
? allUsers
: allUsers.filter((user) => user.role === "USER");
return (
<div className="space-y-6">
<section className="card space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Freischaltung
</p>
<h2 className="text-lg font-semibold">Neue Registrierungen</h2>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{status && <p className="text-sm text-emerald-600">{status}</p>}
{users.length === 0 ? (
<p className="text-sm text-slate-600">Keine offenen Registrierungen.</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-[0.2em] text-slate-500">
<tr>
<th className="pb-2">E-Mail</th>
<th className="pb-2">Name</th>
<th className="pb-2">Rolle</th>
<th className="pb-2">Erstellt</th>
<th className="pb-2">Aktion</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-t border-slate-200">
<td className="py-3 pr-3">{user.email}</td>
<td className="py-3 pr-3">{user.name || "-"}</td>
<td className="py-3 pr-3">{user.role}</td>
<td className="py-3 pr-3">
{new Date(user.createdAt).toLocaleDateString("de-DE")}
</td>
<td className="py-3 pr-3">
<IconButton
label="Freigeben"
onClick={() => approveUser(user.id)}
>
<IconCheck />
</IconButton>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{canManageUsers && (
<section className="card space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Übersicht
</p>
<h2 className="text-lg font-semibold">Alle Benutzer</h2>
</div>
<IconButton label="Benutzer anlegen" onClick={openCreate}>
<IconPlus />
</IconButton>
</div>
{manageableUsers.length === 0 ? (
<p className="text-sm text-slate-600">Keine Benutzer vorhanden.</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-[0.2em] text-slate-500">
<tr>
<th className="pb-2">E-Mail</th>
<th className="pb-2">Name</th>
<th className="pb-2">Rolle</th>
<th className="pb-2">Status</th>
<th className="pb-2">Verifiziert</th>
<th className="pb-2">Fehlversuche</th>
<th className="pb-2">Letzter Versuch</th>
<th className="pb-2">Gesperrt bis</th>
<th className="pb-2">Erstellt</th>
<th className="pb-2">Aktion</th>
</tr>
</thead>
<tbody>
{manageableUsers.map((user) => (
<tr key={user.id} className="border-t border-slate-200">
<td className="py-3 pr-3">{user.email}</td>
<td className="py-3 pr-3">{user.name || "-"}</td>
<td className="py-3 pr-3">{user.role}</td>
<td className="py-3 pr-3">{user.status}</td>
<td className="py-3 pr-3">
{user.emailVerified ? "Ja" : "Nein"}
</td>
<td className="py-3 pr-3">{user.loginStats?.attempts ?? 0}</td>
<td className="py-3 pr-3">
{user.loginStats?.lastAttempt
? new Date(user.loginStats.lastAttempt).toLocaleString("de-DE")
: "-"}
</td>
<td className="py-3 pr-3">
{user.loginStats?.lockedUntil
? new Date(user.loginStats.lockedUntil).toLocaleString("de-DE")
: "-"}
</td>
<td className="py-3 pr-3">
{new Date(user.createdAt).toLocaleDateString("de-DE")}
</td>
<td className="py-3 pr-3">
<div className="flex flex-nowrap gap-2">
<IconButton
label="Bearbeiten"
onClick={() => openEdit(user)}
>
<IconEdit />
</IconButton>
{isSuperAdmin && (
<IconButton
label="Loginversuche zurücksetzen"
onClick={() => resetLoginAttempts(user)}
>
<IconUnlock />
</IconButton>
)}
<IconButton
label={isSuperAdmin ? "Löschen" : "Deaktivieren"}
onClick={() => removeUser(user, isSuperAdmin ? "delete" : "disable")}
>
<IconTrash />
</IconButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{modalOpen && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/40 px-4 py-6">
<div className="card w-full max-w-xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">
{editingUser ? "Benutzer bearbeiten" : "Benutzer anlegen"}
</h3>
<IconButton label="Schließen" onClick={closeModal}>
<IconClose />
</IconButton>
</div>
<form onSubmit={saveUser} className="mt-4 grid gap-3 md:grid-cols-2">
<input
name="email"
type="email"
required
defaultValue={editingUser?.email || ""}
placeholder="E-Mail"
className="rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
/>
<input
name="name"
defaultValue={editingUser?.name || ""}
placeholder="Name"
className="rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
/>
{isSuperAdmin ? (
<select
name="role"
defaultValue={editingUser?.role || "USER"}
className="rounded-xl border border-slate-300 px-3 py-2"
>
<option value="USER">USER</option>
<option value="ADMIN">ADMIN</option>
<option value="SUPERADMIN">SUPERADMIN</option>
</select>
) : (
<input type="hidden" name="role" value="USER" />
)}
<select
name="status"
defaultValue={editingUser?.status || "PENDING"}
className="rounded-xl border border-slate-300 px-3 py-2"
>
<option value="ACTIVE">ACTIVE</option>
<option value="PENDING">PENDING</option>
<option value="DISABLED">DISABLED</option>
</select>
<label className="flex items-center gap-2 text-sm md:col-span-2">
<input
type="checkbox"
name="emailVerified"
defaultChecked={editingUser?.emailVerified || false}
/>
E-Mail verifiziert
</label>
<input
name="password"
type="password"
placeholder={
editingUser ? "Neues Passwort (optional)" : "Passwort"
}
required={!editingUser}
className="rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
/>
<button
type="submit"
className="btn-accent md:col-span-2 flex items-center justify-center"
aria-label="Speichern"
title="Speichern"
>
<IconCheck />
<span className="sr-only">Speichern</span>
</button>
</form>
{modalStatus && (
<p className="mt-3 text-sm text-emerald-600">{modalStatus}</p>
)}
{modalError && (
<p className="mt-3 text-sm text-red-600">{modalError}</p>
)}
</div>
</div>
)}
</div>
);
}
function IconButton({
label,
onClick,
children
}: {
label: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 text-slate-700 hover:bg-slate-100"
>
{children}
</button>
);
}
function IconCheck() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12l4 4L19 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconEdit() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20h9" strokeLinecap="round" />
<path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4L16.5 3.5z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconTrash() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18" strokeLinecap="round" />
<path d="M8 6V4h8v2" strokeLinecap="round" />
<path d="M7 6l1 14h8l1-14" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconPlus() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
);
}
function IconClose() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 6l12 12M18 6l-12 12" strokeLinecap="round" />
</svg>
);
}
function IconUnlock() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M7 11V7a5 5 0 0110 0" strokeLinecap="round" strokeLinejoin="round" />
<rect x="3" y="11" width="18" height="10" rx="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12 15v2" strokeLinecap="round" />
</svg>
);
}