v0.12.43 minor protection added

This commit is contained in:
2026-03-30 16:02:09 -04:00
parent e8e941c436
commit fe836ae69f
18 changed files with 1132 additions and 105 deletions

View File

@@ -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;