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

51
Bulk_User_Import.txt Normal file
View File

@@ -0,0 +1,51 @@
<info_box>
CVS Format (title>
FULL: email,firstname,lastname,password,role,default_usergroup
MINIMUM: email,firstname,lastname,,,
OPTIONAL: email,firstname,lastname,,,default_usergroup
We highly recommend using spreadsheet editor and save as a CSV file to ensure maximum accuracy
You can include this header row (ie: email,firstname,lastname,password,role,usergroup,minor,guardian-user-email)
Examples:
Parent: example@rosterchirp.com,Barney,Rubble,,member,parents,,
Player: example@rosterchirp.com,Barney,Rubble,Ori0n2026!,member,players,,
Minor: Player: example@rosterchirp.com,Barney,Rubble,Ori0n2026!,member,players,minor,example@rosterchirp.com
CVS Details (accordion title) - collapsed by default, click title to expand accordion
<accordion>
CSV requirements:
five commas, exactly, are required per row (rows with more or less will be ignored)
email,firstname,lastname are the minimum required fields
user can onlt be added to one group during a bulk import.
optional fields: these fields can be left blank and the system defaults will be used
User Groups available: *
list $user_groups (single column, multiple rows) that new users can be added to
Roles available: *
member - non-priviledged user (default)
manager - priviledged user: add/edit/remove schedules/users/user groups etc
admin - priviledged user: manager + edit settings, change branding
* Only available group values (user group, roles) will be used, group values that do not exist will be ignored
Option field defaults:
password ($setpass)
role = member
usergroup = <unset>
minor = <unset>
guardian-user-email = <unset>
</accordion>
</info_box>
[Select CVS file] button as it currently exists
checkbox "Ignore first row (header)"
**** Do not include the following text in the details above, they are your build instrcutions
Build Instructions
- validate all CVS requirements, skip rows that do not mean requirements
- even if ignore first row is unchecked, check first header row for any values in "FULL" format, if true ignore row

View File

@@ -1,6 +1,6 @@
{ {
"name": "rosterchirp-backend", "name": "rosterchirp-backend",
"version": "0.12.21", "version": "0.12.22",
"description": "RosterChirp backend server", "description": "RosterChirp backend server",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {

View File

@@ -193,6 +193,14 @@ router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// GET /byuser/:userId — user group IDs for a specific user
router.get('/byuser/:userId', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const rows = await query(req.schema, 'SELECT user_group_id FROM user_group_members WHERE user_id=$1', [req.params.userId]);
res.json({ groupIds: rows.map(r => r.user_group_id) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// GET /:id // GET /:id
router.get('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => { router.get('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
try { try {
@@ -364,6 +372,80 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
}); });
// POST /:id/members/:userId — add a single user to a group (with DM + notifications)
router.post('/:id/members/:userId', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
if (!ug) return res.status(404).json({ error: 'Not found' });
const userId = parseInt(req.params.userId);
const defaultAdmin = await queryOne(req.schema, 'SELECT id FROM users WHERE is_default_admin=TRUE');
if (defaultAdmin && userId === defaultAdmin.id) return res.status(400).json({ error: 'Cannot add default admin to user groups' });
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, userId]);
if (ug.dm_group_id) {
await addUserSilent(req.schema, ug.dm_group_id, userId);
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has joined the conversation.`);
}
// Propagate to multi-group DMs
const mgDms = await query(req.schema, `
SELECT mgd.id, mgd.dm_group_id FROM multi_group_dm_members mgdm
JOIN multi_group_dms mgd ON mgd.id=mgdm.multi_group_dm_id WHERE mgdm.user_group_id=$1
`, [ug.id]);
for (const mg of mgDms) {
if (!mg.dm_group_id) continue;
await addUserSilent(req.schema, mg.dm_group_id, userId);
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has joined this conversation.`);
}
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// DELETE /:id/members/:userId — remove a single user from a group (with DM + notifications)
router.delete('/:id/members/:userId', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
if (!ug) return res.status(404).json({ error: 'Not found' });
const userId = parseInt(req.params.userId);
await exec(req.schema, 'DELETE FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [ug.id, userId]);
if (ug.dm_group_id) {
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [ug.dm_group_id, userId]);
io.in(R(req.schema,'user',userId)).socketsLeave(R(req.schema,'group',ug.dm_group_id));
io.to(R(req.schema,'user',userId)).emit('group:deleted', { groupId: ug.dm_group_id });
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has been removed from the conversation.`);
}
// Propagate to multi-group DMs
const mgDms = await query(req.schema, `
SELECT mgd.id, mgd.dm_group_id FROM multi_group_dm_members mgdm
JOIN multi_group_dms mgd ON mgd.id=mgdm.multi_group_dm_id WHERE mgdm.user_group_id=$1
`, [ug.id]);
for (const mg of mgDms) {
if (!mg.dm_group_id) continue;
const stillIn = await queryOne(req.schema, `
SELECT 1 FROM multi_group_dm_members mgdm JOIN user_group_members ugm ON ugm.user_group_id=mgdm.user_group_id
WHERE mgdm.multi_group_dm_id=$1 AND ugm.user_id=$2
`, [mg.id, userId]);
if (!stillIn) {
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, userId]);
io.in(R(req.schema,'user',userId)).socketsLeave(R(req.schema,'group',mg.dm_group_id));
io.to(R(req.schema,'user',userId)).emit('group:deleted', { groupId: mg.dm_group_id });
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has been removed from this conversation.`);
}
}
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── U2U DM Restrictions ─────────────────────────────────────────────────────── // ── U2U DM Restrictions ───────────────────────────────────────────────────────
// GET /:id/restrictions — get blocked group IDs for a user group // GET /:id/restrictions — get blocked group IDs for a user group

View File

@@ -152,29 +152,47 @@ router.post('/bulk', authMiddleware, teamManagerMiddleware, async (req, res) =>
const results = { created: [], skipped: [] }; const results = { created: [], skipped: [] };
const seenEmails = new Set(); const seenEmails = new Set();
const defaultPw = process.env.USER_PASS || 'user@1234'; const defaultPw = process.env.USER_PASS || 'user@1234';
const validRoles = ['member', 'manager', 'admin'];
try { try {
for (const u of users) { for (const u of users) {
const email = (u.email || '').trim().toLowerCase(); const email = (u.email || '').trim().toLowerCase();
const name = (u.name || '').trim(); const firstName = (u.firstName || '').trim();
if (!name || !email) { results.skipped.push({ email: email || '(blank)', reason: 'Missing name or email' }); continue; } const lastName = (u.lastName || '').trim();
if (!isValidEmail(email)) { results.skipped.push({ email, reason: 'Invalid email address' }); continue; } // Support legacy name field too
if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; } const name = (firstName && lastName) ? `${firstName} ${lastName}` : (u.name || '').trim();
if (!email) { results.skipped.push({ email: '(blank)', reason: 'Email required' }); continue; }
if (!isValidEmail(email)){ results.skipped.push({ email, reason: 'Invalid email address' }); continue; }
if (!name) { results.skipped.push({ email, reason: 'First and last name required' }); continue; }
if (seenEmails.has(email)){ results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; }
seenEmails.add(email); seenEmails.add(email);
const exists = await queryOne(req.schema, "SELECT id FROM users WHERE email=$1 AND status != 'deleted'", [email]); const exists = await queryOne(req.schema, "SELECT id FROM users WHERE email=$1 AND status != 'deleted'", [email]);
if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; } if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; }
try { try {
const resolvedName = await resolveUniqueName(req.schema, name); const resolvedName = await resolveUniqueName(req.schema, name);
const pw = (u.password || '').trim() || defaultPw; const pw = (u.password || '').trim() || defaultPw;
const hash = bcrypt.hashSync(pw, 10); const hash = bcrypt.hashSync(pw, 10);
const newRole = u.role === 'admin' ? 'admin' : 'member'; const newRole = validRoles.includes(u.role) ? u.role : 'member';
const fn = firstName || name.split(' ')[0] || '';
const ln = lastName || name.split(' ').slice(1).join(' ') || '';
const r = await queryResult(req.schema, const r = await queryResult(req.schema,
"INSERT INTO users (name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,'active',TRUE) RETURNING id", "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, email, hash, newRole] [resolvedName, fn, ln, email, hash, newRole]
); );
await addUserToPublicGroups(req.schema, r.rows[0].id); const userId = r.rows[0].id;
await addUserToPublicGroups(req.schema, userId);
if (newRole === 'admin') { if (newRole === 'admin') {
const sgId = await getOrCreateSupportGroup(req.schema); 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, r.rows[0].id]); if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
}
// Add to user group if specified (silent — user was just created, no socket needed)
if (u.userGroupId) {
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [u.userGroupId]);
if (ug) {
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, userId]);
if (ug.dm_group_id) {
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.dm_group_id, userId]);
}
}
} }
results.created.push(email); results.created.push(email);
} catch (e) { results.skipped.push({ email, reason: e.message }); } } catch (e) { results.skipped.push({ email, reason: e.message }); }

