mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
initial
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: users.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Admin-level user management — full CRUD on any user.
|
||||
* Admins can: list all users, view any user, update acc_type,
|
||||
* activate/deactivate, hard-delete, manage groups.
|
||||
*
|
||||
* Endpoints (require authenticate → requireAdmin()):
|
||||
* GET /api/admin/users → all users (paginated)
|
||||
* GET /api/admin/users/:id → any user
|
||||
* PUT /api/admin/users/:id → update acc_type, is_active, personal_info
|
||||
* DELETE /api/admin/users/:id → hard delete
|
||||
* POST /api/admin/users/:id/groups/:gid → add user to group
|
||||
* DELETE /api/admin/users/:id/groups/:gid → remove user from group
|
||||
* GET /api/admin/groups → list groups
|
||||
* POST /api/admin/groups → create group
|
||||
* DELETE /api/admin/groups/:gid → delete group
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op, Sequelize } = require('sequelize')
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate, auditInclude } = require('../../utils/paginate.util');
|
||||
const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
|
||||
|
||||
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes');
|
||||
const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas } = require('../../models/users/user_groups.attributes');
|
||||
|
||||
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at'];
|
||||
|
||||
exports.getUsers = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(mdl_Users, req, {
|
||||
excludeAttributes: usersExclude,
|
||||
jsonbSchemas: usersSchemas,
|
||||
jsonbColumn: 'personal_info',
|
||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||
});
|
||||
|
||||
return R.success(res, 'Users retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET ALL USERS]', err);
|
||||
return R.error(res, 'Could not retrieve users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET single user ───────────────────────────────────────────────────────────
|
||||
exports.getUser = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.params.id, {
|
||||
attributes: { exclude: EXCLUDED },
|
||||
include: [{
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups', // ← matches the association alias
|
||||
through: { attributes: [] }
|
||||
}],
|
||||
});
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
return R.success(res, 'User retrieved.', user);
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][GET USER]", err);
|
||||
return R.error(res, 'Could not retrieve user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT update any user ───────────────────────────────────────────────────────
|
||||
exports.updateUser = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.params.id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
|
||||
const allowed = ['acc_type', 'is_active', 'personal_info'];
|
||||
const updates = {};
|
||||
allowed.forEach((k) => { if (req.body[k] !== undefined) updates[k] = req.body[k]; });
|
||||
|
||||
if (Number(req.params.id) === req.user.user_id && updates.acc_type !== undefined)
|
||||
return R.error(res, 'Admins cannot change their own role.', 400);
|
||||
|
||||
// Enrich personal_info before saving
|
||||
if (updates.personal_info) {
|
||||
updates.personal_info = enrichPersonalInfo(updates.personal_info);
|
||||
}
|
||||
|
||||
updates.updatedBy = req.user.user_id; // ← who updated
|
||||
|
||||
await user.update(updates);
|
||||
const updated = await mdl_Users.findByPk(req.params.id, { attributes: { exclude: EXCLUDED } });
|
||||
return R.success(res, 'User updated.', updated);
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][UPDATE USER]", err);
|
||||
return R.error(res, 'Could not update user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DEACTIVATE user (soft delete) ────────────────────────────────────────────
|
||||
exports.deactivateUser = async (req, res) => {
|
||||
try {
|
||||
if (Number(req.params.id) === req.user.user_id)
|
||||
return R.error(res, 'You cannot deactivate your own account.', 400);
|
||||
|
||||
const user = await mdl_Users.findByPk(req.params.id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (!user.is_active && user.deletedAt)
|
||||
return R.error(res, 'User is already deactivated.', 400);
|
||||
|
||||
await user.update({
|
||||
is_active: false,
|
||||
deletedBy: req.user.user_id, // ← who deactivated
|
||||
});
|
||||
|
||||
await user.destroy();
|
||||
|
||||
await mdl_UserSessions.update(
|
||||
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
|
||||
{ where: { user_id: req.params.id } }
|
||||
);
|
||||
|
||||
return R.success(res, 'User deactivated successfully.');
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][DEACTIVATE USER]", err);
|
||||
return R.error(res, 'Could not deactivate user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE user ──────────────────────────────────────────────────────────────
|
||||
exports.restoreUser = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id },
|
||||
paranoid: false,
|
||||
});
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (!user.deletedAt) return R.error(res, 'User is not deactivated.', 400);
|
||||
|
||||
await user.restore();
|
||||
await user.update({
|
||||
is_active: true,
|
||||
updatedBy: req.user.user_id, // ← who restored
|
||||
});
|
||||
|
||||
return R.success(res, 'User restored successfully.');
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][RESTORE USER]", err);
|
||||
return R.error(res, 'Could not restore user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getGroups = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(mdl_UserGroups, req, {
|
||||
excludeAttributes: groupExclude,
|
||||
jsonbSchemas: groupSchemas,
|
||||
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
||||
findOptions: {
|
||||
attributes: {
|
||||
include: [
|
||||
[
|
||||
Sequelize.literal(`(
|
||||
SELECT CAST(COUNT(*) AS INTEGER)
|
||||
FROM "user_group_members"
|
||||
WHERE "user_group_members"."group_id" = "UserGroup"."group_id"
|
||||
)`),
|
||||
'memberCount',
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Groups retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET GROUPS]', err);
|
||||
return R.error(res, 'Could not retrieve groups.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
|
||||
const members = await paginate(mdl_Users, req, {
|
||||
excludeAttributes: usersExclude,
|
||||
jsonbSchemas: usersSchemas,
|
||||
jsonbColumn: 'personal_info',
|
||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||
findOptions: {
|
||||
include: [
|
||||
{
|
||||
model: mdl_UserGroupMembers,
|
||||
where: { group_id: req.params.gid },
|
||||
attributes: [],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Group retrieved.', { group, members });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET GROUP]', err);
|
||||
return R.error(res, 'Could not retrieve group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.createGroup = async (req, res) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
if (!name) return R.error(res, 'Group name is required.', 400);
|
||||
|
||||
const group = await mdl_UserGroups.create({
|
||||
name,
|
||||
description,
|
||||
createdBy: req.user.user_id,
|
||||
});
|
||||
|
||||
return R.success(res, 'Group created.', group, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CREATE GROUP]', err);
|
||||
return R.error(res, 'Could not create group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateGroup = async (req, res) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
|
||||
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
|
||||
if (name !== undefined) group.name = name;
|
||||
if (description !== undefined) group.description = description;
|
||||
group.updatedBy = req.user.user_id;
|
||||
await group.save();
|
||||
|
||||
return R.success(res, 'Group updated.', group);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPDATE GROUP]', err);
|
||||
return R.error(res, 'Could not update group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.deactivateGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
if (!group.is_active) return R.error(res, 'Group is already deactivated.', 400);
|
||||
|
||||
await group.update({
|
||||
is_active: false,
|
||||
updatedBy: req.user.user_id,
|
||||
deletedBy: req.user.user_id,
|
||||
});
|
||||
await group.destroy();
|
||||
|
||||
return R.success(res, 'Group deactivated.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DEACTIVATE GROUP]', err);
|
||||
return R.error(res, 'Could not deactivate group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.restoreGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findOne({
|
||||
where: { group_id: req.params.gid },
|
||||
paranoid: false, // ← needed to find soft-deleted rows
|
||||
});
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
if (group.is_active) return R.error(res, 'Group is already active.', 400);
|
||||
|
||||
await group.restore();
|
||||
await group.update({
|
||||
is_active: true,
|
||||
updatedBy: req.user.user_id,
|
||||
deletedBy: null,
|
||||
});
|
||||
|
||||
return R.success(res, 'Group restored.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][RESTORE GROUP]', err);
|
||||
return R.error(res, 'Could not restore group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Group membership ──────────────────────────────────────────────────────────
|
||||
// Get List of Members in a Group, to be added
|
||||
exports.getUsersNotInGroup = async (req, res) => {
|
||||
try {
|
||||
const { gid: group_id } = req.params;
|
||||
|
||||
const members = await mdl_UserGroupMembers.findAll({
|
||||
where: { group_id },
|
||||
attributes: ['user_id'],
|
||||
});
|
||||
|
||||
const memberIds = members.map(m => m.user_id);
|
||||
|
||||
const users = await mdl_Users.findAll({
|
||||
where: {
|
||||
user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] },
|
||||
},
|
||||
attributes: [
|
||||
'user_id',
|
||||
[Sequelize.literal(`("User"."personal_info"->'name'->>'full_name')`), 'full_name'],
|
||||
],
|
||||
});
|
||||
|
||||
return R.success(res, 'Users fetched.', users);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USERS NOT IN GROUP]', err);
|
||||
return R.error(res, 'Could not fetch users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Get List of Members in a Group, to be removed
|
||||
exports.getUsersInGroup = async (req, res) => {
|
||||
try {
|
||||
const { gid: group_id } = req.params;
|
||||
|
||||
const group = await mdl_UserGroups.findByPk(group_id, {
|
||||
include: [{
|
||||
model: mdl_Users,
|
||||
as: 'members',
|
||||
through: { attributes: [] },
|
||||
attributes: [
|
||||
'user_id',
|
||||
[Sequelize.literal(`("members"."personal_info"->'name'->>'full_name')`), 'full_name'],
|
||||
],
|
||||
}],
|
||||
});
|
||||
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
|
||||
return R.success(res, 'Group members fetched.', group.members);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USERS IN GROUP]', err);
|
||||
return R.error(res, 'Could not fetch group members.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.addUserToGroup = async (req, res) => {
|
||||
try {
|
||||
const { gid: group_id } = req.params;
|
||||
const { user_ids } = req.body;
|
||||
|
||||
if (!Array.isArray(user_ids) || user_ids.length === 0)
|
||||
return R.error(res, 'No users provided.', 400);
|
||||
|
||||
const existingUsers = await mdl_Users.findAll({
|
||||
where: { user_id: user_ids },
|
||||
attributes: ['user_id'],
|
||||
});
|
||||
|
||||
const existingUserIds = existingUsers.map(u => u.user_id);
|
||||
const notFound = user_ids.filter(id => !existingUserIds.includes(id));
|
||||
|
||||
if (notFound.length > 0)
|
||||
return R.error(res, `Users not found: ${notFound.join(', ')}`, 404);
|
||||
|
||||
// Restore soft-deleted rows
|
||||
await mdl_UserGroupMembers.restore({
|
||||
where: { user_id: user_ids, group_id },
|
||||
});
|
||||
|
||||
await mdl_UserGroupMembers.update(
|
||||
{ deletedBy: null, updatedBy: req.user.user_id },
|
||||
{ where: { user_id: user_ids, group_id }, paranoid: false }
|
||||
);
|
||||
|
||||
// Insert any that didn't exist at all
|
||||
await mdl_UserGroupMembers.bulkCreate(
|
||||
user_ids.map(user_id => ({ user_id, group_id, createdBy: req.user.user_id })),
|
||||
{ ignoreDuplicates: true }
|
||||
);
|
||||
|
||||
return R.success(res, 'Users added to group.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][ADD USERS TO GROUP]', err);
|
||||
return R.error(res, 'Could not add users to group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.removeUserFromGroup = async (req, res) => {
|
||||
try {
|
||||
const { gid: group_id } = req.params;
|
||||
const { user_ids } = req.body;
|
||||
|
||||
if (!Array.isArray(user_ids) || user_ids.length === 0)
|
||||
return R.error(res, 'No users provided.', 400);
|
||||
|
||||
const existingMembers = await mdl_UserGroupMembers.findAll({
|
||||
where: { user_id: user_ids, group_id },
|
||||
attributes: ['user_id'],
|
||||
});
|
||||
|
||||
const existingMemberIds = existingMembers.map(m => m.user_id);
|
||||
const notFound = user_ids.filter(id => !existingMemberIds.includes(id));
|
||||
|
||||
if (notFound.length > 0)
|
||||
return R.error(res, `Memberships not found for users: ${notFound.join(', ')}`, 404);
|
||||
|
||||
await mdl_UserGroupMembers.update(
|
||||
{ deletedBy: req.user.user_id },
|
||||
{ where: { user_id: user_ids, group_id } }
|
||||
);
|
||||
await mdl_UserGroupMembers.destroy({
|
||||
where: { user_id: user_ids, group_id },
|
||||
});
|
||||
|
||||
return R.success(res, 'Users removed from group.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][REMOVE USERS FROM GROUP]', err);
|
||||
return R.error(res, 'Could not remove users from group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Admin sessions management ─────────────────────────────────────────────────
|
||||
exports.getUserSessions = async (req, res) => {
|
||||
try {
|
||||
const sessions = await mdl_UserSessions.findAll({
|
||||
where: { user_id: req.params.id },
|
||||
attributes: { exclude: ['refresh_token_hash'] },
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
return R.success(res, 'Sessions retrieved.', sessions);
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve sessions.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.terminateSession = async (req, res) => {
|
||||
try {
|
||||
const session = await mdl_UserSessions.findByPk(req.params.sid);
|
||||
if (!session) return R.error(res, 'Session not found.', 404);
|
||||
await session.update({ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } });
|
||||
return R.success(res, 'Session terminated.');
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not terminate session.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,318 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: auth.controller.js
|
||||
* Type of Program: Controller
|
||||
* Description: Handles all authentication flows:
|
||||
* 1. System Registration → OTP email → OTP Verify → Auto-Login
|
||||
* 2. System Login (verified users)
|
||||
* 3. Google OAuth callback
|
||||
* 4. Token Refresh
|
||||
* 5. Logout (invalidates session)
|
||||
* 6. OTP Resend
|
||||
* 7. Change Password
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* Mount via routes/auth.routes.js
|
||||
* POST /api/auth/register
|
||||
* POST /api/auth/verify-otp
|
||||
* POST /api/auth/resend-otp
|
||||
* POST /api/auth/login
|
||||
* POST /api/auth/refresh
|
||||
* POST /api/auth/logout
|
||||
* GET /api/auth/google
|
||||
* GET /api/auth/google/callback
|
||||
***********************************************************************************************************************************************************************/
|
||||
const bcrypt = require('bcryptjs');
|
||||
const sequelize = require('../config/db.config')
|
||||
const mdl_Users = require('../models/users/users.mdl');
|
||||
const mdl_UserSessions = require('../models/users/user_sessions.mdl');
|
||||
const { generateTokens, verifyRefreshToken, hashToken } = require('../utils/token.util');
|
||||
const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util');
|
||||
const sendEmail = require('../services/email.service');
|
||||
const R = require('../utils/response.util');
|
||||
|
||||
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'createdAt', 'updatedAt', 'deletedAt', 'createdBy', 'updatedBy', 'deletedBy'];
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
const buildLoginInfo = (req) => ({
|
||||
date: new Date().toISOString(),
|
||||
ip_address: req.ip,
|
||||
device_info: req.headers['user-agent'] || 'unknown',
|
||||
});
|
||||
|
||||
const safeUser = (user, extraExclude = []) => {
|
||||
const u = user.toJSON ? user.toJSON() : { ...user };
|
||||
|
||||
[...EXCLUDED, ...extraExclude].forEach((key) => delete u[key]);
|
||||
|
||||
return u;
|
||||
};
|
||||
|
||||
// ─── Register ──────────────────────────────────────────────────────────────────
|
||||
exports.register = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const existing = await mdl_Users.findOne({ where: { email } });
|
||||
if (existing) return R.error(res, 'Email is already registered.', 409);
|
||||
|
||||
const hashed = await bcrypt.hash(password, 12);
|
||||
const otp = generateOTP();
|
||||
|
||||
const user = await mdl_Users.create({
|
||||
email,
|
||||
password: hashed,
|
||||
otp_code: otp,
|
||||
otp_expires_at: getOTPExpiry(),
|
||||
is_active: true,
|
||||
is_verified: false,
|
||||
reg_type: 'system',
|
||||
acc_type: 'user',
|
||||
createdBy: null,
|
||||
}, { transaction });
|
||||
|
||||
await sendEmail({ to: email, type: "OTP", data: { otp } });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return R.success(res, 'Registration successful. Please check your email for the OTP.', {
|
||||
email: user.email,
|
||||
}, 201);
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
console.error('[AUTH] register error:', err);
|
||||
return R.error(res, 'Registration failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Verify OTP ────────────────────────────────────────────────────────────────
|
||||
exports.verifyOTP = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const { email, otp } = req.body;
|
||||
|
||||
const user = await mdl_Users.findOne({ where: { email } });
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (user.is_verified) return R.error(res, 'Account already verified.', 400);
|
||||
|
||||
if (user.otp_code !== otp) return R.error(res, 'Invalid OTP.', 400);
|
||||
if (isOTPExpired(user.otp_expires_at)) return R.error(res, 'OTP has expired. Please request a new one.', 400);
|
||||
|
||||
await user.update({ is_verified: true, otp_code: null, otp_expires_at: null }, { transaction });
|
||||
|
||||
// Auto-login after verification
|
||||
const { accessToken, refreshToken } = generateTokens(user);
|
||||
const session = await mdl_UserSessions.create({
|
||||
user_id: user.user_id,
|
||||
login_info: buildLoginInfo(req),
|
||||
refresh_token_hash: hashToken(refreshToken),
|
||||
is_active: true,
|
||||
}, { transaction });
|
||||
|
||||
await sendEmail({ to: email, type: "WELCOME", data: { name: email } });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true, // ← JS cannot read this
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
|
||||
return R.success(res, 'Email verified successfully. You are now logged in.', {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
session_id: session.session_id,
|
||||
user: safeUser(user),
|
||||
});
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
console.error('[AUTH] verifyOTP error:', err);
|
||||
return R.error(res, 'OTP verification failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Resend OTP ────────────────────────────────────────────────────────────────
|
||||
exports.resendOTP = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const { email } = req.body;
|
||||
const user = await mdl_Users.findOne({ where: { email } });
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (user.is_verified) return R.error(res, 'Account is already verified.', 400);
|
||||
|
||||
const otp = generateOTP();
|
||||
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() }, { transaction });
|
||||
|
||||
await sendEmail({ to: email, type: "OTP", data: { otp } });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return R.success(res, 'A new OTP has been sent to your email.');
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
console.error('[AUTH] resendOTP error:', err);
|
||||
return R.error(res, 'Could not resend OTP.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── System Login ──────────────────────────────────────────────────────────────
|
||||
exports.login = async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const user = await mdl_Users.findOne({ where: { email } });
|
||||
if (!user) return R.error(res, 'Invalid credentials.', 401);
|
||||
if (user.reg_type === 'google') return R.error(res, 'Please log in with Google.', 400);
|
||||
if (!user.is_active) return R.error(res, 'Account is deactivated.', 403);
|
||||
if (!user.is_verified) return R.error(res, 'Please verify your email first.', 403);
|
||||
|
||||
const match = await bcrypt.compare(password, user.password);
|
||||
if (!match) return R.error(res, 'Invalid credentials.', 401);
|
||||
|
||||
const { accessToken, refreshToken } = generateTokens(user);
|
||||
const session = await mdl_UserSessions.create({
|
||||
user_id: user.user_id,
|
||||
login_info: buildLoginInfo(req),
|
||||
refresh_token_hash: hashToken(refreshToken),
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true, // ← JS cannot read this
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
|
||||
return R.success(res, 'Login successful.', {
|
||||
accessToken,
|
||||
session_id: session.session_id,
|
||||
user: safeUser(user),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[AUTH] login error:', err);
|
||||
return R.error(res, 'Login failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Google OAuth Callback ─────────────────────────────────────────────────────
|
||||
exports.googleCallback = async (req, res) => {
|
||||
try {
|
||||
const user = req.user; // set by passport
|
||||
const { accessToken, refreshToken } = generateTokens(user);
|
||||
|
||||
await mdl_UserSessions.create({
|
||||
user_id: user.user_id,
|
||||
login_info: buildLoginInfo(req),
|
||||
refresh_token_hash: hashToken(refreshToken),
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
// In a real SPA: redirect with tokens in query or set httpOnly cookie
|
||||
return R.success(res, 'Google login successful.', {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: safeUser(user),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[AUTH] googleCallback error:', err);
|
||||
return R.error(res, 'Google authentication failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Refresh Token ─────────────────────────────────────────────────────────────
|
||||
exports.refreshToken = async (req, res) => {
|
||||
try {
|
||||
const refreshToken = req.cookies.refreshToken;
|
||||
if (!refreshToken) return R.error(res, 'Refresh token is required.', 400);
|
||||
|
||||
console.log('[REFRESH] called, token tail:', refreshToken?.slice(-10))
|
||||
console.log('[REFRESH] hash:', hashToken(refreshToken))
|
||||
|
||||
const decoded = verifyRefreshToken(refreshToken);
|
||||
const tokenHash = hashToken(refreshToken);
|
||||
|
||||
console.log({ user_id: decoded.user_id, refresh_token_hash: tokenHash, is_active: true })
|
||||
const session = await mdl_UserSessions.findOne({
|
||||
where: { user_id: decoded.user_id, refresh_token_hash: tokenHash, is_active: true },
|
||||
});
|
||||
if (!session) return R.error(res, 'Session is invalid or expired. Please log in again.', 401);
|
||||
|
||||
const user = await mdl_Users.findByPk(decoded.user_id);
|
||||
if (!user || !user.is_active) return R.error(res, 'User not found or deactivated.', 401);
|
||||
|
||||
const tokens = generateTokens(user);
|
||||
|
||||
// Rotate refresh token
|
||||
await session.update({ refresh_token_hash: hashToken(tokens.refreshToken) });
|
||||
|
||||
console.log('[REFRESH] old token tail:', refreshToken?.slice(-10))
|
||||
console.log('[REFRESH] new token tail:', tokens.refreshToken?.slice(-10))
|
||||
console.log('[REFRESH] are they different:', refreshToken !== tokens.refreshToken)
|
||||
|
||||
res.cookie('refreshToken', tokens.refreshToken, {
|
||||
httpOnly: true, // ← JS cannot read this
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
|
||||
return R.success(res, 'Token refreshed.', { ...tokens, user: safeUser(user) });
|
||||
} catch (err) {
|
||||
return R.error(res, 'Invalid or expired refresh token.', 401);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Logout ────────────────────────────────────────────────────────────────────
|
||||
exports.logout = async (req, res) => {
|
||||
try {
|
||||
const { session_id } = req.body;
|
||||
if (session_id) {
|
||||
await mdl_UserSessions.update(
|
||||
{ is_active: false, logout_info: buildLoginInfo(req) },
|
||||
{ where: { session_id, user_id: req.user.user_id } }
|
||||
);
|
||||
}
|
||||
|
||||
res.clearCookie('refreshToken')
|
||||
res.clearCookie('_csrf')
|
||||
|
||||
return R.success(res, 'Logged out successfully.');
|
||||
} catch (err) {
|
||||
console.error('[AUTH] logout error:', err);
|
||||
return R.error(res, 'Logout failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Change Password ───────────────────────────────────────────────────────────
|
||||
exports.changePassword = async (req, res) => {
|
||||
try {
|
||||
const { current_password, new_password } = req.body;
|
||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||
|
||||
if (user.reg_type === 'google')
|
||||
return R.error(res, 'Google accounts cannot change passwords here.', 400);
|
||||
|
||||
const match = await bcrypt.compare(current_password, user.password);
|
||||
if (!match) return R.error(res, 'Current password is incorrect.', 400);
|
||||
|
||||
const hashed = await bcrypt.hash(new_password, 12);
|
||||
await user.update({ password: hashed });
|
||||
|
||||
// Invalidate all sessions to force re-login
|
||||
await mdl_UserSessions.update({ is_active: false }, { where: { user_id: user.user_id } });
|
||||
|
||||
return R.success(res, 'Password changed. All sessions have been invalidated. Please log in again.');
|
||||
} catch (err) {
|
||||
console.error('[AUTH] changePassword error:', err);
|
||||
return R.error(res, 'Password change failed.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: profile.controller.js (client)
|
||||
* Type of Program: Controller
|
||||
* Description: Self-service profile management for CLIENT users.
|
||||
* All routes require: authenticate → requireClient()
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/client/profile → view own profile
|
||||
* PUT /api/client/profile → update personal_info
|
||||
* GET /api/client/sessions → view own active sessions
|
||||
* DELETE /api/client/sessions/:id → revoke a specific session
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
|
||||
// ─── GET own profile ───────────────────────────────────────────────────────────
|
||||
exports.getProfile = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.user.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
return R.success(res, 'Profile retrieved.', user);
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve profile.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT update own profile ────────────────────────────────────────────────────
|
||||
exports.updateProfile = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||
const { personal_info } = req.body;
|
||||
|
||||
// Deep-merge personal_info so partial updates don't wipe existing data
|
||||
const merged = {
|
||||
...(user.personal_info || {}),
|
||||
...(personal_info || {}),
|
||||
name: {
|
||||
...((user.personal_info?.name) || {}),
|
||||
...((personal_info?.name) || {}),
|
||||
},
|
||||
};
|
||||
|
||||
await user.update({ personal_info: merged });
|
||||
|
||||
const updated = await mdl_Users.findByPk(req.user.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
|
||||
return R.success(res, 'Profile updated.', updated);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT] updateProfile error:', err);
|
||||
return R.error(res, 'Profile update failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET own sessions ──────────────────────────────────────────────────────────
|
||||
exports.getSessions = async (req, res) => {
|
||||
try {
|
||||
const sessions = await mdl_UserSessions.findAll({
|
||||
where: { user_id: req.user.user_id, is_active: true },
|
||||
order: [['createdAt', 'DESC']],
|
||||
attributes: { exclude: ['refresh_token_hash'] },
|
||||
});
|
||||
return R.success(res, 'Sessions retrieved.', sessions);
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve sessions.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DELETE revoke a session ───────────────────────────────────────────────────
|
||||
exports.revokeSession = async (req, res) => {
|
||||
try {
|
||||
const session = await mdl_UserSessions.findOne({
|
||||
where: { session_id: req.params.id, user_id: req.user.user_id },
|
||||
});
|
||||
if (!session) return R.error(res, 'Session not found.', 404);
|
||||
|
||||
await session.update({
|
||||
is_active: false,
|
||||
logout_info: { date: new Date().toISOString(), ip_address: req.ip },
|
||||
});
|
||||
|
||||
return R.success(res, 'Session revoked.');
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not revoke session.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: users.controller.js (staff)
|
||||
* Type of Program: Controller
|
||||
* Description: Staff-level user management.
|
||||
* Staff can VIEW any client/staff user and perform limited actions.
|
||||
* Staff CANNOT modify admin accounts or assign/revoke admin roles.
|
||||
*
|
||||
* Endpoints (require authenticate → requireStaff()):
|
||||
* GET /api/staff/users → paginated user list (non-admins)
|
||||
* GET /api/staff/users/:id → view any non-admin user
|
||||
* PUT /api/staff/users/:id/status → activate / deactivate user
|
||||
* GET /api/staff/users/:id/sessions → view user sessions
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
|
||||
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at'];
|
||||
|
||||
// ─── GET paginated list (no admins) ────────────────────────────────────────────
|
||||
exports.getUsers = async (req, res) => {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||
const limit = Math.min(100, parseInt(req.query.limit) || 20);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { count, rows } = await mdl_Users.findAndCountAll({
|
||||
where: { acc_type: { [Op.ne]: 'admin' } },
|
||||
attributes: { exclude: EXCLUDED },
|
||||
limit,
|
||||
offset,
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
return R.success(res, 'Users retrieved.', {
|
||||
total: count,
|
||||
page,
|
||||
totalPages: Math.ceil(count / limit),
|
||||
users: rows,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[STAFF] getUsers error:', err);
|
||||
return R.error(res, 'Could not retrieve users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET single non-admin user ─────────────────────────────────────────────────
|
||||
exports.getUser = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id, acc_type: { [Op.ne]: 'admin' } },
|
||||
attributes: { exclude: EXCLUDED },
|
||||
});
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
return R.success(res, 'User retrieved.', user);
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT activate / deactivate ─────────────────────────────────────────────────
|
||||
exports.setUserStatus = async (req, res) => {
|
||||
try {
|
||||
const { is_active } = req.body;
|
||||
if (typeof is_active !== 'boolean')
|
||||
return R.error(res, 'is_active must be a boolean.', 400);
|
||||
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id, acc_type: { [Op.ne]: 'admin' } },
|
||||
});
|
||||
if (!user) return R.error(res, 'User not found or operation not permitted.', 404);
|
||||
|
||||
await user.update({ is_active });
|
||||
return R.success(res, `User ${is_active ? 'activated' : 'deactivated'}.`);
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not update user status.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET user sessions ─────────────────────────────────────────────────────────
|
||||
exports.getUserSessions = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id, acc_type: { [Op.ne]: 'admin' } },
|
||||
});
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
|
||||
const sessions = await mdl_UserSessions.findAll({
|
||||
where: { user_id: req.params.id },
|
||||
attributes: { exclude: ['refresh_token_hash'] },
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
return R.success(res, 'Sessions retrieved.', sessions);
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve sessions.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Staff profile (re-use client controller) ──────────────────────────────────
|
||||
// Staff also manage their own profile through the same client endpoints.
|
||||
// No additional staff-specific profile endpoints needed.
|
||||
Reference in New Issue
Block a user