Index: .env.example
===================================================================
--- .env.example	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
+++ .env.example	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -5,16 +5,32 @@
 MEDIA_PATH=./storage/media
 
-# ── Google login via de Klonkt-broker ──────────────────────────────
-# Inloggen gaat via Google, maar gerouteerd door de centrale Klonkt-broker —
-# deze app maakt dus GEEN eigen Google-client aan. Je hoeft hier alleen de
-# broker-URL en het eigen publieke origin te zetten. Zonder deze waarden boot
-# de app gewoon en toont de loginpagina "nog niet geconfigureerd".
-KLONKT_BROKER_URL=https://license.klonkt.com
-# Het eigen publieke origin (scheme + host, geen pad/slash). Bepaalt de callback
-# (<SITE_ORIGIN>/auth/google/callback) en de audience die we eisen. Dit origin
-# moet in de KLONKT_ALLOWED_ORIGINS van de broker staan.
-SITE_ORIGIN=https://example.com
+# Canonieke publieke URL van deze site (scheme + host, geen pad/slash). Gebruikt
+# voor links in e-mails (wachtwoord-reset) i.p.v. request-headers — voorkomt
+# host-header-spoofing. Niet gezet? Dan valt het terug op de request-host (dev).
+PUBLIC_BASE_URL=https://example.com
 
-# Het Google-e-mailadres dat owner/admin (god) is op deze instance.
-# De eerste gebruiker die inlogt wordt sowieso god als de DB nog leeg is.
-ADMIN_EMAIL=
+# ── Beheerder ───────────────────────────────────────────────────────
+# De beheerder logt in met gebruikersnaam + wachtwoord. Bij de eerste keer
+# opstarten maak je via /auth/register je beheerdersaccount aan; daarna is
+# registratie gesloten. Wachtwoord vergeten? -> "Wachtwoord vergeten" (mail,
+# zie SMTP hieronder) of altijd: `npm run reset-admin` op de server.
+
+# ── Google login voor LUISTERAARS (optioneel) ───────────────────────
+# Wil je dat luisteraars met Google kunnen inloggen om te reageren? Maak dan
+# een EIGEN Google OAuth-client aan (Google Cloud Console -> Credentials ->
+# OAuth client ID -> "Web application") en vul hieronder in. Eigen client =
+# eigen Google Cloud project; deze site hangt nergens centraal aan vast.
+# Niet ingesteld? Dan is er simpelweg geen Google-knop. Google-login geeft
+# NOOIT beheerrechten (alleen reageren).
+# GOOGLE_REDIRECT_URI = <jouw domein>/auth/google/callback (exact in de client zetten)
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+GOOGLE_REDIRECT_URI=https://example.com/auth/google/callback
+
+# ── E-mail (optioneel) — voor "wachtwoord vergeten" reset-links ─────
+# Niet ingesteld? Dan werkt reset alleen via `npm run reset-admin` op de server.
+SMTP_HOST=
+SMTP_PORT=587
+SMTP_USER=
+SMTP_PASS=
+SMTP_FROM=
Index: package-lock.json
===================================================================
--- package-lock.json	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
+++ package-lock.json	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -21,7 +21,7 @@
         "helmet": "^7.1.0",
         "htmx.org": "1.9.12",
-        "jose": "^5.10.0",
         "marked": "^11.1.1",
         "multer": "^1.4.5-lts.1",
+        "nodemailer": "^6.9.15",
         "sanitize-html": "^2.17.3",
         "uuid": "^9.0.1",
@@ -1166,13 +1166,4 @@
       }
     },
-    "node_modules/jose": {
-      "version": "5.10.0",
-      "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
-      "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
-      "license": "MIT",
-      "funding": {
-        "url": "https://github.com/sponsors/panva"
-      }
-    },
     "node_modules/marked": {
       "version": "11.2.0",
@@ -1375,4 +1366,13 @@
       "engines": {
         "node": ">=10"
+      }
+    },
+    "node_modules/nodemailer": {
+      "version": "6.10.1",
+      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
+      "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
+      "license": "MIT-0",
+      "engines": {
+        "node": ">=6.0.0"
       }
     },
Index: package.json
===================================================================
--- package.json	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
+++ package.json	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -8,5 +8,6 @@
     "start": "node src/server.js",
     "dev": "node --watch src/server.js",
