e-mailconfig + bulk-import gebruikers met wachtwoorden en persoonvoorstel
This commit is contained in:
parent
e38eacefcb
commit
f40918c077
8 changed files with 282 additions and 0 deletions
14
.env.example
14
.env.example
|
|
@ -25,3 +25,17 @@ DJANGO_SECURE=0
|
||||||
POSTGRES_DB=rooster
|
POSTGRES_DB=rooster
|
||||||
POSTGRES_USER=rooster
|
POSTGRES_USER=rooster
|
||||||
POSTGRES_PASSWORD=vervang-mij-door-een-sterk-wachtwoord
|
POSTGRES_PASSWORD=vervang-mij-door-een-sterk-wachtwoord
|
||||||
|
|
||||||
|
# --- E-mail (o.a. wachtwoordreset) ---------------------------------------
|
||||||
|
# Laat DJANGO_EMAIL_BACKEND leeg/weg om e-mail naar de console-log te sturen
|
||||||
|
# (handig om te testen). Zet onderstaande regel aan voor échte e-mail via SMTP:
|
||||||
|
# DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||||
|
EMAIL_HOST=
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_HOST_USER=
|
||||||
|
EMAIL_HOST_PASSWORD=
|
||||||
|
EMAIL_USE_TLS=1
|
||||||
|
DEFAULT_FROM_EMAIL=Roosterwijs <noreply@jouwschool.nl>
|
||||||
|
# Basis-URL van de frontend voor resetlinks in e-mails (zonder slash op het eind),
|
||||||
|
# bv. http://rooster-test.example.nl:8080 (leeg = afleiden uit het verzoek).
|
||||||
|
FRONTEND_BASE_URL=
|
||||||
|
|
|
||||||
23
DEPLOY.md
23
DEPLOY.md
|
|
@ -31,6 +31,29 @@ Nieuwe **modules** verschijnen daarna pas in de app nadat je ze aanzet via
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# E-mail aanzetten (wachtwoordreset-links)
|
||||||
|
|
||||||
|
Zonder configuratie gaan e-mails naar de **console-log** van de backend-container
|
||||||
|
(zichtbaar met `docker compose logs backend`) — prima om te testen. Voor échte
|
||||||
|
e-mail via SMTP zet je in `.env` op de server:
|
||||||
|
|
||||||
|
```
|
||||||
|
DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||||
|
EMAIL_HOST=smtp.jouwprovider.nl
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_HOST_USER=postvak@jouwschool.nl
|
||||||
|
EMAIL_HOST_PASSWORD=...
|
||||||
|
EMAIL_USE_TLS=1
|
||||||
|
DEFAULT_FROM_EMAIL=Roosterwijs <noreply@jouwschool.nl>
|
||||||
|
FRONTEND_BASE_URL=http://rooster-test.example.nl:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
`FRONTEND_BASE_URL` bepaalt de basis van de resetlink in de e-mail (zonder
|
||||||
|
`FRONTEND_BASE_URL` wordt die afgeleid uit het verzoek). Daarna
|
||||||
|
`docker compose up -d` (of `./scripts/deploy.sh`) om de nieuwe waarden te laden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Draaien op de test-server (Docker)
|
# Draaien op de test-server (Docker)
|
||||||
|
|
||||||
De stack bestaat uit drie containers:
|
De stack bestaat uit drie containers:
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,15 @@ volgt.
|
||||||
- [x] **Hiërarchie vastgelegd:** subgroep → groep (Subgroep.groep) en
|
- [x] **Hiërarchie vastgelegd:** subgroep → groep (Subgroep.groep) en
|
||||||
groep → leerplein (Leerplein.groepen).
|
groep → leerplein (Leerplein.groepen).
|
||||||
|
|
||||||
|
## Toegang & gebruikers
|
||||||
|
|
||||||
|
- [x] Login + gebruikersbeheer gebundeld in de accounts-module; inloggen ook met
|
||||||
|
e-mailadres.
|
||||||
|
- [x] **Wachtwoordreset**: beheerder genereert/zet wachtwoord én self-service via
|
||||||
|
e-maillink (SMTP via .env; standaard console-log).
|
||||||
|
- [x] **Bulk-import van gebruikers** met automatisch gegenereerde wachtwoorden,
|
||||||
|
persoon koppelen/aanmaken en een voorstel op naamgelijkenis.
|
||||||
|
|
||||||
## Module: Urenoverzicht
|
## Module: Urenoverzicht
|
||||||
|
|
||||||
- [x] **Module die berekent hoeveel uur een groep per vak krijgt.** (module `uren`, default uit; endpoint /api/urenoverzicht/)
|
- [x] **Module die berekent hoeveel uur een groep per vak krijgt.** (module `uren`, default uit; endpoint /api/urenoverzicht/)
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,6 @@ urlpatterns = [
|
||||||
path("auth/wachtwoord-vergeten/", views.wachtwoord_vergeten, name="wachtwoord-vergeten"),
|
path("auth/wachtwoord-vergeten/", views.wachtwoord_vergeten, name="wachtwoord-vergeten"),
|
||||||
path("auth/wachtwoord-reset/", views.wachtwoord_reset, name="wachtwoord-reset"),
|
path("auth/wachtwoord-reset/", views.wachtwoord_reset, name="wachtwoord-reset"),
|
||||||
path("gebruikers/<int:pk>/wachtwoord/", views.wachtwoord_zet, name="wachtwoord-zet"),
|
path("gebruikers/<int:pk>/wachtwoord/", views.wachtwoord_zet, name="wachtwoord-zet"),
|
||||||
|
path("gebruikers-import/", views.gebruikers_import, name="gebruikers-import"),
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
De login-endpoints horen hier thuis (de module is in de praktijk altijd aan).
|
De login-endpoints horen hier thuis (de module is in de praktijk altijd aan).
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
from django.contrib.auth import authenticate, get_user_model
|
from django.contrib.auth import authenticate, get_user_model
|
||||||
from django.contrib.auth import login as auth_login
|
from django.contrib.auth import login as auth_login
|
||||||
|
|
@ -19,6 +21,7 @@ from rest_framework.decorators import api_view, permission_classes
|
||||||
from rest_framework.permissions import AllowAny, IsAdminUser
|
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from .models import Gebruikersprofiel, Gebruikersrol
|
||||||
from .serializers import GebruikerSerializer
|
from .serializers import GebruikerSerializer
|
||||||
|
|
||||||
logger = logging.getLogger("roosterwijs.auth")
|
logger = logging.getLogger("roosterwijs.auth")
|
||||||
|
|
@ -145,3 +148,75 @@ def wachtwoord_zet(request, pk):
|
||||||
user.save()
|
user.save()
|
||||||
logger.info("Beheerder %r zette nieuw wachtwoord voor %r", request.user.get_username(), user.get_username())
|
logger.info("Beheerder %r zette nieuw wachtwoord voor %r", request.user.get_username(), user.get_username())
|
||||||
return Response({"wachtwoord": nieuw, "gebruiker": user.get_username()})
|
return Response({"wachtwoord": nieuw, "gebruiker": user.get_username()})
|
||||||
|
|
||||||
|
|
||||||
|
_WW_ALFABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
|
||||||
|
def _genereer_wachtwoord(lengte: int = 10) -> str:
|
||||||
|
return "".join(secrets.choice(_WW_ALFABET) for _ in range(lengte))
|
||||||
|
|
||||||
|
|
||||||
|
def _slug_gebruikersnaam(voornaam: str, achternaam: str) -> str:
|
||||||
|
basis = f"{voornaam}.{achternaam}".strip(".").lower()
|
||||||
|
basis = unicodedata.normalize("NFKD", basis).encode("ascii", "ignore").decode()
|
||||||
|
basis = re.sub(r"[^a-z0-9.]+", "", basis).strip(".")
|
||||||
|
return basis or "gebruiker"
|
||||||
|
|
||||||
|
|
||||||
|
def _uniek(basis: str, gebruikt: set) -> str:
|
||||||
|
naam = basis
|
||||||
|
i = 1
|
||||||
|
while naam in gebruikt or User.objects.filter(username__iexact=naam).exists():
|
||||||
|
i += 1
|
||||||
|
naam = f"{basis}{i}"
|
||||||
|
gebruikt.add(naam)
|
||||||
|
return naam
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([IsAdminUser])
|
||||||
|
def gebruikers_import(request):
|
||||||
|
"""Bulk: maak meerdere gebruikers met automatisch gegenereerd wachtwoord.
|
||||||
|
|
||||||
|
Per rij optioneel een bestaande persoon koppelen (persoon-id) of een nieuwe
|
||||||
|
persoon aanmaken (maak_persoon). Geeft de gegenereerde wachtwoorden terug.
|
||||||
|
"""
|
||||||
|
from core.models import Persoon, Rol
|
||||||
|
|
||||||
|
rijen = request.data.get("gebruikers") or []
|
||||||
|
if not isinstance(rijen, list):
|
||||||
|
return Response({"detail": "gebruikers moet een lijst zijn."}, status=400)
|
||||||
|
geldige_rollen = {r.value for r in Gebruikersrol}
|
||||||
|
resultaat = []
|
||||||
|
gebruikt = set()
|
||||||
|
for r in rijen:
|
||||||
|
vn = str(r.get("voornaam") or "").strip()
|
||||||
|
an = str(r.get("achternaam") or "").strip()
|
||||||
|
if not vn and not an:
|
||||||
|
continue
|
||||||
|
email = str(r.get("email") or "").strip()
|
||||||
|
rol = r.get("rol") if r.get("rol") in geldige_rollen else Gebruikersrol.MEDEWERKER
|
||||||
|
gn = str(r.get("gebruikersnaam") or "").strip() or _slug_gebruikersnaam(vn, an)
|
||||||
|
gn = _uniek(gn, gebruikt)
|
||||||
|
wachtwoord = _genereer_wachtwoord()
|
||||||
|
user = User(username=gn, email=email, first_name=vn, last_name=an)
|
||||||
|
user.set_password(wachtwoord)
|
||||||
|
user.save()
|
||||||
|
persoon = None
|
||||||
|
pid = r.get("persoon")
|
||||||
|
if pid:
|
||||||
|
persoon = Persoon.objects.filter(pk=pid).first()
|
||||||
|
elif r.get("maak_persoon"):
|
||||||
|
prol = Rol.LEERLING if rol == Gebruikersrol.LEERLING else Rol.LEERKRACHT
|
||||||
|
persoon = Persoon.objects.create(voornaam=vn, achternaam=an, rol=prol)
|
||||||
|
Gebruikersprofiel.objects.create(user=user, rol=rol, persoon=persoon)
|
||||||
|
resultaat.append({
|
||||||
|
"gebruikersnaam": gn,
|
||||||
|
"wachtwoord": wachtwoord,
|
||||||
|
"naam": f"{vn} {an}".strip(),
|
||||||
|
"email": email,
|
||||||
|
"rol": rol,
|
||||||
|
"persoon_naam": persoon.volledige_naam if persoon else None,
|
||||||
|
})
|
||||||
|
return Response({"aangemaakt": len(resultaat), "gebruikers": resultaat})
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,14 @@ services:
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_HOST: db
|
POSTGRES_HOST: db
|
||||||
POSTGRES_PORT: 5432
|
POSTGRES_PORT: 5432
|
||||||
|
DJANGO_EMAIL_BACKEND: ${DJANGO_EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
|
||||||
|
EMAIL_HOST: ${EMAIL_HOST:-}
|
||||||
|
EMAIL_PORT: ${EMAIL_PORT:-587}
|
||||||
|
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
|
||||||
|
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
|
||||||
|
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-1}
|
||||||
|
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-Roosterwijs <noreply@roosterwijs.local>}
|
||||||
|
FRONTEND_BASE_URL: ${FRONTEND_BASE_URL:-}
|
||||||
volumes:
|
volumes:
|
||||||
# Verzamelde statics delen met nginx.
|
# Verzamelde statics delen met nginx.
|
||||||
- static_volume:/app/staticfiles
|
- static_volume:/app/staticfiles
|
||||||
|
|
|
||||||
|
|
@ -87,3 +87,4 @@ export async function uploadBestand(resource, id, veld, file) {
|
||||||
export const wachtwoordVergeten = (identifier) => request("POST", "/auth/wachtwoord-vergeten/", { identifier });
|
export const wachtwoordVergeten = (identifier) => request("POST", "/auth/wachtwoord-vergeten/", { identifier });
|
||||||
export const wachtwoordReset = (uid, token, wachtwoord) => request("POST", "/auth/wachtwoord-reset/", { uid, token, wachtwoord });
|
export const wachtwoordReset = (uid, token, wachtwoord) => request("POST", "/auth/wachtwoord-reset/", { uid, token, wachtwoord });
|
||||||
export const adminWachtwoordReset = (id, wachtwoord) => request("POST", `/gebruikers/${id}/wachtwoord/`, wachtwoord ? { wachtwoord } : {});
|
export const adminWachtwoordReset = (id, wachtwoord) => request("POST", `/gebruikers/${id}/wachtwoord/`, wachtwoord ? { wachtwoord } : {});
|
||||||
|
export const gebruikersImport = (gebruikers) => request("POST", "/gebruikers-import/", { gebruikers });
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
uploadBestand,
|
uploadBestand,
|
||||||
wachtwoordReset,
|
wachtwoordReset,
|
||||||
adminWachtwoordReset,
|
adminWachtwoordReset,
|
||||||
|
gebruikersImport,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
const ROLLEN = [
|
const ROLLEN = [
|
||||||
|
|
@ -1401,6 +1402,7 @@ export function GebruikersPage() {
|
||||||
const [form, setForm] = useState(leeg);
|
const [form, setForm] = useState(leeg);
|
||||||
const [editId, setEditId] = useState(null);
|
const [editId, setEditId] = useState(null);
|
||||||
const [gegenereerd, setGegenereerd] = useState(null);
|
const [gegenereerd, setGegenereerd] = useState(null);
|
||||||
|
const [view, setView] = useState("beheer");
|
||||||
|
|
||||||
useEffect(() => { listResource("personen").then(setPersonen).catch(() => {}); }, []);
|
useEffect(() => { listResource("personen").then(setPersonen).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
|
@ -1462,6 +1464,11 @@ export function GebruikersPage() {
|
||||||
<p className="muted">Beheer inloggers en hun rol; koppel een login aan een persoon. Alleen beheerders kunnen dit scherm gebruiken.</p>
|
<p className="muted">Beheer inloggers en hun rol; koppel een login aan een persoon. Alleen beheerders kunnen dit scherm gebruiken.</p>
|
||||||
<Foutmelding msg={error} />
|
<Foutmelding msg={error} />
|
||||||
{gegenereerd && <div className="banner ok">Tijdelijk wachtwoord: <code>{gegenereerd}</code> — geef dit door aan de gebruiker; laat het daarna wijzigen.</div>}
|
{gegenereerd && <div className="banner ok">Tijdelijk wachtwoord: <code>{gegenereerd}</code> — geef dit door aan de gebruiker; laat het daarna wijzigen.</div>}
|
||||||
|
<div className="tabbar">
|
||||||
|
<button className={view === "beheer" ? "tab on" : "tab"} onClick={() => setView("beheer")}>Beheer</button>
|
||||||
|
<button className={view === "import" ? "tab on" : "tab"} onClick={() => setView("import")}>📋 Bulk import</button>
|
||||||
|
</div>
|
||||||
|
{view === "import" ? <GebruikersImportWizard personen={personen} onKlaar={reload} /> : (<>
|
||||||
<form className="form-card" onSubmit={opslaan}>
|
<form className="form-card" onSubmit={opslaan}>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input placeholder="Gebruikersnaam" value={form.username} required onChange={(e) => setForm({ ...form, username: e.target.value })} />
|
<input placeholder="Gebruikersnaam" value={form.username} required onChange={(e) => setForm({ ...form, username: e.target.value })} />
|
||||||
|
|
@ -1509,6 +1516,7 @@ export function GebruikersPage() {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
</>)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3409,3 +3417,146 @@ export function WachtwoordResetPage({ uid, token }) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* -------------------- Gebruikers: bulk-import-wizard ---------------------- */
|
||||||
|
function parseGebruikers(tekst) {
|
||||||
|
const regels = tekst.split(/\r?\n/).map((r) => r.trim()).filter(Boolean);
|
||||||
|
if (regels.length === 0) return [];
|
||||||
|
const sep = detecteerScheiding(regels[0]);
|
||||||
|
const splits = (r) => r.split(sep).map((c) => c.trim());
|
||||||
|
const eerste = splits(regels[0]).map((c) => c.toLowerCase());
|
||||||
|
const header = eerste.some((c) => ["voornaam", "achternaam", "naam", "email", "e-mail", "rol"].includes(c));
|
||||||
|
const kol = header ? eerste : null;
|
||||||
|
const rows = [];
|
||||||
|
for (let i = header ? 1 : 0; i < regels.length; i++) {
|
||||||
|
const c = splits(regels[i]);
|
||||||
|
let voornaam = "", achternaam = "", email = "";
|
||||||
|
if (kol) {
|
||||||
|
const idx = (n) => kol.indexOf(n);
|
||||||
|
if (idx("voornaam") >= 0) voornaam = c[idx("voornaam")] || "";
|
||||||
|
if (idx("achternaam") >= 0) achternaam = c[idx("achternaam")] || "";
|
||||||
|
const em = idx("email") >= 0 ? idx("email") : idx("e-mail");
|
||||||
|
if (em >= 0) email = c[em] || "";
|
||||||
|
if (!voornaam && !achternaam && idx("naam") >= 0) { const d = (c[idx("naam")] || "").split(" "); voornaam = d.shift() || ""; achternaam = d.join(" "); }
|
||||||
|
} else if (c.length === 1) {
|
||||||
|
const d = c[0].split(" "); voornaam = d.shift() || ""; achternaam = d.join(" ");
|
||||||
|
} else {
|
||||||
|
voornaam = c[0] || ""; achternaam = c[1] || ""; if (c[2] && c[2].includes("@")) email = c[2];
|
||||||
|
}
|
||||||
|
if (voornaam || achternaam) rows.push({ voornaam, achternaam, email });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
function naamScore(rij, persoonNaam) {
|
||||||
|
const a = `${rij.voornaam} ${rij.achternaam}`.trim().toLowerCase();
|
||||||
|
const b = (persoonNaam || "").toLowerCase();
|
||||||
|
if (!a || !b) return 0;
|
||||||
|
if (a === b) return 100;
|
||||||
|
const ta = a.split(/\s+/).filter(Boolean), tb = b.split(/\s+/).filter(Boolean);
|
||||||
|
let overlap = 0; ta.forEach((t) => { if (tb.includes(t)) overlap++; });
|
||||||
|
let score = overlap * 10;
|
||||||
|
if (b.includes(a) || a.includes(b)) score += 3;
|
||||||
|
if (ta.length && tb.length && ta[ta.length - 1] === tb[tb.length - 1]) score += 5;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GebruikersImportWizard({ personen, onKlaar }) {
|
||||||
|
const [tekst, setTekst] = useState("");
|
||||||
|
const [rol, setRol] = useState("medewerker");
|
||||||
|
const [koppel, setKoppel] = useState({});
|
||||||
|
const [resultaat, setResultaat] = useState(null);
|
||||||
|
const [bezig, setBezig] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const rijen = parseGebruikers(tekst);
|
||||||
|
const suggesties = (rij) => personen
|
||||||
|
.map((p) => ({ p, s: naamScore(rij, p.volledige_naam) }))
|
||||||
|
.filter((x) => x.s > 0).sort((a, b) => b.s - a.s).slice(0, 5);
|
||||||
|
const defaultKoppel = (rij) => { const top = suggesties(rij)[0]; return top && top.s >= 10 ? String(top.p.id) : "_new"; };
|
||||||
|
const koppelVan = (i, rij) => (koppel[i] !== undefined ? koppel[i] : defaultKoppel(rij));
|
||||||
|
|
||||||
|
async function importeer() {
|
||||||
|
setBezig(true); setError(null);
|
||||||
|
try {
|
||||||
|
const payload = rijen.map((rij, i) => {
|
||||||
|
const k = koppelVan(i, rij);
|
||||||
|
return { voornaam: rij.voornaam, achternaam: rij.achternaam, email: rij.email, rol,
|
||||||
|
persoon: k && k !== "_new" ? Number(k) : null, maak_persoon: k === "_new" };
|
||||||
|
});
|
||||||
|
const r = await gebruikersImport(payload);
|
||||||
|
setResultaat(r.gebruikers || []);
|
||||||
|
setTekst(""); setKoppel({});
|
||||||
|
if (onKlaar) onKlaar();
|
||||||
|
} catch (e) { setError(e.message); }
|
||||||
|
finally { setBezig(false); }
|
||||||
|
}
|
||||||
|
const kopieer = (t) => { try { navigator.clipboard.writeText(t); } catch (e) { /* niet beschikbaar */ } };
|
||||||
|
|
||||||
|
if (resultaat) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="banner ok">{resultaat.length} gebruiker(s) aangemaakt. Noteer of kopieer de wachtwoorden nu — ze worden hierna niet meer getoond.</div>
|
||||||
|
<table className="grid">
|
||||||
|
<thead><tr><th>Gebruikersnaam</th><th>Wachtwoord</th><th>Persoon</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{resultaat.map((g, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{g.gebruikersnaam}</td>
|
||||||
|
<td><code>{g.wachtwoord}</code></td>
|
||||||
|
<td className="muted">{g.persoon_naam || "—"}</td>
|
||||||
|
<td className="row-actions"><button type="button" className="link small" onClick={() => kopieer(`${g.gebruikersnaam}\t${g.wachtwoord}`)}>kopieer</button></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="form-row">
|
||||||
|
<button type="button" className="btn" onClick={() => kopieer(resultaat.map((g) => `${g.gebruikersnaam}\t${g.wachtwoord}`).join("\n"))}>Alles kopiëren</button>
|
||||||
|
<button type="button" className="btn primary" onClick={() => setResultaat(null)}>Nog een import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="form-card">
|
||||||
|
<Foutmelding msg={error} />
|
||||||
|
<p className="muted small">Plak rijen of upload een CSV. Kolommen: <b>voornaam, achternaam, e-mail</b> (komma, puntkomma of tab; koprij optioneel). Wachtwoorden worden automatisch gegenereerd; per persoon wordt een gelijkende bestaande persoon voorgesteld.</p>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="veld-kol"><span className="veld-kop">Rol voor iedereen</span>
|
||||||
|
<select value={rol} onChange={(e) => setRol(e.target.value)}>
|
||||||
|
{GEBRUIKERSROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="link small">CSV-bestand kiezen<input type="file" accept=".csv,.txt,text/csv" style={{ display: "none" }} onChange={(e) => { const f = e.target.files[0]; if (f) { const rd = new FileReader(); rd.onload = () => setTekst(String(rd.result || "")); rd.readAsText(f); } e.target.value = ""; }} /></label>
|
||||||
|
</div>
|
||||||
|
<textarea className="bulk-invoer" rows={5} placeholder={"Jan;Jansen;jan@school.nl\nPiet;Pietersen;piet@school.nl"} value={tekst} onChange={(e) => setTekst(e.target.value)} />
|
||||||
|
{rijen.length > 0 && (
|
||||||
|
<table className="grid">
|
||||||
|
<thead><tr><th>Naam</th><th>E-mail</th><th>Koppelen aan persoon</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{rijen.map((rij, i) => {
|
||||||
|
const sug = suggesties(rij);
|
||||||
|
return (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{rij.voornaam} {rij.achternaam}</td>
|
||||||
|
<td className="muted small">{rij.email || "—"}</td>
|
||||||
|
<td>
|
||||||
|
<select value={koppelVan(i, rij)} onChange={(e) => setKoppel((k) => ({ ...k, [i]: e.target.value }))}>
|
||||||
|
<option value="_new">+ nieuwe persoon aanmaken</option>
|
||||||
|
<option value="">— niet koppelen —</option>
|
||||||
|
{sug.map(({ p }) => <option key={p.id} value={p.id}>{p.volledige_naam} — lijkt hierop</option>)}
|
||||||
|
{personen.filter((p) => !sug.some((x) => x.p.id === p.id)).map((p) => <option key={p.id} value={p.id}>{p.volledige_naam}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
<div className="form-row">
|
||||||
|
<button type="button" className="btn primary" disabled={rijen.length === 0 || bezig} onClick={importeer}>{bezig ? "Bezig…" : `Importeer ${rijen.length} gebruiker(s)`}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue