v0.12.43 minor protection added
This commit is contained in:
@@ -16,6 +16,17 @@ const uploadAvatar = multer({
|
||||
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
||||
});
|
||||
|
||||
// Alias avatar upload (separate from user avatar so filename doesn't collide)
|
||||
const aliasAvatarStorage = multer.diskStorage({
|
||||
destination: '/app/uploads/avatars',
|
||||
filename: (req, file, cb) => cb(null, `alias_${req.params.aliasId}_${Date.now()}${path.extname(file.originalname)}`),
|
||||
});
|
||||
const uploadAliasAvatar = multer({
|
||||
storage: aliasAvatarStorage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
||||
});
|
||||
|
||||
async function resolveUniqueName(schema, baseName, excludeId = null) {
|
||||
const existing = await query(schema,
|
||||
"SELECT name FROM users WHERE status != 'deleted' AND id != $1 AND (name = $2 OR name LIKE $3)",
|
||||
@@ -29,11 +40,28 @@ async function resolveUniqueName(schema, baseName, excludeId = null) {
|
||||
|
||||
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
|
||||
|
||||
// Returns true if the given date-of-birth string corresponds to age <= 15
|
||||
function isMinorFromDOB(dob) {
|
||||
if (!dob) return false;
|
||||
const birth = new Date(dob);
|
||||
if (isNaN(birth)) return false;
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const m = today.getMonth() - birth.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
|
||||
return age <= 15;
|
||||
}
|
||||
|
||||
async function getLoginType(schema) {
|
||||
const row = await queryOne(schema, "SELECT value FROM settings WHERE key='feature_login_type'");
|
||||
return row?.value || 'all_ages';
|
||||
}
|
||||
|
||||
// List users
|
||||
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const users = await query(req.schema,
|
||||
"SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY name ASC"
|
||||
"SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY name ASC"
|
||||
);
|
||||
res.json({ users });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
@@ -86,7 +114,7 @@ router.get('/check-display-name', authMiddleware, async (req, res) => {
|
||||
|
||||
// Create user
|
||||
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { firstName, lastName, email, password, role, phone, isMinor } = req.body;
|
||||
const { firstName, lastName, email, password, role, phone, dateOfBirth } = req.body;
|
||||
if (!firstName?.trim() || !lastName?.trim() || !email)
|
||||
return res.status(400).json({ error: 'First name, last name and email required' });
|
||||
if (!isValidEmail(email.trim())) return res.status(400).json({ error: 'Invalid email address' });
|
||||
@@ -94,45 +122,74 @@ router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const assignedRole = validRoles.includes(role) ? role : 'member';
|
||||
const name = `${firstName.trim()} ${lastName.trim()}`;
|
||||
try {
|
||||
const loginType = await getLoginType(req.schema);
|
||||
if (loginType === 'mixed_age' && !dateOfBirth)
|
||||
return res.status(400).json({ error: 'Date of birth is required in Mixed Age mode' });
|
||||
|
||||
const dob = dateOfBirth || null;
|
||||
const isMinor = isMinorFromDOB(dob);
|
||||
// In mixed_age mode, minors start suspended and need guardian approval
|
||||
const initStatus = (loginType === 'mixed_age' && isMinor) ? 'suspended' : 'active';
|
||||
|
||||
const exists = await queryOne(req.schema, "SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND status != 'deleted'", [email.trim()]);
|
||||
if (exists) return res.status(400).json({ error: 'Email already in use' });
|
||||
const resolvedName = await resolveUniqueName(req.schema, name);
|
||||
const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234';
|
||||
const hash = bcrypt.hashSync(pw, 10);
|
||||
const r = await queryResult(req.schema,
|
||||
"INSERT INTO users (name,first_name,last_name,email,password,role,phone,is_minor,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'active',TRUE) RETURNING id",
|
||||
[resolvedName, firstName.trim(), lastName.trim(), email.trim().toLowerCase(), hash, assignedRole, phone?.trim() || null, !!isMinor]
|
||||
"INSERT INTO users (name,first_name,last_name,email,password,role,phone,is_minor,date_of_birth,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE) RETURNING id",
|
||||
[resolvedName, firstName.trim(), lastName.trim(), email.trim().toLowerCase(), hash, assignedRole, phone?.trim() || null, isMinor, dob, initStatus]
|
||||
);
|
||||
const userId = r.rows[0].id;
|
||||
await addUserToPublicGroups(req.schema, userId);
|
||||
if (initStatus === 'active') await addUserToPublicGroups(req.schema, userId);
|
||||
if (assignedRole === 'admin') {
|
||||
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
|
||||
}
|
||||
const user = await queryOne(req.schema, 'SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [userId]);
|
||||
res.json({ user });
|
||||
const user = await queryOne(req.schema,
|
||||
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,must_change_password,created_at FROM users WHERE id=$1',
|
||||
[userId]
|
||||
);
|
||||
res.json({ user, pendingApproval: initStatus === 'suspended' });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Update user (general — name components, phone, is_minor, role, optional password reset)
|
||||
// Update user (general — name components, phone, DOB, is_minor, role, optional password reset)
|
||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Invalid user ID' });
|
||||
const { firstName, lastName, phone, isMinor, role, password } = req.body;
|
||||
const { firstName, lastName, phone, role, password, dateOfBirth, guardianUserId } = req.body;
|
||||
if (!firstName?.trim() || !lastName?.trim())
|
||||
return res.status(400).json({ error: 'First and last name required' });
|
||||
const validRoles = ['member', 'admin', 'manager'];
|
||||
if (!validRoles.includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||
try {
|
||||
const loginType = await getLoginType(req.schema);
|
||||
if (loginType === 'mixed_age' && !dateOfBirth)
|
||||
return res.status(400).json({ error: 'Date of birth is required in Mixed Age mode' });
|
||||
|
||||
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
|
||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||
if (target.is_default_admin && role !== 'admin')
|
||||
return res.status(403).json({ error: 'Cannot change default admin role' });
|
||||
const name = `${firstName.trim()} ${lastName.trim()}`;
|
||||
|
||||
const dob = dateOfBirth || null;
|
||||
const isMinor = isMinorFromDOB(dob);
|
||||
const name = `${firstName.trim()} ${lastName.trim()}`;
|
||||
const resolvedName = await resolveUniqueName(req.schema, name, id);
|
||||
|
||||
// Validate guardian if provided
|
||||
let guardianId = null;
|
||||
if (guardianUserId) {
|
||||
const gUser = await queryOne(req.schema, 'SELECT id,is_minor FROM users WHERE id=$1 AND status=$2', [parseInt(guardianUserId), 'active']);
|
||||
if (!gUser) return res.status(400).json({ error: 'Guardian user not found or inactive' });
|
||||
if (gUser.is_minor) return res.status(400).json({ error: 'A minor cannot be a guardian' });
|
||||
guardianId = gUser.id;
|
||||
}
|
||||
|
||||
await exec(req.schema,
|
||||
'UPDATE users SET name=$1,first_name=$2,last_name=$3,phone=$4,is_minor=$5,role=$6,updated_at=NOW() WHERE id=$7',
|
||||
[resolvedName, firstName.trim(), lastName.trim(), phone?.trim() || null, !!isMinor, role, id]
|
||||
'UPDATE users SET name=$1,first_name=$2,last_name=$3,phone=$4,is_minor=$5,date_of_birth=$6,guardian_user_id=$7,role=$8,updated_at=NOW() WHERE id=$9',
|
||||
[resolvedName, firstName.trim(), lastName.trim(), phone?.trim() || null, isMinor, dob, guardianId, role, id]
|
||||
);
|
||||
if (password && password.length >= 6) {
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
@@ -143,7 +200,7 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, id]);
|
||||
}
|
||||
const user = await queryOne(req.schema,
|
||||
'SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,must_change_password,last_online,created_at FROM users WHERE id=$1',
|
||||
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,must_change_password,last_online,created_at FROM users WHERE id=$1',
|
||||
[id]
|
||||
);
|
||||
res.json({ user });
|
||||
@@ -324,7 +381,7 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req,
|
||||
|
||||
// Update own profile
|
||||
router.patch('/me/profile', authMiddleware, async (req, res) => {
|
||||
const { displayName, aboutMe, hideAdminTag, allowDm } = req.body;
|
||||
const { displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth, phone } = req.body;
|
||||
try {
|
||||
if (displayName) {
|
||||
const conflict = await queryOne(req.schema,
|
||||
@@ -333,12 +390,14 @@ router.patch('/me/profile', authMiddleware, async (req, res) => {
|
||||
);
|
||||
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
|
||||
}
|
||||
const dob = dateOfBirth || null;
|
||||
const isMinor = isMinorFromDOB(dob);
|
||||
await exec(req.schema,
|
||||
'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, updated_at=NOW() WHERE id=$5',
|
||||
[displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, req.user.id]
|
||||
'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, date_of_birth=$5, is_minor=$6, phone=$7, updated_at=NOW() WHERE id=$8',
|
||||
[displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, dob, isMinor, phone?.trim() || null, req.user.id]
|
||||
);
|
||||
const user = await queryOne(req.schema,
|
||||
'SELECT id,name,email,role,status,avatar,about_me,display_name,hide_admin_tag,allow_dm FROM users WHERE id=$1',
|
||||
'SELECT id,name,email,role,status,avatar,about_me,display_name,hide_admin_tag,allow_dm,date_of_birth,phone FROM users WHERE id=$1',
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ user });
|
||||
@@ -376,4 +435,173 @@ router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (
|
||||
}
|
||||
});
|
||||
|
||||
// ── Guardian alias routes (Guardian Only mode) ──────────────────────────────
|
||||
|
||||
// List current user's aliases
|
||||
router.get('/me/aliases', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const aliases = await query(req.schema,
|
||||
'SELECT id,first_name,last_name,email,date_of_birth,avatar,phone FROM guardian_aliases WHERE guardian_id=$1 ORDER BY first_name,last_name',
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ aliases });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Create alias
|
||||
router.post('/me/aliases', authMiddleware, async (req, res) => {
|
||||
const { firstName, lastName, email, dateOfBirth, phone } = req.body;
|
||||
if (!firstName?.trim() || !lastName?.trim()) return res.status(400).json({ error: 'First and last name required' });
|
||||
try {
|
||||
const r = await queryResult(req.schema,
|
||||
'INSERT INTO guardian_aliases (guardian_id,first_name,last_name,email,date_of_birth,phone) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id',
|
||||
[req.user.id, firstName.trim(), lastName.trim(), email?.trim() || null, dateOfBirth || null, phone?.trim() || null]
|
||||
);
|
||||
const aliasId = r.rows[0].id;
|
||||
|
||||
// Auto-add alias to players group if designated
|
||||
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
|
||||
const playersGroupId = parseInt(playersRow?.value);
|
||||
if (playersGroupId) {
|
||||
await exec(req.schema,
|
||||
'INSERT INTO alias_group_members (user_group_id,alias_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||
[playersGroupId, aliasId]
|
||||
);
|
||||
}
|
||||
const alias = await queryOne(req.schema,
|
||||
'SELECT id,first_name,last_name,email,date_of_birth,avatar,phone FROM guardian_aliases WHERE id=$1',
|
||||
[aliasId]
|
||||
);
|
||||
res.json({ alias });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Update alias
|
||||
router.patch('/me/aliases/:aliasId', authMiddleware, async (req, res) => {
|
||||
const aliasId = parseInt(req.params.aliasId);
|
||||
const { firstName, lastName, email, dateOfBirth, phone } = req.body;
|
||||
if (!firstName?.trim() || !lastName?.trim()) return res.status(400).json({ error: 'First and last name required' });
|
||||
try {
|
||||
const existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
|
||||
if (!existing) return res.status(404).json({ error: 'Alias not found' });
|
||||
await exec(req.schema,
|
||||
'UPDATE guardian_aliases SET first_name=$1,last_name=$2,email=$3,date_of_birth=$4,phone=$5,updated_at=NOW() WHERE id=$6',
|
||||
[firstName.trim(), lastName.trim(), email?.trim() || null, dateOfBirth || null, phone?.trim() || null, aliasId]
|
||||
);
|
||||
const alias = await queryOne(req.schema,
|
||||
'SELECT id,first_name,last_name,email,date_of_birth,avatar,phone FROM guardian_aliases WHERE id=$1',
|
||||
[aliasId]
|
||||
);
|
||||
res.json({ alias });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Delete alias
|
||||
router.delete('/me/aliases/:aliasId', authMiddleware, async (req, res) => {
|
||||
const aliasId = parseInt(req.params.aliasId);
|
||||
try {
|
||||
const existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
|
||||
if (!existing) return res.status(404).json({ error: 'Alias not found' });
|
||||
await exec(req.schema, 'DELETE FROM guardian_aliases WHERE id=$1', [aliasId]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Upload alias avatar
|
||||
router.post('/me/aliases/:aliasId/avatar', authMiddleware, uploadAliasAvatar.single('avatar'), async (req, res) => {
|
||||
const aliasId = parseInt(req.params.aliasId);
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
try {
|
||||
const existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
|
||||
if (!existing) return res.status(404).json({ error: 'Alias not found' });
|
||||
const sharp = require('sharp');
|
||||
const filePath = req.file.path;
|
||||
const MAX_DIM = 256;
|
||||
const image = sharp(filePath);
|
||||
const meta = await image.metadata();
|
||||
const needsResize = meta.width > MAX_DIM || meta.height > MAX_DIM;
|
||||
let avatarUrl;
|
||||
if (req.file.size >= 500 * 1024 || needsResize) {
|
||||
const outPath = filePath.replace(/\.[^.]+$/, '.webp');
|
||||
await sharp(filePath).resize(MAX_DIM,MAX_DIM,{fit:'cover',withoutEnlargement:true}).webp({quality:82}).toFile(outPath);
|
||||
require('fs').unlinkSync(filePath);
|
||||
avatarUrl = `/uploads/avatars/${path.basename(outPath)}`;
|
||||
} else {
|
||||
avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||
}
|
||||
await exec(req.schema, 'UPDATE guardian_aliases SET avatar=$1,updated_at=NOW() WHERE id=$2', [avatarUrl, aliasId]);
|
||||
res.json({ avatarUrl });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Search minor users (Mixed Age — for Add Child in profile)
|
||||
router.get('/search-minors', authMiddleware, async (req, res) => {
|
||||
const { q } = req.query;
|
||||
try {
|
||||
const users = await query(req.schema,
|
||||
`SELECT id,name,first_name,last_name,date_of_birth,avatar,phone FROM users
|
||||
WHERE is_minor=TRUE AND status='suspended' AND guardian_user_id IS NULL AND status!='deleted'
|
||||
AND (name ILIKE $1 OR first_name ILIKE $1 OR last_name ILIKE $1)
|
||||
ORDER BY name ASC LIMIT 20`,
|
||||
[`%${q || ''}%`]
|
||||
);
|
||||
res.json({ users });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Approve guardian link (Mixed Age — manager+ sets guardian, clears approval flag, unsuspends)
|
||||
router.patch('/:id/approve-guardian', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
try {
|
||||
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
|
||||
if (!minor) return res.status(404).json({ error: 'User not found' });
|
||||
if (!minor.guardian_approval_required) return res.status(400).json({ error: 'No pending approval' });
|
||||
await exec(req.schema,
|
||||
"UPDATE users SET guardian_approval_required=FALSE,status='active',updated_at=NOW() WHERE id=$1",
|
||||
[id]
|
||||
);
|
||||
await addUserToPublicGroups(req.schema, id);
|
||||
const user = await queryOne(req.schema,
|
||||
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status FROM users WHERE id=$1',
|
||||
[id]
|
||||
);
|
||||
res.json({ user });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Deny guardian link (Mixed Age — clears guardian, keeps suspended)
|
||||
router.patch('/:id/deny-guardian', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
try {
|
||||
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
|
||||
if (!minor) return res.status(404).json({ error: 'User not found' });
|
||||
await exec(req.schema,
|
||||
'UPDATE users SET guardian_approval_required=FALSE,guardian_user_id=NULL,updated_at=NOW() WHERE id=$1',
|
||||
[id]
|
||||
);
|
||||
const user = await queryOne(req.schema,
|
||||
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status FROM users WHERE id=$1',
|
||||
[id]
|
||||
);
|
||||
res.json({ user });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Guardian self-link (Mixed Age — user links themselves as guardian of a minor, triggers approval)
|
||||
router.patch('/me/link-minor/:minorId', authMiddleware, async (req, res) => {
|
||||
const minorId = parseInt(req.params.minorId);
|
||||
try {
|
||||
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [minorId]);
|
||||
if (!minor) return res.status(404).json({ error: 'Minor user not found' });
|
||||
if (!minor.is_minor) return res.status(400).json({ error: 'User is not flagged as a minor' });
|
||||
if (minor.guardian_user_id && !minor.guardian_approval_required)
|
||||
return res.status(400).json({ error: 'This minor already has an approved guardian' });
|
||||
await exec(req.schema,
|
||||
'UPDATE users SET guardian_user_id=$1,guardian_approval_required=TRUE,updated_at=NOW() WHERE id=$2',
|
||||
[req.user.id, minorId]
|
||||
);
|
||||
res.json({ success: true, pendingApproval: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user