v0.12.22 User Manager updates
This commit is contained in:
51
Bulk_User_Import.txt
Normal file
51
Bulk_User_Import.txt
Normal 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
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }); }
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 2–4 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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user