Aktueller Stand
This commit is contained in:
482
components/AdminUserApprovals.tsx
Normal file
482
components/AdminUserApprovals.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
"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) => {
|
||||
const ok = window.confirm(`Benutzer ${user.email} deaktivieren?`);
|
||||
if (!ok) return;
|
||||
|
||||
const response = await fetch(`/api/users?id=${user.id}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setError(data.error || "Löschen fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("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="Deaktivieren"
|
||||
onClick={() => removeUser(user)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user