From 18e4a922414fa652a407d3566c5977f736be97f6 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Thu, 2 Apr 2026 15:22:38 -0400 Subject: [PATCH] minor bug fixes --- backend/src/routes/users.js | 17 +++----- frontend/src/components/ProfileModal.jsx | 53 ++++++++++++++++++++++- frontend/src/components/SettingsModal.jsx | 4 +- frontend/src/pages/UserManagerPage.jsx | 34 ++++++++------- 4 files changed, 79 insertions(+), 29 deletions(-) diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 3dc6331..5abf4be 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -123,9 +123,6 @@ router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => { 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 @@ -164,10 +161,6 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => 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') @@ -235,12 +228,16 @@ router.post('/bulk', authMiddleware, teamManagerMiddleware, async (req, res) => const newRole = validRoles.includes(u.role) ? u.role : 'member'; const fn = firstName || name.split(' ')[0] || ''; const ln = lastName || name.split(' ').slice(1).join(' ') || ''; + const dob = (u.dateOfBirth || u.dob || '').trim() || null; + const isMinor = isMinorFromDOB(dob); + const loginType = await getLoginType(req.schema); + const initStatus = (loginType === 'mixed_age' && isMinor) ? 'suspended' : 'active'; const r = await queryResult(req.schema, - "INSERT INTO users (name,first_name,last_name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,'active',TRUE) RETURNING id", - [resolvedName, fn, ln, email, hash, newRole] + "INSERT INTO users (name,first_name,last_name,email,password,role,date_of_birth,is_minor,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,TRUE) RETURNING id", + [resolvedName, fn, ln, email, hash, newRole, dob, isMinor, initStatus] ); const userId = r.rows[0].id; - await addUserToPublicGroups(req.schema, userId); + if (initStatus === 'active') await addUserToPublicGroups(req.schema, userId); if (newRole === '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]); diff --git a/frontend/src/components/ProfileModal.jsx b/frontend/src/components/ProfileModal.jsx index 8d83dd6..a92a6f8 100644 --- a/frontend/src/components/ProfileModal.jsx +++ b/frontend/src/components/ProfileModal.jsx @@ -36,8 +36,10 @@ export default function ProfileModal({ onClose }) { const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag); const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0); - // Minor age protection — DOB/phone display only + // Minor age protection — DOB/phone display + mixed_age forced-DOB gate const [loginType, setLoginType] = useState('all_ages'); + // True when mixed_age mode and the user still has no DOB on record + const needsDob = loginType === 'mixed_age' && !user?.date_of_birth; const savedScale = parseFloat(localStorage.getItem(LS_FONT_KEY)); const [fontScale, setFontScale] = useState( @@ -105,6 +107,55 @@ export default function ProfileModal({ onClose }) { } }; + // ── Forced DOB gate for mixed_age users ─────────────────────────────────── + if (needsDob) { + return ( +
+
+

Date of Birth Required

+

+ Your organisation requires a date of birth on file. Please enter yours to continue. +