-    "migrate": "node src/db/migrate.js"
+    "migrate": "node src/db/migrate.js",
+    "reset-admin": "node scripts/reset-admin.mjs"
   },
   "dependencies": {
@@ -23,7 +24,7 @@
     "helmet": "^7.1.0",
     "htmx.org": "1.9.12",
-    "jose": "^5.10.0",
     "marked": "^11.1.1",
     "multer": "^1.4.5-lts.1",
+    "nodemailer": "^6.9.15",
     "sanitize-html": "^2.17.3",
     "uuid": "^9.0.1",
Index: scripts/reset-admin.mjs
===================================================================
--- scripts/reset-admin.mjs	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
+++ scripts/reset-admin.mjs	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -0,0 +1,49 @@
+#!/usr/bin/env node
+// Break-glass: reset (of zet) het wachtwoord van een beheerder. Werkt altijd,
+// zonder e-mail — de self-hoster heeft immers shell-/servertoegang.
+//
+// Gebruik:
+//   npm run reset-admin                       # reset de (eerste) god-user, print nieuw wachtwoord
+//   npm run reset-admin -- <user|email>       # reset specifieke user, print nieuw wachtwoord
+//   npm run reset-admin -- <user|email> <pw>  # zet een gekozen wachtwoord
+//
+// Draai dit vanuit de projectroot zodat DATABASE_PATH/.env correct geladen wordt.
+
+import 'dotenv/config';
+import crypto from 'crypto';
+import bcrypt from 'bcryptjs';
+import db from '../src/config/database.js';
+
+const arg = process.argv[2];
+const pwArg = process.argv[3];
+
+let user;
+if (arg) {
+  user = db.prepare('SELECT * FROM users WHERE username = ? OR LOWER(email) = LOWER(?)').get(arg, arg);
+} else {
+  // Geen arg: pak de beheerder (god of admin), anders de allereerste user.
+  user =
+    db.prepare("SELECT * FROM users WHERE role IN ('god','admin') ORDER BY created_at LIMIT 1").get() ||
+    db.prepare('SELECT * FROM users ORDER BY created_at LIMIT 1').get();
+}
+
+if (!user) {
+  console.error(arg ? `Geen user gevonden voor "${arg}".` : 'Geen god-user gevonden.');
+  process.exit(1);
+}
+
+if (pwArg && pwArg.length < 8) {
+  console.error('Wachtwoord moet minstens 8 tekens zijn.');
+  process.exit(1);
+}
+
+const newPw = pwArg || crypto.randomBytes(9).toString('base64url');
+db.prepare(`
+  UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL,
+    updated_at = CURRENT_TIMESTAMP WHERE id = ?
+`).run(bcrypt.hashSync(newPw, 10), user.id);
+
+console.log(`Wachtwoord gereset voor ${user.username} <${user.email}> (rol: ${user.role}).`);
+if (!pwArg) console.log(`Nieuw wachtwoord: ${newPw}`);
+console.log('Log nu in via /auth/login en wijzig het eventueel in je account.');
+process.exit(0);
Index: src/config/google.js
===================================================================
--- src/config/google.js	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
+++ src/config/google.js	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -1,75 +1,60 @@
-// Google-login via de centrale Klonkt-broker (license.klonkt.com).
+// Google OAuth2 voor LUISTERAARS (reageren). Per-instance: de self-hoster zet
+// z'n EIGEN Google-client in .env. Zo hangt elke site aan z'n eigen Google Cloud
+// project — geen centrale afhankelijkheid, geen gedeelde aansprakelijkheid.
 //
-// Deze instance praat NOOIT zelf met Google. De broker doet de OAuth-dans met één
-// centrale Google-client en stuurt een kortlevend, gesigneerd identity-token terug;
-// dat verifiëren we offline tegen de broker-pubkey. Zo hoeft geen enkele self-host
-// een eigen Google-client aan te maken.
+// Optioneel: staat dit niet ingesteld, dan is er simpelweg geen "Login met
+// Google"-knop en blijft de rest van de site werken. Google-login geeft NOOIT
+// beheerrechten — beheer gaat via het wachtwoord-account.
 //
 // Config via env:
-//   KLONKT_BROKER_URL = https://license.klonkt.com
-//   SITE_ORIGIN       = het eigen publieke origin (bv https://roboburr.com) —
-//                       bepaalt de callback + de audience die we eisen.
-//   ADMIN_EMAIL       = de Google-mail die owner/admin (god) is op deze instance.
+//   GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
+//   GOOGLE_REDIRECT_URI = https://<dit-domein>/auth/google/callback
 
-import { importSPKI, jwtVerify } from 'jose';
+const CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
+const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || '';
+const REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || '';
 
-const ALG = 'EdDSA';
-const ISSUER = 'klonkt-license';
+const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
+const TOKEN_URL = 'https://oauth2.googleapis.com/token';
+const USERINFO_URL = 'https://openidconnect.googleapis.com/v1/userinfo';
 
-const BROKER_URL = (process.env.KLONKT_BROKER_URL || '').replace(/\/$/, '');
-const SITE_ORIGIN = (process.env.SITE_ORIGIN || '').replace(/\/$/, '');
-
-export function brokerConfigured() {
-  return !!(BROKER_URL && SITE_ORIGIN);
+export function googleConfigured() {
+  return !!(CLIENT_ID && CLIENT_SECRET && REDIRECT_URI);
 }
 
-// Waar de broker naartoe terugstuurt (moet in de broker-allowlist staan).
-export function callbackUrl() {
-  return `${SITE_ORIGIN}/auth/google/callback`;
+export function authorizeUrl(state) {
+  const p = new URLSearchParams({
+    client_id: CLIENT_ID,
+    redirect_uri: REDIRECT_URI,
+    response_type: 'code',
+    scope: 'openid email profile',
+    state,
+    access_type: 'online',
+    prompt: 'select_account',
+  });
+  return `${AUTH_URL}?${p.toString()}`;
 }
 
-export function brokerStartUrl(istate) {
-  const p = new URLSearchParams({ return: callbackUrl(), istate });
-  return `${BROKER_URL}/auth/google/start?${p.toString()}`;
+export async function exchangeCode(code) {
+  const body = new URLSearchParams({
+    code,
+    client_id: CLIENT_ID,
+    client_secret: CLIENT_SECRET,
+    redirect_uri: REDIRECT_URI,
+    grant_type: 'authorization_code',
+  });
+  const r = await fetch(TOKEN_URL, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body,
+  });
+  if (!r.ok) throw new Error(`Google token-exchange faalde: ${r.status}`);
+  return r.json(); // { access_token, id_token, ... }
 }
 
-// Broker-pubkey ophalen + cachen (bij fout NIET permanent cachen).
-let _pubkeyPromise = null;
-function getPublicKey() {
-  if (!_pubkeyPromise) {
-    _pubkeyPromise = (async () => {
-      const r = await fetch(`${BROKER_URL}/pubkey`);
-      if (!r.ok) throw new Error(`broker /pubkey faalde: ${r.status}`);
-      return importSPKI(await r.text(), ALG);
-    })().catch((e) => {
-      _pubkeyPromise = null;
-      throw e;
-    });
-  }
-  return _pubkeyPromise;
+// Returns { sub, email, email_verified, name, picture }.
+export async function fetchUserinfo(accessToken) {
+  const r = await fetch(USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } });
+  if (!r.ok) throw new Error(`Google userinfo faalde: ${r.status}`);
+  return r.json();
 }
-
-// Verifieer het identity-token van de broker. Returnt de payload
-// { typ:'identity', sub, email, name, picture, jti, exp, ... }.
-export async function verifyIdentityToken(token) {
-  const key = await getPublicKey();
-  const { payload } = await jwtVerify(token, key, {
-    issuer: ISSUER,
-    algorithms: [ALG],
-    audience: SITE_ORIGIN, // token moet voor ÓNZE site bedoeld zijn
-  });
-  if (payload.typ !== 'identity') throw new Error('verkeerd tokentype');
-  return payload;
-}
-
-// Kleine in-memory jti-cache tegen replay binnen de (korte) geldigheidsduur.
-// Returnt false als de jti al gebruikt is.
-const _usedJti = new Map(); // jti -> exp (epoch seconds)
-export function consumeJti(jti, expEpoch) {
-  if (!jti) return true; // geen jti = niets te dedupen
-  const now = Math.floor(Date.now() / 1000);
-  for (const [k, e] of _usedJti) if (e < now) _usedJti.delete(k);
-  if (_usedJti.has(jti)) return false;
-  _usedJti.set(jti, expEpoch || now + 600);
-  return true;
-}
Index: src/config/mailer.js
===================================================================
--- src/config/mailer.js	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
+++ src/config/mailer.js	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -0,0 +1,37 @@
+// E-mail versturen (wachtwoord-reset). Optioneel: alleen actief als de self-hoster
+// SMTP heeft ingesteld. Niet ingesteld? Dan valt de reset terug op de CLI
+// (`npm run reset-admin`) — zie README.
+//
+// Config via env:
+//   SMTP_HOST, SMTP_PORT (default 587), SMTP_USER, SMTP_PASS
+//   SMTP_FROM (afzender; default = SMTP_USER)
+
+import nodemailer from 'nodemailer';
+
+const HOST = process.env.SMTP_HOST || '';
+const PORT = parseInt(process.env.SMTP_PORT || '587', 10);
+const USER = process.env.SMTP_USER || '';
+const PASS = process.env.SMTP_PASS || '';
+const FROM = process.env.SMTP_FROM || USER;
+
+export function mailerConfigured() {
+  return !!(HOST && USER && PASS);
+}
+
+let _transport = null;
+function transport() {
+  if (!_transport) {
+    _transport = nodemailer.createTransport({
+      host: HOST,
+      port: PORT,
+      secure: PORT === 465, // 465 = impliciete TLS; 587 = STARTTLS
+      auth: { user: USER, pass: PASS },
+    });
+  }
+  return _transport;
+}
+
+export async function sendMail({ to, subject, text, html }) {
+  if (!mailerConfigured()) throw new Error('SMTP niet geconfigureerd');
+  return transport().sendMail({ from: FROM, to, subject, text, html });
+}
Index: src/routes/account.js
===================================================================
--- src/routes/account.js	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
+++ src/routes/account.js	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -17,4 +17,5 @@
 import fs from 'fs';
 import { fileURLToPath } from 'url';
+import bcrypt from 'bcryptjs';
 import multer from 'multer';
 import { v4 as uuid } from 'uuid';
@@ -56,7 +57,9 @@
 router.get('/', requireAuth, (req, res) => {
   const account = db.prepare(`
-    SELECT id, username, email, role, bio, avatar_url, created_at
+    SELECT id, username, email, role, bio, avatar_url, created_at, password_hash
     FROM users WHERE id = ?
   `).get(req.session.user.id);
+  const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth');
+  if (account) delete account.password_hash; // niet naar de view lekken
 
   renderPage(req, res, 'pages/account', {
@@ -64,4 +67,5 @@
     bodyClass: 'on-special',
     account,
+    hasPassword,
     success: req.query.success || null,
     error: req.query.error || null,
@@ -83,6 +87,32 @@
 // longer read or written.
 
-// Wachtwoord-wijzigen is verwijderd: inloggen gaat sinds de Google-OAuth-migratie
-// volledig via Google, er is geen wachtwoord meer om te wijzigen.
+// ==================== CHANGE PASSWORD ====================
+router.post('/password', requireAuth, (req, res) => {
+  const { current, new_password, confirm } = req.body;
+  if (!current || !new_password || !confirm) {
+    return res.redirect('/account?error=' + encodeURIComponent('Alle wachtwoordvelden zijn verplicht'));
+  }
+  if (new_password.length < 8) {
+    return res.redirect('/account?error=' + encodeURIComponent('Nieuw wachtwoord moet minstens 8 tekens zijn'));
+  }
+  if (new_password !== confirm) {
+    return res.redirect('/account?error=' + encodeURIComponent('Nieuwe wachtwoorden komen niet overeen'));
+  }
+
+  const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id);
+  // Google-only accounts (luisteraars) hebben geen echt wachtwoord.
+  if (!row || !row.password_hash || row.password_hash === '!google-oauth') {
+    return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)'));
+  }
+  if (!bcrypt.compareSync(current, row.password_hash)) {
+    return res.redirect('/account?error=' + encodeURIComponent('Huidig wachtwoord is onjuist'));
+  }
+
+  const newHash = bcrypt.hashSync(new_password, 10);
+  db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
+    .run(newHash, req.session.user.id);
+
+  res.redirect('/account?success=' + encodeURIComponent('Wachtwoord gewijzigd'));
+});
 
 // ==================== UPLOAD AVATAR ====================
Index: src/routes/auth.js
===================================================================
--- src/routes/auth.js	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
+++ src/routes/auth.js	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -1,40 +1,237 @@
 import express from 'express';
 import crypto from 'crypto';
+import bcrypt from 'bcryptjs';
 import { v4 as uuid } from 'uuid';
 import db from '../config/database.js';
 import { renderPage } from '../middleware/render.js';
+import { loginLimiter, registerLimiter } from '../middleware/rate-limit.js';
 import { safeNext } from '../middleware/auth.js';
-import { brokerConfigured, brokerStartUrl, verifyIdentityToken, consumeJti } from '../config/google.js';
+import { googleConfigured, authorizeUrl, exchangeCode, fetchUserinfo } from '../config/google.js';
+import { mailerConfigured, sendMail } from '../config/mailer.js';
 
 const router = express.Router();
-const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim().toLowerCase();
-
-// ==================== LOGIN (Google via Klonkt-broker) ====================
-// Het oude username/wachtwoord-systeem is verwijderd; inloggen gaat via Google,
-// gerouteerd door de centrale broker (geen Google-creds op deze instance).
+
+// Vaste dummy-hash: zo draait login altijd één bcrypt-vergelijking, ook als de
+// user niet bestaat of geen wachtwoord heeft — geen timing-oracle voor enumeratie.
+const DUMMY_HASH = bcrypt.hashSync('constant-time-login-guard', 10);
+
+// Canonieke basis-URL voor links in e-mails (reset). Uit headers bouwen is
+// spoofbaar (X-Forwarded-Host); een vaste config sluit dat uit.
+function publicBaseUrl(req) {
+  const cfg = (process.env.PUBLIC_BASE_URL || '').replace(/\/$/, '');
+  if (cfg) return cfg;
+  // Fallback (dev): trust-proxy-gesaneerde protocol + Host-header (NIET de rauwe
+  // X-Forwarded-Host).
+  return `${req.protocol}://${req.get('host')}`;
+}
+
+function hashToken(raw) {
+  return crypto.createHash('sha256').update(String(raw)).digest('hex');
+}
+
+// Eerste-keer-setup? Pas zolang er nog geen enkele gebruiker is mag /register een
+// beheerder aanmaken. Daarna is registratie dicht (luisteraars komen via Google).
+function isSetupMode() {
+  return db.prepare('SELECT COUNT(*) AS c FROM users').get().c === 0;
+}
+
+// ==================== LOGIN (beheerder = wachtwoord) ====================
 router.get('/login', (req, res) => {
   const next = safeNext(req.query.next) || '';
   if (req.session.user) return res.redirect(next || '/');
+  if (isSetupMode()) return res.redirect('/auth/register' + (next ? '?next=' + encodeURIComponent(next) : ''));
   renderPage(req, res, 'pages/auth-login', {
     pageTitle: 'Inloggen',
     bodyClass: 'on-special',
-    googleReady: brokerConfigured(),
-    error: req.query.error || null,
+    error: null,
+    success: req.query.success || null,
+    username: '',
+    googleReady: googleConfigured(),
     next,
   });
 });
 
-// Start de login: random state in de sessie (CSRF), dan door naar de broker.
+router.post('/login', loginLimiter, (req, res) => {
+  const { username, password } = req.body;
+  const next = safeNext(req.body.next) || '';
+
+  const renderErr = (error, status = 400) => {
+    res.status(status);
+    return renderPage(req, res, 'pages/auth-login', {
+      pageTitle: 'Inloggen', bodyClass: 'on-special',
+      error, success: null, username: username || '', googleReady: googleConfigured(), next,
+    });
+  };
+
+  if (!username || !password) return renderErr('Gebruikersnaam en wachtwoord vereist');
+
+  const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username);
+  // Altijd één bcrypt-vergelijking (dummy als de user geen bruikbaar wachtwoord
+  // heeft) zodat de responstijd niets over het bestaan van een account verraadt.
+  const usable = !!(user && user.password_hash && user.password_hash !== '!google-oauth');
+  const ok = bcrypt.compareSync(password, usable ? user.password_hash : DUMMY_HASH);
+  if (!usable || !ok) return renderErr('Ongeldige inloggegevens', 401);
+
+  req.session.user = {
+    id: user.id, username: user.username, email: user.email, role: user.role,
+    avatar_url: user.avatar_url, palette: user.palette, theme: user.theme,
+  };
+  res.redirect(next || '/');
+});
+
+// ==================== EERSTE-KEER-SETUP (beheerder aanmaken) ====================
+router.get('/register', (req, res) => {
+  const next = safeNext(req.query.next) || '';
+  if (req.session.user) return res.redirect(next || '/');
+  // Geen publieke registratie: alleen de allereerste beheerder mag hier aangemaakt.
+  if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : ''));
+  renderPage(req, res, 'pages/auth-register', {
+    pageTitle: 'Beheerder aanmaken', bodyClass: 'on-special',
+    error: null, username: '', email: '', next,
+  });
+});
+
+router.post('/register', registerLimiter, (req, res) => {
+  const { username, email, password } = req.body;
+  const next = safeNext(req.body.next) || '';
+  const renderErr = (error) => renderPage(req, res, 'pages/auth-register', {
+    pageTitle: 'Beheerder aanmaken', bodyClass: 'on-special',
+    error, username: username || '', email: email || '', next,
+  });
+
+  // Hard gesloten zodra er een gebruiker is — voorkomt een tweede "admin" via deze route.
+  if (!isSetupMode()) return res.redirect('/auth/login');
+
+  if (!username || !email || !password) return renderErr('Alle velden zijn verplicht');
+  if (!/^[a-z0-9_-]{3,32}$/i.test(username)) {
+    return renderErr('Gebruikersnaam: 3-32 tekens, letters/cijfers/_/- alleen');
+  }
+  if (password.length < 8) return renderErr('Wachtwoord moet minstens 8 tekens zijn');
+
+  const userId = uuid();
+  const hash = bcrypt.hashSync(password, 10);
+  // De allereerste gebruiker is de beheerder (god).
+  db.prepare(`
+    INSERT INTO users (id, username, email, password_hash, role, theme, palette)
+    VALUES (?, ?, ?, ?, 'god', 'dark', 'sage')
+  `).run(userId, username, email, hash);
+
+  // Persoonlijke site auto-aanmaken (single-tenant-ombouw volgt later).
+  if (!db.prepare('SELECT 1 FROM sites LIMIT 1').get()) {
+    const siteId = uuid();
+    db.prepare(`
+      INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language)
+      VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl')
+    `).run(siteId, username.toLowerCase(), username + "'s Site", 'Welkom', userId);
+    db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId);
+  }
+
+  req.session.user = { id: userId, username, email, role: 'god', palette: 'sage', theme: 'dark' };
+  res.redirect(next || '/');
+});
+
+// ==================== WACHTWOORD VERGETEN (aanvraag) ====================
+router.get('/reset-request', (req, res) => {
+  if (req.session.user) return res.redirect('/');
+  renderPage(req, res, 'pages/auth-reset-request', {
+    pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
+    error: null, sent: false, devResetUrl: null, mailer: mailerConfigured(),
+  });
+});
+
+router.post('/reset-request', registerLimiter, async (req, res) => {
+  const email = (req.body.email || '').trim().toLowerCase();
+  let devResetUrl = null;
+
+  if (email) {
+    const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email);
+    if (user) {
+      const token = crypto.randomBytes(32).toString('hex'); // ruw: gaat alleen de mail/link in
+      const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min
+      // Alleen de HASH opslaan: DB-leestoegang levert zo geen bruikbaar token op.
+      db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?')
+        .run(hashToken(token), expires, user.id);
+
+      const url = `${publicBaseUrl(req)}/auth/reset/${token}`;
+
+      if (mailerConfigured()) {
+        try {
+          await sendMail({
+            to: user.email,
+            subject: 'Wachtwoord resetten',
+            text: `Reset je wachtwoord via deze link (30 min geldig):\n\n${url}\n\nNiet aangevraagd? Negeer deze mail.`,
+            html: `<p>Reset je wachtwoord via deze link (30 min geldig):</p><p><a href="${url}">${url}</a></p><p>Niet aangevraagd? Negeer deze mail.</p>`,
+          });
+        } catch (e) {
+          console.error('[reset-request] mail faalde:', e.message);
+        }
+      } else if (process.env.NODE_ENV !== 'production') {
+        // Dev zonder SMTP: link in log + op de pagina tonen.
+        console.log(`[password-reset] ${user.email} -> ${url}`);
+        devResetUrl = url;
+      } else {
+        // Productie zonder SMTP: NOOIT het token loggen. Verwijs naar de CLI break-glass.
+        console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`);
+      }
+    }
+  }
+
+  // Anti-enumeratie: zelfde antwoord ongeacht of het adres bestaat.
+  renderPage(req, res, 'pages/auth-reset-request', {
+    pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
+    error: null, sent: true, devResetUrl, mailer: mailerConfigured(),
+  });
+});
+
+// ==================== WACHTWOORD RESETTEN (toepassen) ====================
+router.get('/reset/:token', (req, res) => {
+  const row = db.prepare(`
+    SELECT id, username FROM users
+    WHERE reset_token = ? AND reset_token_expires > datetime('now')
+  `).get(hashToken(req.params.token));
+  renderPage(req, res, 'pages/auth-reset', {
+    pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
+    error: row ? null : 'Deze reset-link is ongeldig of verlopen.',
+    token: row ? req.params.token : null,
+    username: row ? row.username : null,
+  });
+});
+
+router.post('/reset/:token', (req, res) => {
+  const { new_password, confirm } = req.body;
+  const row = db.prepare(`
+    SELECT id, username FROM users
+    WHERE reset_token = ? AND reset_token_expires > datetime('now')
+  `).get(hashToken(req.params.token));
+
+  const renderError = (msg) => renderPage(req, res, 'pages/auth-reset', {
+    pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
+    error: msg, token: row ? req.params.token : null, username: row ? row.username : null,
+  });
+
+  if (!row) return renderError('Deze reset-link is ongeldig of verlopen.');
+  if (!new_password || new_password.length < 8) return renderError('Wachtwoord moet minstens 8 tekens zijn');
+  if (new_password !== confirm) return renderError('Wachtwoorden komen niet overeen');
+
+  const hash = bcrypt.hashSync(new_password, 10);
+  db.prepare(`
+    UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL,
+      updated_at = CURRENT_TIMESTAMP WHERE id = ?
+  `).run(hash, row.id);
+  res.redirect('/auth/login?success=' + encodeURIComponent('Wachtwoord gereset — log nu in.'));
+});
+
+// ==================== GOOGLE-LOGIN (luisteraars/reageerders) ====================
+// Per-instance, eigen Google-client. Geeft ALTIJD rol member — nooit beheer.
 router.get('/google', (req, res) => {
-  if (!brokerConfigured()) {
-    return res.redirect('/auth/login?error=' + encodeURIComponent('Google-login is op deze site nog niet geconfigureerd.'));
-  }
-  const istate = crypto.randomBytes(16).toString('hex');
-  req.session.loginState = istate;
-  req.session.loginNext = safeNext(req.query.next) || '';
-  res.redirect(brokerStartUrl(istate));
-});
-
-// Leid een geldige, unieke username af uit naam/e-mail (schema vereist username).
+  if (!googleConfigured()) {
+    return res.redirect('/auth/login?error=' + encodeURIComponent('Google-login is op deze site niet ingesteld.'));
+  }
+  const state = crypto.randomBytes(16).toString('hex');
+  req.session.oauthState = state;
+  req.session.oauthNext = safeNext(req.query.next) || '';
+  res.redirect(authorizeUrl(state));
+});
+
 function uniqueUsername(base) {
   let u = String(base || 'luisteraar').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 28);
@@ -48,93 +245,49 @@
 }
 
-// Callback van de broker: ?klonkt_id_token=&klonkt_state=(&klonkt_login_error=).
-// We checken state (CSRF), verifiëren het token (sig + audience + replay), en
-// vinden-of-maken de user op e-mail. ADMIN_EMAIL bepaalt wie owner/admin (god) is.
 router.get('/google/callback', async (req, res) => {
   const fail = (msg) => res.redirect('/auth/login?error=' + encodeURIComponent(msg));
   try {
-    const { klonkt_id_token: idToken, klonkt_state: state, klonkt_login_error: loginError } = req.query;
-
-    // State-check (CSRF) eerst — bind de callback aan de sessie die login startte.
-    const expected = req.session.loginState;
-    const next = safeNext(req.session.loginNext) || '';
-    delete req.session.loginState;
-    delete req.session.loginNext;
-    if (!expected || state !== expected) return fail('Login afgebroken of ongeldige sessie.');
-
-    if (loginError === 'unverified_email') return fail('Geen geverifieerd Google-e-mailadres.');
-    if (!idToken) return fail('Geen logintoken ontvangen.');
-
-    let payload;
-    try {
-      payload = await verifyIdentityToken(String(idToken));
-    } catch (e) {
-      console.error('[auth/google/callback] tokenverificatie:', e.message);
-      return fail('Logintoken ongeldig of verlopen.');
-    }
-    if (!consumeJti(payload.jti, payload.exp)) return fail('Logintoken al gebruikt.');
-
-    const email = (payload.email || '').trim().toLowerCase();
-    if (!email) return fail('Geen e-mailadres in logintoken.');
-
-    const isAdmin = !!ADMIN_EMAIL && email === ADMIN_EMAIL;
+    const { code, state } = req.query;
+    if (!code || !state || state !== req.session.oauthState) return fail('Login afgebroken of ongeldige sessie.');
+    const next = safeNext(req.session.oauthNext) || '';
+    delete req.session.oauthState;
+    delete req.session.oauthNext;
+
+    const tok = await exchangeCode(String(code));
+    const info = await fetchUserinfo(tok.access_token);
+    const email = (info.email || '').trim().toLowerCase();
+    if (!email || info.email_verified === false) return fail('Geen geverifieerd Google-e-mailadres.');
+
     let user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);
 
-    if (!user) {
-      const userCount = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
-      // god = de ingestelde ADMIN_EMAIL. De "eerste user wordt god"-bootstrap geldt
-      // ALLEEN als er helemaal geen ADMIN_EMAIL is ingesteld — anders zou een vreemde
-      // die toevallig als eerste inlogt op een verse install eigenaar worden.
-      const role = isAdmin ? 'god' : (!ADMIN_EMAIL && userCount === 0 ? 'god' : 'member');
+    if (user) {
+      // Strikte scheiding: Google geeft nooit beheer. Hoort dit adres bij een
+      // beheerder, dan moet diegene met wachtwoord inloggen (geen Google-bypass).
+      if (user.role === 'god' || user.role === 'admin') {
+        return fail('Dit adres hoort bij een beheerder — log in met je wachtwoord.');
+      }
+      // Bestaande luisteraar: koppel google_sub/avatar als die ontbreken; weiger
+      // als al aan een ander Google-account gekoppeld.
+      if (user.google_sub && info.sub && user.google_sub !== info.sub) {
+        return fail('Dit e-mailadres is al aan een ander Google-account gekoppeld.');
+      }
+      db.prepare(`
+        UPDATE users SET google_sub = COALESCE(google_sub, ?), avatar_url = COALESCE(avatar_url, ?),
+          updated_at = CURRENT_TIMESTAMP WHERE id = ?
+      `).run(info.sub || null, info.picture || null, user.id);
+    } else {
+      // Nieuwe luisteraar — altijd member.
       const userId = uuid();
-      const username = uniqueUsername(payload.name || email.split('@')[0]);
-      // password_hash is NOT NULL in het schema; er is geen wachtwoord-login meer,
-      // dus we zetten een onbruikbare sentinel.
+      const username = uniqueUsername(info.name || email.split('@')[0]);
       db.prepare(`
         INSERT INTO users (id, username, email, password_hash, role, avatar_url, theme, palette, google_sub)
-        VALUES (?, ?, ?, '!google-oauth', ?, ?, 'dark', 'sage', ?)
-      `).run(userId, username, payload.email || email, role, payload.picture || null, payload.sub || null);
-
-      // Eerste/admin-user krijgt een persoonlijke site (zoals de oude flow), maar
-      // alleen als er nog geen site bestaat.
-      if (role === 'god' && !db.prepare('SELECT 1 FROM sites LIMIT 1').get()) {
-        const siteId = uuid();
-        db.prepare(`
-          INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language)
-          VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl')
-        `).run(siteId, username.toLowerCase(), username + "'s Site", 'Welkom', userId);
-        db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId);
-      }
+        VALUES (?, ?, ?, '!google-oauth', 'member', ?, 'dark', 'sage', ?)
+      `).run(userId, username, info.email || email, info.picture || null, info.sub || null);
       user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
-    } else {
-      // Identiteits-integriteit: als deze user al aan een ander Google-account
-      // gekoppeld is (google_sub mismatch), weiger — voorkomt overname als een
-      // geverifieerd e-mailadres ooit naar een andere Google-sub verhuist.
-      if (user.google_sub && payload.sub && user.google_sub !== payload.sub) {
-        return fail('Dit e-mailadres is al aan een ander Google-account gekoppeld.');
-      }
-      // Bestaande user (gekoppeld op e-mail): koppel Google-id + avatar als die
-      // nog ontbreken, en promoveer naar god als dit ADMIN_EMAIL is.
-      db.prepare(`
-        UPDATE users
-        SET google_sub = COALESCE(google_sub, ?),
-            avatar_url = COALESCE(avatar_url, ?),
-            updated_at = CURRENT_TIMESTAMP
-        WHERE id = ?
-      `).run(payload.sub || null, payload.picture || null, user.id);
-      if (isAdmin && user.role !== 'god') {
-        db.prepare("UPDATE users SET role = 'god' WHERE id = ?").run(user.id);
-        user.role = 'god';
-      }
     }
 
     req.session.user = {
-      id: user.id,
-      username: user.username,
-      email: user.email,
-      role: user.role,
-      avatar_url: user.avatar_url,
-      palette: user.palette,
-      theme: user.theme,
+      id: user.id, username: user.username, email: user.email, role: user.role,
+      avatar_url: user.avatar_url, palette: user.palette, theme: user.theme,
     };
     res.redirect(next || '/');
@@ -146,10 +299,6 @@
 
 // ==================== LOGOUT ====================
-router.get('/logout', (req, res) => {
-  req.session.destroy(() => res.redirect('/'));
-});
-router.post('/logout', (req, res) => {
-  req.session.destroy(() => res.redirect('/'));
-});
+router.get('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
+router.post('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
 
 export default router;
Index: src/server.js
===================================================================
--- src/server.js	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
+++ src/server.js	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -148,4 +148,18 @@
 app.use(loadTheme);
 
+// Lichtgewicht CSRF-defense: weiger cross-origin state-wijzigende requests.
+// Same-origin forms + HTMX sturen een matchende Origin; ontbreekt Origin dan
+// laten we door (non-browser clients). sameSite:'lax' op de sessiecookie is de
+// tweede laag. (Geldt niet voor GET/HEAD/OPTIONS.)
+app.use((req, res, next) => {
+  if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
+  const origin = req.get('origin');
+  if (!origin) return next(); // geen Origin -> geen browser-CSRF-vector
+  let originHost;
+  try { originHost = new URL(origin).host; } catch { return res.status(403).send('Ongeldige origin'); }
+  if (originHost !== req.get('host')) return res.status(403).send('Cross-origin request geweigerd');
+  next();
+});
+
 app.use('/auth', authRoutes);
 app.use('/account', accountRoutes);
@@ -328,5 +342,5 @@
   console.log(`   ✓ Privacy:  Self-hosted fonts, no third-party requests`);
   console.log(`   ✓ Layout:   v9 editorial feel (top nav, profile header)`);
-  console.log(`   ✓ Auth:     Google login / logout`);
+  console.log(`   ✓ Auth:     wachtwoord (beheer) + Google (luisteraars) / logout`);
   console.log(`   ✓ Posts:    create / edit / view / archive`);
   console.log(`   ✓ Realtime: WebSocket server ready (Prutter)`);
Index: src/views/pages/account.ejs
===================================================================
--- src/views/pages/account.ejs	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
+++ src/views/pages/account.ejs	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -65,9 +65,34 @@
   </section>
 
-  <%# ── INLOG ─────────────────────────────────────────────── %>
+  <%# ── WACHTWOORD ────────────────────────────────────────── %>
+  <% if (hasPassword) { %>
+  <section class="ax-card">
+    <div class="ax-card-title">Wachtwoord wijzigen</div>
+    <form action="/account/password" method="post" class="ax-form">
+      <label class="ax-field">
+        <span>Huidig wachtwoord</span>
+        <input type="password" name="current" required autocomplete="current-password">
+      </label>
+      <div class="ax-row ax-row-2">
+        <label class="ax-field">
+          <span>Nieuw wachtwoord <small>(min 8 tekens)</small></span>
+          <input type="password" name="new_password" required minlength="8" autocomplete="new-password">
+        </label>
+        <label class="ax-field">
+          <span>Bevestig nieuw wachtwoord</span>
+          <input type="password" name="confirm" required minlength="8" autocomplete="new-password">
+        </label>
+      </div>
+      <div class="ax-form-actions">
+        <button type="submit" class="ax-btn ax-btn-primary">🔒 Wachtwoord wijzigen</button>
+      </div>
+    </form>
+  </section>
+  <% } else { %>
   <section class="ax-card">
     <div class="ax-card-title">Inloggen</div>
     <p class="ax-tagline" style="margin:0">Je bent ingelogd via Google (<%= account.email %>). Er is geen apart wachtwoord.</p>
   </section>
+  <% } %>
 
 </div>
Index: src/views/pages/auth-login.ejs
===================================================================
--- src/views/pages/auth-login.ejs	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
+++ src/views/pages/auth-login.ejs	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -1,21 +1,34 @@
 <div class="container auth-page">
   <h1>Inloggen</h1>
+  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
   <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
 
-  <div class="auth-form auth-google">
-    <% if (googleReady) { %>
-      <p class="auth-intro">Log in met je Google-account om mee te praten.</p>
-      <a class="btn btn-google" href="/auth/google<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">
-        <svg class="g-icon" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
-          <path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z"/>
-          <path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/>
-          <path fill="#FBBC05" d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33z"/>
-          <path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/>
-        </svg>
-        <span>Inloggen met Google</span>
-      </a>
-    <% } else { %>
-      <p class="auth-intro">Inloggen via Google is op deze site nog niet ingesteld.</p>
+  <form method="post" action="/auth/login" class="auth-form">
+    <% if (typeof next !== 'undefined' && next) { %>
+      <input type="hidden" name="next" value="<%= next %>">
     <% } %>
-  </div>
+    <label>
+      <span>Gebruikersnaam of e-mail</span>
+      <input type="text" name="username" value="<%= username %>" required autofocus autocomplete="username">
+    </label>
+    <label>
+      <span>Wachtwoord</span>
+      <input type="password" name="password" required autocomplete="current-password">
+    </label>
+    <button type="submit" class="btn btn-primary">Inloggen</button>
+    <p class="auth-link auth-link-muted"><a href="/auth/reset-request">Wachtwoord vergeten?</a></p>
+  </form>
+
+  <% if (googleReady) { %>
+    <div class="auth-divider"><span>of als luisteraar</span></div>
+    <a class="btn btn-google" href="/auth/google<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">
+      <svg class="g-icon" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
+        <path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z"/>
+        <path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/>
+        <path fill="#FBBC05" d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33z"/>
+        <path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/>
+      </svg>
+      <span>Inloggen met Google</span>
+    </a>
+  <% } %>
 </div>
Index: src/views/pages/auth-register.ejs
===================================================================
--- src/views/pages/auth-register.ejs	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
+++ src/views/pages/auth-register.ejs	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -1,4 +1,5 @@
 <div class="container auth-page">
-  <h1>Register</h1>
+  <h1>Beheerder aanmaken</h1>
+  <p class="auth-intro">Eerste keer opzetten — maak je beheerdersaccount aan. Dit kan maar één keer.</p>
   <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
   <form method="post" action="/auth/register" class="auth-form">
@@ -7,19 +8,16 @@
     <% } %>
     <label>
-      <span>Username (3-32 chars, letters/numbers/_-)</span>
+      <span>Gebruikersnaam (3-32 tekens, letters/cijfers/_-)</span>
       <input type="text" name="username" value="<%= username %>" required autofocus autocomplete="username" pattern="[a-zA-Z0-9_-]{3,32}">
     </label>
     <label>
-      <span>Email</span>
+      <span>E-mail</span>
       <input type="email" name="email" value="<%= email %>" required autocomplete="email">
     </label>
     <label>
-      <span>Password (min 8 chars)</span>
+      <span>Wachtwoord (min 8 tekens)</span>
       <input type="password" name="password" required minlength="8" autocomplete="new-password">
     </label>
-    <button type="submit" class="btn btn-primary">Create account</button>
-    <p class="auth-link">Already registered?
-      <a href="/auth/login<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">Login</a>
-    </p>
+    <button type="submit" class="btn btn-primary">Beheerder aanmaken</button>
   </form>
 </div>
Index: src/views/pages/auth-reset-request.ejs
===================================================================
--- src/views/pages/auth-reset-request.ejs	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
+++ src/views/pages/auth-reset-request.ejs	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -1,12 +1,12 @@
 <div class="container auth-page">
-  <h1>Reset password</h1>
+  <h1>Wachtwoord resetten</h1>
 
   <% if (sent) { %>
     <div class="alert alert-success">
-      If an account exists for that email, a reset link has been sent.
+      Als er een account bestaat voor dat e-mailadres, is er een reset-link verstuurd.
     </div>
     <% if (devResetUrl) { %>
       <div class="alert alert-info">
-        <strong>Dev mode:</strong> reset link below (no email server configured).
+        <strong>Geen e-mailserver ingesteld</strong> — reset-link hieronder.
         <p style="margin: 0.5rem 0 0; word-break: break-all;">
           <a href="<%= devResetUrl %>"><%= devResetUrl %></a>
@@ -14,7 +14,10 @@
       </div>
     <% } %>
-    <p class="auth-link"><a href="/auth/login">&larr; Back to login</a></p>
+    <% if (typeof mailer !== 'undefined' && !mailer) { %>
+      <p class="auth-link-muted">Geen e-mail ingesteld? De beheerder kan ook <code>npm run reset-admin</code> draaien op de server.</p>
+    <% } %>
+    <p class="auth-link"><a href="/auth/login">&larr; Terug naar inloggen</a></p>
   <% } else { %>
-    <p class="auth-tagline">Enter your email and we'll send a reset link.</p>
+    <p class="auth-tagline">Vul je e-mail in; we sturen een reset-link.</p>
 
     <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
@@ -22,9 +25,9 @@
     <form method="post" action="/auth/reset-request" class="auth-form">
       <label>
-        <span>Email</span>
+        <span>E-mail</span>
         <input type="email" name="email" required autofocus autocomplete="email">
       </label>
-      <button type="submit" class="btn btn-primary">Send reset link</button>
-      <p class="auth-link"><a href="/auth/login">&larr; Back to login</a></p>
+      <button type="submit" class="btn btn-primary">Stuur reset-link</button>
+      <p class="auth-link"><a href="/auth/login">&larr; Terug naar inloggen</a></p>
     </form>
   <% } %>
Index: src/views/pages/auth-reset.ejs
===================================================================
--- src/views/pages/auth-reset.ejs	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
+++ src/views/pages/auth-reset.ejs	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -1,14 +1,14 @@
 <div class="container auth-page">
-  <h1>Reset password</h1>
+  <h1>Wachtwoord resetten</h1>
 
   <% if (!token) { %>
     <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
     <p class="auth-link">
-      <a href="/auth/reset-request">Request a new reset link</a>
+      <a href="/auth/reset-request">Vraag een nieuwe reset-link aan</a>
       &middot;
-      <a href="/auth/login">Back to login</a>
+      <a href="/auth/login">Terug naar inloggen</a>
     </p>
   <% } else { %>
-    <p class="auth-tagline">Set a new password for <strong><%= username %></strong>.</p>
+    <p class="auth-tagline">Stel een nieuw wachtwoord in voor <strong><%= username %></strong>.</p>
 
     <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
@@ -16,12 +16,12 @@
     <form method="post" action="/auth/reset/<%= token %>" class="auth-form">
       <label>
-        <span>New password (min 8 chars)</span>
+        <span>Nieuw wachtwoord (min 8 tekens)</span>
         <input type="password" name="new_password" required minlength="8" autofocus autocomplete="new-password">
       </label>
       <label>
-        <span>Confirm new password</span>
+        <span>Bevestig nieuw wachtwoord</span>
         <input type="password" name="confirm" required minlength="8" autocomplete="new-password">
       </label>
-      <button type="submit" class="btn btn-primary">Set password</button>
+      <button type="submit" class="btn btn-primary">Wachtwoord instellen</button>
     </form>
   <% } %>
Index: src/views/pages/welcome.ejs
===================================================================
--- src/views/pages/welcome.ejs	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
+++ src/views/pages/welcome.ejs	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -5,7 +5,8 @@
   <% if (!user) { %>
     <div class="welcome-actions">
-      <a href="/auth/login" class="btn btn-primary">Inloggen met Google</a>
+      <a href="/auth/register" class="btn btn-primary">Beheerder aanmaken</a>
+      <a href="/auth/login" class="btn">Al een account? Inloggen</a>
     </div>
-    <p class="welcome-note">De eerste gebruiker die inlogt wordt <strong>god</strong> en krijgt automatisch een eigen site.</p>
+    <p class="welcome-note">Maak je beheerdersaccount aan om te beginnen — daarna is registratie gesloten.</p>
   <% } else { %>
     <p>Hi <%= user.username %>! No site is configured yet.</p>
Index: src/views/partials/shared-styles.ejs
===================================================================
--- src/views/partials/shared-styles.ejs	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
+++ src/views/partials/shared-styles.ejs	(revision 9e27d64c70dff768f7a07cb5f10586ad65f56c42)
@@ -125,4 +125,6 @@
 .btn-google:hover { background: #f7f8f8; border-color: #dadce0; }
 .btn-google .g-icon { flex-shrink: 0; }
+.auth-divider { display: flex; align-items: center; gap: 0.75rem; margin: 1.25rem 0; color: var(--ink-muted); font-size: 0.85rem; }
+.auth-divider::before, .auth-divider::after { content: ''; flex: 1; height: 1px; background: var(--rule); }
 
 /* HTMX loading indicator */
