EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
users.js
Go to the documentation of this file.
1/**
2 * @file users.js
3 * @brief User Management and Authentication API Routes
4 * @description Comprehensive user authentication, authorization, and profile management
5 * for MSP/PSA platform. Handles user CRUD, JWT-based authentication, password management,
6 * email verification, multi-factor authentication (MFA/TOTP), and tenant-based access control.
7 *
8 * **Core Features:**
9 * - User registration and management (admin-controlled)
10 * - JWT token-based authentication with 30-day expiration
11 * - Password reset via email with time-limited tokens
12 * - Email verification workflow
13 * - Self-service password changes
14 * - Theme preferences per user
15 * - Multi-factor authentication (TOTP/Google Authenticator)
16 * - Multi-tenant user isolation
17 * - Role-based access control (admin/user roles)
18 *
19 * **Authentication Flow:**
20 * 1. POST /login with credentials (+ optional MFA code)
21 * 2. Receive JWT token with user_id, role, tenant_id, is_msp
22 * 3. Include token in Authorization: Bearer <token> header
23 * 4. Token valid for 30 days, refresh required after expiration
24 *
25 * **Multi-Factor Authentication:**
26 * - TOTP-based (compatible with Google Authenticator, Authy, etc.)
27 * - Setup: Generate secret, scan QR code, verify with code
28 * - Enable: Store secret and set mfa_enabled flag
29 * - Login: Require TOTP code in addition to password
30 * - Disable: Verify current code, remove secret
31 *
32 * **Password Security:**
33 * - Bcrypt hashing with salt (10 rounds)
34 * - Password reset tokens valid for 30 minutes (configurable)
35 * - Email verification tokens valid for 60 minutes (configurable)
36 * - Tokens single-use (marked used_at after consumption)
37 *
38 * **Multi-Tenant Access:**
39 * - Root MSP users (is_msp=true) can see/manage all users
40 * - Regular users only see users in their tenant
41 * - Admin users can create users in their tenant or specified tenant_id
42 *
43 * **Database Schema:**
44 * - users: Main user records with credentials
45 * - password_reset_tokens: Time-limited reset tokens
46 * - email_verification_tokens: Email verification tokens
47 * - tenants: Tenant/MSP organization records
48 * @module routes/users
49 * @requires express
50 * @requires services/db
51 * @requires bcrypt
52 * @requires jsonwebtoken
53 * @requires speakeasy (for MFA/TOTP)
54 * @requires middleware/auth
55 * @requires middleware/tenant
56 * @requires services/email
57 * @author RMM-PSA Platform
58 * @date 2026-02-10
59 * @since 2.0.0
60 */
61const express = require('express');
62const router = express.Router();
63const pool = require('../services/db');
64const bcrypt = require('bcrypt');
65const jwt = require('jsonwebtoken');
66const authenticateToken = require('../middleware/auth');
67const { setTenantContext } = require('../middleware/tenant');
68
69/**
70 * @api {post} /users Create new user
71 * @apiName CreateUser
72 * @apiGroup Users
73 * @apiDescription Creates a new user account. Admin-only operation. Root MSP admins can
74 * create users in any tenant by specifying tenant_id. Regular admins create users in
75 * their own tenant. Password is bcrypt-hashed before storage.
76 *
77 * **Tenant Assignment:**
78 * - Root MSP: Can specify any tenant_id to create users in sub-tenants
79 * - Regular Admin: Creates users in their own tenant only
80 * - If tenant_id omitted, uses current tenant from JWT
81 *
82 * **Roles:**
83 * - admin: Full access, can manage users and system settings
84 * - user: Standard access, cannot manage users
85 * @apiHeader {string} Authorization Bearer JWT token with admin role
86 * @apiHeader {string} Content-Type application/json
87 * @apiParam {string} name Full name of user (required)
88 * @apiParam {string} email Email address (required, unique)
89 * @apiParam {string} role User role: "admin" or "user" (required)
90 * @apiParam {string} password Plain text password (required, will be hashed)
91 * @apiParam {number} [tenant_id] Tenant ID (MSP root only)
92 * @apiSuccess {number} user_id Auto-generated user ID
93 * @apiSuccess {string} name User's full name
94 * @apiSuccess {string} email User's email
95 * @apiSuccess {string} role User's role
96 * @apiSuccess {number} tenant_id Assigned tenant ID
97 * @apiError {number} 403 Admin access required
98 * @apiError {number} 400 Missing tenant context
99 * @apiError {number} 500 User creation failed
100 * @apiExample {curl} Example Request:
101 * curl -X POST https://api.example.com/users \
102 * -H "Authorization: Bearer TOKEN" \
103 * -H "Content-Type: application/json" \
104 * -d '{"name":"John","email":"john@example.com","role":"user","password":"pass123"}'
105 *
106 * @apiExample {json} Success Response:
107 * HTTP/1.1 201 Created
108 * {
109 * "user_id": 42,
110 * "name": "John",
111 * "email": "john@example.com",
112 * "role": "user",
113 * "tenant_id": 5
114 * }
115 */
116router.post('/', authenticateToken, setTenantContext, async (req, res) => {
117 // only admin can create users
118 if (!req.user || req.user.role !== 'admin') return res.status(403).send('Admin only');
119 const { name, email, role, password, tenant_id } = req.body;
120
121 try {
122 // Determine which tenant to assign the user to
123 let targetTenantId;
124
125 if (tenant_id && req.tenant?.isMsp) {
126 // Root user can specify any tenant
127 targetTenantId = tenant_id;
128 } else if (tenant_id) {
129 // Non-root users can only create users in their own tenant
130 targetTenantId = req.tenant?.id || req.user?.tenantId;
131 } else {
132 // No tenant_id provided, use current tenant
133 targetTenantId = req.tenant?.id || req.user?.tenantId;
134 }
135
136 if (!targetTenantId) {
137 return res.status(400).json({ error: 'Tenant context required' });
138 }
139
140 const salt = await bcrypt.genSalt(10);
141 const password_hash = await bcrypt.hash(password, salt);
142
143 const result = await pool.query(
144 `INSERT INTO users (name, email, role, password_hash, tenant_id)
145 VALUES ($1, $2, $3, $4, $5) RETURNING user_id, name, email, role, tenant_id`,
146 [name, email, role, password_hash, targetTenantId]
147 );
148
149 res.status(201).json(result.rows[0]);
150 } catch (err) {
151 console.error('User creation error:', err);
152 res.status(500).json({ error: 'User creation failed: ' + err.message });
153 }
154});
155
156/**
157 * @api {post} /users/register Register new user (Legacy)
158 * @apiName RegisterUser
159 * @apiGroup Users
160 * @apiDescription Legacy endpoint for user registration. Functions identically to POST /users.
161 * Maintained for backwards compatibility with existing clients. Admin-only operation.
162 *
163 * **Deprecation Notice:**
164 * Use POST /users instead. This endpoint will be removed in future versions.
165 * @apiHeader {string} Authorization Bearer JWT token with admin role
166 * @apiHeader {string} Content-Type application/json
167 * @apiParam {string} name Full name
168 * @apiParam {string} email Email address
169 * @apiParam {string} role User role
170 * @apiParam {string} password Password (will be hashed)
171 * @apiParam {number} [tenant_id] Tenant ID
172 * @apiSuccess {Object} user Created user object
173 * @apiError {number} 403 Admin only
174 * @apiError {number} 500 Registration failed
175 * @since 1.0.0
176 * @deprecated Use POST /users instead
177 * @see {@link module:routes/users.createUser}
178 */
179router.post('/register', authenticateToken, setTenantContext, async (req, res) => {
180 // only admin can create users
181 if (!req.user || req.user.role !== 'admin') return res.status(403).send('Admin only');
182 const { name, email, role, password, tenant_id } = req.body;
183
184 try {
185 // Determine which tenant to assign the user to
186 let targetTenantId;
187
188 if (tenant_id && req.tenant?.isMsp) {
189 // Root user can specify any tenant
190 targetTenantId = tenant_id;
191 } else if (tenant_id) {
192 // Non-root users can only create users in their own tenant
193 targetTenantId = req.tenant?.id || req.user?.tenantId;
194 } else {
195 // No tenant_id provided, use current tenant
196 targetTenantId = req.tenant?.id || req.user?.tenantId;
197 }
198
199 if (!targetTenantId) {
200 return res.status(400).json({ error: 'Tenant context required' });
201 }
202
203 const salt = await bcrypt.genSalt(10);
204 const password_hash = await bcrypt.hash(password, salt);
205
206 const result = await pool.query(
207 `INSERT INTO users (name, email, role, password_hash, tenant_id)
208 VALUES ($1, $2, $3, $4, $5) RETURNING user_id, name, email, role, tenant_id`,
209 [name, email, role, password_hash, targetTenantId]
210 );
211
212 res.status(201).json(result.rows[0]);
213 } catch (err) {
214 console.error('Registration error:', err);
215 res.status(500).json({ error: 'User registration failed: ' + err.message });
216 }
217});
218
219/**
220 * @api {get} /users List all users
221 * @apiName ListUsers
222 * @apiGroup Users
223 * @apiDescription Retrieves list of users with tenant filtering and optional search.
224 * Root MSP users see all users across all tenants. Regular users only see users in
225 * their own tenant. Returns up to 50 results when searching.
226 *
227 * **Tenant Filtering:**
228 * - MSP Root (is_msp=true): Returns all users from all tenants
229 * - Regular users: Returns only users from their tenant
230 *
231 * **Search Behavior:**
232 * - Searches in name and email fields (case-insensitive ILIKE)
233 * - Limited to 50 results to prevent excessive data transfer
234 * - Returns empty array if no matches
235 * @apiHeader {string} Authorization Bearer JWT token
236 * @apiParam {string} [search] Search query for name or email
237 * @apiSuccess {Array} users Array of user objects (password_hash excluded)
238 * @apiSuccess {number} users.user_id User ID
239 * @apiSuccess {string} users.name User name
240 * @apiSuccess {string} users.email User email
241 * @apiSuccess {string} users.role User role
242 * @apiSuccess {number} users.tenant_id Tenant ID
243 * @apiExample {curl} List All Users:
244 * curl -H "Authorization: Bearer eyJhbGc..." \
245 * "https://api.everydaytech.au/users"
246 * @apiExample {curl} Search Users:
247 * curl -H "Authorization: Bearer eyJhbGc..." \
248 * "https://api.everydaytech.au/users?search=john"
249 * @apiExample {json} Success Response:
250 * HTTP/1.1 200 OK
251 * [
252 * {
253 * "user_id": 42,
254 * "name": "John Smith",
255 * "email": "john@example.com",
256 * "role": "user",
257 * "tenant_id": 5
258 * }
259 * ]
260 * @since 2.0.0
261 */
262router.get('/', authenticateToken, setTenantContext, async (req, res) => {
263 try {
264 const { search } = req.query;
265
266 // Root users can see all users, regular users only see their tenant's users
267 const tenantFilter = req.tenant?.isMsp ? '' : 'WHERE tenant_id = $1';
268 const params = req.tenant?.isMsp ? [] : [req.tenant?.id || req.user?.tenantId];
269
270 if (search && search.trim() !== '') {
271 const q = `%${search.trim()}%`;
272 const searchFilter = req.tenant?.isMsp
273 ? 'WHERE (name ILIKE $1 OR email ILIKE $1)'
274 : 'WHERE tenant_id = $1 AND (name ILIKE $2 OR email ILIKE $2)';
275 const searchParams = req.tenant?.isMsp ? [q] : [req.tenant?.id || req.user?.tenantId, q];
276
277 const result = await pool.query(
278 `SELECT user_id, name, email, role, tenant_id FROM users ${searchFilter} LIMIT 50`,
279 searchParams
280 );
281 return res.json(result.rows);
282 }
283
284 const result = await pool.query(
285 `SELECT user_id, name, email, role, tenant_id FROM users ${tenantFilter}`,
286 params
287 );
288 res.json(result.rows);
289 } catch (err) {
290 console.error('Error fetching users:', err);
291 res.status(500).send('Server error');
292 }
293});
294
295/**
296 * @api {put} /users/:id Update user
297 * @apiName UpdateUser
298 * @apiGroup Users
299 * @apiDescription Updates an existing user's information. Admin-only operation. Can update
300 * name, email, role, and optionally password. If password provided, it is bcrypt-hashed
301 * before storage.
302 *
303 * **Update Behavior:**
304 * - Password update is optional (omit to keep existing password)
305 * - If password included, old password is replaced with new hash
306 * - Email changes do not automatically reverify email (email_verified remains unchanged)
307 * - Role changes take effect immediately
308 * @apiHeader {string} Authorization Bearer JWT token with admin role
309 * @apiHeader {string} Content-Type application/json
310 * @apiParam {number} id User ID (URL parameter)
311 * @apiParam {string} name Updated name
312 * @apiParam {string} email Updated email
313 * @apiParam {string} role Updated role
314 * @apiParam {string} [password] New password (optional, will be hashed if provided)
315 * @apiSuccess {number} user_id User ID
316 * @apiSuccess {string} name Updated name
317 * @apiSuccess {string} email Updated email
318 * @apiSuccess {string} role Updated role
319 * @apiError {number} 403 Admin only
320 * @apiError {number} 500 Update failed
321 * @apiExample {curl} Example Request:
322 * curl -X PUT \
323 * -H "Authorization: Bearer eyJhbGc..." \
324 * -H "Content-Type: application/json" \
325 * -d '{
326 * "name": "Jane Doe",
327 * "email": "jane.doe@example.com",
328 * "role": "admin",
329 * "password": "NewP@ssw0rd"
330 * }' \
331 * "https://api.everydaytech.au/users/42"
332 * @since 2.0.0
333 */
334router.put('/:id', authenticateToken, setTenantContext, async (req, res) => {
335 if (!req.user || req.user.role !== 'admin') return res.status(403).send('Admin only');
336 const { id } = req.params;
337 const { name, email, role, password } = req.body;
338 try {
339 if (password) {
340 const salt = await bcrypt.genSalt(10);
341 const password_hash = await bcrypt.hash(password, salt);
342 const result = await pool.query('UPDATE users SET name=$1, email=$2, role=$3, password_hash=$4 WHERE user_id=$5 RETURNING user_id, name, email, role', [name, email, role, password_hash, id]);
343 return res.json(result.rows[0]);
344 }
345 const result = await pool.query('UPDATE users SET name=$1, email=$2, role=$3 WHERE user_id=$4 RETURNING user_id, name, email, role', [name, email, role, id]);
346 res.json(result.rows[0]);
347 } catch (err) {
348 console.error('Error updating user:', err);
349 res.status(500).send('Update failed');
350 }
351});
352
353/**
354 * @api {delete} /users/:id Delete user
355 * @apiName DeleteUser
356 * @apiGroup Users
357 * @apiDescription Permanently deletes a user account. Admin-only operation. This action
358 * cannot be undone. User's associated data (tickets, notes, time entries) may become
359 * orphaned depending on database constraints.
360 *
361 * **Warning:**
362 * Hard delete removes user from database permanently. Consider deactivating users
363 * instead by setting a status field (if implemented) to preserve audit trails.
364 * @apiHeader {string} Authorization Bearer JWT token with admin role
365 * @apiParam {number} id User ID to delete (URL parameter)
366 * @apiSuccess {number} 204 No Content (user deleted successfully)
367 * @apiError {number} 403 Admin only
368 * @apiError {number} 500 Delete failed
369 * @apiExample {curl} Example Request:
370 * curl -X DELETE \
371 * -H "Authorization: Bearer eyJhbGc..." \
372 * "https://api.everydaytech.au/users/42"
373 * @since 2.0.0
374 */
375router.delete('/:id', authenticateToken, setTenantContext, async (req, res) => {
376 if (!req.user || req.user.role !== 'admin') return res.status(403).send('Admin only');
377 const { id } = req.params;
378 try {
379 await pool.query('DELETE FROM users WHERE user_id=$1', [id]);
380 res.sendStatus(204);
381 } catch (err) {
382 console.error('Error deleting user:', err);
383 res.status(500).send('Delete failed');
384 }
385});
386
387/**
388 * @api {post} /users/login User login
389 * @apiName LoginUser
390 * @apiGroup Authentication
391 * @apiDescription Authenticates user with email/password and optional MFA code. Returns JWT
392 * token valid for 30 days containing user_id, role, tenant_id, and is_msp flag. Token must
393 * be included in Authorization header for protected endpoints.
394 *
395 * **Authentication Flow:**
396 * 1. Verify email exists in users table
397 * 2. Compare password with bcrypt hash
398 * 3. If MFA enabled, verify TOTP code (6-digit, 30-second window)
399 * 4. Fetch tenant info (subdomain, is_msp flag)
400 * 5. Generate JWT token with user + tenant data
401 * 6. Return token and tenant information
402 *
403 * **MFA Behavior:**
404 * - If user has mfa_enabled=true, mfaCode parameter required
405 * - TOTP code verified with ±1 time window (90 seconds total)
406 * - Login fails if MFA enabled but code not provided or invalid
407 *
408 * **JWT Payload:**
409 * - user_id: User's unique ID
410 * - role: "admin" or "user"
411 * - name: User's display name
412 * - tenant_id: User's tenant ID
413 * - is_msp: Whether tenant is MSP root (boolean)
414 * @apiHeader {string} Content-Type application/json
415 * @apiParam {string} email User email address (required)
416 * @apiParam {string} password User password (required)
417 * @apiParam {string} [mfaCode] 6-digit TOTP code (required if MFA enabled)
418 * @apiSuccess {string} token JWT bearer token (valid 30 days)
419 * @apiSuccess {number} tenant_id User's tenant ID
420 * @apiSuccess {string} subdomain Tenant subdomain
421 * @apiSuccess {boolean} is_msp Whether tenant is MSP root
422 * @apiError {number} 401 Invalid credentials
423 * @apiError {number} 401 MFA required (when mfaCode not provided)
424 * @apiError {number} 401 Invalid MFA code
425 * @apiError {number} 500 Login failed (server error)
426 * @apiExample {curl} Login Without MFA:
427 * curl -X POST \
428 * -H "Content-Type: application/json" \
429 * -d '{
430 * "email": "john@example.com",
431 * "password": "MyP@ssw0rd"
432 * }' \
433 * "https://api.everydaytech.au/users/login"
434 * @apiExample {curl} Login With MFA:
435 * curl -X POST \
436 * -H "Content-Type: application/json" \
437 * -d '{
438 * "email": "john@example.com",
439 * "password": "MyP@ssw0rd",
440 * "mfaCode": "123456"
441 * }' \
442 * "https://api.everydaytech.au/users/login"
443 * @apiExample {json} Success Response:
444 * HTTP/1.1 200 OK
445 * {
446 * "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
447 * "tenant_id": 5,
448 * "subdomain": "acmecorp",
449 * "is_msp": false
450 * }
451 * @apiExample {json} Error Response (MFA Required):
452 * HTTP/1.1 401 Unauthorized
453 * {
454 * "error": "MFA required"
455 * }
456 *
457 * @since 2.0.0
458 * @see {@link module:routes/users.getMe} for retrieving user info with token
459 */
460router.post('/login', async (req, res) => {
461 const { email, password, mfaCode } = req.body;
462
463 try {
464 const result = await pool.query(
465 `SELECT * FROM users WHERE email = $1`,
466 [email]
467 );
468
469 if (result.rows.length === 0) return res.status(401).json({ error: 'Invalid credentials' });
470
471 const user = result.rows[0];
472 const valid = await bcrypt.compare(password, user.password_hash);
473
474 if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
475
476 // If MFA enabled, require a valid TOTP code
477 if (user.mfa_enabled) {
478 if (!mfaCode) {
479 return res.status(401).json({ error: 'MFA required' });
480 }
481 try {
482 const speakeasy = require('speakeasy');
483 const ok = speakeasy.totp.verify({ secret: user.mfa_secret, encoding: 'base32', token: mfaCode, window: 1 });
484 if (!ok) return res.status(401).json({ error: 'Invalid MFA code' });
485 } catch (e) {
486 console.error('MFA verify error:', e);
487 return res.status(500).json({ error: 'MFA verification failed' });
488 }
489 }
490
491 // Get user's tenant info
492 let subdomain = null;
493 let tenant_id = user.tenant_id || null;
494 let is_msp = false;
495 if (tenant_id) {
496 const tenantRes = await pool.query('SELECT subdomain, is_msp FROM tenants WHERE tenant_id = $1', [tenant_id]);
497 if (tenantRes.rows.length > 0) {
498 subdomain = tenantRes.rows[0].subdomain;
499 is_msp = tenantRes.rows[0].is_msp || false;
500 }
501 }
502
503 // Include tenant_id and is_msp in JWT payload
504 const token = jwt.sign(
505 { user_id: user.user_id, role: user.role, name: user.name, tenant_id, is_msp },
506 process.env.JWT_SECRET,
507 { expiresIn: '30d' }
508 );
509
510 // Set token as HTTP-only cookie for iframe requests (PDFs, etc.)
511 // Use SameSite=None for production cross-origin (App Platform separate domains)
512 res.cookie('auth_token', token, {
513 httpOnly: true,
514 secure: true, // Always require HTTPS in production
515 sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
516 maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
517 domain: process.env.NODE_ENV === 'production' ? '.ondigitalocean.app' : undefined
518 });
519
520 res.json({ token, tenant_id, subdomain, is_msp });
521 } catch (err) {
522 console.error('Login error:', err);
523 res.status(500).send('Login failed');
524 }
525});
526
527/**
528 * @api {get} /users/me/theme Get user theme
529 * @apiName GetUserTheme
530 * @apiGroup User Profile
531 * @apiDescription Retrieves the current user's theme preference. Used for UI theming
532 * (light/dark mode, color schemes). Returns null if no theme set.
533 *
534 * **Theme Values:**
535 * Typical values: "light", "dark", "auto" (or custom theme names)
536 * @apiHeader {string} Authorization Bearer JWT token
537 * @apiSuccess {string} theme User's theme preference (or null)
538 * @apiExample {curl} Example Request:
539 * curl -H "Authorization: Bearer eyJhbGc..." \
540 * "https://api.everydaytech.au/users/me/theme"
541 * @apiExample {json} Success Response:
542 * HTTP/1.1 200 OK
543 * {
544 * "theme": "dark"
545 * }
546 * @since 2.0.0
547 * @see {@link module:routes/users.updateUserTheme} for setting theme
548 */
549router.get('/me/theme', authenticateToken, async (req, res) => {
550 try {
551 const userId = req.user.user_id;
552 const result = await pool.query('SELECT theme FROM users WHERE user_id = $1', [userId]);
553 const theme = result.rows[0] ? result.rows[0].theme : null;
554 res.json({ theme });
555 } catch (err) {
556 console.error('Error fetching user theme:', err);
557 res.status(500).send('Server error');
558 }
559});
560
561/**
562 * @api {get} /users/me Get current user
563 * @apiName GetCurrentUser
564 * @apiGroup User Profile
565 * @apiDescription Retrieves the authenticated user's profile information. Returns user
566 * details extracted from JWT token. Does not include password_hash or sensitive fields.
567 *
568 * **Use Cases:**
569 * - Display user profile in UI
570 * - Verify authentication status
571 * - Get user role for client-side authorization
572 * - Retrieve theme preference
573 * @apiHeader {string} Authorization Bearer JWT token
574 * @apiSuccess {number} user_id User's unique ID
575 * @apiSuccess {string} name User's full name
576 * @apiSuccess {string} email User's email address
577 * @apiSuccess {string} role User's role (admin or user)
578 * @apiSuccess {string} theme User's theme preference
579 * @apiError {number} 404 User not found (token valid but user deleted)
580 * @apiError {number} 500 Server error
581 * @apiExample {curl} Example Request:
582 * curl -H "Authorization: Bearer eyJhbGc..." \
583 * "https://api.everydaytech.au/users/me"
584 * @apiExample {json} Success Response:
585 * HTTP/1.1 200 OK
586 * {
587 * "user_id": 42,
588 * "name": "John Smith",
589 * "email": "john@example.com",
590 * "role": "user",
591 * "theme": "dark"
592 * }
593 * @since 2.0.0
594 * @see {@link module:routes/users.login} for obtaining JWT token
595 */
596router.get('/me', authenticateToken, async (req, res) => {
597 try {
598 const userId = req.user.user_id;
599 const result = await pool.query('SELECT user_id, name, email, role, theme FROM users WHERE user_id = $1', [userId]);
600 if (result.rows.length === 0) {
601 return res.status(404).send('User not found');
602 }
603 res.json(result.rows[0]);
604 } catch (err) {
605 console.error('Error fetching user info:', err);
606 res.status(500).send('Server error');
607 }
608});
609
610/**
611 * @api {put} /users/me/theme Update user theme
612 * @apiName UpdateUserTheme
613 * @apiGroup User Profile
614 * @apiDescription Updates the current user's theme preference. Theme is stored per-user
615 * and persists across sessions. Used for UI theming customization.
616 *
617 * **Theme Values:**
618 * Common values: "light", "dark", "auto"
619 * Can be any string value for custom themes
620 * @apiHeader {string} Authorization Bearer JWT token
621 * @apiHeader {string} Content-Type application/json
622 * @apiParam {string} theme Theme preference (e.g., "light", "dark")
623 * @apiSuccess {string} theme Updated theme value
624 * @apiExample {curl} Example Request:
625 * curl -X PUT \
626 * -H "Authorization: Bearer eyJhbGc..." \
627 * -H "Content-Type: application/json" \
628 * -d '{"theme": "dark"}' \
629 * "https://api.everydaytech.au/users/me/theme"
630 * @apiExample {json} Success Response:
631 * HTTP/1.1 200 OK
632 * {
633 * "theme": "dark"
634 * }
635 * @since 2.0.0
636 */
637router.put('/me/theme', authenticateToken, async (req, res) => {
638 try {
639 const userId = req.user.user_id;
640 const { theme } = req.body;
641 const result = await pool.query('UPDATE users SET theme = $1 WHERE user_id = $2 RETURNING theme', [theme, userId]);
642 res.json({ theme: result.rows[0].theme });
643 } catch (err) {
644 console.error('Error updating user theme:', err);
645 res.status(500).send('Server error');
646 }
647});
648
649/**
650 * @api {post} /users/request-password-reset Request password reset
651 * @apiName RequestPasswordReset
652 * @apiGroup Password Management
653 * @apiDescription Initiates password reset flow by sending email with time-limited reset link.
654 * Generates secure random token valid for 30 minutes (configurable via RESET_TOKEN_TTL_MIN).
655 * Email contains link to reset password page with token embedded.
656 *
657 * **Security:**
658 * - Does not reveal whether email exists (always returns ok: true)
659 * - Token is single-use (marked used_at after consumption)
660 * - Tokens expire after 30 minutes by default
661 * - Reset link includes token as query parameter
662 *
663 * **Email Content:**
664 * Sends email with reset link: {PUBLIC_APP_URL}/reset-password?token={token}
665 * @apiHeader {string} Content-Type application/json
666 * @apiParam {string} email User's email address (required)
667 * @apiSuccess {boolean} ok Always true (even if email doesn't exist)
668 * @apiError {number} 400 Email is required
669 * @apiError {number} 500 Failed to create reset token
670 * @apiExample {curl} Example Request:
671 * curl -X POST \
672 * -H "Content-Type: application/json" \
673 * -d '{"email": "john@example.com"}' \
674 * "https://api.everydaytech.au/users/request-password-reset"
675 * @apiExample {json} Success Response:
676 * HTTP/1.1 200 OK
677 * {
678 * "ok": true
679 * }
680 * @since 2.0.0
681 * @see {@link module:routes/users.resetPassword} for completing reset
682 * @see {@link module:services/email.sendEmail} for email sending
683 */
684router.post('/request-password-reset', async (req, res) => {
685 const { email } = req.body;
686 if (!email) return res.status(400).json({ error: 'Email is required' });
687 try {
688 const userRes = await pool.query('SELECT user_id, email FROM users WHERE email=$1', [email]);
689 if (userRes.rows.length === 0) {
690 // Do not reveal that email does not exist
691 return res.json({ ok: true });
692 }
693 const user = userRes.rows[0];
694 const crypto = require('crypto');
695 const token = crypto.randomBytes(32).toString('hex');
696 const ttlMinutes = parseInt(process.env.RESET_TOKEN_TTL_MIN || '30', 10);
697 await pool.query(
698 `INSERT INTO password_reset_tokens (token, user_id, expires_at)
699 VALUES ($1, $2, NOW() + INTERVAL '${ttlMinutes} minutes')`,
700 [token, user.user_id]
701 );
702
703 const appUrl = process.env.PUBLIC_APP_URL || 'http://localhost:5173';
704 const link = `${appUrl}/reset-password?token=${encodeURIComponent(token)}`;
705 const { sendEmail } = require('../services/email');
706 await sendEmail({
707 to: email,
708 subject: 'Password reset instructions',
709 text: `Click the link to reset your password: ${link}`,
710 html: `<p>Click the link to reset your password:</p><p><a href="${link}">${link}</a></p>`
711 });
712
713 res.json({ ok: true });
714 } catch (err) {
715 console.error('request-password-reset error:', err);
716 res.status(500).json({ error: 'Failed to create reset token' });
717 }
718});
719
720/**
721 * @api {post} /users/reset-password Reset password with token
722 * @apiName ResetPassword
723 * @apiGroup Password Management
724 * @apiDescription Completes password reset using token from email. New password is bcrypt-hashed
725 * and stored. Token is marked as used to prevent reuse. Clears password_reset_required flag.
726 *
727 * **Token Validation:**
728 * - Token must exist in password_reset_tokens table
729 * - Token must not be used (used_at IS NULL)
730 * - Token must not be expired (expires_at > NOW())
731 * - Token is single-use (marked used_at after successful reset)
732 *
733 * **Password Storage:**
734 * New password is bcrypt-hashed with 10-round salt before storage.
735 * @apiHeader {string} Content-Type application/json
736 * @apiParam {string} token Password reset token from email (required)
737 * @apiParam {string} password New password to set (required)
738 * @apiSuccess {boolean} ok Always true when successful
739 * @apiError {number} 400 Token and password required
740 * @apiError {number} 400 Invalid or expired token
741 * @apiError {number} 500 Reset failed
742 * @apiExample {curl} Example Request:
743 * curl -X POST \
744 * -H "Content-Type: application/json" \
745 * -d '{
746 * "token": "abc123def456...",
747 * "password": "NewS3cur3P@ss!"
748 * }' \
749 * "https://api.everydaytech.au/users/reset-password"
750 * @apiExample {json} Success Response:
751 * HTTP/1.1 200 OK
752 * {
753 * "ok": true
754 * }
755 * @since 2.0.0
756 * @see {@link module:routes/users.requestPasswordReset} for initiating reset
757 */
758router.post('/reset-password', async (req, res) => {
759 const { token, password } = req.body;
760 if (!token || !password) return res.status(400).json({ error: 'Token and password required' });
761 try {
762 const tRes = await pool.query(
763 `SELECT t.user_id FROM password_reset_tokens t
764 WHERE t.token=$1 AND t.used_at IS NULL AND t.expires_at > NOW()`,
765 [token]
766 );
767 if (tRes.rows.length === 0) return res.status(400).json({ error: 'Invalid or expired token' });
768 const userId = tRes.rows[0].user_id;
769 const salt = await bcrypt.genSalt(10);
770 const password_hash = await bcrypt.hash(password, salt);
771 await pool.query('UPDATE users SET password_hash=$1, password_reset_required=false WHERE user_id=$2', [password_hash, userId]);
772 await pool.query('UPDATE password_reset_tokens SET used_at=NOW() WHERE token=$1', [token]);
773 res.json({ ok: true });
774 } catch (err) {
775 console.error('reset-password error:', err);
776 res.status(500).json({ error: 'Reset failed' });
777 }
778});
779
780/**
781 * @api {post} /users/verify-email/request Request email verification
782 * @apiName RequestEmailVerification
783 * @apiGroup Email Verification
784 * @apiDescription Sends email verification link to authenticated user. Generates secure
785 * random token valid for 60 minutes (configurable via EMAIL_TOKEN_TTL_MIN). If email
786 * already verified, returns success without sending email.
787 *
788 * **Verification Flow:**
789 * 1. User requests verification (this endpoint)
790 * 2. System sends email with verification link
791 * 3. User clicks link (opens /verify-email?token={token})
792 * 4. Frontend calls POST /users/verify-email with token
793 * 5. User's email_verified flag set to true
794 *
795 * **Token Security:**
796 * - Token valid for 60 minutes by default
797 * - Single-use token (marked used_at after verification)
798 * - Secure random generation (32 bytes)
799 * @apiHeader {string} Authorization Bearer JWT token
800 * @apiSuccess {boolean} ok Always true when successful
801 * @apiSuccess {boolean} [alreadyVerified] true if email already verified
802 * @apiError {number} 404 User not found
803 * @apiError {number} 500 Failed to send verification email
804 * @apiExample {curl} Example Request:
805 * curl -X POST \
806 * -H "Authorization: Bearer eyJhbGc..." \
807 * "https://api.everydaytech.au/users/verify-email/request"
808 * @apiExample {json} Success Response:
809 * HTTP/1.1 200 OK
810 * {
811 * "ok": true
812 * }
813 * @apiExample {json} Already Verified:
814 * HTTP/1.1 200 OK
815 * {
816 * "ok": true,
817 * "alreadyVerified": true
818 * }
819 * @since 2.0.0
820 * @see {@link module:routes/users.verifyEmail} for completing verification
821 */
822router.post('/verify-email/request', authenticateToken, async (req, res) => {
823 try {
824 const userId = req.user.user_id;
825 const uRes = await pool.query('SELECT email, email_verified FROM users WHERE user_id=$1', [userId]);
826 if (uRes.rows.length === 0) return res.status(404).json({ error: 'User not found' });
827 const { email, email_verified } = uRes.rows[0];
828 if (email_verified) return res.json({ ok: true, alreadyVerified: true });
829
830 const crypto = require('crypto');
831 const token = crypto.randomBytes(32).toString('hex');
832 const ttlMinutes = parseInt(process.env.EMAIL_TOKEN_TTL_MIN || '60', 10);
833 await pool.query(
834 `INSERT INTO email_verification_tokens (token, user_id, expires_at)
835 VALUES ($1, $2, NOW() + INTERVAL '${ttlMinutes} minutes')`,
836 [token, userId]
837 );
838
839 const appUrl = process.env.PUBLIC_APP_URL || 'http://localhost:5173';
840 const link = `${appUrl}/verify-email?token=${encodeURIComponent(token)}`;
841 const { sendEmail } = require('../services/email');
842 await sendEmail({
843 to: email,
844 subject: 'Verify your email',
845 text: `Verify your email address: ${link}`,
846 html: `<p>Verify your email address:</p><p><a href="${link}">${link}</a></p>`
847 });
848
849 res.json({ ok: true });
850 } catch (err) {
851 console.error('verify-email/request error:', err);
852 res.status(500).json({ error: 'Failed to send verification email' });
853 }
854});
855
856/**
857 * @api {post} /users/verify-email Verify email address
858 * @apiName VerifyEmail
859 * @apiGroup Email Verification
860 * @apiDescription Completes email verification using token from email. Sets user's
861 * email_verified flag to true. Token is marked as used to prevent reuse.
862 *
863 * **Token Validation:**
864 * - Token must exist in email_verification_tokens table
865 * - Token must not be used (used_at IS NULL)
866 * - Token must not be expired (expires_at > NOW())
867 * - Token is single-use
868 * @apiHeader {string} Content-Type application/json
869 * @apiParam {string} token Email verification token from email (required)
870 * @apiSuccess {boolean} ok Always true when successful
871 * @apiError {number} 400 Token required
872 * @apiError {number} 400 Invalid or expired token
873 * @apiError {number} 500 Verification failed
874 * @apiExample {curl} Example Request:
875 * curl -X POST \
876 * -H "Content-Type: application/json" \
877 * -d '{"token": "abc123def456..."}' \
878 * "https://api.everydaytech.au/users/verify-email"
879 * @apiExample {json} Success Response:
880 * HTTP/1.1 200 OK
881 * {
882 * "ok": true
883 * }
884 * @since 2.0.0
885 * @see {@link module:routes/users.requestEmailVerification} for initiating verification
886 */
887router.post('/verify-email', async (req, res) => {
888 const { token } = req.body;
889 if (!token) return res.status(400).json({ error: 'Token required' });
890 try {
891 const tRes = await pool.query(
892 `SELECT user_id FROM email_verification_tokens
893 WHERE token=$1 AND used_at IS NULL AND expires_at > NOW()`,
894 [token]
895 );
896 if (tRes.rows.length === 0) return res.status(400).json({ error: 'Invalid or expired token' });
897 const userId = tRes.rows[0].user_id;
898 await pool.query('UPDATE users SET email_verified=true WHERE user_id=$1', [userId]);
899 await pool.query('UPDATE email_verification_tokens SET used_at=NOW() WHERE token=$1', [token]);
900 res.json({ ok: true });
901 } catch (err) {
902 console.error('verify-email error:', err);
903 res.status(500).json({ error: 'Verification failed' });
904 }
905});
906
907/**
908 * @api {put} /users/me/password Change own password
909 * @apiName ChangeOwnPassword
910 * @apiGroup User Profile
911 * @apiDescription Allows authenticated user to change their own password. Requires current
912 * password for verification. New password is bcrypt-hashed before storage.
913 *
914 * **Security:**
915 * - Current password must be correct (verified against hash)
916 * - New password immediately becomes active
917 * - Does NOT invalidate existing JWT tokens
918 * - User remains logged in after password change
919 *
920 * **Best Practice:**
921 * Implement password strength requirements on client side (length, complexity).
922 * @apiHeader {string} Authorization Bearer JWT token
923 * @apiHeader {string} Content-Type application/json
924 * @apiParam {string} currentPassword User's current password (required for verification)
925 * @apiParam {string} newPassword New password to set (required)
926 * @apiSuccess {boolean} ok Always true when successful
927 * @apiError {number} 400 Missing currentPassword or newPassword
928 * @apiError {number} 401 Invalid current password
929 * @apiError {number} 404 User not found
930 * @apiError {number} 500 Password change failed
931 * @apiExample {curl} Example Request:
932 * curl -X PUT \
933 * -H "Authorization: Bearer eyJhbGc..." \
934 * -H "Content-Type: application/json" \
935 * -d '{
936 * "currentPassword": "OldP@ss123",
937 * "newPassword": "NewS3cur3P@ss!"
938 * }' \
939 * "https://api.everydaytech.au/users/me/password"
940 * @apiExample {json} Success Response:
941 * HTTP/1.1 200 OK
942 * {
943 * "ok": true
944 * }
945 * @since 2.0.0
946 */
947router.put('/me/password', authenticateToken, async (req, res) => {
948 const { currentPassword, newPassword } = req.body;
949 if (!currentPassword || !newPassword) return res.status(400).json({ error: 'currentPassword and newPassword required' });
950 try {
951 const userId = req.user.user_id;
952 const uRes = await pool.query('SELECT password_hash FROM users WHERE user_id=$1', [userId]);
953 if (uRes.rows.length === 0) return res.status(404).json({ error: 'User not found' });
954 const valid = await bcrypt.compare(currentPassword, uRes.rows[0].password_hash);
955 if (!valid) return res.status(401).json({ error: 'Invalid current password' });
956 const salt = await bcrypt.genSalt(10);
957 const password_hash = await bcrypt.hash(newPassword, salt);
958 await pool.query('UPDATE users SET password_hash=$1 WHERE user_id=$2', [password_hash, userId]);
959 res.json({ ok: true });
960 } catch (err) {
961 console.error('me/password error:', err);
962 res.status(500).json({ error: 'Password change failed' });
963 }
964});
965
966/**
967 * @api {post} /users/:id/reset-password Admin reset user password
968 * @apiName AdminResetPassword
969 * @apiGroup Password Management
970 * @apiDescription Admin-initiated password reset for a user. Generates reset token and sends
971 * email with reset link. Sets password_reset_required flag to force user to change password
972 * on next login. Admin-only operation.
973 *
974 * **Use Cases:**
975 * - User forgot password and can't access email
976 * - Security incident requiring password reset
977 * - New user account setup
978 * - Compromised account recovery
979 *
980 * **Reset Flow:**
981 * 1. Admin calls this endpoint with user ID
982 * 2. Reset token generated (30 minute expiry)
983 * 3. Email sent to user with reset link
984 * 4. password_reset_required flag set to true
985 * 5. User clicks link and sets new password
986 * 6. Flag cleared after successful reset
987 * @apiHeader {string} Authorization Bearer JWT token with admin role
988 * @apiHeader {string} Content-Type application/json
989 * @apiParam {number} id User ID to reset (URL parameter)
990 * @apiSuccess {boolean} ok Always true when successful
991 * @apiError {number} 403 Admin only
992 * @apiError {number} 404 User not found
993 * @apiError {number} 500 Failed to initiate reset
994 * @apiExample {curl} Example Request:
995 * curl -X POST \
996 * -H "Authorization: Bearer eyJhbGc..." \
997 * "https://api.everydaytech.au/users/42/reset-password"
998 * @apiExample {json} Success Response:
999 * HTTP/1.1 200 OK
1000 * {
1001 * "ok": true
1002 * }
1003 * @since 2.0.0
1004 */
1005router.post('/:id/reset-password', authenticateToken, async (req, res) => {
1006 // require admin role to reset
1007 if (!req.user || req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
1008 try {
1009 const { id } = req.params;
1010 const uRes = await pool.query('SELECT user_id, email FROM users WHERE user_id=$1', [id]);
1011 if (uRes.rows.length === 0) return res.status(404).json({ error: 'User not found' });
1012
1013 const crypto = require('crypto');
1014 const token = crypto.randomBytes(32).toString('hex');
1015 const ttlMinutes = parseInt(process.env.RESET_TOKEN_TTL_MIN || '30', 10);
1016 await pool.query(
1017 `INSERT INTO password_reset_tokens (token, user_id, expires_at)
1018 VALUES ($1, $2, NOW() + INTERVAL '${ttlMinutes} minutes')`,
1019 [token, id]
1020 );
1021
1022 const appUrl = process.env.PUBLIC_APP_URL || 'http://localhost:5173';
1023 const link = `${appUrl}/reset-password?token=${encodeURIComponent(token)}`;
1024 const { sendEmail } = require('../services/email');
1025 await sendEmail({
1026 to: uRes.rows[0].email,
1027 subject: 'Admin initiated password reset',
1028 text: `An administrator has initiated a password reset for your account. Reset here: ${link}`,
1029 html: `<p>An administrator has initiated a password reset for your account.</p><p><a href="${link}">Reset Password</a></p>`
1030 });
1031
1032 await pool.query('UPDATE users SET password_reset_required=true WHERE user_id=$1', [id]);
1033
1034 res.json({ ok: true });
1035 } catch (err) {
1036 console.error('admin reset-password error:', err);
1037 res.status(500).json({ error: 'Failed to initiate reset' });
1038 }
1039});
1040
1041/**
1042 * @api {post} /users/mfa/setup Setup MFA/2FA
1043 * @apiName SetupMFA
1044 * @apiGroup Multi-Factor Authentication
1045 * @apiDescription Generates TOTP secret for multi-factor authentication setup. Returns
1046 * secret in base32 format and otpauth URL for QR code generation. User scans QR code with
1047 * authenticator app (Google Authenticator, Authy, etc.) and then calls /mfa/enable with
1048 * verification code.
1049 *
1050 * **MFA Setup Flow:**
1051 * 1. User calls /mfa/setup to get secret
1052 * 2. Frontend generates QR code from otpauth_url
1053 * 3. User scans QR code with authenticator app
1054 * 4. App displays 6-digit TOTP code
1055 * 5. User enters code, frontend calls /mfa/enable with base32 + code
1056 * 6. Code verified, MFA enabled
1057 *
1058 * **TOTP Details:**
1059 * - Algorithm: SHA-1
1060 * - Digits: 6
1061 * - Period: 30 seconds
1062 * - Compatible with Google Authenticator, Authy, Microsoft Authenticator, etc.
1063 *
1064 * **Security:**
1065 * Secret generated but not yet stored in database. Only stored when /mfa/enable
1066 * called with valid verification code.
1067 * @apiHeader {string} Authorization Bearer JWT token
1068 * @apiSuccess {string} base32 TOTP secret in base32 encoding (store for /mfa/enable)
1069 * @apiSuccess {string} otpauth_url OTP auth URL for QR code generation
1070 * @apiError {number} 404 User not found
1071 * @apiError {number} 500 MFA setup failed
1072 * @apiExample {curl} Example Request:
1073 * curl -X POST \
1074 * -H "Authorization: Bearer eyJhbGc..." \
1075 * "https://api.everydaytech.au/users/mfa/setup"
1076 * @apiExample {json} Success Response:
1077 * HTTP/1.1 200 OK
1078 * {
1079 * "base32": "JBSWY3DPEHPK3PXP",
1080 * "otpauth_url": "otpauth://totp/IBG%20MSP%20Platform%20(john@example.com)?secret=JBSWY3DPEHPK3PXP&issuer=IBG%20MSP%20Platform"
1081 * }
1082 * @since 2.0.0
1083 * @see {@link module:routes/users.enableMFA} for completing MFA setup
1084 * @see {@link module:routes/users.login} for MFA-enabled login
1085 */
1086router.post('/mfa/setup', authenticateToken, async (req, res) => {
1087 try {
1088 const speakeasy = require('speakeasy');
1089 const uRes = await pool.query('SELECT email FROM users WHERE user_id=$1', [req.user.user_id]);
1090 if (uRes.rows.length === 0) return res.status(404).json({ error: 'User not found' });
1091 const email = uRes.rows[0].email;
1092 const secret = speakeasy.generateSecret({ name: `IBG MSP Platform (${email})` });
1093 res.json({ base32: secret.base32, otpauth_url: secret.otpauth_url });
1094 } catch (err) {
1095 console.error('mfa/setup error:', err);
1096 res.status(500).json({ error: 'MFA setup failed' });
1097 }
1098});
1099
1100/**
1101 * @api {post} /users/mfa/enable Enable MFA/2FA
1102 * @apiName EnableMFA
1103 * @apiGroup Multi-Factor Authentication
1104 * @apiDescription Enables multi-factor authentication after verifying TOTP code. User must
1105 * provide base32 secret from /mfa/setup and valid 6-digit code from authenticator app.
1106 * Stores secret and sets mfa_enabled flag to true.
1107 *
1108 * **Verification:**
1109 * - TOTP code verified with ±1 time window (90 seconds total)
1110 * - Code must match secret generated in /mfa/setup
1111 * - If verification fails, MFA not enabled
1112 *
1113 *After Enabling:**
1114 * - User required to provide TOTP code on every login
1115 * - Login endpoint expects mfaCode parameter
1116 * - Can be disabled via /mfa/disable with valid code
1117 * @apiHeader {string} Authorization Bearer JWT token
1118 * @apiHeader {string} Content-Type application/json
1119 * @apiParam {string} base32 Secret from /mfa/setup (required)
1120 * @apiParam {string} code 6-digit TOTP code from authenticator app (required)
1121 * @apiSuccess {boolean} ok Always true when successful
1122 * @apiError {number} 400 base32 and code required
1123 * @apiError {number} 400 Invalid code (TOTP verification failed)
1124 * @apiError {number} 500 Failed to enable MFA
1125 * @apiExample {curl} Example Request:
1126 * curl -X POST \
1127 * -H "Authorization: Bearer eyJhbGc..." \
1128 * -H "Content-Type: application/json" \
1129 * -d '{
1130 * "base32": "JBSWY3DPEHPK3PXP",
1131 * "code": "123456"
1132 * }' \
1133 * "https://api.everydaytech.au/users/mfa/enable"
1134 * @apiExample {json} Success Response:
1135 * HTTP/1.1 200 OK
1136 * {
1137 * "ok": true
1138 * }
1139 * @apiExample {json} Error Response (Invalid Code):
1140 * HTTP/1.1 400 Bad Request
1141 * {
1142 * "error": "Invalid code"
1143 * }
1144 * @since 2.0.0
1145 * @see {@link module:routes/users.setupMFA} for getting secret
1146 * @see {@link module:routes/users.disableMFA} for disabling MFA
1147 */
1148router.post('/mfa/enable', authenticateToken, async (req, res) => {
1149 const { base32, code } = req.body;
1150 if (!base32 || !code) return res.status(400).json({ error: 'base32 and code required' });
1151 try {
1152 const speakeasy = require('speakeasy');
1153 const ok = speakeasy.totp.verify({ secret: base32, encoding: 'base32', token: code, window: 1 });
1154 if (!ok) return res.status(400).json({ error: 'Invalid code' });
1155 await pool.query('UPDATE users SET mfa_enabled=true, mfa_secret=$1 WHERE user_id=$2', [base32, req.user.user_id]);
1156 res.json({ ok: true });
1157 } catch (err) {
1158 console.error('mfa/enable error:', err);
1159 res.status(500).json({ error: 'Failed to enable MFA' });
1160 }
1161});
1162
1163/**
1164 * @api {post} /users/mfa/disable Disable MFA/2FA
1165 * @apiName DisableMFA
1166 * @apiGroup Multi-Factor Authentication
1167 * @apiDescription Disables multi-factor authentication for user. Requires valid TOTP code
1168 * for verification. Clears mfa_enabled flag and removes mfa_secret from database.
1169 *
1170 * **Security:**
1171 * - User must provide valid TOTP code to disable MFA
1172 * - Prevents unauthorized MFA removal
1173 * - Code verified with ±1 time window
1174 *
1175 * **After Disabling:**
1176 * - User can login with just email/password
1177 * - No TOTP code required
1178 * - Secret removed from database
1179 * - User can re-enable MFA anytime by running setup flow again
1180 * @apiHeader {string} Authorization Bearer JWT token
1181 * @apiHeader {string} Content-Type application/json
1182 * @apiParam {string} code 6-digit TOTP code from authenticator app (required)
1183 * @apiSuccess {boolean} ok Always true when successful
1184 * @apiError {number} 400 MFA is not enabled
1185 * @apiError {number} 400 Invalid code
1186 * @apiError {number} 500 Failed to disable MFA
1187 * @apiExample {curl} Example Request:
1188 * curl -X POST \
1189 * -H "Authorization: Bearer eyJhbGc..." \
1190 * -H "Content-Type: application/json" \
1191 * -d '{"code": "123456"}' \
1192 * "https://api.everydaytech.au/users/mfa/disable"
1193 * @apiExample {json} Success Response:
1194 * HTTP/1.1 200 OK
1195 * {
1196 * "ok": true
1197 * }
1198 * @apiExample {json} Error Response (Not Enabled):
1199 * HTTP/1.1 400 Bad Request
1200 * {
1201 * "error": "MFA is not enabled"
1202 * }
1203 * @since 2.0.0
1204 * @see {@link module:routes/users.enableMFA} for enabling MFA
1205 */
1206router.post('/mfa/disable', authenticateToken, async (req, res) => {
1207 const { code } = req.body;
1208 try {
1209 const speakeasy = require('speakeasy');
1210 const uRes = await pool.query('SELECT mfa_secret FROM users WHERE user_id=$1', [req.user.user_id]);
1211 const secret = uRes.rows[0]?.mfa_secret;
1212 if (!secret) return res.status(400).json({ error: 'MFA is not enabled' });
1213 const ok = speakeasy.totp.verify({ secret, encoding: 'base32', token: code, window: 1 });
1214 if (!ok) return res.status(400).json({ error: 'Invalid code' });
1215 await pool.query('UPDATE users SET mfa_enabled=false, mfa_secret=NULL WHERE user_id=$1', [req.user.user_id]);
1216 res.json({ ok: true });
1217 } catch (err) {
1218 console.error('mfa/disable error:', err);
1219 res.status(500).json({ error: 'Failed to disable MFA' });
1220 }
1221});
1222
1223module.exports = router;