hellth-hub/apps/admin/app/(authed)/benutzer/page.tsx

188 lines
9 KiB
TypeScript
Raw Permalink Normal View History

"use client";
import { PlusIcon, ShieldCheckIcon } from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import { trpc } from "@/lib/trpc";
type Role = "user" | "admin";
export default function BenutzerPage() {
const utils = trpc.useUtils();
const usersQuery = trpc.users.list.useQuery();
const createUser = trpc.users.create.useMutation({
onSuccess: () => {
setCreateForm({ name: "", email: "", password: "", globalRole: "user" });
return utils.users.list.invalidate();
},
});
const updateUser = trpc.users.update.useMutation({
onSuccess: () => utils.users.list.invalidate(),
});
const startRecovery = trpc.users.startTwoFactorRecovery.useMutation({
onSuccess: () => utils.users.list.invalidate(),
});
const [search, setSearch] = useState("");
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [createForm, setCreateForm] = useState({
name: "",
email: "",
password: "",
globalRole: "user" as Role,
});
const users = usersQuery.data ?? [];
const selectedUser = users.find((user) => user.id === selectedUserId) ?? users[0] ?? null;
const filteredUsers = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle) return users;
return users.filter((user) =>
[user.name, user.email, user.globalRole].filter(Boolean).some((value) => value!.toLowerCase().includes(needle)),
);
}, [search, users]);
function submitCreate() {
if (!createForm.name.trim() || !createForm.email.trim() || createForm.password.length < 16) return;
createUser.mutate({
name: createForm.name.trim(),
email: createForm.email.trim(),
password: createForm.password,
globalRole: createForm.globalRole,
});
}
function updateRole(userId: string, role: Role) {
const user = users.find((item) => item.id === userId);
if (!user) return;
updateUser.mutate({
userId,
name: user.name ?? user.email,
email: user.email,
globalRole: role,
image: user.image ?? null,
});
}
return (
<div className="mx-auto max-w-6xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-[#0F766E]">Admin</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Benutzerverwaltung</h1>
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
Benutzer, Rollen und 2FA-Wiederherstellung für Hellth Hub.
</p>
</header>
<section className="grid gap-4 xl:grid-cols-[1fr_0.75fr]">
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Name, E-Mail oder Rolle suchen"
className="w-full rounded-full border border-gray-200 px-4 py-3 text-sm font-semibold outline-none focus:border-[#0F766E]"
/>
<div className="mt-5 overflow-hidden rounded-3xl border border-gray-100">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-xs uppercase text-gray-400">
<tr>
<th className="px-4 py-3">Benutzer</th>
<th className="px-4 py-3">Rolle</th>
<th className="px-4 py-3">2FA</th>
<th className="px-4 py-3">Aktion</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user) => (
<tr key={user.id} className="border-t border-gray-100">
<td className="px-4 py-3">
<button type="button" onClick={() => setSelectedUserId(user.id)} className="text-left">
<p className="font-black text-gray-950">{user.name ?? "Unbenannt"}</p>
<p className="text-xs font-semibold text-gray-400">{user.email}</p>
</button>
</td>
<td className="px-4 py-3">
<select
value={user.globalRole === "admin" ? "admin" : "user"}
onChange={(event) => updateRole(user.id, event.target.value as Role)}
className="rounded-full border border-gray-200 px-3 py-2 text-xs font-bold"
>
<option value="user">Benutzer</option>
<option value="admin">Admin</option>
</select>
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-3 py-1 text-xs font-bold ${user.twoFactorEnabled ? "bg-emerald-50 text-emerald-700" : "bg-gray-100 text-gray-500"}`}>
{user.twoFactorEnabled ? "aktiv" : "aus"}
</span>
</td>
<td className="px-4 py-3">
<button
type="button"
onClick={() => startRecovery.mutate({ userId: user.id })}
className="rounded-full bg-gray-100 px-3 py-2 text-xs font-black text-gray-700 disabled:opacity-40"
disabled={startRecovery.isPending}
>
2FA zurücksetzen
</button>
</td>
</tr>
))}
{!filteredUsers.length ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-sm font-bold text-gray-400">
Keine Benutzer gefunden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
<aside className="space-y-4">
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">Neuen Benutzer anlegen</h2>
<div className="mt-4 grid gap-3">
<input value={createForm.name} onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))} placeholder="Name" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={createForm.email} onChange={(event) => setCreateForm((current) => ({ ...current, email: event.target.value }))} placeholder="E-Mail" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input type="password" value={createForm.password} onChange={(event) => setCreateForm((current) => ({ ...current, password: event.target.value }))} placeholder="Initiales Passwort, min. 16 Zeichen" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<select value={createForm.globalRole} onChange={(event) => setCreateForm((current) => ({ ...current, globalRole: event.target.value as Role }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm">
<option value="user">Benutzer</option>
<option value="admin">Admin</option>
</select>
<button type="button" onClick={submitCreate} disabled={createUser.isPending} className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50">
<PlusIcon className="h-5 w-5" />
Benutzer anlegen
</button>
{createUser.error ? <p className="rounded-2xl bg-red-50 px-4 py-3 text-sm font-bold text-red-800">{createUser.error.message}</p> : null}
</div>
</section>
{selectedUser ? (
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center gap-3">
<div className="grid h-12 w-12 place-items-center rounded-2xl bg-teal-50 text-lg font-black text-[#0F766E]">
{(selectedUser.name ?? selectedUser.email).slice(0, 1).toUpperCase()}
</div>
<div>
<h2 className="font-black text-gray-950">{selectedUser.name ?? selectedUser.email}</h2>
<p className="text-xs font-semibold text-gray-400">{selectedUser.email}</p>
</div>
</div>
<div className="mt-4 grid gap-2 text-sm font-bold text-gray-500">
<p>Erstellt: {new Date(selectedUser.createdAt).toLocaleDateString("de-DE")}</p>
<p>Letzte Aktivität: {selectedUser.lastActivityAt ? new Date(selectedUser.lastActivityAt).toLocaleString("de-DE") : "unbekannt"}</p>
<p className="inline-flex items-center gap-2">
<ShieldCheckIcon className="h-5 w-5 text-[#0F766E]" />
{selectedUser.twoFactorRecoveryRequired ? "2FA-Wiederherstellung offen" : "2FA-Status sauber"}
</p>
</div>
</section>
) : null}
</aside>
</section>
</div>
);
}