Files
gutscheintracker/app/settings/users.tsx
T

234 lines
9.0 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
type UserRow = {
id: number;
name: string;
email: string | null;
role: string;
oidcVerknuepft: boolean;
createdAt: string;
householdId: number | null;
householdName: string | null;
householdRole: string | null;
};
type InviteRow = { id: number; email: string; role: string; expiresAt: string; createdAt: string };
const inputClass =
"mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink placeholder:text-muted/60";
export default function UsersSection({ ownId, ownEmail }: { ownId: number; ownEmail: string | null }) {
const [users, setUsers] = useState<UserRow[]>([]);
const [invites, setInvites] = useState<InviteRow[]>([]);
const [households, setHouseholds] = useState<{ id: number; name: string }[]>([]);
const [inviteMail, setInviteMail] = useState("");
const [inviteHousehold, setInviteHousehold] = useState("");
const [inviteLink, setInviteLink] = useState("");
const [notice, setNotice] = useState("");
const [myEmail, setMyEmail] = useState(ownEmail ?? "");
async function load() {
const [u, inv, hh] = await Promise.all([
fetch("/api/users").then((r) => (r.ok ? r.json() : [])),
fetch("/api/users/invites").then((r) => (r.ok ? r.json() : [])),
fetch("/api/households").then((r) => (r.ok ? r.json() : [])),
]);
setUsers(Array.isArray(u) ? u : []);
setInvites(Array.isArray(inv) ? inv : []);
setHouseholds(Array.isArray(hh) ? hh.map((h: { id: number; name: string }) => ({ id: h.id, name: h.name })) : []);
}
useEffect(() => {
load();
}, []);
async function invite() {
setNotice("");
setInviteLink("");
const res = await fetch("/api/users/invites", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: inviteMail, householdId: inviteHousehold === "" ? null : Number(inviteHousehold) }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setNotice(data.message ?? "Einladung fehlgeschlagen.");
return;
}
setInviteMail("");
setInviteHousehold("");
setInviteLink(data.url ?? "");
if (data.mailError) setNotice(`Einladung angelegt, aber E-Mail-Versand schlug fehl: ${data.mailError}`);
else setNotice("Einladung erstellt (7 Tage gültig).");
load();
}
async function revoke(id: number) {
await fetch(`/api/users/invites?id=${id}`, { method: "DELETE" });
load();
}
async function removeUser(id: number, name: string) {
if (!window.confirm(`„${name}“ wirklich entfernen? Der Zugang ist danach sofort gesperrt.`)) return;
const res = await fetch(`/api/users/${id}`, { method: "DELETE" });
const data = await res.json().catch(() => ({}));
if (!res.ok) setNotice(data.message ?? "Löschen fehlgeschlagen.");
else setNotice("");
load();
}
async function saveMyEmail() {
setNotice("");
const res = await fetch("/api/users/me", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: myEmail }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) setNotice(data.message ?? "E-Mail konnte nicht gespeichert werden.");
else {
setNotice("Eigene E-Mail gespeichert.");
load();
}
}
return (
<div className="grid gap-4">
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Nutzer</h2>
{users.length === 0 ? (
<p className="mt-2 text-sm text-muted">Noch keine Nutzer.</p>
) : (
<ul className="mt-3 divide-y divide-line">
{users.map((u) => (
<li key={u.id} className="flex items-center justify-between gap-3 py-2.5 text-sm">
<div className="min-w-0">
<p className="font-semibold">
{u.name}
{u.id === ownId && <span className="ml-2 font-normal text-muted">(du)</span>}
</p>
<p className="truncate text-[13px] text-muted">
{u.email ?? "keine E-Mail"} · {u.role === "admin" ? "Server-Admin" : "Mitglied"}
{u.oidcVerknuepft ? " · SSO verknüpft" : ""}
</p>
<p className="truncate text-[13px] text-muted">
{u.householdName ? `Haushalt: ${u.householdName}${u.householdRole === "admin" ? " (Admin)" : ""}` : "Kein Haushalt"}
</p>
</div>
{u.id !== ownId && (
<button
type="button"
onClick={() => removeUser(u.id, u.name)}
className="shrink-0 rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
>
Entfernen
</button>
)}
</li>
))}
</ul>
)}
</section>
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Eigene E-Mail-Adresse</h2>
<p className="mt-1 text-[13px] text-muted">Für die SSO-Verknüpfung nötig. Muss mit der Einladung bzw. dem OIDC-Konto übereinstimmen.</p>
<div className="mt-3 flex gap-2">
<input
value={myEmail}
onChange={(e) => setMyEmail(e.target.value)}
type="email"
autoComplete="email"
placeholder="name@beispiel.de"
aria-label="Eigene E-Mail-Adresse"
className="h-11 min-w-0 flex-1 rounded-field border border-line bg-white px-3.5 text-sm text-ink"
/>
<button
type="button"
onClick={saveMyEmail}
className="h-11 shrink-0 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Speichern
</button>
</div>
</section>
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Per E-Mail einladen</h2>
<p className="mt-1 text-[13px] text-muted">Einladung ist 7 Tage gültig und einmalig einlösbar. Optional direkt einem Haushalt zuordnen.</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div>
<label htmlFor="invite-mail" className="block text-[13px] font-semibold">E-Mail-Adresse</label>
<input
id="invite-mail"
value={inviteMail}
onChange={(e) => setInviteMail(e.target.value)}
type="email"
autoComplete="off"
placeholder="name@beispiel.de"
className="mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink"
/>
</div>
<div>
<label htmlFor="invite-household" className="block text-[13px] font-semibold">Haushalt (optional)</label>
<select
id="invite-household"
value={inviteHousehold}
onChange={(e) => setInviteHousehold(e.target.value)}
className="mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink"
>
<option value="">Kein Haushalt (nur privater Bestand)</option>
{households.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</div>
</div>
<button
type="button"
onClick={invite}
className="mt-3 h-11 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Einladen
</button>
{inviteLink && (
<p className="mt-2 break-all text-[13px]">
Einladungslink:{" "}
<a href={inviteLink} className="font-medium text-brand-700 underline">
{inviteLink}
</a>
</p>
)}
{invites.length > 0 && (
<ul className="mt-3 divide-y divide-line">
{invites.map((inv) => (
<li key={inv.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{inv.email}</p>
<p className="text-[13px] text-muted">gültig bis {new Date(inv.expiresAt).toLocaleDateString("de-DE")}</p>
</div>
<button
type="button"
onClick={() => revoke(inv.id)}
className="shrink-0 rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
>
Zurückziehen
</button>
</li>
))}
</ul>
)}
{notice && (
<p role="status" className="mt-3 text-sm text-muted">
{notice}
</p>
)}
</section>
</div>
);
}