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.
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)
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
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
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)
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
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
50 * @requires services/db
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
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');
70 * @api {post} /users Create new user
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.
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
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"}'
106 * @apiExample {json} Success Response:
107 * HTTP/1.1 201 Created
111 * "email": "john@example.com",
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;
122 // Determine which tenant to assign the user to
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;
132 // No tenant_id provided, use current tenant
133 targetTenantId = req.tenant?.id || req.user?.tenantId;
136 if (!targetTenantId) {
137 return res.status(400).json({ error: 'Tenant context required' });
140 const salt = await bcrypt.genSalt(10);
141 const password_hash = await bcrypt.hash(password, salt);
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]
149 res.status(201).json(result.rows[0]);
151 console.error('User creation error:', err);
152 res.status(500).json({ error: 'User creation failed: ' + err.message });
157 * @api {post} /users/register Register new user (Legacy)
158 * @apiName RegisterUser
160 * @apiDescription Legacy endpoint for user registration. Functions identically to POST /users.
161 * Maintained for backwards compatibility with existing clients. Admin-only operation.
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
176 * @deprecated Use POST /users instead
177 * @see {@link module:routes/users.createUser}
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;
185 // Determine which tenant to assign the user to
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;
195 // No tenant_id provided, use current tenant
196 targetTenantId = req.tenant?.id || req.user?.tenantId;
199 if (!targetTenantId) {
200 return res.status(400).json({ error: 'Tenant context required' });
203 const salt = await bcrypt.genSalt(10);
204 const password_hash = await bcrypt.hash(password, salt);
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]
212 res.status(201).json(result.rows[0]);
214 console.error('Registration error:', err);
215 res.status(500).json({ error: 'User registration failed: ' + err.message });
220 * @api {get} /users List all 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.
227 * **Tenant Filtering:**
228 * - MSP Root (is_msp=true): Returns all users from all tenants
229 * - Regular users: Returns only users from their tenant
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:
254 * "name": "John Smith",
255 * "email": "john@example.com",
262router.get('/', authenticateToken, setTenantContext, async (req, res) => {
264 const { search } = req.query;
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];
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];
277 const result = await pool.query(
278 `SELECT user_id, name, email, role, tenant_id FROM users ${searchFilter} LIMIT 50`,
281 return res.json(result.rows);
284 const result = await pool.query(
285 `SELECT user_id, name, email, role, tenant_id FROM users ${tenantFilter}`,
288 res.json(result.rows);
290 console.error('Error fetching users:', err);
291 res.status(500).send('Server error');
296 * @api {put} /users/:id Update user
297 * @apiName UpdateUser
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
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:
323 * -H "Authorization: Bearer eyJhbGc..." \
324 * -H "Content-Type: application/json" \
326 * "name": "Jane Doe",
327 * "email": "jane.doe@example.com",
329 * "password": "NewP@ssw0rd"
331 * "https://api.everydaytech.au/users/42"
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;
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]);
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]);
348 console.error('Error updating user:', err);
349 res.status(500).send('Update failed');
354 * @api {delete} /users/:id Delete user
355 * @apiName DeleteUser
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.
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:
371 * -H "Authorization: Bearer eyJhbGc..." \
372 * "https://api.everydaytech.au/users/42"
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;
379 await pool.query('DELETE FROM users WHERE user_id=$1', [id]);
382 console.error('Error deleting user:', err);
383 res.status(500).send('Delete failed');
388 * @api {post} /users/login User login
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.
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
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
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:
428 * -H "Content-Type: application/json" \
430 * "email": "john@example.com",
431 * "password": "MyP@ssw0rd"
433 * "https://api.everydaytech.au/users/login"
434 * @apiExample {curl} Login With MFA:
436 * -H "Content-Type: application/json" \
438 * "email": "john@example.com",
439 * "password": "MyP@ssw0rd",
440 * "mfaCode": "123456"
442 * "https://api.everydaytech.au/users/login"
443 * @apiExample {json} Success Response:
446 * "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
448 * "subdomain": "acmecorp",
451 * @apiExample {json} Error Response (MFA Required):
452 * HTTP/1.1 401 Unauthorized
454 * "error": "MFA required"
458 * @see {@link module:routes/users.getMe} for retrieving user info with token
460router.post('/login', async (req, res) => {
461 const { email, password, mfaCode } = req.body;
464 const result = await pool.query(
465 `SELECT * FROM users WHERE email = $1`,
469 if (result.rows.length === 0) return res.status(401).json({ error: 'Invalid credentials' });
471 const user = result.rows[0];
472 const valid = await bcrypt.compare(password, user.password_hash);
474 if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
476 // If MFA enabled, require a valid TOTP code
477 if (user.mfa_enabled) {
479 return res.status(401).json({ error: 'MFA required' });
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' });
486 console.error('MFA verify error:', e);
487 return res.status(500).json({ error: 'MFA verification failed' });
491 // Get user's tenant info
492 let subdomain = null;
493 let tenant_id = user.tenant_id || null;
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;
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,
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, {
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
520 res.json({ token, tenant_id, subdomain, is_msp });
522 console.error('Login error:', err);
523 res.status(500).send('Login failed');
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.
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:
547 * @see {@link module:routes/users.updateUserTheme} for setting theme
549router.get('/me/theme', authenticateToken, async (req, res) => {
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;
556 console.error('Error fetching user theme:', err);
557 res.status(500).send('Server error');
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.
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:
588 * "name": "John Smith",
589 * "email": "john@example.com",
594 * @see {@link module:routes/users.login} for obtaining JWT token
596router.get('/me', authenticateToken, async (req, res) => {
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');
603 res.json(result.rows[0]);
605 console.error('Error fetching user info:', err);
606 res.status(500).send('Server error');
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.
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:
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:
637router.put('/me/theme', authenticateToken, async (req, res) => {
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 });
644 console.error('Error updating user theme:', err);
645 res.status(500).send('Server error');
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.
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
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:
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:
681 * @see {@link module:routes/users.resetPassword} for completing reset
682 * @see {@link module:services/email.sendEmail} for email sending
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' });
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 });
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);
698 `INSERT INTO password_reset_tokens (token, user_id, expires_at)
699 VALUES ($1, $2, NOW() + INTERVAL '${ttlMinutes} minutes')`,
700 [token, user.user_id]
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');
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>`
713 res.json({ ok: true });
715 console.error('request-password-reset error:', err);
716 res.status(500).json({ error: 'Failed to create reset token' });
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.
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)
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:
744 * -H "Content-Type: application/json" \
746 * "token": "abc123def456...",
747 * "password": "NewS3cur3P@ss!"
749 * "https://api.everydaytech.au/users/reset-password"
750 * @apiExample {json} Success Response:
756 * @see {@link module:routes/users.requestPasswordReset} for initiating reset
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' });
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()`,
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 });
775 console.error('reset-password error:', err);
776 res.status(500).json({ error: 'Reset failed' });
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.
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
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:
806 * -H "Authorization: Bearer eyJhbGc..." \
807 * "https://api.everydaytech.au/users/verify-email/request"
808 * @apiExample {json} Success Response:
813 * @apiExample {json} Already Verified:
817 * "alreadyVerified": true
820 * @see {@link module:routes/users.verifyEmail} for completing verification
822router.post('/verify-email/request', authenticateToken, async (req, res) => {
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 });
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);
834 `INSERT INTO email_verification_tokens (token, user_id, expires_at)
835 VALUES ($1, $2, NOW() + INTERVAL '${ttlMinutes} minutes')`,
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');
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>`
849 res.json({ ok: true });
851 console.error('verify-email/request error:', err);
852 res.status(500).json({ error: 'Failed to send verification email' });
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.
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:
876 * -H "Content-Type: application/json" \
877 * -d '{"token": "abc123def456..."}' \
878 * "https://api.everydaytech.au/users/verify-email"
879 * @apiExample {json} Success Response:
885 * @see {@link module:routes/users.requestEmailVerification} for initiating verification
887router.post('/verify-email', async (req, res) => {
888 const { token } = req.body;
889 if (!token) return res.status(400).json({ error: 'Token required' });
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()`,
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 });
902 console.error('verify-email error:', err);
903 res.status(500).json({ error: 'Verification failed' });
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.
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
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:
933 * -H "Authorization: Bearer eyJhbGc..." \
934 * -H "Content-Type: application/json" \
936 * "currentPassword": "OldP@ss123",
937 * "newPassword": "NewS3cur3P@ss!"
939 * "https://api.everydaytech.au/users/me/password"
940 * @apiExample {json} Success Response:
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' });
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 });
961 console.error('me/password error:', err);
962 res.status(500).json({ error: 'Password change failed' });
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.
975 * - User forgot password and can't access email
976 * - Security incident requiring password reset
977 * - New user account setup
978 * - Compromised account recovery
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:
996 * -H "Authorization: Bearer eyJhbGc..." \
997 * "https://api.everydaytech.au/users/42/reset-password"
998 * @apiExample {json} Success Response:
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' });
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' });
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);
1017 `INSERT INTO password_reset_tokens (token, user_id, expires_at)
1018 VALUES ($1, $2, NOW() + INTERVAL '${ttlMinutes} minutes')`,
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');
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>`
1032 await pool.query('UPDATE users SET password_reset_required=true WHERE user_id=$1', [id]);
1034 res.json({ ok: true });
1036 console.error('admin reset-password error:', err);
1037 res.status(500).json({ error: 'Failed to initiate reset' });
1042 * @api {post} /users/mfa/setup Setup MFA/2FA
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.
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
1059 * - Algorithm: SHA-1
1061 * - Period: 30 seconds
1062 * - Compatible with Google Authenticator, Authy, Microsoft Authenticator, etc.
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:
1074 * -H "Authorization: Bearer eyJhbGc..." \
1075 * "https://api.everydaytech.au/users/mfa/setup"
1076 * @apiExample {json} Success Response:
1079 * "base32": "JBSWY3DPEHPK3PXP",
1080 * "otpauth_url": "otpauth://totp/IBG%20MSP%20Platform%20(john@example.com)?secret=JBSWY3DPEHPK3PXP&issuer=IBG%20MSP%20Platform"
1083 * @see {@link module:routes/users.enableMFA} for completing MFA setup
1084 * @see {@link module:routes/users.login} for MFA-enabled login
1086router.post('/mfa/setup', authenticateToken, async (req, res) => {
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 });
1095 console.error('mfa/setup error:', err);
1096 res.status(500).json({ error: 'MFA setup failed' });
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.
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
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:
1127 * -H "Authorization: Bearer eyJhbGc..." \
1128 * -H "Content-Type: application/json" \
1130 * "base32": "JBSWY3DPEHPK3PXP",
1133 * "https://api.everydaytech.au/users/mfa/enable"
1134 * @apiExample {json} Success Response:
1139 * @apiExample {json} Error Response (Invalid Code):
1140 * HTTP/1.1 400 Bad Request
1142 * "error": "Invalid code"
1145 * @see {@link module:routes/users.setupMFA} for getting secret
1146 * @see {@link module:routes/users.disableMFA} for disabling MFA
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' });
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 });
1158 console.error('mfa/enable error:', err);
1159 res.status(500).json({ error: 'Failed to enable MFA' });
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.
1171 * - User must provide valid TOTP code to disable MFA
1172 * - Prevents unauthorized MFA removal
1173 * - Code verified with ±1 time window
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:
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:
1198 * @apiExample {json} Error Response (Not Enabled):
1199 * HTTP/1.1 400 Bad Request
1201 * "error": "MFA is not enabled"
1204 * @see {@link module:routes/users.enableMFA} for enabling MFA
1206router.post('/mfa/disable', authenticateToken, async (req, res) => {
1207 const { code } = req.body;
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 });
1218 console.error('mfa/disable error:', err);
1219 res.status(500).json({ error: 'Failed to disable MFA' });
1223module.exports = router;