Mobile-first Web-App fuer persoenliches Health-, Ernaehrungs-, Aktivitaets- und Einkaufs-Tracking im Home-Lab-Betrieb. Stack: pnpm Monorepo - Next.js 16 + React 19 + Tailwind (apps/admin) - Hono + tRPC + Better Auth mit 2FA und Passkeys (packages/api) - PostgreSQL + Drizzle ORM (packages/db) - Docker fuer Postgres und pgAdmin. Module: - Auth mit Login, 2FA (TOTP + Backup-Codes), Passkeys und Admin-Recovery - Tagesansicht mit Essen, Trinken, Gewicht, Aktivitaeten und Coach-Hinweisen - Lebensmittelkatalog mit Naehrwerten, Portionen, OCR/Barcode/Open Food Facts - Rezepte mit Live-Naehrwertberechnung und Community-Vorschlaegen - Essensplanung und Wochenbudget mit Sportbonus - Einkaufslisten mit Maerkten und Laufweg-Sortierung - Hellfireclub mit Aktivitaetspunkten und gemeinsamer Aktivitaetsplanung - Wearables-Vorbereitung (Fitbit / Google Fit / Health Connect) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
187 lines
9 KiB
TypeScript
187 lines
9 KiB
TypeScript
"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>
|
|
);
|
|
}
|