374 lines
16 KiB
React
374 lines
16 KiB
React
import { useState, useEffect, useRef } from "react";
|
||
import { RED, GREEN } from "../constants/index.js";
|
||
|
||
import { useApp } from "../context/AppContext.jsx";
|
||
|
||
const getToken = () => localStorage.getItem("vf_token");
|
||
const authHdrs = () => ({ "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` });
|
||
|
||
async function apiFetch(path, options = {}) {
|
||
const res = await fetch(`/api${path}`, { ...options, headers: { ...authHdrs(), ...(options.headers || {}) } });
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.error || `HTTP ${res.status}`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
function fmtDatum(iso) {
|
||
if (!iso) return "—";
|
||
const d = new Date(iso);
|
||
return `${d.getDate()}-${d.getMonth() + 1}-${d.getFullYear()}`;
|
||
}
|
||
|
||
function RolSelect({ value, onChange, opties, T, darkMode }) {
|
||
const [open, setOpen] = useState(false);
|
||
const ref = useRef(null);
|
||
|
||
useEffect(() => {
|
||
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||
document.addEventListener("mousedown", handler);
|
||
return () => document.removeEventListener("mousedown", handler);
|
||
}, []);
|
||
|
||
return (
|
||
<div ref={ref} style={{ position: "relative", width: "100%" }}>
|
||
<button type="button" onClick={() => setOpen((o) => !o)} style={{
|
||
width: "100%", background: T.inputBg, border: `1px solid ${T.inputBorder}`,
|
||
borderRadius: 8, color: T.text, padding: "8px 10px", fontSize: 13,
|
||
cursor: "pointer", display: "flex", justifyContent: "space-between", alignItems: "center",
|
||
boxSizing: "border-box",
|
||
}}>
|
||
<span>{value}</span>
|
||
<span style={{ fontSize: 10, color: T.muted }}>▼</span>
|
||
</button>
|
||
{open && (
|
||
<div style={{
|
||
position: "absolute", top: "calc(100% + 4px)", left: 0, right: 0, zIndex: 999,
|
||
background: T.card, border: `1px solid ${T.inputBorder}`,
|
||
borderRadius: 8, overflow: "hidden",
|
||
boxShadow: darkMode ? "0 8px 24px rgba(0,0,0,0.5)" : "0 8px 24px rgba(0,0,0,0.15)",
|
||
}}>
|
||
{opties.map((o) => (
|
||
<div key={o} onClick={() => { onChange(o); setOpen(false); }} style={{
|
||
padding: "9px 12px", fontSize: 13, cursor: "pointer",
|
||
color: o === value ? T.accentTekst : T.text,
|
||
background: o === value ? T.inputBg : "transparent",
|
||
}}
|
||
onMouseEnter={(e) => e.currentTarget.style.background = "rgba(14,221,212,0.10)"}
|
||
onMouseLeave={(e) => e.currentTarget.style.background = o === value ? T.inputBg : "transparent"}
|
||
>
|
||
{o}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function GebruikersBeheer({ onClose }) {
|
||
const { currentUser, darkMode, T } = useApp();
|
||
|
||
const [users, setUsers] = useState([]);
|
||
const [loadErr, setLoadErr] = useState("");
|
||
const [showAdd, setShowAdd] = useState(false);
|
||
const [editUser, setEditUser] = useState(null); // user object dat bewerkt wordt
|
||
const [nieuwNaam, setNieuwNaam] = useState("");
|
||
const [nieuwEmail, setNieuwEmail] = useState("");
|
||
const [nieuwWw, setNieuwWw] = useState("");
|
||
const [nieuwRol, setNieuwRol] = useState("Viewer");
|
||
const [editNaam, setEditNaam] = useState("");
|
||
const [editEmail, setEditEmail] = useState("");
|
||
const [editWw, setEditWw] = useState("");
|
||
const [err, setErr] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
const rollen = ["Admin", "Bewerker", "Viewer"];
|
||
|
||
useEffect(() => {
|
||
apiFetch("/users")
|
||
.then(setUsers)
|
||
.catch((e) => setLoadErr(e.message));
|
||
}, []);
|
||
|
||
const is = {
|
||
background: T.inputBg, border: `1px solid ${T.inputBorder}`,
|
||
borderRadius: 8, color: T.text, padding: "8px 10px",
|
||
fontSize: 13, outline: "none", boxSizing: "border-box", width: "100%",
|
||
};
|
||
|
||
const addUser = async () => {
|
||
if (!nieuwNaam.trim() || !nieuwEmail.trim() || !nieuwWw.trim()) {
|
||
setErr("Naam, e-mail en wachtwoord zijn verplicht.");
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
try {
|
||
const u = await apiFetch("/users", {
|
||
method: "POST",
|
||
body: JSON.stringify({ naam: nieuwNaam, email: nieuwEmail, wachtwoord: nieuwWw, rol: nieuwRol }),
|
||
});
|
||
setUsers((prev) => [...prev, u]);
|
||
setNieuwNaam(""); setNieuwEmail(""); setNieuwWw(""); setErr("");
|
||
setShowAdd(false);
|
||
} catch (e) {
|
||
setErr(e.message);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const openEdit = (u) => {
|
||
setEditUser(u);
|
||
setEditNaam(u.naam);
|
||
setEditEmail(u.email);
|
||
setEditWw("");
|
||
setErr("");
|
||
};
|
||
|
||
const saveEdit = async () => {
|
||
if (!editNaam.trim() || !editEmail.trim()) { setErr("Naam en e-mail zijn verplicht."); return; }
|
||
setSaving(true);
|
||
try {
|
||
const patch = { naam: editNaam.trim(), email: editEmail.trim() };
|
||
if (editWw) patch.wachtwoord = editWw;
|
||
const updated = await apiFetch(`/users/${editUser.id}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify(patch),
|
||
});
|
||
setUsers((prev) => prev.map((x) => x.id === editUser.id ? { ...x, ...updated } : x));
|
||
setEditUser(null); setErr("");
|
||
} catch (e) { setErr(e.message); }
|
||
finally { setSaving(false); }
|
||
};
|
||
|
||
const toggleActief = async (u) => {
|
||
try {
|
||
const updated = await apiFetch(`/users/${u.id}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify({ actief: !u.actief }),
|
||
});
|
||
setUsers((prev) => prev.map((x) => x.id === u.id ? { ...x, ...updated } : x));
|
||
} catch (e) { setErr(e.message); }
|
||
};
|
||
|
||
const removeUser = async (id) => {
|
||
try {
|
||
await apiFetch(`/users/${id}`, { method: "DELETE" });
|
||
setUsers((prev) => prev.filter((u) => u.id !== id));
|
||
} catch (e) { setErr(e.message); }
|
||
};
|
||
|
||
const th = {
|
||
padding: "8px 12px", fontSize: 11, color: T.muted, fontWeight: 600,
|
||
textAlign: "left", borderBottom: `1px solid ${T.border}`,
|
||
background: T.card, textTransform: "uppercase", letterSpacing: "0.05em",
|
||
};
|
||
const td = { padding: "10px 12px", fontSize: 13, color: T.text, borderBottom: `1px solid ${T.border}` };
|
||
|
||
return (
|
||
<div
|
||
onClick={onClose}
|
||
style={{
|
||
position: "fixed", inset: 0, zIndex: 400,
|
||
background: "rgba(0,0,0,0.55)", backdropFilter: "blur(4px)",
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
}}
|
||
>
|
||
<div
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{
|
||
background: T.popupBg, border: `1px solid ${T.border}`,
|
||
borderRadius: 16, width: "100%", maxWidth: 700,
|
||
maxHeight: "90vh", display: "flex", flexDirection: "column",
|
||
boxShadow: "0 16px 48px rgba(0,0,0,0.4)",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{/* Header */}
|
||
<div style={{
|
||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||
padding: "18px 22px", borderBottom: `1px solid ${T.border}`, flexShrink: 0,
|
||
}}>
|
||
<div>
|
||
<span style={{ fontWeight: 700, fontSize: 15, color: T.text }}>Gebruikersbeheer</span>
|
||
<span style={{ marginLeft: 10, fontSize: 11, fontWeight: 600, color: T.accentTekst,
|
||
background: T.inputBg, borderRadius: 99, padding: "2px 8px", border: `1px solid ${T.inputBorder}`,
|
||
}}>
|
||
{users.length} gebruiker{users.length !== 1 ? "s" : ""}
|
||
</span>
|
||
</div>
|
||
<button onClick={onClose} style={{
|
||
background: "transparent", border: "none", cursor: "pointer",
|
||
color: T.muted, fontSize: 20, lineHeight: 1, padding: 4,
|
||
}}>×</button>
|
||
</div>
|
||
|
||
<div style={{ padding: "16px 22px", overflowY: "auto", flex: 1 }}>
|
||
{loadErr && (
|
||
<div style={{ color: RED, fontSize: 13, marginBottom: 12 }}>{loadErr}</div>
|
||
)}
|
||
|
||
{/* Tabel */}
|
||
<div style={{ overflowX: "auto", marginBottom: 16 }}>
|
||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||
<thead>
|
||
<tr>
|
||
<th style={th}>Naam</th>
|
||
<th style={th}>E-mail</th>
|
||
<th style={th}>Aangemaakt</th>
|
||
<th style={th}>Admin</th>
|
||
<th style={th}></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{users.map((u) => {
|
||
const initials = u.naam.split(" ").map((w) => w[0]).join("").toUpperCase().slice(0, 2);
|
||
const isSelf = u.id === currentUser?.id || u.email === currentUser?.email;
|
||
return (
|
||
<tr key={u.id} style={{ opacity: u.actief ? 1 : 0.5 }}>
|
||
<td style={td}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||
<div style={{
|
||
width: 32, height: 32, borderRadius: "50%",
|
||
background: T.inputBg, display: "flex",
|
||
alignItems: "center", justifyContent: "center",
|
||
fontSize: 12, fontWeight: 700, color: T.accentTekst, flexShrink: 0,
|
||
}}>
|
||
{initials}
|
||
</div>
|
||
<span style={{ fontWeight: 600 }}>{u.naam}</span>
|
||
{isSelf && (
|
||
<span style={{
|
||
fontSize: 10, fontWeight: 700, color: T.accentTekst,
|
||
background: T.inputBg, borderRadius: 99,
|
||
padding: "1px 7px", border: `1px solid ${T.inputBorder}`,
|
||
}}>jij</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td style={{ ...td, color: T.muted }}>{u.email}</td>
|
||
<td style={{ ...td, color: T.muted }}>{fmtDatum(u.aangemaakt)}</td>
|
||
<td style={td}>
|
||
{u.rol === "Admin"
|
||
? <span style={{ fontSize: 18 }}>⭐</span>
|
||
: <span style={{ fontSize: 13, color: T.muted }}>—</span>
|
||
}
|
||
</td>
|
||
<td style={{ ...td, whiteSpace: "nowrap" }}>
|
||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||
<button onClick={() => openEdit(u)} style={{
|
||
padding: "3px 8px", background: T.inputBg,
|
||
border: `1px solid ${T.inputBorder}`, borderRadius: 6,
|
||
color: T.accentTekst, cursor: "pointer", fontSize: 12,
|
||
}}>✏️</button>
|
||
{!isSelf && (
|
||
<>
|
||
<button onClick={() => toggleActief(u)} style={{
|
||
padding: "3px 8px", borderRadius: 6, cursor: "pointer",
|
||
fontSize: 11, fontWeight: 700,
|
||
background: u.actief ? "rgba(16,185,129,0.12)" : "rgba(239,68,68,0.1)",
|
||
border: `1px solid ${u.actief ? "rgba(16,185,129,0.4)" : "rgba(239,68,68,0.3)"}`,
|
||
color: u.actief ? GREEN : RED,
|
||
}}>
|
||
{u.actief ? "Actief" : "Inactief"}
|
||
</button>
|
||
<button onClick={() => removeUser(u.id)} style={{
|
||
padding: "3px 8px", background: "rgba(239,68,68,0.1)",
|
||
border: "1px solid rgba(239,68,68,0.25)", borderRadius: 6,
|
||
color: RED, cursor: "pointer", fontSize: 13,
|
||
}}>×</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{err && (
|
||
<div style={{ color: RED, fontSize: 12, marginBottom: 12, padding: "6px 10px", background: "rgba(239,68,68,0.1)", borderRadius: 8, border: "1px solid rgba(239,68,68,0.25)" }}>
|
||
{err}
|
||
</div>
|
||
)}
|
||
|
||
{/* Bewerken modal */}
|
||
{editUser && (
|
||
<div style={{
|
||
background: darkMode ? "rgba(124,58,237,0.06)" : T.inputBg,
|
||
border: `1px solid ${T.inputBorder}`, borderRadius: 10, padding: "16px", marginBottom: 16,
|
||
}}>
|
||
<div style={{ fontSize: 11, color: T.accentTekst, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>
|
||
{editUser.naam} bewerken
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
|
||
<input placeholder="Naam" value={editNaam} onChange={(e) => setEditNaam(e.target.value)} style={is} />
|
||
<input placeholder="E-mail" value={editEmail} onChange={(e) => setEditEmail(e.target.value)} style={is} />
|
||
<input placeholder="Nieuw wachtwoord (optioneel)" type="password" value={editWw} onChange={(e) => setEditWw(e.target.value)} style={{ ...is, gridColumn: "1 / -1" }} />
|
||
</div>
|
||
<div style={{ display: "flex", gap: 8 }}>
|
||
<button onClick={() => { setEditUser(null); setErr(""); }} style={{
|
||
flex: 1, padding: "8px", background: "transparent",
|
||
border: `1px solid ${T.border}`, borderRadius: 8,
|
||
color: T.muted, cursor: "pointer", fontSize: 13, fontWeight: 600,
|
||
}}>Annuleren</button>
|
||
<button onClick={saveEdit} disabled={saving} style={{
|
||
flex: 2, padding: "8px",
|
||
background: T.accentGradient,
|
||
border: "none", borderRadius: 8, color: "#fff",
|
||
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
|
||
}}>{saving ? "Opslaan..." : "Opslaan"}</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Nieuwe gebruiker */}
|
||
{showAdd ? (
|
||
<div style={{
|
||
background: darkMode ? "rgba(124,58,237,0.06)" : T.inputBg,
|
||
border: `1px solid ${T.border}`, borderRadius: 10, padding: "16px",
|
||
}}>
|
||
<div style={{ fontSize: 11, color: T.muted, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 12 }}>
|
||
Nieuwe gebruiker
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
|
||
<input placeholder="Naam" value={nieuwNaam} onChange={(e) => setNieuwNaam(e.target.value)} style={is} />
|
||
<input placeholder="E-mail" value={nieuwEmail} onChange={(e) => setNieuwEmail(e.target.value)} style={is} />
|
||
<input placeholder="Wachtwoord" type="password" value={nieuwWw} onChange={(e) => setNieuwWw(e.target.value)} style={is} />
|
||
<RolSelect value={nieuwRol} onChange={setNieuwRol} opties={rollen} T={T} darkMode={darkMode} />
|
||
</div>
|
||
<div style={{ display: "flex", gap: 8 }}>
|
||
<button onClick={() => { setShowAdd(false); setErr(""); }} style={{
|
||
flex: 1, padding: "8px", background: "transparent",
|
||
border: `1px solid ${T.border}`, borderRadius: 8,
|
||
color: T.muted, cursor: "pointer", fontSize: 13, fontWeight: 600,
|
||
}}>Annuleren</button>
|
||
<button onClick={addUser} disabled={saving} style={{
|
||
flex: 2, padding: "8px",
|
||
background: T.accentGradient,
|
||
border: "none", borderRadius: 8, color: "#fff",
|
||
fontWeight: 700, fontSize: 13, cursor: saving ? "not-allowed" : "pointer",
|
||
}}>
|
||
{saving ? "Opslaan..." : "Toevoegen"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<button onClick={() => setShowAdd(true)} style={{
|
||
padding: "8px 18px",
|
||
background: T.accentGradient,
|
||
border: "none", borderRadius: 8, color: "#fff",
|
||
fontWeight: 700, fontSize: 13, cursor: "pointer",
|
||
}}>+ Nieuwe gebruiker</button>
|
||
)}
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|