234 lines
9.0 KiB
TypeScript
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>
|
|
);
|
|
}
|