source: Klonkt/src/routes/account.js@ a4f6827

main
Last change on this file since a4f6827 was 247988e, checked in by roboburr <roboburr@…>, 3 months ago

feat(auth): admin can link Google and use it to log in

  • Account → "Sign in with Google": link/unlink your Google account (/auth/google/link, requireAuth → stores google_sub on own account).
  • Google callback: link mode alongside login mode. An admin may log in with Google ONLY if their google_sub is linked and matches (otherwise "Google = never admin" rule holds). Unlinking requires a password (no lockout).
  • Admin notice on the login page explains the link route.

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 8.8 KB
RevLine 
[7bc636b]1/**
2 * Account routes — profile, password, avatar.
3 *
4 * Sections:
5 * GET / -> render full account page (profile + password + avatar + danger)
6 * POST /profile -> update bio
7 * POST /password -> change password (verify current, hash new)
8 * POST /avatar -> upload avatar image (multer)
9 * POST /avatar/remove -> clear avatar_url
10 *
11 * Each form has its own POST handler. After success, redirects back to
12 * /account?success=... so the page picks it up via query string.
13 */
14
15import express from 'express';
16import path from 'path';
17import fs from 'fs';
18import { fileURLToPath } from 'url';
[9e27d64]19import bcrypt from 'bcryptjs';
[7bc636b]20import multer from 'multer';
21import { v4 as uuid } from 'uuid';
22import db from '../config/database.js';
[7881080]23import { getPrimarySite } from '../middleware/site.js';
[7bc636b]24import { renderPage } from '../middleware/render.js';
25import { requireAuth } from '../middleware/auth.js';
[247988e]26import { googleConfigured } from '../config/google.js';
[7bc636b]27
28const __dirname = path.dirname(fileURLToPath(import.meta.url));
29const AVATAR_DIR = path.resolve(
30 process.env.AVATAR_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'avatars')
31);
32fs.mkdirSync(AVATAR_DIR, { recursive: true });
33
34const ALLOWED_AVATAR_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
35const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
36
37const avatarStorage = multer.diskStorage({
38 destination: (req, file, cb) => cb(null, AVATAR_DIR),
39 filename: (req, file, cb) => {
40 const ext = path.extname(file.originalname).toLowerCase();
41 cb(null, `${uuid()}${ext}`);
42 },
43});
44const avatarUpload = multer({
45 storage: avatarStorage,
46 limits: { fileSize: MAX_AVATAR_BYTES },
47 fileFilter: (req, file, cb) => {
48 const ext = path.extname(file.originalname).toLowerCase();
49 if (!ALLOWED_AVATAR_EXT.has(ext)) {
50 return cb(new Error('Avatar must be jpg/png/webp/gif'));
51 }
52 cb(null, true);
53 },
54});
55
56const router = express.Router();
57
58// ==================== GET account page ====================
59router.get('/', requireAuth, (req, res) => {
60 const account = db.prepare(`
[247988e]61 SELECT id, username, email, role, bio, avatar_url, created_at, password_hash, google_sub
[7bc636b]62 FROM users WHERE id = ?
63 `).get(req.session.user.id);
[9e27d64]64 const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth');
[247988e]65 const googleLinked = !!(account && account.google_sub);
66 if (account) { delete account.password_hash; delete account.google_sub; } // niet naar de view lekken
[7bc636b]67
68 renderPage(req, res, 'pages/account', {
69 pageTitle: 'Account',
70 bodyClass: 'on-special',
71 account,
[9e27d64]72 hasPassword,
[247988e]73 googleLinked,
74 googleAvailable: googleConfigured(),
[1be21ba]75 editableSite: ownedSite(req.session.user),
[7bc636b]76 success: req.query.success || null,
77 error: req.query.error || null,
78 });
79});
80
[1be21ba]81// De site die deze gebruiker mag bewerken vanuit z'n account: z'n eigen site
82// (owner_id), of voor een god de primaire site. Null als er niets is.
83function ownedSite(user) {
84 if (!user) return null;
85 let site = db.prepare('SELECT id, title, tagline, slug, owner_id FROM sites WHERE owner_id = ? ORDER BY created_at LIMIT 1').get(user.id);
86 if (!site && user.role === 'god') {
[7881080]87 site = getPrimarySite(); // primaire/hoofd-site als fallback
[1be21ba]88 }
89 return site || null;
90}
91
92// ==================== UPDATE SITE-NAAM (eigenaar) ====================
93router.post('/site', requireAuth, (req, res) => {
94 const site = ownedSite(req.session.user);
95 if (!site) return res.redirect('/account?error=' + encodeURIComponent('Geen site om te bewerken.'));
96 if (site.owner_id !== req.session.user.id && req.session.user.role !== 'god') {
97 return res.redirect('/account?error=' + encodeURIComponent('Geen rechten om deze site te bewerken.'));
98 }
99 const title = (req.body.site_title || '').toString().slice(0, 200).trim();
100 if (!title) return res.redirect('/account?error=' + encodeURIComponent('Site-naam mag niet leeg zijn.'));
101 const tagline = (req.body.site_tagline || '').toString().slice(0, 200).trim();
102 db.prepare('UPDATE sites SET title = ?, tagline = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
103 .run(title, tagline || null, site.id);
104 res.redirect('/account?success=' + encodeURIComponent('Site-naam bijgewerkt'));
105});
106
[7bc636b]107// ==================== UPDATE BIO ====================
108router.post('/profile', requireAuth, (req, res) => {
109 const bio = (req.body.bio || '').toString().slice(0, 500).trim();
110 db.prepare('UPDATE users SET bio = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
111 .run(bio || null, req.session.user.id);
112 res.redirect('/account?success=' + encodeURIComponent('Profile updated'));
113});
114
115// P57 — /preferences route removed. Per-user theme/palette was a multi-tenant
116// holdover that conflicts with the site-default model: visitors should see
117// the site's appearance, not whatever a user once picked. The users.theme and
118// users.palette columns stay in the schema (no migration needed) but are no
119// longer read or written.
120
[9e27d64]121// ==================== CHANGE PASSWORD ====================
122router.post('/password', requireAuth, (req, res) => {
123 const { current, new_password, confirm } = req.body;
124 if (!current || !new_password || !confirm) {
125 return res.redirect('/account?error=' + encodeURIComponent('Alle wachtwoordvelden zijn verplicht'));
126 }
127 if (new_password.length < 8) {
128 return res.redirect('/account?error=' + encodeURIComponent('Nieuw wachtwoord moet minstens 8 tekens zijn'));
129 }
130 if (new_password !== confirm) {
131 return res.redirect('/account?error=' + encodeURIComponent('Nieuwe wachtwoorden komen niet overeen'));
132 }
133
134 const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id);
135 // Google-only accounts (luisteraars) hebben geen echt wachtwoord.
136 if (!row || !row.password_hash || row.password_hash === '!google-oauth') {
137 return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)'));
138 }
139 if (!bcrypt.compareSync(current, row.password_hash)) {
140 return res.redirect('/account?error=' + encodeURIComponent('Huidig wachtwoord is onjuist'));
141 }
142
143 const newHash = bcrypt.hashSync(new_password, 10);
144 db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
145 .run(newHash, req.session.user.id);
146
147 res.redirect('/account?success=' + encodeURIComponent('Wachtwoord gewijzigd'));
148});
[7bc636b]149
[247988e]150// Google-account ontkoppelen. Alleen toegestaan als er nog een wachtwoord is,
151// anders zou je jezelf buitensluiten (geen login-methode meer over).
152router.post('/google/unlink', requireAuth, (req, res) => {
153 const row = db.prepare('SELECT password_hash, google_sub FROM users WHERE id = ?').get(req.session.user.id);
154 if (!row || !row.google_sub) {
155 return res.redirect('/account?error=' + encodeURIComponent('Er is geen Google-account gekoppeld'));
156 }
157 if (!row.password_hash || row.password_hash === '!google-oauth') {
158 return res.redirect('/account?error=' + encodeURIComponent('Stel eerst een wachtwoord in — anders kun je niet meer inloggen.'));
159 }
160 db.prepare('UPDATE users SET google_sub = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(req.session.user.id);
161 res.redirect('/account?success=' + encodeURIComponent('Google-account ontkoppeld'));
162});
163
[7bc636b]164// ==================== UPLOAD AVATAR ====================
165router.post('/avatar', requireAuth, (req, res) => {
166 avatarUpload.single('avatar')(req, res, (err) => {
167 if (err) {
168 return res.redirect('/account?error=' + encodeURIComponent(err.message));
169 }
170 if (!req.file) {
171 return res.redirect('/account?error=' + encodeURIComponent('No file uploaded'));
172 }
173
174 const url = `/media/avatars/${req.file.filename}`;
175
176 // Remove the old avatar file (if it lives in our avatar dir)
177 const old = db.prepare('SELECT avatar_url FROM users WHERE id = ?').get(req.session.user.id)?.avatar_url;
178 if (old && old.startsWith('/media/avatars/')) {
179 const oldPath = path.join(AVATAR_DIR, path.basename(old));
180 try { fs.unlinkSync(oldPath); } catch {}
181 }
182
183 db.prepare('UPDATE users SET avatar_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
184 .run(url, req.session.user.id);
185 req.session.user.avatar_url = url;
186
187 res.redirect('/account?success=' + encodeURIComponent('Avatar updated'));
188 });
189});
190
191// ==================== REMOVE AVATAR ====================
192router.post('/avatar/remove', requireAuth, (req, res) => {
193 const old = db.prepare('SELECT avatar_url FROM users WHERE id = ?').get(req.session.user.id)?.avatar_url;
194 if (old && old.startsWith('/media/avatars/')) {
195 const oldPath = path.join(AVATAR_DIR, path.basename(old));
196 try { fs.unlinkSync(oldPath); } catch {}
197 }
198 db.prepare('UPDATE users SET avatar_url = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
199 .run(req.session.user.id);
200 req.session.user.avatar_url = null;
201 res.redirect('/account?success=' + encodeURIComponent('Avatar removed'));
202});
203
204export default router;
Note: See TracBrowser for help on using the repository browser.