From 72094d7d1574d538b7680b5a026c7655cd6bf513 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Tue, 24 Mar 2026 15:19:32 -0400 Subject: [PATCH] v0.12.22 User Manager updates --- Bulk_User_Import.txt | 51 ++++++ backend/package.json | 2 +- backend/src/routes/usergroups.js | 82 +++++++++ backend/src/routes/users.js | 42 +++-- build.sh | 2 +- frontend/package.json | 2 +- frontend/src/pages/UserManagerPage.jsx | 243 +++++++++++++++++++++---- frontend/src/utils/api.js | 12 +- 8 files changed, 372 insertions(+), 64 deletions(-) create mode 100644 Bulk_User_Import.txt diff --git a/Bulk_User_Import.txt b/Bulk_User_Import.txt new file mode 100644 index 0000000..ce359a5 --- /dev/null +++ b/Bulk_User_Import.txt @@ -0,0 +1,51 @@ + + 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 + + + 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 = + minor = + guardian-user-email = + + + + +[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 \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index ca1c9a7..40c4374 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.21", + "version": "0.12.22", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/usergroups.js b/backend/src/routes/usergroups.js index e0b6471..6d30591 100644 --- a/backend/src/routes/usergroups.js +++ b/backend/src/routes/usergroups.js @@ -193,6 +193,14 @@ router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => { } 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 router.get('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => { 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 ─────────────────────────────────────────────────────── // GET /:id/restrictions — get blocked group IDs for a user group diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 2fc4b56..f375305 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -152,29 +152,47 @@ router.post('/bulk', authMiddleware, teamManagerMiddleware, async (req, res) => const results = { created: [], skipped: [] }; const seenEmails = new Set(); const defaultPw = process.env.USER_PASS || 'user@1234'; + const validRoles = ['member', 'manager', 'admin']; try { for (const u of users) { - const email = (u.email || '').trim().toLowerCase(); - const name = (u.name || '').trim(); - if (!name || !email) { results.skipped.push({ email: email || '(blank)', reason: 'Missing name or email' }); continue; } - if (!isValidEmail(email)) { results.skipped.push({ email, reason: 'Invalid email address' }); continue; } - if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; } + const email = (u.email || '').trim().toLowerCase(); + const firstName = (u.firstName || '').trim(); + const lastName = (u.lastName || '').trim(); + // Support legacy name field too + 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); 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; } try { const resolvedName = await resolveUniqueName(req.schema, name); - const pw = (u.password || '').trim() || defaultPw; - const hash = bcrypt.hashSync(pw, 10); - const newRole = u.role === 'admin' ? 'admin' : 'member'; + const pw = (u.password || '').trim() || defaultPw; + const hash = bcrypt.hashSync(pw, 10); + 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, - "INSERT INTO users (name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,'active',TRUE) RETURNING id", - [resolvedName, email, hash, newRole] + "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] ); - await addUserToPublicGroups(req.schema, r.rows[0].id); + const userId = r.rows[0].id; + 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, 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); } catch (e) { results.skipped.push({ email, reason: e.message }); } diff --git a/build.sh b/build.sh index 94d4611..de5a669 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.21}" +VERSION="${1:-0.12.22}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index 01c41c0..2414252 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.21", + "version": "0.12.22", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/pages/UserManagerPage.jsx b/frontend/src/pages/UserManagerPage.jsx index 0f6b27c..50547d4 100644 --- a/frontend/src/pages/UserManagerPage.jsx +++ b/frontend/src/pages/UserManagerPage.jsx @@ -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 2–4 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 }) { + {/* Row 4b: User Groups */} + {allUserGroups?.length > 0 && ( +
+ {lbl('User Groups', false, '(optional)')} +
+ {allUserGroups.map(g => ( + + ))} +
+
+ )} + {/* Row 5: Password */}
{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 ( -
-
-

CSV Format

- {"name,email,password,role\nJane Smith,jane@company.com,,member\nBob Jones,bob@company.com,TempPass1,admin"} -

Name and email required. Blank password defaults to {userPass}, blank role defaults to member.

+
+ + {/* 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)}} - {csvRows.length > 0 && } + {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} line{csvInvalid.length!==1?'s':''} skipped

-
- {csvInvalid.map((e,i) =>
{e.line}— {e.reason}
)} +

{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.created.length} user{bulkResult.created.length!==1?'s':''} created +

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

{bulkResult.skipped.length} skipped:

+

{bulkResult.skipped.length} skipped:

{bulkResult.skipped.map((s,i) => (
- {s.email}{s.reason} + {s.email} + {s.reason}
))}
@@ -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 Users +
)} @@ -485,6 +647,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o { load(); goList(); }} onCancel={goList} isMobile={isMobile} @@ -497,7 +660,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o {/* BULK IMPORT */} {view === 'bulk' && (
- +
)}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 2d22c97..0ca2efd 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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);