mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
testing 127.0.0.1 issue
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
+126
-35
@@ -71,6 +71,24 @@ const setRefreshCookie = (res, refreshToken) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The Google OAuth callback can't carry its outcome (otpRequired, ban details,
|
||||||
|
// errors) as a query string on the redirect without flashing it in the address
|
||||||
|
// bar — the browser lands on that literal URL before any frontend JS runs, so
|
||||||
|
// client-side scrubbing is always at least a frame too late. Instead, the
|
||||||
|
// outcome is stashed in a short-lived signed cookie and handed to the frontend
|
||||||
|
// only when it explicitly asks for it via GET /auth/google/result.
|
||||||
|
const GOOGLE_RESULT_COOKIE = '_googleAuthResult';
|
||||||
|
|
||||||
|
const setGoogleResultCookie = (res, payload) => {
|
||||||
|
res.cookie(GOOGLE_RESULT_COOKIE, JSON.stringify(payload), {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 2 * 60 * 1000, // 2 minutes — just long enough for the callback redirect to land
|
||||||
|
signed: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Mint Session ──────────────────────────────────────────────────────────────
|
// ─── Mint Session ──────────────────────────────────────────────────────────────
|
||||||
// Shared by verifyOTP and the trusted-device fast path in login/googleCallback —
|
// Shared by verifyOTP and the trusted-device fast path in login/googleCallback —
|
||||||
// the only two places tokens/sessions get minted.
|
// the only two places tokens/sessions get minted.
|
||||||
@@ -390,19 +408,24 @@ exports.googleCallback = async (req, res) => {
|
|||||||
const { code, state, error } = req.query;
|
const { code, state, error } = req.query;
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return res.redirect(`${CALLBACK_PAGE}?error=${encodeURIComponent(error)}`);
|
setGoogleResultCookie(res, { error });
|
||||||
|
return res.redirect(CALLBACK_PAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and immediately clear the oauth state cookie.
|
// Read and immediately clear the oauth state cookie.
|
||||||
const rawCookie = req.signedCookies['_oauth'];
|
const rawCookie = req.signedCookies['_oauth'];
|
||||||
res.clearCookie('_oauth');
|
res.clearCookie('_oauth');
|
||||||
|
|
||||||
if (!rawCookie) return res.redirect(`${CALLBACK_PAGE}?error=session_expired`);
|
if (!rawCookie) {
|
||||||
|
setGoogleResultCookie(res, { error: 'session_expired' });
|
||||||
|
return res.redirect(CALLBACK_PAGE);
|
||||||
|
}
|
||||||
|
|
||||||
const { state: expectedState, nonce, codeVerifier } = JSON.parse(rawCookie);
|
const { state: expectedState, nonce, codeVerifier } = JSON.parse(rawCookie);
|
||||||
|
|
||||||
if (!state || state !== expectedState) {
|
if (!state || state !== expectedState) {
|
||||||
return res.redirect(`${CALLBACK_PAGE}?error=state_mismatch`);
|
setGoogleResultCookie(res, { error: 'state_mismatch' });
|
||||||
|
return res.redirect(CALLBACK_PAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange authorization code → { id_token, access_token, ... }
|
// Exchange authorization code → { id_token, access_token, ... }
|
||||||
@@ -467,13 +490,16 @@ exports.googleCallback = async (req, res) => {
|
|||||||
const status = await checkAccountStatus(user);
|
const status = await checkAccountStatus(user);
|
||||||
if (!status.ok) {
|
if (!status.ok) {
|
||||||
if (status.code === 'deactivated') {
|
if (status.code === 'deactivated') {
|
||||||
return res.redirect(`${CALLBACK_PAGE}?error=account_deactivated`);
|
setGoogleResultCookie(res, { error: 'account_deactivated' });
|
||||||
|
return res.redirect(CALLBACK_PAGE);
|
||||||
}
|
}
|
||||||
const params = new URLSearchParams({ error: 'account_banned' });
|
setGoogleResultCookie(res, {
|
||||||
if (status.reason) params.set('reason', status.reason);
|
error: 'account_banned',
|
||||||
if (status.ban_type) params.set('ban_type', status.ban_type);
|
reason: status.reason ?? null,
|
||||||
if (status.ban_expires_at) params.set('expires_at', new Date(status.ban_expires_at).toISOString());
|
ban_type: status.ban_type ?? null,
|
||||||
return res.redirect(`${CALLBACK_PAGE}?${params.toString()}`);
|
expires_at: status.ban_expires_at ? new Date(status.ban_expires_at).toISOString() : null,
|
||||||
|
});
|
||||||
|
return res.redirect(CALLBACK_PAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every Google sign-in (new or returning account) still has to clear the
|
// Every Google sign-in (new or returning account) still has to clear the
|
||||||
@@ -492,7 +518,9 @@ exports.googleCallback = async (req, res) => {
|
|||||||
|
|
||||||
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
|
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
|
||||||
|
|
||||||
return res.redirect(`${CALLBACK_PAGE}?otpRequired=false`);
|
// No cookie needed here — the refresh cookie set above is itself the
|
||||||
|
// signal. The frontend just calls restoreSession() and it succeeds.
|
||||||
|
return res.redirect(CALLBACK_PAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
const otp = generateOTP();
|
const otp = generateOTP();
|
||||||
@@ -501,10 +529,29 @@ exports.googleCallback = async (req, res) => {
|
|||||||
sendEmail({ to: user.email, type: 'LOGIN_OTP', data: { otp } })
|
sendEmail({ to: user.email, type: 'LOGIN_OTP', data: { otp } })
|
||||||
.catch(err => console.error('[AUTH] googleCallback: Failed to send login OTP email:', err));
|
.catch(err => console.error('[AUTH] googleCallback: Failed to send login OTP email:', err));
|
||||||
|
|
||||||
return res.redirect(`${CALLBACK_PAGE}?otpRequired=true&email=${encodeURIComponent(user.email)}`);
|
setGoogleResultCookie(res, { otpRequired: true, email: user.email });
|
||||||
|
return res.redirect(CALLBACK_PAGE);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[AUTH] googleCallback OIDC error:', err);
|
console.error('[AUTH] googleCallback OIDC error:', err);
|
||||||
return res.redirect(`${process.env.FRONTEND_URL}/auth/callback/google?error=auth_failed`);
|
setGoogleResultCookie(res, { error: 'auth_failed' });
|
||||||
|
return res.redirect(`${process.env.FRONTEND_URL}/auth/callback/google`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Google OIDC — Result handoff ─────────────────────────────────────────────
|
||||||
|
// Single-use: reads and immediately clears the cookie stashed by googleCallback.
|
||||||
|
// Returns {} when nothing is pending (trusted-device path — frontend should
|
||||||
|
// just call restoreSession(), since the real refresh cookie was already set).
|
||||||
|
exports.googleResult = (req, res) => {
|
||||||
|
const raw = req.signedCookies[GOOGLE_RESULT_COOKIE];
|
||||||
|
res.clearCookie(GOOGLE_RESULT_COOKIE);
|
||||||
|
|
||||||
|
if (!raw) return R.success(res, 'No pending Google auth result.', {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return R.success(res, 'Pending Google auth result.', JSON.parse(raw));
|
||||||
|
} catch (_) {
|
||||||
|
return R.success(res, 'No pending Google auth result.', {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -563,11 +610,17 @@ exports.logout = async (req, res) => {
|
|||||||
|
|
||||||
logActivity(req.user.user_id, 'logout', { entityType: 'session', entityId: session_id ? Number(session_id) : null });
|
logActivity(req.user.user_id, 'logout', { entityType: 'session', entityId: session_id ? Number(session_id) : null });
|
||||||
|
|
||||||
await trustedDevice.revokeByToken(req.user.user_id, req.cookies[trustedDevice.COOKIE_NAME]);
|
// Ordinary logout intentionally does NOT touch trusted_devices or clear
|
||||||
|
// device_trust: expires_at (rolling 30-day window) is what ends the
|
||||||
|
// OTP-skip, not the act of logging out. Clearing/revoking here would
|
||||||
|
// force OTP on the very next login on the same device, which defeats
|
||||||
|
// the point of trusted_devices. Trust is only force-revoked elsewhere
|
||||||
|
// for actual security events — password change/reset, admin ban/
|
||||||
|
// deactivate/force-logout, or a specific session being terminated
|
||||||
|
// (see trustedDevice.service.js: revokeAllForUser / revokeBySessionId).
|
||||||
|
|
||||||
res.clearCookie('refreshToken')
|
res.clearCookie('refreshToken')
|
||||||
res.clearCookie('_csrf')
|
res.clearCookie('_csrf')
|
||||||
res.clearCookie(trustedDevice.COOKIE_NAME)
|
|
||||||
|
|
||||||
return R.success(res, 'Logged out successfully.');
|
return R.success(res, 'Logged out successfully.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -606,47 +659,85 @@ exports.changePassword = async (req, res) => {
|
|||||||
|
|
||||||
// ─── Forgot Password — Request OTP ─────────────────────────────────────────────
|
// ─── Forgot Password — Request OTP ─────────────────────────────────────────────
|
||||||
// Same procedure for every acc_type (admin/staff/user) — only reg_type matters.
|
// Same procedure for every acc_type (admin/staff/user) — only reg_type matters.
|
||||||
|
// Response is identical whether the email is unknown or deactivated/suspended —
|
||||||
|
// only a real, eligible account actually gets an OTP — EXCEPT for Google-linked
|
||||||
|
// accounts, which get an explicit "use Google sign-in" dialog by design (accepted
|
||||||
|
// tradeoff: this does reveal that a given email is a Google-linked account).
|
||||||
exports.forgotPassword = async (req, res) => {
|
exports.forgotPassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email } = req.body;
|
const { email } = req.body;
|
||||||
const user = await mdl_Users.findOne({ where: { email } });
|
const genericMessage = 'If an account exists for this email, a reset code has been sent.';
|
||||||
if (!user) return R.error(res, 'User not found.', 404);
|
|
||||||
if (user.reg_type === 'google')
|
|
||||||
return R.error(res, 'This account uses Google sign-in. Please log in with Google.', 400);
|
|
||||||
|
|
||||||
const status = await checkAccountStatus(user);
|
const user = await mdl_Users.findOne({ where: { email } });
|
||||||
if (!status.ok) {
|
|
||||||
if (status.code === 'deactivated') return R.error(res, 'Account is deactivated.', 403);
|
if (user && user.reg_type === 'google') {
|
||||||
return R.error(res, 'Your account has been suspended.', 403, { banned: true });
|
return R.error(
|
||||||
|
res,
|
||||||
|
'This account was created using Google. Sign in with Google instead — there’s no password to reset for accounts created this way.',
|
||||||
|
400,
|
||||||
|
{ google: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const otp = generateOTP();
|
if (user) {
|
||||||
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() });
|
const status = await checkAccountStatus(user);
|
||||||
|
if (status.ok) {
|
||||||
|
const otp = generateOTP();
|
||||||
|
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() });
|
||||||
|
|
||||||
sendEmail({ to: email, type: 'RESET_PASSWORD_OTP', data: { otp } })
|
sendEmail({ to: email, type: 'RESET_PASSWORD_OTP', data: { otp } })
|
||||||
.catch(err => console.error('[AUTH] Failed to send reset-password OTP email:', err));
|
.catch(err => console.error('[AUTH] Failed to send reset-password OTP email:', err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return R.success(res, 'An OTP has been sent to your email.', { email: user.email });
|
return R.success(res, genericMessage, { email });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[AUTH] forgotPassword error:', err);
|
console.error('[AUTH] forgotPassword error:', err);
|
||||||
return R.error(res, 'Could not process request.', 500);
|
return R.error(res, 'Could not process request.', 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Forgot Password — Reset with OTP ──────────────────────────────────────────
|
// ─── Forgot Password — Verify OTP only (step 2 of 3) ───────────────────────────
|
||||||
exports.resetPassword = async (req, res) => {
|
// Checks the code without consuming it or touching the password, so the reset
|
||||||
|
// flow can gate the "new password" step behind a verified code. resetPassword
|
||||||
|
// re-checks the same OTP when the password is actually submitted.
|
||||||
|
exports.verifyResetOTP = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email, otp, new_password } = req.body;
|
const { email, otp } = req.body;
|
||||||
|
const genericError = 'Invalid or expired code.';
|
||||||
|
|
||||||
const user = await mdl_Users.findOne({ where: { email } });
|
const user = await mdl_Users.findOne({ where: { email } });
|
||||||
if (!user) return R.error(res, 'User not found.', 404);
|
if (!user || user.reg_type === 'google') return R.error(res, genericError, 400);
|
||||||
if (user.reg_type === 'google')
|
|
||||||
return R.error(res, 'This account uses Google sign-in. Please log in with Google.', 400);
|
|
||||||
|
|
||||||
const storedOTP = Buffer.from(user.otp_code ?? '');
|
const storedOTP = Buffer.from(user.otp_code ?? '');
|
||||||
const givenOTP = Buffer.from(otp ?? '');
|
const givenOTP = Buffer.from(otp ?? '');
|
||||||
if (storedOTP.length !== givenOTP.length || !crypto.timingSafeEqual(storedOTP, givenOTP))
|
if (storedOTP.length !== givenOTP.length || !crypto.timingSafeEqual(storedOTP, givenOTP))
|
||||||
return R.error(res, 'Invalid OTP.', 400);
|
return R.error(res, genericError, 400);
|
||||||
if (isOTPExpired(user.otp_expires_at)) return R.error(res, 'OTP has expired. Please request a new one.', 400);
|
if (isOTPExpired(user.otp_expires_at)) return R.error(res, genericError, 400);
|
||||||
|
|
||||||
|
return R.success(res, 'Code verified.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AUTH] verifyResetOTP error:', err);
|
||||||
|
return R.error(res, 'Could not process request.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Forgot Password — Reset with OTP ──────────────────────────────────────────
|
||||||
|
// Same generic error for unknown email / Google-linked / wrong OTP / expired OTP
|
||||||
|
// so this endpoint can't be used to enumerate accounts either.
|
||||||
|
exports.resetPassword = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, otp, new_password } = req.body;
|
||||||
|
const genericError = 'Invalid or expired code.';
|
||||||
|
|
||||||
|
const user = await mdl_Users.findOne({ where: { email } });
|
||||||
|
if (!user || user.reg_type === 'google') return R.error(res, genericError, 400);
|
||||||
|
|
||||||
|
const storedOTP = Buffer.from(user.otp_code ?? '');
|
||||||
|
const givenOTP = Buffer.from(otp ?? '');
|
||||||
|
if (storedOTP.length !== givenOTP.length || !crypto.timingSafeEqual(storedOTP, givenOTP))
|
||||||
|
return R.error(res, genericError, 400);
|
||||||
|
if (isOTPExpired(user.otp_expires_at)) return R.error(res, genericError, 400);
|
||||||
|
|
||||||
const hashed = await bcrypt.hash(new_password, 12);
|
const hashed = await bcrypt.hash(new_password, 12);
|
||||||
await user.update({ password: hashed, otp_code: null, otp_expires_at: null });
|
await user.update({ password: hashed, otp_code: null, otp_expires_at: null });
|
||||||
|
|||||||
@@ -189,8 +189,7 @@ exports.getMyInProgressCourses = async (req, res) => {
|
|||||||
if (!pending.length) return R.success(res, 'No courses in progress.', []);
|
if (!pending.length) return R.success(res, 'No courses in progress.', []);
|
||||||
|
|
||||||
const result = await Promise.all(pending.map(async (row) => {
|
const result = await Promise.all(pending.map(async (row) => {
|
||||||
const courseId = row.course_id;
|
const courseId = row.course_id;
|
||||||
const readingDone = row.status === 'completed';
|
|
||||||
|
|
||||||
const [lessons_total, lessons_completed] = await Promise.all([
|
const [lessons_total, lessons_completed] = await Promise.all([
|
||||||
Lesson.count({
|
Lesson.count({
|
||||||
@@ -207,8 +206,13 @@ exports.getMyInProgressCourses = async (req, res) => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let pending_quizzes = [];
|
// "Reading done" is derived independently from lesson counts — row.status now also
|
||||||
let pending_assessment = null;
|
// requires the course assessment to be passed, so it can't be used as the reading gate.
|
||||||
|
const readingDone = lessons_total > 0 && lessons_completed === lessons_total;
|
||||||
|
|
||||||
|
let pending_quizzes = [];
|
||||||
|
let pending_assessment = null;
|
||||||
|
let assessment_configured = true;
|
||||||
|
|
||||||
if (readingDone) {
|
if (readingDone) {
|
||||||
const unitQuizzes = await UnitQuiz.findAll({
|
const unitQuizzes = await UnitQuiz.findAll({
|
||||||
@@ -244,6 +248,7 @@ exports.getMyInProgressCourses = async (req, res) => {
|
|||||||
attributes: ['assessment_id', 'title', 'is_required', 'passing_score'],
|
attributes: ['assessment_id', 'title', 'is_required', 'passing_score'],
|
||||||
where: { course_id: courseId },
|
where: { course_id: courseId },
|
||||||
});
|
});
|
||||||
|
assessment_configured = !!assessment;
|
||||||
if (assessment) {
|
if (assessment) {
|
||||||
const [hasPassed, attemptCount] = await Promise.all([
|
const [hasPassed, attemptCount] = await Promise.all([
|
||||||
QuizAttempt.findOne({ where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true } }),
|
QuizAttempt.findOne({ where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true } }),
|
||||||
@@ -262,12 +267,13 @@ exports.getMyInProgressCourses = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
course_id: courseId,
|
course_id: courseId,
|
||||||
title: row.course.title,
|
title: row.course.title,
|
||||||
reading_status: row.status,
|
reading_status: readingDone ? 'completed' : 'in_progress',
|
||||||
|
assessment_configured,
|
||||||
lessons_total,
|
lessons_total,
|
||||||
lessons_completed,
|
lessons_completed,
|
||||||
last_accessed_at: row.last_accessed_at,
|
last_accessed_at: row.last_accessed_at,
|
||||||
pending_quizzes,
|
pending_quizzes,
|
||||||
pending_assessment,
|
pending_assessment,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -299,6 +299,13 @@ exports.getCourse = async (req, res) => {
|
|||||||
required: false,
|
required: false,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
model: mdl_Category,
|
||||||
|
as: "categories",
|
||||||
|
through: { attributes: [] },
|
||||||
|
required: false,
|
||||||
|
attributes: ["id", "name", "slug"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
order: [
|
order: [
|
||||||
[{ model: Unit, as: "units" }, "order_index", "ASC"],
|
[{ model: Unit, as: "units" }, "order_index", "ASC"],
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ const isMember = async (userId, groupId) => {
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// REPLACEMENT: getGroupTaskList in task.controller.js (client)
|
// REPLACEMENT: getGroupTaskList in task.controller.js (client)
|
||||||
//
|
//
|
||||||
// GET /client/groups/:groupId/task-lists/:taskListId?status=ongoing|done|overdue
|
// GET /client/groups/:groupId/task-lists/:taskListId?status=ongoing|completed|overdue
|
||||||
//
|
//
|
||||||
// has_completed is now computed per-task as: ALL of the task's requirements
|
// has_completed is now computed per-task as: ALL of the task's requirements
|
||||||
// individually have a completion signal — matching RequirementsStatusPanel's
|
// individually have a completion signal — matching RequirementsStatusPanel's
|
||||||
@@ -147,17 +147,17 @@ const isMember = async (userId, groupId) => {
|
|||||||
// requirement_id (+ reference_id)
|
// requirement_id (+ reference_id)
|
||||||
//
|
//
|
||||||
// Task bucket:
|
// Task bucket:
|
||||||
// done → every requirement passes its check above
|
// completed → every requirement passes its check above
|
||||||
// (a task with zero requirements is vacuously "ongoing", per
|
// (a task with zero requirements is vacuously "ongoing", per
|
||||||
// earlier spec — zero requirements should not normally happen)
|
// earlier spec — zero requirements should not normally happen)
|
||||||
// overdue → not done AND task.deadline < now
|
// overdue → not completed AND task.deadline < now
|
||||||
// ongoing → otherwise
|
// ongoing → otherwise
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.getGroupTaskList = async (req, res) => {
|
exports.getGroupTaskList = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { groupId, taskListId } = req.params;
|
const { groupId, taskListId } = req.params;
|
||||||
const { status } = req.query; // optional: 'ongoing' | 'done' | 'overdue'
|
const { status } = req.query; // optional: 'ongoing' | 'completed' | 'overdue'
|
||||||
const userId = req.user.user_id;
|
const userId = req.user.user_id;
|
||||||
|
|
||||||
const member = await isMember(userId, groupId);
|
const member = await isMember(userId, groupId);
|
||||||
@@ -259,7 +259,7 @@ exports.getGroupTaskList = async (req, res) => {
|
|||||||
|
|
||||||
let bucket;
|
let bucket;
|
||||||
if (has_completed) {
|
if (has_completed) {
|
||||||
bucket = 'done';
|
bucket = 'completed';
|
||||||
} else if (task.deadline && new Date(task.deadline).getTime() < now) {
|
} else if (task.deadline && new Date(task.deadline).getTime() < now) {
|
||||||
bucket = 'overdue';
|
bucket = 'overdue';
|
||||||
} else {
|
} else {
|
||||||
@@ -287,7 +287,7 @@ exports.getGroupTaskList = async (req, res) => {
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// REPLACEMENT: getGroupTaskLists in task.controller.js (client) — plural
|
// REPLACEMENT: getGroupTaskLists in task.controller.js (client) — plural
|
||||||
//
|
//
|
||||||
// GET /client/groups/:groupId/task-lists?status=ongoing|done|overdue
|
// GET /client/groups/:groupId/task-lists?status=ongoing|completed|overdue
|
||||||
//
|
//
|
||||||
// Updated to match getGroupTaskList (singular): has_completed per task now
|
// Updated to match getGroupTaskList (singular): has_completed per task now
|
||||||
// means ALL of that task's requirements individually have a completion signal
|
// means ALL of that task's requirements individually have a completion signal
|
||||||
@@ -295,8 +295,8 @@ exports.getGroupTaskList = async (req, res) => {
|
|||||||
//
|
//
|
||||||
// TaskList bucket (based on per-task has_completed, computed below):
|
// TaskList bucket (based on per-task has_completed, computed below):
|
||||||
// TaskList has zero tasks → Ongoing (nothing to do yet)
|
// TaskList has zero tasks → Ongoing (nothing to do yet)
|
||||||
// ALL tasks have has_completed → Done
|
// ALL tasks have has_completed → Completed
|
||||||
// NOT all done AND any incomplete
|
// NOT all completed AND any incomplete
|
||||||
// task has deadline < now → Overdue
|
// task has deadline < now → Overdue
|
||||||
// Otherwise → Ongoing
|
// Otherwise → Ongoing
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -304,7 +304,7 @@ exports.getGroupTaskList = async (req, res) => {
|
|||||||
exports.getGroupTaskLists = async (req, res) => {
|
exports.getGroupTaskLists = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { groupId } = req.params;
|
const { groupId } = req.params;
|
||||||
const { status } = req.query; // optional: 'ongoing' | 'done' | 'overdue'
|
const { status } = req.query; // optional: 'ongoing' | 'completed' | 'overdue'
|
||||||
const userId = req.user.user_id;
|
const userId = req.user.user_id;
|
||||||
|
|
||||||
const member = await isMember(userId, groupId);
|
const member = await isMember(userId, groupId);
|
||||||
@@ -423,7 +423,7 @@ exports.getGroupTaskLists = async (req, res) => {
|
|||||||
} else {
|
} else {
|
||||||
const allDone = tasks.every((t) => t.has_completed);
|
const allDone = tasks.every((t) => t.has_completed);
|
||||||
if (allDone) {
|
if (allDone) {
|
||||||
bucket = 'done';
|
bucket = 'completed';
|
||||||
} else {
|
} else {
|
||||||
const anyOverdue = tasks.some((t) =>
|
const anyOverdue = tasks.some((t) =>
|
||||||
!t.has_completed && t.deadline && new Date(t.deadline).getTime() < now
|
!t.has_completed && t.deadline && new Date(t.deadline).getTime() < now
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ const sequelize = require('../../config/db.config');
|
|||||||
const { Task, TaskRequirement } = require('../../models/task/task.mdl');
|
const { Task, TaskRequirement } = require('../../models/task/task.mdl');
|
||||||
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
|
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
|
||||||
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||||
|
const { Course } = require('../../models/courses/courses.mdl');
|
||||||
|
const CourseAssessment = require('../../models/courses/course_assessment.mdl');
|
||||||
|
const QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
|
||||||
const logActivity = require('../../utils/logActivity.util');
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
|
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
|
||||||
@@ -67,8 +70,10 @@ const deriveUnitCompletion = async (userId, unitRequirementId, t) => {
|
|||||||
|
|
||||||
// ─── Helper: derive course completion ────────────────────────────────────────
|
// ─── Helper: derive course completion ────────────────────────────────────────
|
||||||
// Course is complete when ALL read_unit progress rows under this course requirement
|
// Course is complete when ALL read_unit progress rows under this course requirement
|
||||||
// for this user are marked completed.
|
// for this user are marked completed AND, if the course has a built assessment,
|
||||||
const deriveCourseCompletion = async (userId, courseRequirementId, t) => {
|
// the user has passed it. A course with no assessment yet can never be "complete" —
|
||||||
|
// finishing the reading alone isn't course completion.
|
||||||
|
const deriveCourseCompletion = async (userId, courseRequirementId, courseUuid, t) => {
|
||||||
const rows = await TaskProgress.findAll({
|
const rows = await TaskProgress.findAll({
|
||||||
where: {
|
where: {
|
||||||
requirement_id: courseRequirementId,
|
requirement_id: courseRequirementId,
|
||||||
@@ -78,7 +83,28 @@ const deriveCourseCompletion = async (userId, courseRequirementId, t) => {
|
|||||||
transaction: t,
|
transaction: t,
|
||||||
});
|
});
|
||||||
if (!rows.length) return false;
|
if (!rows.length) return false;
|
||||||
return rows.every((r) => r.completed);
|
const allUnitsRead = rows.every((r) => r.completed);
|
||||||
|
if (!allUnitsRead) return false;
|
||||||
|
|
||||||
|
const course = await Course.findOne({
|
||||||
|
where: { uuid: courseUuid },
|
||||||
|
attributes: ['course_id'],
|
||||||
|
transaction: t,
|
||||||
|
});
|
||||||
|
if (!course) return false;
|
||||||
|
|
||||||
|
const assessment = await CourseAssessment.findOne({
|
||||||
|
where: { course_id: course.course_id },
|
||||||
|
attributes: ['assessment_id'],
|
||||||
|
transaction: t,
|
||||||
|
});
|
||||||
|
if (!assessment) return false;
|
||||||
|
|
||||||
|
const passedAttempt = await QuizAttempt.findOne({
|
||||||
|
where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true },
|
||||||
|
transaction: t,
|
||||||
|
});
|
||||||
|
return !!passedAttempt;
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -352,7 +378,7 @@ exports.updateProgress = async (req, res) => {
|
|||||||
if (course_requirement_id) {
|
if (course_requirement_id) {
|
||||||
const courseReq = await getRequirement(course_requirement_id, taskId);
|
const courseReq = await getRequirement(course_requirement_id, taskId);
|
||||||
if (courseReq && courseReq.type === 'read_course') {
|
if (courseReq && courseReq.type === 'read_course') {
|
||||||
const courseDone = await deriveCourseCompletion(userId, course_requirement_id, t);
|
const courseDone = await deriveCourseCompletion(userId, course_requirement_id, courseReq.reference_id, t);
|
||||||
|
|
||||||
await TaskProgress.upsert(
|
await TaskProgress.upsert(
|
||||||
{
|
{
|
||||||
|
|||||||
+12
-3
@@ -18,14 +18,21 @@
|
|||||||
* POST /api/auth/change-password → change password (requires authenticate)
|
* POST /api/auth/change-password → change password (requires authenticate)
|
||||||
* POST /api/auth/forgot-password → same procedure for every acc_type — checks
|
* POST /api/auth/forgot-password → same procedure for every acc_type — checks
|
||||||
* reg_type is 'system' (not Google), sends OTP
|
* reg_type is 'system' (not Google), sends OTP
|
||||||
|
* POST /api/auth/verify-reset-otp → checks OTP validity only, does not consume it
|
||||||
|
* or change the password (step 2 of the 3-step
|
||||||
|
* reset flow: email → OTP → new password)
|
||||||
* POST /api/auth/reset-password → verifies OTP + sets new password in one step
|
* POST /api/auth/reset-password → verifies OTP + sets new password in one step
|
||||||
* GET /api/auth/google → initiate Google OIDC (generates state/nonce/PKCE)
|
* GET /api/auth/google → initiate Google OIDC (generates state/nonce/PKCE)
|
||||||
* GET /api/auth/google/callback → verifies + exchanges code, sends a login OTP,
|
* GET /api/auth/google/callback → verifies + exchanges code, sends a login OTP,
|
||||||
* redirects to the frontend with otpRequired=true
|
* redirects to the frontend callback page (no query
|
||||||
|
* params — outcome is stashed in a signed cookie)
|
||||||
|
* GET /api/auth/google/result → single-use read of that cookie (otpRequired/email,
|
||||||
|
* or an error), so it never has to live in the URL
|
||||||
*
|
*
|
||||||
* Author: rgrgogu
|
* Author: rgrgogu
|
||||||
* Date Created: Oct. 6, 2025
|
* Date Created: Oct. 6, 2025
|
||||||
* Date Modified: Jul. 4, 2026 — mandatory OTP on every login + forgot/reset password (Kenneth Obsequio)
|
* Date Modified: Jul. 6, 2026 — Google callback handoff moved from URL params to a signed
|
||||||
|
* cookie + /google/result endpoint (Kenneth Obsequio)
|
||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -38,7 +45,7 @@ const { validate } = require('../middleware/validate.middleware');
|
|||||||
const {
|
const {
|
||||||
registerValidator, loginValidator,
|
registerValidator, loginValidator,
|
||||||
verifyOTPValidator, resendOTPValidator, changePassValidator,
|
verifyOTPValidator, resendOTPValidator, changePassValidator,
|
||||||
forgotPasswordValidator, resetPasswordValidator,
|
forgotPasswordValidator, verifyResetOTPValidator, resetPasswordValidator,
|
||||||
} = require('../validators/auth.validator');
|
} = require('../validators/auth.validator');
|
||||||
|
|
||||||
// ── CSRF token (GET — no CSRF needed on GETs) ──────────────────────────────────
|
// ── CSRF token (GET — no CSRF needed on GETs) ──────────────────────────────────
|
||||||
@@ -53,10 +60,12 @@ router.post('/refresh', authLimiter, authCtrl.refreshToken);
|
|||||||
router.post('/logout', authenticate, authLimiter, authCtrl.logout);
|
router.post('/logout', authenticate, authLimiter, authCtrl.logout);
|
||||||
router.post('/change-password', authenticate, sensitiveOpsLimiter, ...changePassValidator, validate, authCtrl.changePassword);
|
router.post('/change-password', authenticate, sensitiveOpsLimiter, ...changePassValidator, validate, authCtrl.changePassword);
|
||||||
router.post('/forgot-password', otpLimiter, ...forgotPasswordValidator, validate, authCtrl.forgotPassword);
|
router.post('/forgot-password', otpLimiter, ...forgotPasswordValidator, validate, authCtrl.forgotPassword);
|
||||||
|
router.post('/verify-reset-otp', otpLimiter, ...verifyResetOTPValidator, validate, authCtrl.verifyResetOTP);
|
||||||
router.post('/reset-password', otpLimiter, ...resetPasswordValidator, validate, authCtrl.resetPassword);
|
router.post('/reset-password', otpLimiter, ...resetPasswordValidator, validate, authCtrl.resetPassword);
|
||||||
|
|
||||||
// ── Google OIDC ────────────────────────────────────────────────────────────────
|
// ── Google OIDC ────────────────────────────────────────────────────────────────
|
||||||
router.get('/google', authLimiter, authCtrl.googleRedirect);
|
router.get('/google', authLimiter, authCtrl.googleRedirect);
|
||||||
router.get('/google/callback', authCtrl.googleCallback);
|
router.get('/google/callback', authCtrl.googleCallback);
|
||||||
|
router.get('/google/result', authCtrl.googleResult);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
* Route Map:
|
* Route Map:
|
||||||
* GET /client/groups → my groups
|
* GET /client/groups → my groups
|
||||||
* GET /client/groups/:groupId → single group
|
* GET /client/groups/:groupId → single group
|
||||||
* GET /client/groups/:groupId/task-lists → task lists (?status=ongoing|done|overdue)
|
* GET /client/groups/:groupId/task-lists → task lists (?status=ongoing|completed|overdue)
|
||||||
* GET /client/groups/:groupId/task-lists/:taskListId → single task list
|
* GET /client/groups/:groupId/task-lists/:taskListId → single task list
|
||||||
* GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId → task + requirements + latest completion
|
* GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId → task + requirements + latest completion
|
||||||
* GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress → full progress snapshot
|
* GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress → full progress snapshot
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
* Derivation rules:
|
* Derivation rules:
|
||||||
* unit → completed when ALL its non-deleted lessons have a completed row for this user
|
* unit → completed when ALL its non-deleted lessons have a completed row for this user
|
||||||
* course → completed when ALL its non-deleted units have a completed row for this user
|
* course → completed when ALL its non-deleted units have a completed row for this user
|
||||||
|
* AND, if the course has a course assessment, the user has passed it.
|
||||||
|
* A course with no assessment built yet can never reach 'completed' here —
|
||||||
|
* reading alone isn't course completion.
|
||||||
*
|
*
|
||||||
* Author: Kenneth Obsequio (@lash0000)
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
* Date Created: Jun. 21, 2026
|
* Date Created: Jun. 21, 2026
|
||||||
@@ -22,6 +25,24 @@ const sequelize = require('../config/db.config');
|
|||||||
const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl');
|
const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl');
|
||||||
const Lesson = require('../models/courses/lessons.mdl');
|
const Lesson = require('../models/courses/lessons.mdl');
|
||||||
const Unit = require('../models/courses/units.mdl');
|
const Unit = require('../models/courses/units.mdl');
|
||||||
|
const CourseAssessment = require('../models/courses/course_assessment.mdl');
|
||||||
|
const QuizAttempt = require('../models/courses/quiz_attempt.mdl');
|
||||||
|
|
||||||
|
// A course only counts as fully complete once it has a built assessment AND the user passed it.
|
||||||
|
async function hasPassedCourseAssessment(userId, courseId, t) {
|
||||||
|
const assessment = await CourseAssessment.findOne({
|
||||||
|
where: { course_id: courseId },
|
||||||
|
attributes: ['assessment_id'],
|
||||||
|
transaction: t,
|
||||||
|
});
|
||||||
|
if (!assessment) return false;
|
||||||
|
|
||||||
|
const passedAttempt = await QuizAttempt.findOne({
|
||||||
|
where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true },
|
||||||
|
transaction: t,
|
||||||
|
});
|
||||||
|
return !!passedAttempt;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Core UPSERT ─────────────────────────────────────────────────────────────
|
// ─── Core UPSERT ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -74,7 +95,8 @@ async function deriveUnitStatus(userId, courseId, unitId, t) {
|
|||||||
return completedCount === lessons.length ? 'completed' : 'in_progress';
|
return completedCount === lessons.length ? 'completed' : 'in_progress';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Course is completed when every non-deleted unit under it has a completed row for this user.
|
// Course is completed when every non-deleted unit under it has a completed row for this user
|
||||||
|
// AND the course's assessment (if one has been built) has been passed by this user.
|
||||||
async function deriveCourseStatus(userId, courseId, t) {
|
async function deriveCourseStatus(userId, courseId, t) {
|
||||||
const units = await Unit.findAll({
|
const units = await Unit.findAll({
|
||||||
where: { course_id: courseId },
|
where: { course_id: courseId },
|
||||||
@@ -95,7 +117,11 @@ async function deriveCourseStatus(userId, courseId, t) {
|
|||||||
transaction: t,
|
transaction: t,
|
||||||
});
|
});
|
||||||
|
|
||||||
return completedCount === units.length ? 'completed' : 'in_progress';
|
const allUnitsRead = completedCount === units.length;
|
||||||
|
if (!allUnitsRead) return 'in_progress';
|
||||||
|
|
||||||
|
const assessmentPassed = await hasPassedCourseAssessment(userId, courseId, t);
|
||||||
|
return assessmentPassed ? 'completed' : 'in_progress';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main entry point ─────────────────────────────────────────────────────────
|
// ─── Main entry point ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
+13
-56
@@ -46,64 +46,21 @@ const DEFAULT_BUCKET = process.env.S3_BUCKET;
|
|||||||
// from the machine running Garage itself; S3_PUBLIC_URL is the externally
|
// from the machine running Garage itself; S3_PUBLIC_URL is the externally
|
||||||
// reachable address (tunnel/CDN/domain).
|
// reachable address (tunnel/CDN/domain).
|
||||||
//
|
//
|
||||||
// Rather than always preferring one, probe S3_ENDPOINT and use it when it's
|
// This used to probe S3_ENDPOINT from the server and prefer it when reachable,
|
||||||
// actually reachable (same-machine dev setup — no extra hop through the
|
// but that measures the wrong machine: Garage is always co-located with this
|
||||||
// tunnel), falling back to S3_PUBLIC_URL when it isn't (any other machine).
|
// backend (see docker-compose.yml), so the probe was *always* reachable from
|
||||||
//
|
// here and always resolved to 127.0.0.1 — even for browsers on other machines,
|
||||||
// The probe runs once at startup and then on a background timer — never on
|
// which then failed to connect to it. There is no way for the server to
|
||||||
// the request path itself. A machine without Garage would otherwise pay the
|
// determine what's reachable from the client by probing itself, so just trust
|
||||||
// full HeadBucket timeout on whichever upload/asset request happens to land
|
// config: prefer S3_PUBLIC_URL whenever it's set, and only fall back to
|
||||||
// right after the cache expires; polling in the background means every
|
// S3_ENDPOINT for pure single-machine dev setups with no public URL at all.
|
||||||
// request just reads the last known-good host instantly.
|
function resolvePublicHost() {
|
||||||
const PROBE_TIMEOUT_MS = 1500;
|
return process.env.S3_PUBLIC_URL || process.env.S3_ENDPOINT || "";
|
||||||
const PROBE_CACHE_MS = 15000;
|
|
||||||
|
|
||||||
let hostCache = { host: process.env.S3_PUBLIC_URL || process.env.S3_ENDPOINT || "" };
|
|
||||||
|
|
||||||
async function probeEndpoint(endpoint) {
|
|
||||||
const probe = new S3Client({
|
|
||||||
endpoint,
|
|
||||||
region: process.env.S3_REGION || "garage",
|
|
||||||
credentials,
|
|
||||||
forcePathStyle: true,
|
|
||||||
});
|
|
||||||
await Promise.race([
|
|
||||||
probe.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET })),
|
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), PROBE_TIMEOUT_MS)),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshHostCache() {
|
// Public client — built against whichever host resolvePublicHost() picks.
|
||||||
const endpoint = process.env.S3_ENDPOINT;
|
function getPublicClient() {
|
||||||
const publicUrl = process.env.S3_PUBLIC_URL || "";
|
const endpoint = resolvePublicHost();
|
||||||
|
|
||||||
if (!endpoint) { hostCache = { host: publicUrl }; return; }
|
|
||||||
if (!publicUrl) { hostCache = { host: endpoint }; return; }
|
|
||||||
|
|
||||||
try {
|
|
||||||
await probeEndpoint(endpoint);
|
|
||||||
hostCache = { host: endpoint };
|
|
||||||
} catch {
|
|
||||||
hostCache = { host: publicUrl };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kick off the first probe immediately so the cache is populated before any
|
|
||||||
// request needs it, then keep it fresh in the background. unref() so this
|
|
||||||
// timer alone doesn't keep the process (or a test run) alive.
|
|
||||||
const initialProbe = refreshHostCache();
|
|
||||||
const refreshTimer = setInterval(refreshHostCache, PROBE_CACHE_MS);
|
|
||||||
refreshTimer.unref?.();
|
|
||||||
|
|
||||||
async function resolvePublicHost() {
|
|
||||||
await initialProbe; // no-op after the first call — already resolved
|
|
||||||
return hostCache.host;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public client — lazily built against whichever host resolvePublicHost()
|
|
||||||
// picks, so it follows the reachability check instead of a fixed endpoint.
|
|
||||||
async function getPublicClient() {
|
|
||||||
const endpoint = await resolvePublicHost();
|
|
||||||
return new S3Client({
|
return new S3Client({
|
||||||
endpoint,
|
endpoint,
|
||||||
region: process.env.S3_REGION || "garage",
|
region: process.env.S3_REGION || "garage",
|
||||||
|
|||||||
@@ -6,9 +6,14 @@
|
|||||||
* OTP; trust rolls forward 30 days on each trusted login and is
|
* OTP; trust rolls forward 30 days on each trusted login and is
|
||||||
* tied to both an opaque cookie token (device_trust) and a
|
* tied to both an opaque cookie token (device_trust) and a
|
||||||
* User-Agent fingerprint, so a stolen cookie alone isn't enough
|
* User-Agent fingerprint, so a stolen cookie alone isn't enough
|
||||||
* once the fingerprint no longer matches. Trust is revoked on
|
* once the fingerprint no longer matches. Ordinary logout does
|
||||||
* logout, password change/reset, admin ban/deactivate, or a
|
* NOT revoke trust or clear the device_trust cookie — expires_at
|
||||||
* single session being terminated.
|
* is the only thing that ends the OTP-skip window in the normal
|
||||||
|
* case, so logging out and back in on the same device still
|
||||||
|
* skips OTP until the 30-day window actually lapses. Trust is
|
||||||
|
* only force-revoked by password change/reset, admin ban/
|
||||||
|
* deactivate/force-logout, or a single session being explicitly
|
||||||
|
* terminated.
|
||||||
* Author: Kenneth Obsequio
|
* Author: Kenneth Obsequio
|
||||||
* Date Created: Jul. 5, 2026
|
* Date Created: Jul. 5, 2026
|
||||||
***********************************************************************************************************************************************************************
|
***********************************************************************************************************************************************************************
|
||||||
@@ -103,6 +108,12 @@ const issueOrRefresh = async (res, userId, fingerprintHash, sessionId) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes trust for one specific device by its cookie token. Not called by
|
||||||
|
* the normal logout flow (see auth.controller.js exports.logout) — ordinary
|
||||||
|
* logout intentionally leaves trust intact. Kept as a primitive for a
|
||||||
|
* future explicit "forget this device" action, should one be added.
|
||||||
|
*/
|
||||||
const revokeByToken = async (userId, rawToken) => {
|
const revokeByToken = async (userId, rawToken) => {
|
||||||
if (!rawToken) return;
|
if (!rawToken) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* - resendOTPValidator → POST /auth/resend-otp
|
* - resendOTPValidator → POST /auth/resend-otp
|
||||||
* - changePassValidator → POST /auth/change-password
|
* - changePassValidator → POST /auth/change-password
|
||||||
* - forgotPasswordValidator → POST /auth/forgot-password
|
* - forgotPasswordValidator → POST /auth/forgot-password
|
||||||
|
* - verifyResetOTPValidator → POST /auth/verify-reset-otp
|
||||||
* - resetPasswordValidator → POST /auth/reset-password
|
* - resetPasswordValidator → POST /auth/reset-password
|
||||||
* Author: rgrgogu
|
* Author: rgrgogu
|
||||||
* Date Created: Oct. 6, 2025
|
* Date Created: Oct. 6, 2025
|
||||||
@@ -50,6 +51,11 @@ const forgotPasswordValidator = [
|
|||||||
body('email').isEmail().withMessage('Valid email is required.'),
|
body('email').isEmail().withMessage('Valid email is required.'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const verifyResetOTPValidator = [
|
||||||
|
body('email').isEmail().withMessage('Valid email is required.'),
|
||||||
|
body('otp').isLength({ min: 6, max: 6 }).isNumeric().withMessage('OTP must be 6 digits.'),
|
||||||
|
];
|
||||||
|
|
||||||
const resetPasswordValidator = [
|
const resetPasswordValidator = [
|
||||||
body('email').isEmail().withMessage('Valid email is required.'),
|
body('email').isEmail().withMessage('Valid email is required.'),
|
||||||
body('otp').isLength({ min: 6, max: 6 }).isNumeric().withMessage('OTP must be 6 digits.'),
|
body('otp').isLength({ min: 6, max: 6 }).isNumeric().withMessage('OTP must be 6 digits.'),
|
||||||
@@ -62,5 +68,5 @@ const resetPasswordValidator = [
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
registerValidator, loginValidator,
|
registerValidator, loginValidator,
|
||||||
verifyOTPValidator, resendOTPValidator, changePassValidator,
|
verifyOTPValidator, resendOTPValidator, changePassValidator,
|
||||||
forgotPasswordValidator, resetPasswordValidator,
|
forgotPasswordValidator, verifyResetOTPValidator, resetPasswordValidator,
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user