+
+ + setDob(e.target.value)} + autoComplete="off" + style={{ borderColor: dob ? undefined : 'var(--error)' }} + /> +
+ +
+
+ ); + } + return (
e.target === e.currentTarget && onClose()}>
diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx index b79f0b6..fdc66fe 100644 --- a/frontend/src/components/SettingsModal.jsx +++ b/frontend/src/components/SettingsModal.jsx @@ -162,7 +162,7 @@ function TeamManagementTab() { const LOGIN_TYPE_OPTIONS = [ { id: 'all_ages', - label: 'All Ages', + label: 'Unrestricted', desc: 'No age restrictions. All users interact normally. Default behaviour.', }, { @@ -172,7 +172,7 @@ const LOGIN_TYPE_OPTIONS = [ }, { id: 'mixed_age', - label: 'Mixed Age', + label: 'Restricted', desc: "Parents, or user managers, add the minor's user account to their guardian profile. Minor aged users cannot login until a manager approves the guardian link.", }, ]; diff --git a/frontend/src/pages/UserManagerPage.jsx b/frontend/src/pages/UserManagerPage.jsx index c8ee8e2..f433a9a 100644 --- a/frontend/src/pages/UserManagerPage.jsx +++ b/frontend/src/pages/UserManagerPage.jsx @@ -14,12 +14,13 @@ function isValidPhone(p) { return /^\d{7,15}$/.test(digits); } -// Format: email,firstname,lastname,password,role,usergroup (exactly 5 commas / 6 fields) -function parseCSV(text, ignoreFirstRow, allUserGroups) { +// Format: email,firstname,lastname,dob,password,role,usergroup (exactly 6 commas / 7 fields) +function parseCSV(text, ignoreFirstRow, allUserGroups, loginType) { const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean); const rows = [], invalid = []; const groupMap = new Map((allUserGroups || []).map(g => [g.name.toLowerCase(), g])); const validRoles = ['member', 'manager', 'admin']; + const requireDob = loginType === 'mixed_age'; for (let i = 0; i < lines.length; i++) { const line = lines[i]; @@ -27,12 +28,13 @@ function parseCSV(text, ignoreFirstRow, allUserGroups) { if (i === 0 && (ignoreFirstRow || /^e-?mail$/i.test(line.split(',')[0].trim()))) continue; const parts = line.split(','); - if (parts.length !== 6) { invalid.push({ line, reason: `Must have exactly 5 commas (has ${parts.length - 1})` }); continue; } - const [email, firstName, lastName, password, roleRaw, usergroupRaw] = parts.map(p => p.trim()); + if (parts.length !== 7) { invalid.push({ line, reason: `Must have exactly 6 commas (has ${parts.length - 1})` }); continue; } + const [email, firstName, lastName, dobRaw, password, roleRaw, usergroupRaw] = parts.map(p => p.trim()); if (!email || !isValidEmail(email)) { invalid.push({ line, reason: `Invalid email: "${email || '(blank)'}"` }); continue; } if (!firstName) { invalid.push({ line, reason: 'First name required' }); continue; } if (!lastName) { invalid.push({ line, reason: 'Last name required' }); continue; } + if (requireDob && !dobRaw) { invalid.push({ line, reason: 'Date of birth required in Restricted login type' }); continue; } const role = validRoles.includes(roleRaw.toLowerCase()) ? roleRaw.toLowerCase() : 'member'; const matchedGroup = usergroupRaw ? groupMap.get(usergroupRaw.toLowerCase()) : null; @@ -42,6 +44,7 @@ function parseCSV(text, ignoreFirstRow, allUserGroups) { firstName, lastName, password, + dateOfBirth: dobRaw || null, role, userGroupId: matchedGroup?.id || null, userGroupName: usergroupRaw || null, @@ -165,7 +168,6 @@ function UserForm({ user, userPass, allUserGroups, nonMinorUsers, loginType, onD if (!lastName.trim()) return toast('Last name is required', 'error'); if (!isValidPhone(phone)) return toast('Invalid phone number', 'error'); if (!['member', 'admin', 'manager'].includes(role)) return toast('Role is required', 'error'); - if (loginType === 'mixed_age' && !dob) return toast('Date of birth is required in Mixed Age mode', 'error'); if (isEdit && pwEnabled && (!password || password.length < 6)) return toast('New password must be at least 6 characters', 'error'); @@ -286,7 +288,7 @@ function UserForm({ user, userPass, allUserGroups, nonMinorUsers, loginType, onD {/* Row 4: DOB + Guardian */}
- {lbl('Date of Birth', loginType === 'mixed_age', loginType !== 'mixed_age' ? '(optional)' : undefined)} + {lbl('Date of Birth', false, '(optional)')} setDob(e.target.value)} autoComplete="off" onFocus={onIF} onBlur={onIB} /> @@ -388,7 +390,7 @@ function UserForm({ user, userPass, allUserGroups, nonMinorUsers, loginType, onD } // ── Bulk Import Form ────────────────────────────────────────────────────────── -function BulkImportForm({ userPass, allUserGroups, onCreated }) { +function BulkImportForm({ userPass, allUserGroups, loginType, onCreated }) { const toast = useToast(); const fileRef = useRef(null); const [csvFile, setCsvFile] = useState(null); @@ -403,9 +405,9 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) { // Re-parse whenever raw text or options change useEffect(() => { if (!rawText) return; - const { rows, invalid } = parseCSV(rawText, ignoreFirst, allUserGroups); + const { rows, invalid } = parseCSV(rawText, ignoreFirst, allUserGroups, loginType); setCsvRows(rows); setCsvInvalid(invalid); - }, [rawText, ignoreFirst, allUserGroups]); + }, [rawText, ignoreFirst, allUserGroups, loginType]); const handleFile = e => { const file = e.target.files?.[0]; if (!file) return; @@ -435,11 +437,11 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) { {/* Format info box */}

CSV Format

- {'FULL: email,firstname,lastname,password,role,usergroup'} - {'MINIMUM: email,firstname,lastname,,,'} + {'FULL: email,firstname,lastname,dob,password,role,usergroup'} + {'MINIMUM: email,firstname,lastname,,,,'}

Examples:

- {'example@rosterchirp.com,Barney,Rubble,,member,parents'} - {'example@rosterchirp.com,Barney,Rubble,Ori0n2026!,member,players'} + {'example@rosterchirp.com,Barney,Rubble,1970-11-21,,member,parents'} + {'example@rosterchirp.com,Barney,Rubble,2013-06-11,Ori0n2026!,member,players'}

Blank password defaults to {userPass}. Blank role defaults to member. We recommend using a spreadsheet editor and saving as CSV.

@@ -455,8 +457,8 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) {

CSV Requirements

    -
  • Exactly 5 commas per row (rows with more or less will be skipped)
  • -
  • email, firstname, lastname are required
  • +
  • Exactly six (6) commas per row (rows with more or less will be skipped)
  • +
  • email, firstname, lastname are required fields{loginType === 'mixed_age' ? <> (DOB field required for Restricted login type) : ''}.
  • A user can only be added to one group during bulk import
  • Optional fields left blank will use system defaults
@@ -711,7 +713,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o {/* BULK IMPORT */} {view === 'bulk' && (
- +
)}