minor bug fixes

This commit is contained in:
2026-04-02 15:22:38 -04:00
parent 97b308e9f0
commit 18e4a92241
4 changed files with 79 additions and 29 deletions

View File

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

View File

@@ -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 (
<div className="modal-overlay">
<div className="modal" style={{ maxWidth: 380 }}>
<h2 className="modal-title" style={{ marginBottom: 8 }}>Date of Birth Required</h2>
<p className="text-sm" style={{ color: 'var(--text-secondary)', marginBottom: 16, lineHeight: 1.5 }}>
Your organisation requires a date of birth on file. Please enter yours to continue.
</p>
<div className="flex-col gap-1" style={{ marginBottom: 16 }}>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
Date of Birth <span style={{ color: 'var(--error)' }}>*</span>
</label>
<input
className="input"
type="text"
placeholder="YYYY-MM-DD"
value={dob}
onChange={e => setDob(e.target.value)}
autoComplete="off"
style={{ borderColor: dob ? undefined : 'var(--error)' }}
/>
</div>
<button
className="btn btn-primary"
style={{ width: '100%' }}
disabled={loading || !dob.trim()}
onClick={async () => {
if (!dob.trim()) return;
setLoading(true);
try {
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth: dob.trim(), phone: phone || null });
updateUser(updated);
toast('Profile updated', 'success');
// needsDob will re-evaluate to false now that user.date_of_birth is set
} catch (e) {
toast(e.message, 'error');
} finally {
setLoading(false);
}
}}
>
{loading ? 'Saving…' : 'Save & Continue'}
</button>
</div>
</div>
);
}
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal">

View File

@@ -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.",
},
];

View File

@@ -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 */}
<div style={{ display:'grid', gridTemplateColumns:colGrid, gap:12, marginBottom:12 }}>
<div>
{lbl('Date of Birth', loginType === 'mixed_age', loginType !== 'mixed_age' ? '(optional)' : undefined)}
{lbl('Date of Birth', false, '(optional)')}
<input className="input" type="text" placeholder="YYYY-MM-DD"
value={dob} onChange={e => 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 */}
<div style={{ background:'var(--background)', border:'1px dashed var(--border)', borderRadius:'var(--radius)', padding:'12px 14px' }}>
<p style={{ fontSize:13, fontWeight:600, marginBottom:8 }}>CSV Format</p>
<code style={codeStyle}>{'FULL: email,firstname,lastname,password,role,usergroup'}</code>
<code style={codeStyle}>{'MINIMUM: email,firstname,lastname,,,'}</code>
<code style={codeStyle}>{'FULL: email,firstname,lastname,dob,password,role,usergroup'}</code>
<code style={codeStyle}>{'MINIMUM: email,firstname,lastname,,,,'}</code>
<p style={{ fontSize:12, color:'var(--text-tertiary)', margin:'8px 0 6px' }}>Examples:</p>
<code style={codeStyle}>{'example@rosterchirp.com,Barney,Rubble,,member,parents'}</code>
<code style={codeStyle}>{'example@rosterchirp.com,Barney,Rubble,Ori0n2026!,member,players'}</code>
<code style={codeStyle}>{'example@rosterchirp.com,Barney,Rubble,1970-11-21,,member,parents'}</code>
<code style={codeStyle}>{'example@rosterchirp.com,Barney,Rubble,2013-06-11,Ori0n2026!,member,players'}</code>
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:8 }}>
Blank password defaults to <strong>{userPass}</strong>. Blank role defaults to member. We recommend using a spreadsheet editor and saving as CSV.
</p>
@@ -455,8 +457,8 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) {
<div>
<p style={{ fontWeight:600, marginBottom:4 }}>CSV Requirements</p>
<ul style={{ paddingLeft:16, margin:0, lineHeight:1.8 }}>
<li>Exactly 5 commas per row (rows with more or less will be skipped)</li>
<li><code>email</code>, <code>firstname</code>, <code>lastname</code> are required</li>
<li>Exactly six (6) commas per row (rows with more or less will be skipped)</li>
<li><code>email</code>, <code>firstname</code>, <code>lastname</code> are required fields{loginType === 'mixed_age' ? <> (DOB field required for <strong>Restricted</strong> login type)</> : ''}.</li>
<li>A user can only be added to one group during bulk import</li>
<li>Optional fields left blank will use system defaults</li>
</ul>
@@ -711,7 +713,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
{/* BULK IMPORT */}
{view === 'bulk' && (
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 16, overscrollBehavior:'contain' }}>
<BulkImportForm userPass={userPass} allUserGroups={allUserGroups} onCreated={load} />
<BulkImportForm userPass={userPass} allUserGroups={allUserGroups} loginType={loginType} onCreated={load} />
</div>
)}
</div>