Feld war beim Bearbeiten an form.password gebunden und erschien erst, nachdem im ersten Feld schon getippt wurde – wirkte, als reiche ein einziges Passwortfeld. Jetzt immer sichtbar, Pflicht bleibt weiterhin nur, wenn tatsächlich ein neues Passwort gesetzt wird.
268 lines
7.8 KiB
TypeScript
268 lines
7.8 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { createClient } from "@/lib/supabase/client";
|
||
import type { Profile, Role } from "@/lib/types";
|
||
|
||
interface UserForm {
|
||
id: string | null; // null = neuer Benutzer
|
||
email: string;
|
||
full_name: string;
|
||
role: Role;
|
||
password: string; // bei Bearbeitung optional (leer = unverändert)
|
||
passwordRepeat: string;
|
||
}
|
||
|
||
const EMPTY_FORM: UserForm = {
|
||
id: null,
|
||
email: "",
|
||
full_name: "",
|
||
role: "staff",
|
||
password: "",
|
||
passwordRepeat: "",
|
||
};
|
||
|
||
const ROLE_LABELS: Record<Role, string> = {
|
||
admin: "Administrator",
|
||
staff: "Kantinenmitarbeiter",
|
||
};
|
||
|
||
export default function UserManager() {
|
||
const supabase = useMemo(() => createClient(), []);
|
||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||
const [me, setMe] = useState<string | null>(null);
|
||
const [form, setForm] = useState<UserForm | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
const load = useCallback(async () => {
|
||
const [{ data: profileData, error: profileError }, { data: userData }] =
|
||
await Promise.all([
|
||
supabase.from("profiles").select("*").order("created_at"),
|
||
supabase.auth.getUser(),
|
||
]);
|
||
if (profileError) setError(`Laden fehlgeschlagen: ${profileError.message}`);
|
||
else setProfiles(profileData as Profile[]);
|
||
setMe(userData.user?.id ?? null);
|
||
}, [supabase]);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, [load]);
|
||
|
||
async function callApi(method: string, body: unknown): Promise<boolean> {
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const res = await fetch("/api/admin/users", {
|
||
method,
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => null);
|
||
setError(data?.error ?? `Fehler (${res.status})`);
|
||
return false;
|
||
}
|
||
return true;
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
return false;
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function save(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!form) return;
|
||
if (form.password && form.password !== form.passwordRepeat) {
|
||
setError("Die Passwörter stimmen nicht überein.");
|
||
return;
|
||
}
|
||
const ok = form.id
|
||
? await callApi("PATCH", {
|
||
id: form.id,
|
||
full_name: form.full_name,
|
||
role: form.role,
|
||
password: form.password || undefined,
|
||
})
|
||
: await callApi("POST", {
|
||
email: form.email,
|
||
full_name: form.full_name,
|
||
role: form.role,
|
||
password: form.password,
|
||
});
|
||
if (ok) {
|
||
setForm(null);
|
||
await load();
|
||
}
|
||
}
|
||
|
||
async function remove(profile: Profile) {
|
||
if (
|
||
!window.confirm(
|
||
`Benutzer „${profile.email}“ wirklich löschen? Der Zugang wird dauerhaft entfernt.`
|
||
)
|
||
)
|
||
return;
|
||
if (await callApi("DELETE", { id: profile.id })) await load();
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-3xl">
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
|
||
Benutzer
|
||
</h1>
|
||
<button
|
||
type="button"
|
||
className="btn btn-add"
|
||
onClick={() => setForm(EMPTY_FORM)}
|
||
>
|
||
Neuer Benutzer
|
||
</button>
|
||
</div>
|
||
|
||
{error && (
|
||
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
{form && (
|
||
<form onSubmit={save} className="card mb-4 space-y-3">
|
||
<h2 className="font-semibold">
|
||
{form.id ? "Benutzer bearbeiten" : "Neuer Benutzer"}
|
||
</h2>
|
||
{!form.id && (
|
||
<div>
|
||
<label className="label" htmlFor="user-email">
|
||
E-Mail-Adresse
|
||
</label>
|
||
<input
|
||
id="user-email"
|
||
type="email"
|
||
className="input"
|
||
value={form.email}
|
||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||
required
|
||
/>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<label className="label" htmlFor="user-name">
|
||
Name
|
||
</label>
|
||
<input
|
||
id="user-name"
|
||
className="input"
|
||
value={form.full_name}
|
||
onChange={(e) => setForm({ ...form, full_name: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="max-w-60">
|
||
<label className="label" htmlFor="user-role">
|
||
Rolle
|
||
</label>
|
||
<select
|
||
id="user-role"
|
||
className="input"
|
||
value={form.role}
|
||
onChange={(e) =>
|
||
setForm({ ...form, role: e.target.value as Role })
|
||
}
|
||
>
|
||
<option value="staff">Kantinenmitarbeiter</option>
|
||
<option value="admin">Administrator</option>
|
||
</select>
|
||
</div>
|
||
<div className="max-w-60">
|
||
<label className="label" htmlFor="user-password">
|
||
{form.id ? "Neues Passwort (optional)" : "Passwort"}
|
||
</label>
|
||
<input
|
||
id="user-password"
|
||
type="password"
|
||
className="input"
|
||
value={form.password}
|
||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||
minLength={8}
|
||
required={!form.id}
|
||
autoComplete="new-password"
|
||
/>
|
||
</div>
|
||
<div className="max-w-60">
|
||
<label className="label" htmlFor="user-password-repeat">
|
||
Passwort wiederholen
|
||
</label>
|
||
<input
|
||
id="user-password-repeat"
|
||
type="password"
|
||
className="input"
|
||
value={form.passwordRepeat}
|
||
onChange={(e) =>
|
||
setForm({ ...form, passwordRepeat: e.target.value })
|
||
}
|
||
minLength={8}
|
||
required={!form.id || !!form.password}
|
||
autoComplete="new-password"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button type="submit" className="btn btn-add" disabled={busy}>
|
||
Speichern
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-neutral"
|
||
onClick={() => setForm(null)}
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
|
||
{profiles.map((p) => (
|
||
<li key={p.id} className="flex items-center gap-3 px-4 py-2 text-sm">
|
||
<span className="mr-auto">
|
||
<span className="font-semibold">{p.full_name || p.email}</span>{" "}
|
||
<span className="text-gray-500">
|
||
– {p.email} – {ROLE_LABELS[p.role]}
|
||
</span>
|
||
{p.id === me && <span className="text-gray-400"> (du)</span>}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
onClick={() =>
|
||
setForm({
|
||
id: p.id,
|
||
email: p.email,
|
||
full_name: p.full_name,
|
||
role: p.role,
|
||
password: "",
|
||
passwordRepeat: "",
|
||
})
|
||
}
|
||
>
|
||
Bearbeiten
|
||
</button>
|
||
{p.id !== me && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-del"
|
||
onClick={() => remove(p)}
|
||
>
|
||
Löschen
|
||
</button>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|