minor bug fixes
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user