feat: add password change, class unenroll, and optional notify flags
Let admins skip SMS on user, class, and invoice actions, and let users change their own password with the current one.
This commit is contained in:
@@ -114,6 +114,16 @@
|
||||
"en": "Invalid username or password.",
|
||||
"fa": "نام کاربری یا رمز عبور اشتباه است."
|
||||
},
|
||||
"INVALID_CURRENT_PASSWORD": {
|
||||
"statusCode": 401,
|
||||
"en": "Current password is incorrect.",
|
||||
"fa": "رمز عبور فعلی اشتباه است."
|
||||
},
|
||||
"WEAK_PASSWORD": {
|
||||
"statusCode": 400,
|
||||
"en": "Password must be at least 6 characters.",
|
||||
"fa": "رمز عبور باید حداقل ۶ نویسه باشد."
|
||||
},
|
||||
"TOKEN_EXPIRED": {
|
||||
"statusCode": 401,
|
||||
"en": "Token has expired. Please login again.",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// /utils/notifyFlags.js
|
||||
'use strict';
|
||||
|
||||
const NOTIFY_FIELD_KEYS = [
|
||||
'notify',
|
||||
'notifySms',
|
||||
'notifyEmail',
|
||||
'notifyBot',
|
||||
'sendSms',
|
||||
'sendEmail',
|
||||
'sendBot'
|
||||
];
|
||||
|
||||
const toFlag = (value, fallback = true) => {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
||||
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const pickNotifyFlags = (source = {}) => {
|
||||
const nested = source && typeof source.notify === 'object' && source.notify !== null
|
||||
? source.notify
|
||||
: {};
|
||||
|
||||
return {
|
||||
sms: toFlag(nested.sms ?? source.notifySms ?? source.sendSms, true),
|
||||
email: toFlag(nested.email ?? source.notifyEmail ?? source.sendEmail, true),
|
||||
bot: toFlag(nested.bot ?? source.notifyBot ?? source.sendBot, true)
|
||||
};
|
||||
};
|
||||
|
||||
const omitNotifyFields = (source = {}) => {
|
||||
const next = { ...source };
|
||||
NOTIFY_FIELD_KEYS.forEach((key) => {
|
||||
delete next[key];
|
||||
});
|
||||
return next;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
pickNotifyFlags,
|
||||
omitNotifyFields
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { pickNotifyFlags, omitNotifyFields } = require('./notifyFlags');
|
||||
|
||||
describe('pickNotifyFlags', () => {
|
||||
it('defaults all channels to enabled when the body is empty', () => {
|
||||
assert.deepEqual(pickNotifyFlags(), { sms: true, email: true, bot: true });
|
||||
assert.deepEqual(pickNotifyFlags({}), { sms: true, email: true, bot: true });
|
||||
});
|
||||
|
||||
it('reads nested notify flags and keeps unspecified channels enabled', () => {
|
||||
assert.deepEqual(pickNotifyFlags({ notify: { sms: false } }), {
|
||||
sms: false,
|
||||
email: true,
|
||||
bot: true
|
||||
});
|
||||
});
|
||||
|
||||
it('honors explicit false for email and bot', () => {
|
||||
assert.deepEqual(pickNotifyFlags({
|
||||
notify: { sms: true, email: false, bot: false }
|
||||
}), {
|
||||
sms: true,
|
||||
email: false,
|
||||
bot: false
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts top-level aliases and string booleans', () => {
|
||||
assert.deepEqual(pickNotifyFlags({
|
||||
notifySms: 'false',
|
||||
notifyEmail: 'true',
|
||||
sendBot: '0'
|
||||
}), {
|
||||
sms: false,
|
||||
email: true,
|
||||
bot: false
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('omitNotifyFields', () => {
|
||||
it('strips notify fields without mutating the original body', () => {
|
||||
const body = {
|
||||
user: 'abc',
|
||||
amount: 1000,
|
||||
notify: { sms: false },
|
||||
notifySms: false
|
||||
};
|
||||
const cleaned = omitNotifyFields(body);
|
||||
assert.deepEqual(cleaned, { user: 'abc', amount: 1000 });
|
||||
assert.equal(body.notifySms, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// /utils/passwordRules.js
|
||||
'use strict';
|
||||
|
||||
const AppError = require('./AppError');
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 6;
|
||||
|
||||
const assertPasswordStrength = (password) => {
|
||||
if (typeof password !== 'string' || password.trim().length < MIN_PASSWORD_LENGTH) {
|
||||
throw new AppError('WEAK_PASSWORD');
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
MIN_PASSWORD_LENGTH,
|
||||
assertPasswordStrength
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
'use strict';
|
||||
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { assertPasswordStrength, MIN_PASSWORD_LENGTH } = require('./passwordRules');
|
||||
|
||||
describe('assertPasswordStrength', () => {
|
||||
it(`rejects passwords shorter than ${MIN_PASSWORD_LENGTH} characters`, () => {
|
||||
assert.throws(() => assertPasswordStrength('ab12'), { errorCode: 'WEAK_PASSWORD' });
|
||||
assert.throws(() => assertPasswordStrength(''), { errorCode: 'WEAK_PASSWORD' });
|
||||
assert.throws(() => assertPasswordStrength(null), { errorCode: 'WEAK_PASSWORD' });
|
||||
});
|
||||
|
||||
it('accepts passwords that meet the minimum length', () => {
|
||||
assert.doesNotThrow(() => assertPasswordStrength('ab1234'));
|
||||
assert.doesNotThrow(() => assertPasswordStrength('longer-secret'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user