492 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|