import { useState, useEffect, useRef, useCallback } from 'react'; import { useToast } from '../contexts/ToastContext.jsx'; import { api } from '../utils/api.js'; import Avatar from '../components/Avatar.jsx'; import UserFooter from '../components/UserFooter.jsx'; import PasswordInput from '../components/PasswordInput.jsx'; const SIDEBAR_W = 320; function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); } function isValidPhone(p) { if (!p || !p.trim()) return true; const digits = p.replace(/[\s\-\(\)\+\.x#]/g, ''); return /^\d{7,15}$/.test(digits); } // Format: email,firstname,lastname,password,role,usergroup (exactly 5 commas / 6 fields) function parseCSV(text, ignoreFirstRow, allUserGroups) { 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']; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Skip first row if checkbox set OR if it looks like a header (first field = 'email') 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 (!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; } const role = validRoles.includes(roleRaw.toLowerCase()) ? roleRaw.toLowerCase() : 'member'; const matchedGroup = usergroupRaw ? groupMap.get(usergroupRaw.toLowerCase()) : null; rows.push({ email: email.toLowerCase(), firstName, lastName, password, role, userGroupId: matchedGroup?.id || null, userGroupName: usergroupRaw || null, }); } return { rows, invalid }; } // ── User Row (accordion list item) ─────────────────────────────────────────── function UserRow({ u, onUpdated, onEdit }) { const toast = useToast(); const [open, setOpen] = useState(false); const handleSuspend = async () => { if (!confirm(`Suspend ${u.name}?`)) return; try { await api.suspendUser(u.id); toast('User suspended', 'success'); onUpdated(); } catch (e) { toast(e.message, 'error'); } }; const handleActivate = async () => { try { await api.activateUser(u.id); toast('User activated', 'success'); onUpdated(); } catch (e) { toast(e.message, 'error'); } }; const handleDelete = async () => { if (u.role === 'admin') return toast('Demote to member before deleting an admin', 'error'); if (!confirm(`Delete ${u.name}?\n\nThis will:\n• Anonymise their account and free their email for re-use\n• Remove all their messages from conversations\n• Freeze any direct messages they were part of\n• Remove all their group memberships\n\nThis cannot be undone.`)) return; try { await api.deleteUser(u.id); toast('User deleted', 'success'); onUpdated(); } catch (e) { toast(e.message, 'error'); } }; return (
{open && !u.is_default_admin && (
{u.status === 'active' ? ( ) : u.status === 'suspended' ? ( ) : null}
)}
); } // ── User Form (create / edit) ───────────────────────────────────────────────── function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, onIF, onIB }) { const toast = useToast(); const isEdit = !!user; const [firstName, setFirstName] = useState(user?.first_name || ''); const [lastName, setLastName] = useState(user?.last_name || ''); const [email, setEmail] = useState(user?.email || ''); const [phone, setPhone] = useState(user?.phone || ''); const [role, setRole] = useState(user?.role || 'member'); const [isMinor, setIsMinor] = useState(!!user?.is_minor); const [password, setPassword] = useState(''); const [pwEnabled, setPwEnabled] = useState(!isEdit); const [saving, setSaving] = useState(false); const [selectedGroupIds, setSelectedGroupIds] = useState(new Set()); const [origGroupIds, setOrigGroupIds] = useState(new Set()); useEffect(() => { if (!isEdit || !user?.id || !allUserGroups?.length) return; api.getUserGroupsForUser(user.id) .then(({ groupIds }) => { const ids = new Set((groupIds || []).map(Number)); setSelectedGroupIds(ids); setOrigGroupIds(ids); }) .catch(() => {}); }, [isEdit, user?.id]); const fmtLastLogin = (ts) => { if (!ts) return 'Never'; const d = new Date(ts); const today = new Date(); today.setHours(0,0,0,0); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); const dd = new Date(d); dd.setHours(0,0,0,0); if (dd >= today) return 'Today'; if (dd >= yesterday) return 'Yesterday'; return dd.toISOString().slice(0, 10); }; const handleSubmit = async () => { if (!isEdit && (!email.trim() || !isValidEmail(email.trim()))) return toast('Valid email address required', 'error'); if (!firstName.trim()) return toast('First name is required', 'error'); 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 (!isEdit && (!password || password.length < 6)) return toast('Password must be at least 6 characters', 'error'); if (isEdit && pwEnabled && (!password || password.length < 6)) return toast('New password must be at least 6 characters', 'error'); setSaving(true); try { if (isEdit) { await api.updateUser(user.id, { firstName: firstName.trim(), lastName: lastName.trim(), phone: phone.trim(), isMinor, role, ...(pwEnabled && password ? { password } : {}), }); // Sync group memberships: add newly selected, remove deselected for (const gId of selectedGroupIds) { if (!origGroupIds.has(gId)) await api.addUserToGroup(gId, user.id).catch(() => {}); } for (const gId of origGroupIds) { if (!selectedGroupIds.has(gId)) await api.removeUserFromGroup(gId, user.id).catch(() => {}); } toast('User updated', 'success'); } else { const { user: newUser } = await api.createUser({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), phone: phone.trim(), isMinor, role, password, }); // Add to selected groups for (const gId of selectedGroupIds) { await api.addUserToGroup(gId, newUser.id).catch(() => {}); } toast('User created', 'success'); } onDone(); } catch (e) { toast(e.message, 'error'); } finally { setSaving(false); } }; const colGrid = isMobile ? '1fr' : '1fr 1fr'; const lbl = (text, required, note) => ( ); return (
{/* Back + title */}
{isEdit ? 'Edit User' : 'Create User'}
{/* Row 1: Login (email) — full width */}
{lbl('Login (email)', !isEdit)} setEmail(e.target.value)} disabled={isEdit} style={{ width:'100%', ...(isEdit ? { opacity:0.6, cursor:'not-allowed' } : {}) }} autoComplete="new-password" onFocus={onIF} onBlur={onIB} />
{/* Row 2: First Name + Last Name */}
{lbl('First Name', true)} setFirstName(e.target.value)} autoComplete="new-password" autoCapitalize="words" onFocus={onIF} onBlur={onIB} />
{lbl('Last Name', true)} setLastName(e.target.value)} autoComplete="new-password" autoCapitalize="words" onFocus={onIF} onBlur={onIB} />
{/* Row 3: Phone + Role */}
{lbl('Phone', false, '(optional)')} setPhone(e.target.value)} autoComplete="new-password" onFocus={onIF} onBlur={onIB} />
{lbl('App Role', true)}
{/* Row 4: Is minor */}
{/* Row 4b: User Groups */} {allUserGroups?.length > 0 && (
{lbl('User Groups', false, '(optional)')}
{allUserGroups.map(g => ( ))}
)} {/* Row 5: Password */}
{lbl('Password', (!isEdit) || (isEdit && pwEnabled), isEdit && !pwEnabled ? '(not changing — click Reset Password to set a new one)' : !isEdit ? `(blank = ${userPass})` : null )}
setPassword(e.target.value)} placeholder={isEdit && !pwEnabled ? '••••••••' : 'Min 6 characters'} disabled={!pwEnabled} autoComplete="new-password" onFocus={onIF} onBlur={onIB} />
{/* Row 6: Buttons */}
{isEdit && !pwEnabled && ( )} {isEdit && pwEnabled && ( )}
{/* Row 7 (edit only): Last login + must change password */} {isEdit && (
Last Login: {fmtLastLogin(user.last_online)} {!!user.must_change_password && ( ⚠ Must change password )}
)}
); } // ── Bulk Import Form ────────────────────────────────────────────────────────── function BulkImportForm({ userPass, allUserGroups, onCreated }) { const toast = useToast(); const fileRef = useRef(null); const [csvFile, setCsvFile] = useState(null); const [rawText, setRawText] = useState(''); const [csvRows, setCsvRows] = useState([]); const [csvInvalid, setCsvInvalid] = useState([]); const [bulkResult, setBulkResult] = useState(null); const [loading, setLoading] = useState(false); const [ignoreFirst, setIgnoreFirst] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); // Re-parse whenever raw text or options change useEffect(() => { if (!rawText) return; const { rows, invalid } = parseCSV(rawText, ignoreFirst, allUserGroups); setCsvRows(rows); setCsvInvalid(invalid); }, [rawText, ignoreFirst, allUserGroups]); const handleFile = e => { const file = e.target.files?.[0]; if (!file) return; setCsvFile(file); setBulkResult(null); const reader = new FileReader(); reader.onload = ev => setRawText(ev.target.result); reader.readAsText(file); }; const handleImport = async () => { if (!csvRows.length) return; setLoading(true); try { const result = await api.bulkUsers(csvRows); setBulkResult(result); setCsvRows([]); setCsvFile(null); setCsvInvalid([]); setRawText(''); if (fileRef.current) fileRef.current.value = ''; onCreated(); } catch(e) { toast(e.message, 'error'); } finally { setLoading(false); } }; const codeStyle = { fontSize:12, color:'var(--text-secondary)', display:'block', background:'var(--surface)', padding:'6px 8px', borderRadius:4, border:'1px solid var(--border)', whiteSpace:'pre', fontFamily:'monospace', marginBottom:4 }; return (
{/* Format info box */}