View File

@@ -13,7 +13,7 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
VERSION="${1:-0.12.21}" VERSION="${1:-0.12.22}"
ACTION="${2:-}" ACTION="${2:-}"
REGISTRY="${REGISTRY:-}" REGISTRY="${REGISTRY:-}"
IMAGE_NAME="rosterchirp" IMAGE_NAME="rosterchirp"

View File

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

View File

@@ -14,18 +14,38 @@ function isValidPhone(p) {
return /^\d{7,15}$/.test(digits); 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 lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
const rows = [], invalid = []; 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++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const line = lines[i];
if (i === 0 && /^name\s*,/i.test(line)) continue; // Skip first row if checkbox set OR if it looks like a header (first field = 'email')
const parts = line.split(',').map(p => p.trim()); if (i === 0 && (ignoreFirstRow || /^e-?mail$/i.test(line.split(',')[0].trim()))) continue;
if (parts.length < 2 || parts.length > 4) { invalid.push({ line, reason: 'Must have 24 comma-separated fields' }); continue; }
const [name, email, password, role] = parts; const parts = line.split(',');
if (!name || !/\S+\s+\S+/.test(name)) { invalid.push({ line, reason: 'Name must be two words (First Last)' }); continue; } if (parts.length !== 6) { invalid.push({ line, reason: `Must have exactly 5 commas (has ${parts.length - 1})` }); continue; }
if (!email || !isValidEmail(email)) { invalid.push({ line, reason: `Invalid email: "${email}"` }); continue; } const [email, firstName, lastName, password, roleRaw, usergroupRaw] = parts.map(p => p.trim());
rows.push({ name: name.trim(), email: email.trim().toLowerCase(), password: (password || '').trim(), role: (role || 'member').trim().toLowerCase() });
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 }; return { rows, invalid };
} }
@@ -91,7 +111,7 @@ function UserRow({ u, onUpdated, onEdit }) {
} }
// ── User Form (create / edit) ───────────────────────────────────────────────── // ── 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 toast = useToast();
const isEdit = !!user; const isEdit = !!user;
@@ -104,6 +124,19 @@ function UserForm({ user, userPass, onDone, onCancel, isMobile, onIF, onIB }) {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [pwEnabled, setPwEnabled] = useState(!isEdit); const [pwEnabled, setPwEnabled] = useState(!isEdit);
const [saving, setSaving] = useState(false); 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) => { const fmtLastLogin = (ts) => {
if (!ts) return 'Never'; if (!ts) return 'Never';
@@ -138,9 +171,16 @@ function UserForm({ user, userPass, onDone, onCancel, isMobile, onIF, onIB }) {
role, role,
...(pwEnabled && password ? { password } : {}), ...(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'); toast('User updated', 'success');
} else { } else {
await api.createUser({ const { user: newUser } = await api.createUser({
firstName: firstName.trim(), firstName: firstName.trim(),
lastName: lastName.trim(), lastName: lastName.trim(),
email: email.trim(), email: email.trim(),
@@ -149,6 +189,10 @@ function UserForm({ user, userPass, onDone, onCancel, isMobile, onIF, onIB }) {
role, role,
password, password,
}); });
// Add to selected groups
for (const gId of selectedGroupIds) {
await api.addUserToGroup(gId, newUser.id).catch(() => {});
}
toast('User created', 'success'); toast('User created', 'success');
} }
onDone(); onDone();
@@ -238,6 +282,24 @@ function UserForm({ user, userPass, onDone, onCancel, isMobile, onIF, onIB }) {
</label> </label>
</div> </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 */} {/* Row 5: Password */}
<div style={{ marginBottom:16 }}> <div style={{ marginBottom:16 }}>
{lbl('Password', {lbl('Password',
@@ -292,64 +354,161 @@ function UserForm({ user, userPass, onDone, onCancel, isMobile, onIF, onIB }) {
} }
// ── Bulk Import Form ────────────────────────────────────────────────────────── // ── Bulk Import Form ──────────────────────────────────────────────────────────
function BulkImportForm({ userPass, onCreated }) { function BulkImportForm({ userPass, allUserGroups, onCreated }) {
const toast = useToast(); const toast = useToast();
const fileRef = useRef(null); const fileRef = useRef(null);
const [csvFile, setCsvFile] = useState(null); const [csvFile, setCsvFile] = useState(null);
const [csvRows, setCsvRows] = useState([]); const [rawText, setRawText] = useState('');
const [csvInvalid, setCsvInvalid] = useState([]); const [csvRows, setCsvRows] = useState([]);
const [bulkResult, setBulkResult] = useState(null); const [csvInvalid, setCsvInvalid] = useState([]);
const [loading, setLoading] = useState(false); 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 handleFile = e => {
const file = e.target.files?.[0]; if (!file) return; const file = e.target.files?.[0]; if (!file) return;
setCsvFile(file); setBulkResult(null); setCsvFile(file); setBulkResult(null);
const reader = new FileReader(); 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); reader.readAsText(file);
}; };
const handleImport = async () => { const handleImport = async () => {
if (!csvRows.length) return; if (!csvRows.length) return;
setLoading(true); setLoading(true);
try { try {
const result = await api.bulkUsers(csvRows); 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 = ''; if (fileRef.current) fileRef.current.value = '';
onCreated(); onCreated();
} catch(e) { toast(e.message, 'error'); } } catch(e) { toast(e.message, 'error'); }
finally { setLoading(false); } 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 ( return (
<div style={{ maxWidth:560 }} className="flex-col gap-4"> <div style={{ maxWidth:580, display:'flex', flexDirection:'column', gap:16 }}>
<div className="card" style={{ background:'var(--background)', border:'1px dashed var(--border)' }}>
<p className="text-sm font-medium" style={{ marginBottom:6 }}>CSV Format</p> {/* Format info box */}
<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> <div style={{ background:'var(--background)', border:'1px dashed var(--border)', borderRadius:'var(--radius)', padding:'12px 14px' }}>
<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> <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> </div>
{/* File picker row */}
<div style={{ display:'flex', alignItems:'center', gap:10, flexWrap:'wrap' }}> <div style={{ display:'flex', alignItems:'center', gap:10, flexWrap:'wrap' }}>
<label className="btn btn-secondary" style={{ cursor:'pointer', margin:0 }}> <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> </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>} {csvFile && (
{csvRows.length > 0 && <button className="btn btn-primary" onClick={handleImport} disabled={loading}>{loading ? 'Creating…' : `Create ${csvRows.length} User${csvRows.length!==1?'s':''}`}</button>} <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> </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 && ( {csvInvalid.length > 0 && (
<div style={{ background:'rgba(229,57,53,0.07)', border:'1px solid #e53935', borderRadius:'var(--radius)', padding:10 }}> <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> <p style={{ fontSize:13, fontWeight:600, color:'#e53935', marginBottom:6 }}>{csvInvalid.length} row{csvInvalid.length!==1?'s':''} skipped</p>
<div style={{ maxHeight:100, overflowY:'auto' }}> <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>)} {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>
</div> </div>
)} )}
{/* Result */}
{bulkResult && ( {bulkResult && (
<div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', padding:12 }}> <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 && ( {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)' }}> <div style={{ maxHeight:112, overflowY:'auto', border:'1px solid var(--border)', borderRadius:'var(--radius)' }}>
{bulkResult.skipped.map((s,i) => ( {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 }}> <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>
))} ))}
</div> </div>
@@ -364,13 +523,14 @@ function BulkImportForm({ userPass, onCreated }) {
// ── Main page ───────────────────────────────────────────────────────────────── // ── Main page ─────────────────────────────────────────────────────────────────
export default function UserManagerPage({ isMobile = false, onProfile, onHelp, onAbout }) { export default function UserManagerPage({ isMobile = false, onProfile, onHelp, onAbout }) {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [view, setView] = useState('list'); // 'list' | 'create' | 'edit' | 'bulk' const [view, setView] = useState('list'); // 'list' | 'create' | 'edit' | 'bulk'
const [editUser, setEditUser] = useState(null); const [editUser, setEditUser] = useState(null);
const [userPass, setUserPass] = useState('user@1234'); const [userPass, setUserPass] = useState('user@1234');
const [allUserGroups, setAllUserGroups] = useState([]);
const [inputFocused, setInputFocused] = useState(false); const [inputFocused, setInputFocused] = useState(false);
const onIF = () => setInputFocused(true); const onIF = () => setInputFocused(true);
const onIB = () => setInputFocused(false); const onIB = () => setInputFocused(false);
@@ -385,6 +545,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
useEffect(() => { useEffect(() => {
load(); load();
api.getSettings().then(({ settings }) => { if (settings.user_pass) setUserPass(settings.user_pass); }).catch(() => {}); 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]); }, [load]);
const filtered = users 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> <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 && 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 ${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> </div>
)} )}
@@ -485,6 +647,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
<UserForm <UserForm
user={view === 'edit' ? editUser : null} user={view === 'edit' ? editUser : null}
userPass={userPass} userPass={userPass}
allUserGroups={allUserGroups}
onDone={() => { load(); goList(); }} onDone={() => { load(); goList(); }}
onCancel={goList} onCancel={goList}
isMobile={isMobile} isMobile={isMobile}
@@ -497,7 +660,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
{/* BULK IMPORT */} {/* BULK IMPORT */}
{view === 'bulk' && ( {view === 'bulk' && (
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 16, overscrollBehavior:'contain' }}> <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>
)} )}
</div> </div>

View File

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