v0.12.22 User Manager updates

This commit is contained in:
2026-03-24 15:19:32 -04:00
parent 65e7cc4007
commit 72094d7d15
8 changed files with 372 additions and 64 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "rosterchirp-frontend",
"version": "0.12.21",
"version": "0.12.22",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -14,18 +14,38 @@ function isValidPhone(p) {
return /^\d{7,15}$/.test(digits);
}
function parseCSV(text) {
// 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];
if (i === 0 && /^name\s*,/i.test(line)) continue;
const parts = line.split(',').map(p => p.trim());
if (parts.length < 2 || parts.length > 4) { invalid.push({ line, reason: 'Must have 24 comma-separated fields' }); continue; }
const [name, email, password, role] = parts;
if (!name || !/\S+\s+\S+/.test(name)) { invalid.push({ line, reason: 'Name must be two words (First Last)' }); continue; }
if (!email || !isValidEmail(email)) { invalid.push({ line, reason: `Invalid email: "${email}"` }); continue; }
rows.push({ name: name.trim(), email: email.trim().toLowerCase(), password: (password || '').trim(), role: (role || 'member').trim().toLowerCase() });
// 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 };
}
@@ -91,7 +111,7 @@ function UserRow({ u, onUpdated, onEdit }) {
}
// ── User Form (create / edit) ─────────────────────────────────────────────────
function UserForm({ user, userPass, onDone, onCancel, isMobile, onIF, onIB }) {
function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, onIF, onIB }) {
const toast = useToast();
const isEdit = !!user;
@@ -104,6 +124,19 @@ function UserForm({ user, userPass, onDone, onCancel, isMobile, onIF, onIB }) {
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';
@@ -138,9 +171,16 @@ function UserForm({ user, userPass, onDone, onCancel, isMobile, onIF, onIB }) {
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 {
await api.createUser({
const { user: newUser } = await api.createUser({
firstName: firstName.trim(),
lastName: lastName.trim(),
email: email.trim(),
@@ -149,6 +189,10 @@ function UserForm({ user, userPass, onDone, onCancel, isMobile, onIF, onIB }) {
role,
password,
});
// Add to selected groups
for (const gId of selectedGroupIds) {
await api.addUserToGroup(gId, newUser.id).catch(() => {});
}
toast('User created', 'success');
}
onDone();
@@ -238,6 +282,24 @@ function UserForm({ user, userPass, onDone, onCancel, isMobile, onIF, onIB }) {
</label>
</div>
{/* Row 4b: User Groups */}
{allUserGroups?.length > 0 && (
<div style={{ marginBottom:12 }}>
{lbl('User Groups', false, '(optional)')}
<div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', maxHeight:160, overflowY:'auto', marginTop:6 }}>
{allUserGroups.map(g => (
<label key={g.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'7px 10px', borderBottom:'1px solid var(--border)', cursor:'pointer' }}>
<input type="checkbox"
checked={selectedGroupIds.has(g.id)}
onChange={() => setSelectedGroupIds(prev => { const n = new Set(prev); n.has(g.id) ? n.delete(g.id) : n.add(g.id); return n; })}
style={{ accentColor:'var(--primary)', width:15, height:15 }} />
<span style={{ fontSize:13 }}>{g.name}</span>
</label>
))}
</div>
</div>
)}
{/* Row 5: Password */}
<div style={{ marginBottom:16 }}>
{lbl('Password',
@@ -292,64 +354,161 @@ function UserForm({ user, userPass, onDone, onCancel, isMobile, onIF, onIB }) {
}
// ── Bulk Import Form ──────────────────────────────────────────────────────────
function BulkImportForm({ userPass, onCreated }) {
function BulkImportForm({ userPass, allUserGroups, onCreated }) {
const toast = useToast();
const fileRef = useRef(null);
const [csvFile, setCsvFile] = useState(null);
const [csvRows, setCsvRows] = useState([]);
const [csvInvalid, setCsvInvalid] = useState([]);
const [bulkResult, setBulkResult] = useState(null);
const [loading, setLoading] = useState(false);
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 => { const { rows, invalid } = parseCSV(ev.target.result); setCsvRows(rows); setCsvInvalid(invalid); };
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([]);
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 (
<div style={{ maxWidth:560 }} className="flex-col gap-4">
<div className="card" style={{ background:'var(--background)', border:'1px dashed var(--border)' }}>
<p className="text-sm font-medium" style={{ marginBottom:6 }}>CSV Format</p>
<code style={{ fontSize:12, color:'var(--text-secondary)', display:'block', background:'var(--surface)', padding:8, borderRadius:4, border:'1px solid var(--border)', whiteSpace:'pre' }}>{"name,email,password,role\nJane Smith,jane@company.com,,member\nBob Jones,bob@company.com,TempPass1,admin"}</code>
<p className="text-xs" style={{ color:'var(--text-tertiary)', marginTop:8 }}>Name and email required. Blank password defaults to <strong>{userPass}</strong>, blank role defaults to member.</p>
<div style={{ maxWidth:580, display:'flex', flexDirection:'column', gap:16 }}>
{/* 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>
<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>
<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>
{/* CSV Details accordion */}
<button onClick={() => setDetailsOpen(o => !o)}
style={{ display:'flex', alignItems:'center', gap:6, marginTop:10, background:'none', border:'none', cursor:'pointer', fontSize:13, fontWeight:600, color:'var(--primary)', padding:0 }}>
CSV Details
<span style={{ fontSize:10, opacity:0.7 }}>{detailsOpen ? '▲' : '▼'}</span>
</button>
{detailsOpen && (
<div style={{ marginTop:8, paddingTop:8, borderTop:'1px solid var(--border)', fontSize:12, color:'var(--text-secondary)', display:'flex', flexDirection:'column', gap:10 }}>
<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>A user can only be added to one group during bulk import</li>
<li>Optional fields left blank will use system defaults</li>
</ul>
</div>
{allUserGroups?.length > 0 && (
<div>
<p style={{ fontWeight:600, marginBottom:4 }}>User Groups available</p>
<div style={{ display:'flex', flexDirection:'column', gap:1 }}>
{allUserGroups.map(g => <span key={g.id} style={{ fontFamily:'monospace', fontSize:11 }}>{g.name}</span>)}
</div>
</div>
)}
<div>
<p style={{ fontWeight:600, marginBottom:4 }}>Roles available</p>
<ul style={{ paddingLeft:16, margin:0, lineHeight:1.8 }}>
<li><code>member</code> non-privileged user <span style={{ color:'var(--text-tertiary)' }}>(default)</span></li>
<li><code>manager</code> privileged: manage schedules/users/groups</li>
<li><code>admin</code> privileged: manager + settings + branding</li>
</ul>
</div>
<p style={{ color:'var(--text-tertiary)', marginTop:2 }}>
Optional field defaults: password = <strong>{userPass}</strong>, role = member, usergroup = (none), minor = (none)
</p>
</div>
)}
</div>
{/* File picker row */}
<div style={{ display:'flex', alignItems:'center', gap:10, flexWrap:'wrap' }}>
<label className="btn btn-secondary" style={{ cursor:'pointer', margin:0 }}>
Select CSV File<input ref={fileRef} type="file" accept=".csv,.txt" style={{ display:'none' }} onChange={handleFile} />
Select CSV File
<input ref={fileRef} type="file" accept=".csv,.txt" style={{ display:'none' }} onChange={handleFile} />
</label>
{csvFile && <span className="text-sm" style={{ color:'var(--text-secondary)' }}>{csvFile.name}{csvRows.length > 0 && <span style={{ color:'var(--text-tertiary)', marginLeft:6 }}>({csvRows.length} valid)</span>}</span>}
{csvRows.length > 0 && <button className="btn btn-primary" onClick={handleImport} disabled={loading}>{loading ? 'Creating…' : `Create ${csvRows.length} User${csvRows.length!==1?'s':''}`}</button>}
{csvFile && (
<span style={{ fontSize:13, color:'var(--text-secondary)' }}>
{csvFile.name}
{csvRows.length > 0 && <span style={{ color:'var(--text-tertiary)', marginLeft:6 }}>({csvRows.length} valid row{csvRows.length!==1?'s':''})</span>}
</span>
)}
</div>
{/* Ignore first row checkbox */}
<label style={{ display:'flex', alignItems:'center', gap:8, cursor:'pointer', fontSize:13, color:'var(--text-primary)', userSelect:'none' }}>
<input type="checkbox" checked={ignoreFirst} onChange={e => setIgnoreFirst(e.target.checked)}
style={{ accentColor:'var(--primary)', width:15, height:15 }} />
Ignore first row (header)
</label>
{/* Import button */}
{csvRows.length > 0 && (
<div>
<button className="btn btn-primary" onClick={handleImport} disabled={loading}>
{loading ? 'Creating…' : `Create ${csvRows.length} User${csvRows.length!==1?'s':''}`}
</button>
</div>
)}
{/* Skipped rows */}
{csvInvalid.length > 0 && (
<div style={{ background:'rgba(229,57,53,0.07)', border:'1px solid #e53935', borderRadius:'var(--radius)', padding:10 }}>
<p className="text-sm font-medium" style={{ color:'#e53935', marginBottom:6 }}>{csvInvalid.length} line{csvInvalid.length!==1?'s':''} skipped</p>
<div style={{ maxHeight:100, overflowY:'auto' }}>
{csvInvalid.map((e,i) => <div key={i} style={{ fontSize:12, padding:'2px 0', color:'var(--text-secondary)' }}><code style={{ fontSize:11 }}>{e.line}</code><span style={{ color:'#e53935', marginLeft:8 }}> {e.reason}</span></div>)}
<p style={{ fontSize:13, fontWeight:600, color:'#e53935', marginBottom:6 }}>{csvInvalid.length} row{csvInvalid.length!==1?'s':''} skipped</p>
<div style={{ maxHeight:120, overflowY:'auto' }}>
{csvInvalid.map((e,i) => (
<div key={i} style={{ fontSize:12, padding:'2px 0', color:'var(--text-secondary)' }}>
<code style={{ fontSize:11 }}>{e.line}</code>
<span style={{ color:'#e53935', marginLeft:8 }}> {e.reason}</span>
</div>
))}
</div>
</div>
)}
{/* Result */}
{bulkResult && (
<div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', padding:12 }}>
<p className="text-sm font-medium" style={{ color:'var(--success)', marginBottom: bulkResult.skipped.length ? 8 : 0 }}> {bulkResult.created.length} user{bulkResult.created.length!==1?'s':''} created</p>
<p style={{ fontSize:13, fontWeight:600, color:'var(--success)', marginBottom: bulkResult.skipped.length ? 8 : 0 }}>
{bulkResult.created.length} user{bulkResult.created.length!==1?'s':''} created
</p>
{bulkResult.skipped.length > 0 && (
<>
<p className="text-sm font-medium" style={{ color:'var(--text-secondary)', marginBottom:6 }}>{bulkResult.skipped.length} skipped:</p>
<p style={{ fontSize:13, fontWeight:600, color:'var(--text-secondary)', marginBottom:6 }}>{bulkResult.skipped.length} skipped:</p>
<div style={{ maxHeight:112, overflowY:'auto', border:'1px solid var(--border)', borderRadius:'var(--radius)' }}>
{bulkResult.skipped.map((s,i) => (
<div key={i} style={{ display:'flex', justifyContent:'space-between', padding:'5px 10px', borderBottom: i<bulkResult.skipped.length-1?'1px solid var(--border)':'none', fontSize:13, gap:12 }}>
<span>{s.email}</span><span style={{ color:'var(--text-tertiary)', flexShrink:0 }}>{s.reason}</span>
<span>{s.email}</span>
<span style={{ color:'var(--text-tertiary)', flexShrink:0 }}>{s.reason}</span>
</div>
))}
</div>
@@ -364,13 +523,14 @@ function BulkImportForm({ userPass, onCreated }) {
// ── 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 [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);
@@ -385,6 +545,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
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
@@ -443,6 +604,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
<span style={{ fontWeight:700, fontSize:14, marginRight:4, color:'var(--text-primary)' }}>Users</span>
<button className={`btn btn-sm ${!isFormView && view !== 'bulk' ? 'btn-primary' : 'btn-secondary'}`} onClick={goList}>All</button>
<button className={`btn btn-sm ${isFormView ? 'btn-primary' : 'btn-secondary'}`} onClick={goCreate}>+ Create</button>
<button className={`btn btn-sm ${view === 'bulk' ? 'btn-primary' : 'btn-secondary'}`} onClick={goBulk}>Bulk</button>
</div>
)}
@@ -485,6 +647,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
<UserForm
user={view === 'edit' ? editUser : null}
userPass={userPass}
allUserGroups={allUserGroups}
onDone={() => { load(); goList(); }}
onCancel={goList}
isMobile={isMobile}
@@ -497,7 +660,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} onCreated={load} />
<BulkImportForm userPass={userPass} allUserGroups={allUserGroups} onCreated={load} />
</div>
)}
</div>

View File

@@ -132,18 +132,13 @@ export const api = {
getMyUserGroups: () => req('GET', '/usergroups/me'),
getUserGroups: () => req('GET', '/usergroups'),
getUserGroup: (id) => req('GET', `/usergroups/${id}`),
getUserGroupsForUser: (userId) => req('GET', `/usergroups/byuser/${userId}`),
createUserGroup: (body) => req('POST', '/usergroups', body),
updateUserGroup: (id, body) => req('PATCH', `/usergroups/${id}`, body),
deleteUserGroup: (id) => req('DELETE', `/usergroups/${id}`),
getUserGroup: (id) => req('GET', `/usergroups/${id}`),
updateUserGroupMembers: (id, memberIds) => req('PATCH', `/usergroups/${id}`, { memberIds }),
getMultiGroupDms: () => req('GET', '/usergroups/multigroup'),
createMultiGroupDm: (body) => req('POST', '/usergroups/multigroup', body),
deleteMultiGroupDm: (id) => req('DELETE', `/usergroups/multigroup/${id}`),
// U2U Restrictions
getGroupRestrictions: (id) => req('GET', `/usergroups/${id}/restrictions`),
addUserToGroup: (groupId, userId) => req('POST', `/usergroups/${groupId}/members/${userId}`, {}),
removeUserFromGroup: (groupId, userId) => req('DELETE', `/usergroups/${groupId}/members/${userId}`),
removeUserGroupMember: (groupId, userId) => req('DELETE', `/usergroups/${groupId}/members/${userId}`),
setGroupRestrictions: (id, blockedGroupIds) => req('PUT', `/usergroups/${id}/restrictions`, { blockedGroupIds }),
// Multi-group DMs
getMultiGroupDms: () => req('GET', '/usergroups/multigroup'),
createMultiGroupDm: (body) => req('POST', '/usergroups/multigroup', body),
@@ -151,7 +146,6 @@ export const api = {
deleteMultiGroupDm: (id) => req('DELETE', `/usergroups/multigroup/${id}`),
// U2U Restrictions
getGroupRestrictions: (id) => req('GET', `/usergroups/${id}/restrictions`),
removeUserGroupMember: (groupId, userId) => req('DELETE', `/usergroups/${groupId}/members/${userId}`),
setGroupRestrictions: (id, blockedGroupIds) => req('PUT', `/usergroups/${id}/restrictions`, { blockedGroupIds }),
uploadLogo: (file) => {
const form = new FormData(); form.append('logo', file);