CSV Format

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

Examples:

{'example@rosterchirp.com,Barney,Rubble,,member,parents'} {'example@rosterchirp.com,Barney,Rubble,Ori0n2026!,member,players'}

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

{/* CSV Details accordion */} {detailsOpen && (

CSV Requirements

  • Exactly 5 commas per row (rows with more or less will be skipped)
  • email, firstname, lastname are required
  • A user can only be added to one group during bulk import
  • Optional fields left blank will use system defaults
{allUserGroups?.length > 0 && (

User Groups available

{allUserGroups.map(g => {g.name})}
)}

Roles available

  • member — non-privileged user (default)
  • manager — privileged: manage schedules/users/groups
  • admin — privileged: manager + settings + branding

Optional field defaults: password = {userPass}, role = member, usergroup = (none), minor = (none)

)}
{/* File picker row */}
{csvFile && ( {csvFile.name} {csvRows.length > 0 && ({csvRows.length} valid row{csvRows.length!==1?'s':''})} )}
{/* Ignore first row checkbox */} {/* Import button */} {csvRows.length > 0 && (
)} {/* Skipped rows */} {csvInvalid.length > 0 && (

{csvInvalid.length} row{csvInvalid.length!==1?'s':''} skipped

{csvInvalid.map((e,i) => (
{e.line} — {e.reason}
))}
)} {/* Result */} {bulkResult && (

✓ {bulkResult.created.length} user{bulkResult.created.length!==1?'s':''} created

{bulkResult.skipped.length > 0 && ( <>

{bulkResult.skipped.length} skipped:

{bulkResult.skipped.map((s,i) => (
{s.email} {s.reason}
))}
)}
)}
); } // ── Main page ───────────────────────────────────────────────────────────────── export default function UserManagerPage({ isMobile = false, onProfile, onHelp, onAbout }) { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [search, setSearch] = useState(''); const [view, setView] = useState('list'); // 'list' | 'create' | 'edit' | 'bulk' const [editUser, setEditUser] = useState(null); const [userPass, setUserPass] = useState('user@1234'); const [allUserGroups, setAllUserGroups] = useState([]); const [inputFocused, setInputFocused] = useState(false); const onIF = () => setInputFocused(true); const onIB = () => setInputFocused(false); const load = useCallback(async () => { setLoadError(''); setLoading(true); try { const { users } = await api.getUsers(); setUsers(users || []); } catch(e) { setLoadError(e.message || 'Failed to load users'); } finally { setLoading(false); } }, []); useEffect(() => { load(); api.getSettings().then(({ settings }) => { if (settings.user_pass) setUserPass(settings.user_pass); }).catch(() => {}); api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a,b) => a.name.localeCompare(b.name)))).catch(() => {}); }, [load]); const filtered = users .filter(u => !search || u.name?.toLowerCase().includes(search.toLowerCase()) || u.display_name?.toLowerCase().includes(search.toLowerCase()) || u.email?.toLowerCase().includes(search.toLowerCase()) ) .sort((a, b) => a.name.localeCompare(b.name)); const goList = () => { setView('list'); setEditUser(null); }; const goCreate = () => { setView('create'); setEditUser(null); }; const goEdit = (u) => { setView('edit'); setEditUser(u); }; const goBulk = () => { setView('bulk'); setEditUser(null); }; const navItem = (label, active, onClick) => ( ); const isFormView = view === 'create' || view === 'edit'; return (
{/* ── Desktop sidebar ── */} {!isMobile && (
User Manager
View
{navItem(`All Users${!loading ? ` (${users.length})` : ''}`, view === 'list' || view === 'edit', goList)} {navItem('+ Create User', view === 'create', goCreate)} {navItem('Bulk Import CSV', view === 'bulk', goBulk)}
)} {/* ── Right panel ── */}
{/* Mobile tab bar */} {isMobile && (
Users
)} {/* Content */}
{/* LIST VIEW */} {view === 'list' && ( <>
setSearch(e.target.value)} onFocus={onIF} onBlur={onIB} autoComplete="new-password" autoCorrect="off" spellCheck={false} style={{ width:'100%', maxWidth: isMobile ? '100%' : 400 }} />
{loading ? (
) : loadError ? (
⚠ {loadError}
) : filtered.length === 0 ? (
{search ? 'No users match your search.' : 'No users yet.'}
) : ( filtered.map(u => ) )}
)} {/* CREATE / EDIT FORM */} {isFormView && (
{ load(); goList(); }} onCancel={goList} isMobile={isMobile} onIF={onIF} onIB={onIB} />
)} {/* BULK IMPORT */} {view === 'bulk' && (
)}
{/* Mobile footer — fixed, hidden when keyboard is up */} {isMobile && !inputFocused && (
)}
); }