198 lines
7.6 KiB
TypeScript
198 lines
7.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
type Member = { id: number; name: string; email: string | null; role: string; oidcVerknuepft: boolean };
|
|
type InviteRow = { id: number; email: string; expiresAt: string };
|
|
|
|
const inputClass =
|
|
"h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink placeholder:text-muted/60";
|
|
|
|
/** Eigener Haushalt (für Haushalts-Admins, die kein Server-Admin sind). */
|
|
export function HouseholdPanel({ householdId, householdName, ownId, ownEmail }: { householdId: number; householdName: string; ownId: number; ownEmail: string | null }) {
|
|
const [members, setMembers] = useState<Member[]>([]);
|
|
const [invites, setInvites] = useState<InviteRow[]>([]);
|
|
const [inviteMail, setInviteMail] = useState("");
|
|
const [inviteLink, setInviteLink] = useState("");
|
|
const [myEmail, setMyEmail] = useState(ownEmail ?? "");
|
|
const [notice, setNotice] = useState("");
|
|
|
|
async function load() {
|
|
const [u, inv] = await Promise.all([
|
|
fetch("/api/users").then((r) => (r.ok ? r.json() : [])),
|
|
fetch("/api/users/invites").then((r) => (r.ok ? r.json() : [])),
|
|
]);
|
|
setMembers(Array.isArray(u) ? u : []);
|
|
setInvites(Array.isArray(inv) ? inv : []);
|
|
}
|
|
|
|
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 }),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setNotice(data.message ?? "Einladung fehlgeschlagen.");
|
|
return;
|
|
}
|
|
setInviteMail("");
|
|
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 removeMember(id: number, name: string) {
|
|
if (id === ownId) return;
|
|
if (!window.confirm(`„${name}“ aus „${householdName}“ entfernen? Private Gutscheine bleiben erhalten.`)) return;
|
|
const res = await fetch(`/api/users/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ householdId: null }),
|
|
});
|
|
if (!res.ok) setNotice("Entfernen 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.");
|
|
}
|
|
|
|
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">Haushalt: {householdName}</h2>
|
|
{members.length === 0 ? (
|
|
<p className="mt-2 text-sm text-muted">Noch keine Mitglieder.</p>
|
|
) : (
|
|
<ul className="mt-3 divide-y divide-line">
|
|
{members.map((m) => (
|
|
<li key={m.id} className="flex items-center justify-between gap-3 py-2.5 text-sm">
|
|
<div className="min-w-0">
|
|
<p className="font-semibold">
|
|
{m.name}
|
|
{m.id === ownId && <span className="ml-2 font-normal text-muted">(du)</span>}
|
|
</p>
|
|
<p className="truncate text-[13px] text-muted">
|
|
{m.email ?? "keine E-Mail"} · {m.role === "admin" ? "Haushalts-Admin" : "Mitglied"}
|
|
{m.oidcVerknuepft ? " · SSO verknüpft" : ""}
|
|
</p>
|
|
</div>
|
|
{m.id !== ownId && m.role !== "admin" && (
|
|
<button
|
|
type="button"
|
|
onClick={() => removeMember(m.id, m.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.</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 fügt direkt „{householdName}“ hinzu.</p>
|
|
<div className="mt-3 flex gap-2">
|
|
<input
|
|
value={inviteMail}
|
|
onChange={(e) => setInviteMail(e.target.value)}
|
|
type="email"
|
|
autoComplete="off"
|
|
placeholder="name@beispiel.de"
|
|
aria-label="E-Mail-Adresse für Einladung"
|
|
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={invite}
|
|
className="h-11 shrink-0 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
|
|
>
|
|
Einladen
|
|
</button>
|
|
</div>
|
|
{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>
|
|
);
|
|
}
|