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);