Changeset 9e27d64 in Klonkt
- Timestamp:
- 06/14/2026 07:31:23 AM (3 months ago)
- Branches:
- main
- Children:
- 6351545
- Parents:
- 32cc601
- Files:
-
- 2 added
- 14 edited
-
.env.example (modified) (1 diff)
-
package-lock.json (modified) (3 diffs)
-
package.json (modified) (2 diffs)
-
scripts/reset-admin.mjs (added)
-
src/config/google.js (modified) (1 diff)
-
src/config/mailer.js (added)
-
src/routes/account.js (modified) (4 diffs)
-
src/routes/auth.js (modified) (3 diffs)
-
src/server.js (modified) (2 diffs)
-
src/views/pages/account.ejs (modified) (1 diff)
-
src/views/pages/auth-login.ejs (modified) (1 diff)
-
src/views/pages/auth-register.ejs (modified) (2 diffs)
-
src/views/pages/auth-reset-request.ejs (modified) (3 diffs)
-
src/views/pages/auth-reset.ejs (modified) (2 diffs)
-
src/views/pages/welcome.ejs (modified) (1 diff)
-
src/views/partials/shared-styles.ejs (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
.env.example
r32cc601 r9e27d64 5 5 MEDIA_PATH=./storage/media 6 6 7 # ── Google login via de Klonkt-broker ────────────────────────────── 8 # Inloggen gaat via Google, maar gerouteerd door de centrale Klonkt-broker — 9 # deze app maakt dus GEEN eigen Google-client aan. Je hoeft hier alleen de 10 # broker-URL en het eigen publieke origin te zetten. Zonder deze waarden boot 11 # de app gewoon en toont de loginpagina "nog niet geconfigureerd". 12 KLONKT_BROKER_URL=https://license.klonkt.com 13 # Het eigen publieke origin (scheme + host, geen pad/slash). Bepaalt de callback 14 # (<SITE_ORIGIN>/auth/google/callback) en de audience die we eisen. Dit origin 15 # moet in de KLONKT_ALLOWED_ORIGINS van de broker staan. 16 SITE_ORIGIN=https://example.com 7 # Canonieke publieke URL van deze site (scheme + host, geen pad/slash). Gebruikt 8 # voor links in e-mails (wachtwoord-reset) i.p.v. request-headers — voorkomt 9 # host-header-spoofing. Niet gezet? Dan valt het terug op de request-host (dev). 10 PUBLIC_BASE_URL=https://example.com 17 11 18 # Het Google-e-mailadres dat owner/admin (god) is op deze instance. 19 # De eerste gebruiker die inlogt wordt sowieso god als de DB nog leeg is. 20 ADMIN_EMAIL= 12 # ── Beheerder ─────────────────────────────────────────────────────── 13 # De beheerder logt in met gebruikersnaam + wachtwoord. Bij de eerste keer 14 # opstarten maak je via /auth/register je beheerdersaccount aan; daarna is 15 # registratie gesloten. Wachtwoord vergeten? -> "Wachtwoord vergeten" (mail, 16 # zie SMTP hieronder) of altijd: `npm run reset-admin` op de server. 17 18 # ── Google login voor LUISTERAARS (optioneel) ─────────────────────── 19 # Wil je dat luisteraars met Google kunnen inloggen om te reageren? Maak dan 20 # een EIGEN Google OAuth-client aan (Google Cloud Console -> Credentials -> 21 # OAuth client ID -> "Web application") en vul hieronder in. Eigen client = 22 # eigen Google Cloud project; deze site hangt nergens centraal aan vast. 23 # Niet ingesteld? Dan is er simpelweg geen Google-knop. Google-login geeft 24 # NOOIT beheerrechten (alleen reageren). 25 # GOOGLE_REDIRECT_URI = <jouw domein>/auth/google/callback (exact in de client zetten) 26 GOOGLE_CLIENT_ID= 27 GOOGLE_CLIENT_SECRET= 28 GOOGLE_REDIRECT_URI=https://example.com/auth/google/callback 29 30 # ── E-mail (optioneel) — voor "wachtwoord vergeten" reset-links ───── 31 # Niet ingesteld? Dan werkt reset alleen via `npm run reset-admin` op de server. 32 SMTP_HOST= 33 SMTP_PORT=587 34 SMTP_USER= 35 SMTP_PASS= 36 SMTP_FROM= -
package-lock.json
r32cc601 r9e27d64 21 21 "helmet": "^7.1.0", 22 22 "htmx.org": "1.9.12", 23 "jose": "^5.10.0",24 23 "marked": "^11.1.1", 25 24 "multer": "^1.4.5-lts.1", 25 "nodemailer": "^6.9.15", 26 26 "sanitize-html": "^2.17.3", 27 27 "uuid": "^9.0.1", … … 1166 1166 } 1167 1167 }, 1168 "node_modules/jose": {1169 "version": "5.10.0",1170 "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",1171 "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",1172 "license": "MIT",1173 "funding": {1174 "url": "https://github.com/sponsors/panva"1175 }1176 },1177 1168 "node_modules/marked": { 1178 1169 "version": "11.2.0", … … 1375 1366 "engines": { 1376 1367 "node": ">=10" 1368 } 1369 }, 1370 "node_modules/nodemailer": { 1371 "version": "6.10.1", 1372 "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", 1373 "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", 1374 "license": "MIT-0", 1375 "engines": { 1376 "node": ">=6.0.0" 1377 1377 } 1378 1378 }, -
package.json
r32cc601 r9e27d64 8 8 "start": "node src/server.js", 9 9 "dev": "node --watch src/server.js", 10 "migrate": "node src/db/migrate.js" 10 "migrate": "node src/db/migrate.js", 11 "reset-admin": "node scripts/reset-admin.mjs" 11 12 }, 12 13 "dependencies": { … … 23 24 "helmet": "^7.1.0", 24 25 "htmx.org": "1.9.12", 25 "jose": "^5.10.0",26 26 "marked": "^11.1.1", 27 27 "multer": "^1.4.5-lts.1", 28 "nodemailer": "^6.9.15", 28 29 "sanitize-html": "^2.17.3", 29 30 "uuid": "^9.0.1", -
src/config/google.js
r32cc601 r9e27d64 1 // Google-login via de centrale Klonkt-broker (license.klonkt.com). 1 // Google OAuth2 voor LUISTERAARS (reageren). Per-instance: de self-hoster zet 2 // z'n EIGEN Google-client in .env. Zo hangt elke site aan z'n eigen Google Cloud 3 // project — geen centrale afhankelijkheid, geen gedeelde aansprakelijkheid. 2 4 // 3 // Deze instance praat NOOIT zelf met Google. De broker doet de OAuth-dans met één 4 // centrale Google-client en stuurt een kortlevend, gesigneerd identity-token terug; 5 // dat verifiëren we offline tegen de broker-pubkey. Zo hoeft geen enkele self-host 6 // een eigen Google-client aan te maken. 5 // Optioneel: staat dit niet ingesteld, dan is er simpelweg geen "Login met 6 // Google"-knop en blijft de rest van de site werken. Google-login geeft NOOIT 7 // beheerrechten — beheer gaat via het wachtwoord-account. 7 8 // 8 9 // Config via env: 9 // KLONKT_BROKER_URL = https://license.klonkt.com 10 // SITE_ORIGIN = het eigen publieke origin (bv https://roboburr.com) — 11 // bepaalt de callback + de audience die we eisen. 12 // ADMIN_EMAIL = de Google-mail die owner/admin (god) is op deze instance. 10 // GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET 11 // GOOGLE_REDIRECT_URI = https://<dit-domein>/auth/google/callback 13 12 14 import { importSPKI, jwtVerify } from 'jose'; 13 const CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''; 14 const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || ''; 15 const REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || ''; 15 16 16 const ALG = 'EdDSA'; 17 const ISSUER = 'klonkt-license'; 17 const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; 18 const TOKEN_URL = 'https://oauth2.googleapis.com/token'; 19 const USERINFO_URL = 'https://openidconnect.googleapis.com/v1/userinfo'; 18 20 19 const BROKER_URL = (process.env.KLONKT_BROKER_URL || '').replace(/\/$/, ''); 20 const SITE_ORIGIN = (process.env.SITE_ORIGIN || '').replace(/\/$/, ''); 21 22 export function brokerConfigured() { 23 return !!(BROKER_URL && SITE_ORIGIN); 21 export function googleConfigured() { 22 return !!(CLIENT_ID && CLIENT_SECRET && REDIRECT_URI); 24 23 } 25 24 26 // Waar de broker naartoe terugstuurt (moet in de broker-allowlist staan). 27 export function callbackUrl() { 28 return `${SITE_ORIGIN}/auth/google/callback`; 25 export function authorizeUrl(state) { 26 const p = new URLSearchParams({ 27 client_id: CLIENT_ID, 28 redirect_uri: REDIRECT_URI, 29 response_type: 'code', 30 scope: 'openid email profile', 31 state, 32 access_type: 'online', 33 prompt: 'select_account', 34 }); 35 return `${AUTH_URL}?${p.toString()}`; 29 36 } 30 37 31 export function brokerStartUrl(istate) { 32 const p = new URLSearchParams({ return: callbackUrl(), istate }); 33 return `${BROKER_URL}/auth/google/start?${p.toString()}`; 38 export async function exchangeCode(code) { 39 const body = new URLSearchParams({ 40 code, 41 client_id: CLIENT_ID, 42 client_secret: CLIENT_SECRET, 43 redirect_uri: REDIRECT_URI, 44 grant_type: 'authorization_code', 45 }); 46 const r = await fetch(TOKEN_URL, { 47 method: 'POST', 48 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 49 body, 50 }); 51 if (!r.ok) throw new Error(`Google token-exchange faalde: ${r.status}`); 52 return r.json(); // { access_token, id_token, ... } 34 53 } 35 54 36 // Broker-pubkey ophalen + cachen (bij fout NIET permanent cachen). 37 let _pubkeyPromise = null; 38 function getPublicKey() { 39 if (!_pubkeyPromise) { 40 _pubkeyPromise = (async () => { 41 const r = await fetch(`${BROKER_URL}/pubkey`); 42 if (!r.ok) throw new Error(`broker /pubkey faalde: ${r.status}`); 43 return importSPKI(await r.text(), ALG); 44 })().catch((e) => { 45 _pubkeyPromise = null; 46 throw e; 47 }); 48 } 49 return _pubkeyPromise; 55 // Returns { sub, email, email_verified, name, picture }. 56 export async function fetchUserinfo(accessToken) { 57 const r = await fetch(USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } }); 58 if (!r.ok) throw new Error(`Google userinfo faalde: ${r.status}`); 59 return r.json(); 50 60 } 51 52 // Verifieer het identity-token van de broker. Returnt de payload53 // { typ:'identity', sub, email, name, picture, jti, exp, ... }.54 export async function verifyIdentityToken(token) {55 const key = await getPublicKey();56 const { payload } = await jwtVerify(token, key, {57 issuer: ISSUER,58 algorithms: [ALG],59 audience: SITE_ORIGIN, // token moet voor ÓNZE site bedoeld zijn60 });61 if (payload.typ !== 'identity') throw new Error('verkeerd tokentype');62 return payload;63 }64 65 // Kleine in-memory jti-cache tegen replay binnen de (korte) geldigheidsduur.66 // Returnt false als de jti al gebruikt is.67 const _usedJti = new Map(); // jti -> exp (epoch seconds)68 export function consumeJti(jti, expEpoch) {69 if (!jti) return true; // geen jti = niets te dedupen70 const now = Math.floor(Date.now() / 1000);71 for (const [k, e] of _usedJti) if (e < now) _usedJti.delete(k);72 if (_usedJti.has(jti)) return false;73 _usedJti.set(jti, expEpoch || now + 600);74 return true;75 } -
src/routes/account.js
r32cc601 r9e27d64 17 17 import fs from 'fs'; 18 18 import { fileURLToPath } from 'url'; 19 import bcrypt from 'bcryptjs'; 19 20 import multer from 'multer'; 20 21 import { v4 as uuid } from 'uuid'; … … 56 57 router.get('/', requireAuth, (req, res) => { 57 58 const account = db.prepare(` 58 SELECT id, username, email, role, bio, avatar_url, created_at 59 SELECT id, username, email, role, bio, avatar_url, created_at, password_hash 59 60 FROM users WHERE id = ? 60 61 `).get(req.session.user.id); 62 const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth'); 63 if (account) delete account.password_hash; // niet naar de view lekken 61 64 62 65 renderPage(req, res, 'pages/account', { … … 64 67 bodyClass: 'on-special', 65 68 account, 69 hasPassword, 66 70 success: req.query.success || null, 67 71 error: req.query.error || null, … … 83 87 // longer read or written. 84 88 85 // Wachtwoord-wijzigen is verwijderd: inloggen gaat sinds de Google-OAuth-migratie 86 // volledig via Google, er is geen wachtwoord meer om te wijzigen. 89 // ==================== CHANGE PASSWORD ==================== 90 router.post('/password', requireAuth, (req, res) => { 91 const { current, new_password, confirm } = req.body; 92 if (!current || !new_password || !confirm) { 93 return res.redirect('/account?error=' + encodeURIComponent('Alle wachtwoordvelden zijn verplicht')); 94 } 95 if (new_password.length < 8) { 96 return res.redirect('/account?error=' + encodeURIComponent('Nieuw wachtwoord moet minstens 8 tekens zijn')); 97 } 98 if (new_password !== confirm) { 99 return res.redirect('/account?error=' + encodeURIComponent('Nieuwe wachtwoorden komen niet overeen')); 100 } 101 102 const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id); 103 // Google-only accounts (luisteraars) hebben geen echt wachtwoord. 104 if (!row || !row.password_hash || row.password_hash === '!google-oauth') { 105 return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)')); 106 } 107 if (!bcrypt.compareSync(current, row.password_hash)) { 108 return res.redirect('/account?error=' + encodeURIComponent('Huidig wachtwoord is onjuist')); 109 } 110 111 const newHash = bcrypt.hashSync(new_password, 10); 112 db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') 113 .run(newHash, req.session.user.id); 114 115 res.redirect('/account?success=' + encodeURIComponent('Wachtwoord gewijzigd')); 116 }); 87 117 88 118 // ==================== UPLOAD AVATAR ==================== -
src/routes/auth.js
r32cc601 r9e27d64 1 1 import express from 'express'; 2 2 import crypto from 'crypto'; 3 import bcrypt from 'bcryptjs'; 3 4 import { v4 as uuid } from 'uuid'; 4 5 import db from '../config/database.js'; 5 6 import { renderPage } from '../middleware/render.js'; 7 import { loginLimiter, registerLimiter } from '../middleware/rate-limit.js'; 6 8 import { safeNext } from '../middleware/auth.js'; 7 import { brokerConfigured, brokerStartUrl, verifyIdentityToken, consumeJti } from '../config/google.js'; 9 import { googleConfigured, authorizeUrl, exchangeCode, fetchUserinfo } from '../config/google.js'; 10 import { mailerConfigured, sendMail } from '../config/mailer.js'; 8 11 9 12 const router = express.Router(); 10 const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim().toLowerCase(); 11 12 // ==================== LOGIN (Google via Klonkt-broker) ==================== 13 // Het oude username/wachtwoord-systeem is verwijderd; inloggen gaat via Google, 14 // gerouteerd door de centrale broker (geen Google-creds op deze instance). 13 14 // Vaste dummy-hash: zo draait login altijd één bcrypt-vergelijking, ook als de 15 // user niet bestaat of geen wachtwoord heeft — geen timing-oracle voor enumeratie. 16 const DUMMY_HASH = bcrypt.hashSync('constant-time-login-guard', 10); 17 18 // Canonieke basis-URL voor links in e-mails (reset). Uit headers bouwen is 19 // spoofbaar (X-Forwarded-Host); een vaste config sluit dat uit. 20 function publicBaseUrl(req) { 21 const cfg = (process.env.PUBLIC_BASE_URL || '').replace(/\/$/, ''); 22 if (cfg) return cfg; 23 // Fallback (dev): trust-proxy-gesaneerde protocol + Host-header (NIET de rauwe 24 // X-Forwarded-Host). 25 return `${req.protocol}://${req.get('host')}`; 26 } 27 28 function hashToken(raw) { 29 return crypto.createHash('sha256').update(String(raw)).digest('hex'); 30 } 31 32 // Eerste-keer-setup? Pas zolang er nog geen enkele gebruiker is mag /register een 33 // beheerder aanmaken. Daarna is registratie dicht (luisteraars komen via Google). 34 function isSetupMode() { 35 return db.prepare('SELECT COUNT(*) AS c FROM users').get().c === 0; 36 } 37 38 // ==================== LOGIN (beheerder = wachtwoord) ==================== 15 39 router.get('/login', (req, res) => { 16 40 const next = safeNext(req.query.next) || ''; 17 41 if (req.session.user) return res.redirect(next || '/'); 42 if (isSetupMode()) return res.redirect('/auth/register' + (next ? '?next=' + encodeURIComponent(next) : '')); 18 43 renderPage(req, res, 'pages/auth-login', { 19 44 pageTitle: 'Inloggen', 20 45 bodyClass: 'on-special', 21 googleReady: brokerConfigured(), 22 error: req.query.error || null, 46 error: null, 47 success: req.query.success || null, 48 username: '', 49 googleReady: googleConfigured(), 23 50 next, 24 51 }); 25 52 }); 26 53 27 // Start de login: random state in de sessie (CSRF), dan door naar de broker. 54 router.post('/login', loginLimiter, (req, res) => { 55 const { username, password } = req.body; 56 const next = safeNext(req.body.next) || ''; 57 58 const renderErr = (error, status = 400) => { 59 res.status(status); 60 return renderPage(req, res, 'pages/auth-login', { 61 pageTitle: 'Inloggen', bodyClass: 'on-special', 62 error, success: null, username: username || '', googleReady: googleConfigured(), next, 63 }); 64 }; 65 66 if (!username || !password) return renderErr('Gebruikersnaam en wachtwoord vereist'); 67 68 const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username); 69 // Altijd één bcrypt-vergelijking (dummy als de user geen bruikbaar wachtwoord 70 // heeft) zodat de responstijd niets over het bestaan van een account verraadt. 71 const usable = !!(user && user.password_hash && user.password_hash !== '!google-oauth'); 72 const ok = bcrypt.compareSync(password, usable ? user.password_hash : DUMMY_HASH); 73 if (!usable || !ok) return renderErr('Ongeldige inloggegevens', 401); 74 75 req.session.user = { 76 id: user.id, username: user.username, email: user.email, role: user.role, 77 avatar_url: user.avatar_url, palette: user.palette, theme: user.theme, 78 }; 79 res.redirect(next || '/'); 80 }); 81 82 // ==================== EERSTE-KEER-SETUP (beheerder aanmaken) ==================== 83 router.get('/register', (req, res) => { 84 const next = safeNext(req.query.next) || ''; 85 if (req.session.user) return res.redirect(next || '/'); 86 // Geen publieke registratie: alleen de allereerste beheerder mag hier aangemaakt. 87 if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : '')); 88 renderPage(req, res, 'pages/auth-register', { 89 pageTitle: 'Beheerder aanmaken', bodyClass: 'on-special', 90 error: null, username: '', email: '', next, 91 }); 92 }); 93 94 router.post('/register', registerLimiter, (req, res) => { 95 const { username, email, password } = req.body; 96 const next = safeNext(req.body.next) || ''; 97 const renderErr = (error) => renderPage(req, res, 'pages/auth-register', { 98 pageTitle: 'Beheerder aanmaken', bodyClass: 'on-special', 99 error, username: username || '', email: email || '', next, 100 }); 101 102 // Hard gesloten zodra er een gebruiker is — voorkomt een tweede "admin" via deze route. 103 if (!isSetupMode()) return res.redirect('/auth/login'); 104 105 if (!username || !email || !password) return renderErr('Alle velden zijn verplicht'); 106 if (!/^[a-z0-9_-]{3,32}$/i.test(username)) { 107 return renderErr('Gebruikersnaam: 3-32 tekens, letters/cijfers/_/- alleen'); 108 } 109 if (password.length < 8) return renderErr('Wachtwoord moet minstens 8 tekens zijn'); 110 111 const userId = uuid(); 112 const hash = bcrypt.hashSync(password, 10); 113 // De allereerste gebruiker is de beheerder (god). 114 db.prepare(` 115 INSERT INTO users (id, username, email, password_hash, role, theme, palette) 116 VALUES (?, ?, ?, ?, 'god', 'dark', 'sage') 117 `).run(userId, username, email, hash); 118 119 // Persoonlijke site auto-aanmaken (single-tenant-ombouw volgt later). 120 if (!db.prepare('SELECT 1 FROM sites LIMIT 1').get()) { 121 const siteId = uuid(); 122 db.prepare(` 123 INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language) 124 VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl') 125 `).run(siteId, username.toLowerCase(), username + "'s Site", 'Welkom', userId); 126 db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId); 127 } 128 129 req.session.user = { id: userId, username, email, role: 'god', palette: 'sage', theme: 'dark' }; 130 res.redirect(next || '/'); 131 }); 132 133 // ==================== WACHTWOORD VERGETEN (aanvraag) ==================== 134 router.get('/reset-request', (req, res) => { 135 if (req.session.user) return res.redirect('/'); 136 renderPage(req, res, 'pages/auth-reset-request', { 137 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special', 138 error: null, sent: false, devResetUrl: null, mailer: mailerConfigured(), 139 }); 140 }); 141 142 router.post('/reset-request', registerLimiter, async (req, res) => { 143 const email = (req.body.email || '').trim().toLowerCase(); 144 let devResetUrl = null; 145 146 if (email) { 147 const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email); 148 if (user) { 149 const token = crypto.randomBytes(32).toString('hex'); // ruw: gaat alleen de mail/link in 150 const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min 151 // Alleen de HASH opslaan: DB-leestoegang levert zo geen bruikbaar token op. 152 db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?') 153 .run(hashToken(token), expires, user.id); 154 155 const url = `${publicBaseUrl(req)}/auth/reset/${token}`; 156 157 if (mailerConfigured()) { 158 try { 159 await sendMail({ 160 to: user.email, 161 subject: 'Wachtwoord resetten', 162 text: `Reset je wachtwoord via deze link (30 min geldig):\n\n${url}\n\nNiet aangevraagd? Negeer deze mail.`, 163 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>`, 164 }); 165 } catch (e) { 166 console.error('[reset-request] mail faalde:', e.message); 167 } 168 } else if (process.env.NODE_ENV !== 'production') { 169 // Dev zonder SMTP: link in log + op de pagina tonen. 170 console.log(`[password-reset] ${user.email} -> ${url}`); 171 devResetUrl = url; 172 } else { 173 // Productie zonder SMTP: NOOIT het token loggen. Verwijs naar de CLI break-glass. 174 console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`); 175 } 176 } 177 } 178 179 // Anti-enumeratie: zelfde antwoord ongeacht of het adres bestaat. 180 renderPage(req, res, 'pages/auth-reset-request', { 181 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special', 182 error: null, sent: true, devResetUrl, mailer: mailerConfigured(), 183 }); 184 }); 185 186 // ==================== WACHTWOORD RESETTEN (toepassen) ==================== 187 router.get('/reset/:token', (req, res) => { 188 const row = db.prepare(` 189 SELECT id, username FROM users 190 WHERE reset_token = ? AND reset_token_expires > datetime('now') 191 `).get(hashToken(req.params.token)); 192 renderPage(req, res, 'pages/auth-reset', { 193 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special', 194 error: row ? null : 'Deze reset-link is ongeldig of verlopen.', 195 token: row ? req.params.token : null, 196 username: row ? row.username : null, 197 }); 198 }); 199 200 router.post('/reset/:token', (req, res) => { 201 const { new_password, confirm } = req.body; 202 const row = db.prepare(` 203 SELECT id, username FROM users 204 WHERE reset_token = ? AND reset_token_expires > datetime('now') 205 `).get(hashToken(req.params.token)); 206 207 const renderError = (msg) => renderPage(req, res, 'pages/auth-reset', { 208 pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special', 209 error: msg, token: row ? req.params.token : null, username: row ? row.username : null, 210 }); 211 212 if (!row) return renderError('Deze reset-link is ongeldig of verlopen.'); 213 if (!new_password || new_password.length < 8) return renderError('Wachtwoord moet minstens 8 tekens zijn'); 214 if (new_password !== confirm) return renderError('Wachtwoorden komen niet overeen'); 215 216 const hash = bcrypt.hashSync(new_password, 10); 217 db.prepare(` 218 UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL, 219 updated_at = CURRENT_TIMESTAMP WHERE id = ? 220 `).run(hash, row.id); 221 res.redirect('/auth/login?success=' + encodeURIComponent('Wachtwoord gereset — log nu in.')); 222 }); 223 224 // ==================== GOOGLE-LOGIN (luisteraars/reageerders) ==================== 225 // Per-instance, eigen Google-client. Geeft ALTIJD rol member — nooit beheer. 28 226 router.get('/google', (req, res) => { 29 if (!brokerConfigured()) { 30 return res.redirect('/auth/login?error=' + encodeURIComponent('Google-login is op deze site nog niet geconfigureerd.')); 31 } 32 const istate = crypto.randomBytes(16).toString('hex'); 33 req.session.loginState = istate; 34 req.session.loginNext = safeNext(req.query.next) || ''; 35 res.redirect(brokerStartUrl(istate)); 36 }); 37 38 // Leid een geldige, unieke username af uit naam/e-mail (schema vereist username). 227 if (!googleConfigured()) { 228 return res.redirect('/auth/login?error=' + encodeURIComponent('Google-login is op deze site niet ingesteld.')); 229 } 230 const state = crypto.randomBytes(16).toString('hex'); 231 req.session.oauthState = state; 232 req.session.oauthNext = safeNext(req.query.next) || ''; 233 res.redirect(authorizeUrl(state)); 234 }); 235 39 236 function uniqueUsername(base) { 40 237 let u = String(base || 'luisteraar').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 28); … … 48 245 } 49 246 50 // Callback van de broker: ?klonkt_id_token=&klonkt_state=(&klonkt_login_error=).51 // We checken state (CSRF), verifiëren het token (sig + audience + replay), en52 // vinden-of-maken de user op e-mail. ADMIN_EMAIL bepaalt wie owner/admin (god) is.53 247 router.get('/google/callback', async (req, res) => { 54 248 const fail = (msg) => res.redirect('/auth/login?error=' + encodeURIComponent(msg)); 55 249 try { 56 const { klonkt_id_token: idToken, klonkt_state: state, klonkt_login_error: loginError } = req.query; 57 58 // State-check (CSRF) eerst — bind de callback aan de sessie die login startte. 59 const expected = req.session.loginState; 60 const next = safeNext(req.session.loginNext) || ''; 61 delete req.session.loginState; 62 delete req.session.loginNext; 63 if (!expected || state !== expected) return fail('Login afgebroken of ongeldige sessie.'); 64 65 if (loginError === 'unverified_email') return fail('Geen geverifieerd Google-e-mailadres.'); 66 if (!idToken) return fail('Geen logintoken ontvangen.'); 67 68 let payload; 69 try { 70 payload = await verifyIdentityToken(String(idToken)); 71 } catch (e) { 72 console.error('[auth/google/callback] tokenverificatie:', e.message); 73 return fail('Logintoken ongeldig of verlopen.'); 74 } 75 if (!consumeJti(payload.jti, payload.exp)) return fail('Logintoken al gebruikt.'); 76 77 const email = (payload.email || '').trim().toLowerCase(); 78 if (!email) return fail('Geen e-mailadres in logintoken.'); 79 80 const isAdmin = !!ADMIN_EMAIL && email === ADMIN_EMAIL; 250 const { code, state } = req.query; 251 if (!code || !state || state !== req.session.oauthState) return fail('Login afgebroken of ongeldige sessie.'); 252 const next = safeNext(req.session.oauthNext) || ''; 253 delete req.session.oauthState; 254 delete req.session.oauthNext; 255 256 const tok = await exchangeCode(String(code)); 257 const info = await fetchUserinfo(tok.access_token); 258 const email = (info.email || '').trim().toLowerCase(); 259 if (!email || info.email_verified === false) return fail('Geen geverifieerd Google-e-mailadres.'); 260 81 261 let user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email); 82 262 83 if (!user) { 84 const userCount = db.prepare('SELECT COUNT(*) AS c FROM users').get().c; 85 // god = de ingestelde ADMIN_EMAIL. De "eerste user wordt god"-bootstrap geldt 86 // ALLEEN als er helemaal geen ADMIN_EMAIL is ingesteld — anders zou een vreemde 87 // die toevallig als eerste inlogt op een verse install eigenaar worden. 88 const role = isAdmin ? 'god' : (!ADMIN_EMAIL && userCount === 0 ? 'god' : 'member'); 263 if (user) { 264 // Strikte scheiding: Google geeft nooit beheer. Hoort dit adres bij een 265 // beheerder, dan moet diegene met wachtwoord inloggen (geen Google-bypass). 266 if (user.role === 'god' || user.role === 'admin') { 267 return fail('Dit adres hoort bij een beheerder — log in met je wachtwoord.'); 268 } 269 // Bestaande luisteraar: koppel google_sub/avatar als die ontbreken; weiger 270 // als al aan een ander Google-account gekoppeld. 271 if (user.google_sub && info.sub && user.google_sub !== info.sub) { 272 return fail('Dit e-mailadres is al aan een ander Google-account gekoppeld.'); 273 } 274 db.prepare(` 275 UPDATE users SET google_sub = COALESCE(google_sub, ?), avatar_url = COALESCE(avatar_url, ?), 276 updated_at = CURRENT_TIMESTAMP WHERE id = ? 277 `).run(info.sub || null, info.picture || null, user.id); 278 } else { 279 // Nieuwe luisteraar — altijd member. 89 280 const userId = uuid(); 90 const username = uniqueUsername(payload.name || email.split('@')[0]); 91 // password_hash is NOT NULL in het schema; er is geen wachtwoord-login meer, 92 // dus we zetten een onbruikbare sentinel. 281 const username = uniqueUsername(info.name || email.split('@')[0]); 93 282 db.prepare(` 94 283 INSERT INTO users (id, username, email, password_hash, role, avatar_url, theme, palette, google_sub) 95 VALUES (?, ?, ?, '!google-oauth', ?, ?, 'dark', 'sage', ?) 96 `).run(userId, username, payload.email || email, role, payload.picture || null, payload.sub || null); 97 98 // Eerste/admin-user krijgt een persoonlijke site (zoals de oude flow), maar 99 // alleen als er nog geen site bestaat. 100 if (role === 'god' && !db.prepare('SELECT 1 FROM sites LIMIT 1').get()) { 101 const siteId = uuid(); 102 db.prepare(` 103 INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language) 104 VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl') 105 `).run(siteId, username.toLowerCase(), username + "'s Site", 'Welkom', userId); 106 db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId); 107 } 284 VALUES (?, ?, ?, '!google-oauth', 'member', ?, 'dark', 'sage', ?) 285 `).run(userId, username, info.email || email, info.picture || null, info.sub || null); 108 286 user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); 109 } else {110 // Identiteits-integriteit: als deze user al aan een ander Google-account111 // gekoppeld is (google_sub mismatch), weiger — voorkomt overname als een112 // geverifieerd e-mailadres ooit naar een andere Google-sub verhuist.113 if (user.google_sub && payload.sub && user.google_sub !== payload.sub) {114 return fail('Dit e-mailadres is al aan een ander Google-account gekoppeld.');115 }116 // Bestaande user (gekoppeld op e-mail): koppel Google-id + avatar als die117 // nog ontbreken, en promoveer naar god als dit ADMIN_EMAIL is.118 db.prepare(`119 UPDATE users120 SET google_sub = COALESCE(google_sub, ?),121 avatar_url = COALESCE(avatar_url, ?),122 updated_at = CURRENT_TIMESTAMP123 WHERE id = ?124 `).run(payload.sub || null, payload.picture || null, user.id);125 if (isAdmin && user.role !== 'god') {126 db.prepare("UPDATE users SET role = 'god' WHERE id = ?").run(user.id);127 user.role = 'god';128 }129 287 } 130 288 131 289 req.session.user = { 132 id: user.id, 133 username: user.username, 134 email: user.email, 135 role: user.role, 136 avatar_url: user.avatar_url, 137 palette: user.palette, 138 theme: user.theme, 290 id: user.id, username: user.username, email: user.email, role: user.role, 291 avatar_url: user.avatar_url, palette: user.palette, theme: user.theme, 139 292 }; 140 293 res.redirect(next || '/'); … … 146 299 147 300 // ==================== LOGOUT ==================== 148 router.get('/logout', (req, res) => { 149 req.session.destroy(() => res.redirect('/')); 150 }); 151 router.post('/logout', (req, res) => { 152 req.session.destroy(() => res.redirect('/')); 153 }); 301 router.get('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); }); 302 router.post('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); }); 154 303 155 304 export default router; -
src/server.js
r32cc601 r9e27d64 148 148 app.use(loadTheme); 149 149 150 // Lichtgewicht CSRF-defense: weiger cross-origin state-wijzigende requests. 151 // Same-origin forms + HTMX sturen een matchende Origin; ontbreekt Origin dan 152 // laten we door (non-browser clients). sameSite:'lax' op de sessiecookie is de 153 // tweede laag. (Geldt niet voor GET/HEAD/OPTIONS.) 154 app.use((req, res, next) => { 155 if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next(); 156 const origin = req.get('origin'); 157 if (!origin) return next(); // geen Origin -> geen browser-CSRF-vector 158 let originHost; 159 try { originHost = new URL(origin).host; } catch { return res.status(403).send('Ongeldige origin'); } 160 if (originHost !== req.get('host')) return res.status(403).send('Cross-origin request geweigerd'); 161 next(); 162 }); 163 150 164 app.use('/auth', authRoutes); 151 165 app.use('/account', accountRoutes); … … 328 342 console.log(` ✓ Privacy: Self-hosted fonts, no third-party requests`); 329 343 console.log(` ✓ Layout: v9 editorial feel (top nav, profile header)`); 330 console.log(` ✓ Auth: Google login/ logout`);344 console.log(` ✓ Auth: wachtwoord (beheer) + Google (luisteraars) / logout`); 331 345 console.log(` ✓ Posts: create / edit / view / archive`); 332 346 console.log(` ✓ Realtime: WebSocket server ready (Prutter)`); -
src/views/pages/account.ejs
r32cc601 r9e27d64 65 65 </section> 66 66 67 <%# ── INLOG ─────────────────────────────────────────────── %> 67 <%# ── WACHTWOORD ────────────────────────────────────────── %> 68 <% if (hasPassword) { %> 69 <section class="ax-card"> 70 <div class="ax-card-title">Wachtwoord wijzigen</div> 71 <form action="/account/password" method="post" class="ax-form"> 72 <label class="ax-field"> 73 <span>Huidig wachtwoord</span> 74 <input type="password" name="current" required autocomplete="current-password"> 75 </label> 76 <div class="ax-row ax-row-2"> 77 <label class="ax-field"> 78 <span>Nieuw wachtwoord <small>(min 8 tekens)</small></span> 79 <input type="password" name="new_password" required minlength="8" autocomplete="new-password"> 80 </label> 81 <label class="ax-field"> 82 <span>Bevestig nieuw wachtwoord</span> 83 <input type="password" name="confirm" required minlength="8" autocomplete="new-password"> 84 </label> 85 </div> 86 <div class="ax-form-actions"> 87 <button type="submit" class="ax-btn ax-btn-primary">🔒 Wachtwoord wijzigen</button> 88 </div> 89 </form> 90 </section> 91 <% } else { %> 68 92 <section class="ax-card"> 69 93 <div class="ax-card-title">Inloggen</div> 70 94 <p class="ax-tagline" style="margin:0">Je bent ingelogd via Google (<%= account.email %>). Er is geen apart wachtwoord.</p> 71 95 </section> 96 <% } %> 72 97 73 98 </div> -
src/views/pages/auth-login.ejs
r32cc601 r9e27d64 1 1 <div class="container auth-page"> 2 2 <h1>Inloggen</h1> 3 <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %> 3 4 <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %> 4 5 5 <div class="auth-form auth-google"> 6 <% if (googleReady) { %> 7 <p class="auth-intro">Log in met je Google-account om mee te praten.</p> 8 <a class="btn btn-google" href="/auth/google<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>"> 9 <svg class="g-icon" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"> 10 <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"/> 11 <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"/> 12 <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"/> 13 <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"/> 14 </svg> 15 <span>Inloggen met Google</span> 16 </a> 17 <% } else { %> 18 <p class="auth-intro">Inloggen via Google is op deze site nog niet ingesteld.</p> 6 <form method="post" action="/auth/login" class="auth-form"> 7 <% if (typeof next !== 'undefined' && next) { %> 8 <input type="hidden" name="next" value="<%= next %>"> 19 9 <% } %> 20 </div> 10 <label> 11 <span>Gebruikersnaam of e-mail</span> 12 <input type="text" name="username" value="<%= username %>" required autofocus autocomplete="username"> 13 </label> 14 <label> 15 <span>Wachtwoord</span> 16 <input type="password" name="password" required autocomplete="current-password"> 17 </label> 18 <button type="submit" class="btn btn-primary">Inloggen</button> 19 <p class="auth-link auth-link-muted"><a href="/auth/reset-request">Wachtwoord vergeten?</a></p> 20 </form> 21 22 <% if (googleReady) { %> 23 <div class="auth-divider"><span>of als luisteraar</span></div> 24 <a class="btn btn-google" href="/auth/google<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>"> 25 <svg class="g-icon" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"> 26 <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"/> 27 <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"/> 28 <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"/> 29 <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"/> 30 </svg> 31 <span>Inloggen met Google</span> 32 </a> 33 <% } %> 21 34 </div> -
src/views/pages/auth-register.ejs
r32cc601 r9e27d64 1 1 <div class="container auth-page"> 2 <h1>Register</h1> 2 <h1>Beheerder aanmaken</h1> 3 <p class="auth-intro">Eerste keer opzetten — maak je beheerdersaccount aan. Dit kan maar één keer.</p> 3 4 <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %> 4 5 <form method="post" action="/auth/register" class="auth-form"> … … 7 8 <% } %> 8 9 <label> 9 <span> Username (3-32 chars, letters/numbers/_-)</span>10 <span>Gebruikersnaam (3-32 tekens, letters/cijfers/_-)</span> 10 11 <input type="text" name="username" value="<%= username %>" required autofocus autocomplete="username" pattern="[a-zA-Z0-9_-]{3,32}"> 11 12 </label> 12 13 <label> 13 <span>E mail</span>14 <span>E-mail</span> 14 15 <input type="email" name="email" value="<%= email %>" required autocomplete="email"> 15 16 </label> 16 17 <label> 17 <span> Password (min 8 chars)</span>18 <span>Wachtwoord (min 8 tekens)</span> 18 19 <input type="password" name="password" required minlength="8" autocomplete="new-password"> 19 20 </label> 20 <button type="submit" class="btn btn-primary">Create account</button> 21 <p class="auth-link">Already registered? 22 <a href="/auth/login<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">Login</a> 23 </p> 21 <button type="submit" class="btn btn-primary">Beheerder aanmaken</button> 24 22 </form> 25 23 </div> -
src/views/pages/auth-reset-request.ejs
r32cc601 r9e27d64 1 1 <div class="container auth-page"> 2 <h1> Reset password</h1>2 <h1>Wachtwoord resetten</h1> 3 3 4 4 <% if (sent) { %> 5 5 <div class="alert alert-success"> 6 If an account exists for that email, a reset link has been sent.6 Als er een account bestaat voor dat e-mailadres, is er een reset-link verstuurd. 7 7 </div> 8 8 <% if (devResetUrl) { %> 9 9 <div class="alert alert-info"> 10 <strong> Dev mode:</strong> reset link below (no email server configured).10 <strong>Geen e-mailserver ingesteld</strong> — reset-link hieronder. 11 11 <p style="margin: 0.5rem 0 0; word-break: break-all;"> 12 12 <a href="<%= devResetUrl %>"><%= devResetUrl %></a> … … 14 14 </div> 15 15 <% } %> 16 <p class="auth-link"><a href="/auth/login">← Back to login</a></p> 16 <% if (typeof mailer !== 'undefined' && !mailer) { %> 17 <p class="auth-link-muted">Geen e-mail ingesteld? De beheerder kan ook <code>npm run reset-admin</code> draaien op de server.</p> 18 <% } %> 19 <p class="auth-link"><a href="/auth/login">← Terug naar inloggen</a></p> 17 20 <% } else { %> 18 <p class="auth-tagline"> Enter your email and we'll send a resetlink.</p>21 <p class="auth-tagline">Vul je e-mail in; we sturen een reset-link.</p> 19 22 20 23 <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %> … … 22 25 <form method="post" action="/auth/reset-request" class="auth-form"> 23 26 <label> 24 <span>E mail</span>27 <span>E-mail</span> 25 28 <input type="email" name="email" required autofocus autocomplete="email"> 26 29 </label> 27 <button type="submit" class="btn btn-primary">S end resetlink</button>28 <p class="auth-link"><a href="/auth/login">← Back to login</a></p>30 <button type="submit" class="btn btn-primary">Stuur reset-link</button> 31 <p class="auth-link"><a href="/auth/login">← Terug naar inloggen</a></p> 29 32 </form> 30 33 <% } %> -
src/views/pages/auth-reset.ejs
r32cc601 r9e27d64 1 1 <div class="container auth-page"> 2 <h1> Reset password</h1>2 <h1>Wachtwoord resetten</h1> 3 3 4 4 <% if (!token) { %> 5 5 <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %> 6 6 <p class="auth-link"> 7 <a href="/auth/reset-request"> Request a new reset link</a>7 <a href="/auth/reset-request">Vraag een nieuwe reset-link aan</a> 8 8 · 9 <a href="/auth/login"> Back to login</a>9 <a href="/auth/login">Terug naar inloggen</a> 10 10 </p> 11 11 <% } else { %> 12 <p class="auth-tagline">S et a new password for <strong><%= username %></strong>.</p>12 <p class="auth-tagline">Stel een nieuw wachtwoord in voor <strong><%= username %></strong>.</p> 13 13 14 14 <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %> … … 16 16 <form method="post" action="/auth/reset/<%= token %>" class="auth-form"> 17 17 <label> 18 <span>N ew password (min 8 chars)</span>18 <span>Nieuw wachtwoord (min 8 tekens)</span> 19 19 <input type="password" name="new_password" required minlength="8" autofocus autocomplete="new-password"> 20 20 </label> 21 21 <label> 22 <span> Confirm new password</span>22 <span>Bevestig nieuw wachtwoord</span> 23 23 <input type="password" name="confirm" required minlength="8" autocomplete="new-password"> 24 24 </label> 25 <button type="submit" class="btn btn-primary"> Set password</button>25 <button type="submit" class="btn btn-primary">Wachtwoord instellen</button> 26 26 </form> 27 27 <% } %> -
src/views/pages/welcome.ejs
r32cc601 r9e27d64 5 5 <% if (!user) { %> 6 6 <div class="welcome-actions"> 7 <a href="/auth/login" class="btn btn-primary">Inloggen met Google</a> 7 <a href="/auth/register" class="btn btn-primary">Beheerder aanmaken</a> 8 <a href="/auth/login" class="btn">Al een account? Inloggen</a> 8 9 </div> 9 <p class="welcome-note"> De eerste gebruiker die inlogt wordt <strong>god</strong> en krijgt automatisch een eigen site.</p>10 <p class="welcome-note">Maak je beheerdersaccount aan om te beginnen — daarna is registratie gesloten.</p> 10 11 <% } else { %> 11 12 <p>Hi <%= user.username %>! No site is configured yet.</p> -
src/views/partials/shared-styles.ejs
r32cc601 r9e27d64 125 125 .btn-google:hover { background: #f7f8f8; border-color: #dadce0; } 126 126 .btn-google .g-icon { flex-shrink: 0; } 127 .auth-divider { display: flex; align-items: center; gap: 0.75rem; margin: 1.25rem 0; color: var(--ink-muted); font-size: 0.85rem; } 128 .auth-divider::before, .auth-divider::after { content: ''; flex: 1; height: 1px; background: var(--rule); } 127 129 128 130 /* HTMX loading indicator */
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)