From 2e3e4100f5ae0636934100f2d82c2809903124ce Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Tue, 24 Mar 2026 07:34:03 -0400 Subject: [PATCH] v0.12.12 user manager update --- backend/package.json | 2 +- .../migrations/009_user_profile_fields.sql | 17 + backend/src/routes/users.js | 66 ++- build.sh | 2 +- frontend/package.json | 2 +- frontend/src/index.css | 1 + frontend/src/pages/UserManagerPage.jsx | 397 +++++++++++------- frontend/src/utils/api.js | 1 + 8 files changed, 327 insertions(+), 161 deletions(-) create mode 100644 backend/src/models/migrations/009_user_profile_fields.sql diff --git a/backend/package.json b/backend/package.json index 5c80ad8..be224db 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.11", + "version": "0.12.12", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/models/migrations/009_user_profile_fields.sql b/backend/src/models/migrations/009_user_profile_fields.sql new file mode 100644 index 0000000..f192ce5 --- /dev/null +++ b/backend/src/models/migrations/009_user_profile_fields.sql @@ -0,0 +1,17 @@ +-- Migration 009: Extended user profile fields +ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_minor BOOLEAN NOT NULL DEFAULT FALSE; + +-- Back-fill first_name / last_name from existing combined name for non-deleted users +UPDATE users +SET + first_name = SPLIT_PART(TRIM(name), ' ', 1), + last_name = CASE + WHEN POSITION(' ' IN TRIM(name)) > 0 + THEN NULLIF(TRIM(SUBSTR(TRIM(name), POSITION(' ' IN TRIM(name)) + 1)), '') + ELSE NULL + END +WHERE first_name IS NULL + AND TRIM(name) NOT IN ('Deleted User', ''); diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index d9c2d26..2fc4b56 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -33,7 +33,7 @@ function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); } router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => { try { const users = await query(req.schema, - "SELECT id,name,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY created_at ASC" + "SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY name ASC" ); res.json({ users }); } catch (e) { res.status(500).json({ error: e.message }); } @@ -82,26 +82,66 @@ router.get('/check-display-name', authMiddleware, async (req, res) => { // Create user router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => { - const { name, email, password, role } = req.body; - if (!name || !email) return res.status(400).json({ error: 'Name and email required' }); - if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email address' }); + const { firstName, lastName, email, password, role, phone, isMinor } = req.body; + if (!firstName?.trim() || !lastName?.trim() || !email) + return res.status(400).json({ error: 'First name, last name and email required' }); + if (!isValidEmail(email.trim())) return res.status(400).json({ error: 'Invalid email address' }); + const validRoles = ['member', 'admin', 'manager']; + const assignedRole = validRoles.includes(role) ? role : 'member'; + const name = `${firstName.trim()} ${lastName.trim()}`; try { - 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 LOWER(email) = LOWER($1) AND status != 'deleted'", [email.trim()]); if (exists) return res.status(400).json({ error: 'Email already in use' }); - const resolvedName = await resolveUniqueName(req.schema, name.trim()); + const resolvedName = await resolveUniqueName(req.schema, name); const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234'; const hash = bcrypt.hashSync(pw, 10); 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, role === 'admin' ? 'admin' : 'member'] + "INSERT INTO users (name,first_name,last_name,email,password,role,phone,is_minor,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'active',TRUE) RETURNING id", + [resolvedName, firstName.trim(), lastName.trim(), email.trim().toLowerCase(), hash, assignedRole, phone?.trim() || null, !!isMinor] ); const userId = r.rows[0].id; await addUserToPublicGroups(req.schema, userId); - if (role === 'admin') { + if (assignedRole === '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, userId]); } - const user = await queryOne(req.schema, 'SELECT id,name,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [userId]); + const user = await queryOne(req.schema, 'SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [userId]); + res.json({ user }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +// Update user (general — name components, phone, is_minor, role, optional password reset) +router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => { + const id = parseInt(req.params.id); + if (isNaN(id)) return res.status(400).json({ error: 'Invalid user ID' }); + const { firstName, lastName, phone, isMinor, role, password } = req.body; + if (!firstName?.trim() || !lastName?.trim()) + return res.status(400).json({ error: 'First and last name required' }); + const validRoles = ['member', 'admin', 'manager']; + if (!validRoles.includes(role)) return res.status(400).json({ error: 'Invalid role' }); + try { + const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]); + if (!target) return res.status(404).json({ error: 'User not found' }); + if (target.is_default_admin && role !== 'admin') + return res.status(403).json({ error: 'Cannot change default admin role' }); + const name = `${firstName.trim()} ${lastName.trim()}`; + const resolvedName = await resolveUniqueName(req.schema, name, id); + await exec(req.schema, + 'UPDATE users SET name=$1,first_name=$2,last_name=$3,phone=$4,is_minor=$5,role=$6,updated_at=NOW() WHERE id=$7', + [resolvedName, firstName.trim(), lastName.trim(), phone?.trim() || null, !!isMinor, role, id] + ); + if (password && password.length >= 6) { + const hash = bcrypt.hashSync(password, 10); + await exec(req.schema, 'UPDATE users SET password=$1,must_change_password=TRUE,updated_at=NOW() WHERE id=$2', [hash, id]); + } + if (role === 'admin' && target.role !== '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, id]); + } + const user = await queryOne(req.schema, + 'SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,must_change_password,last_online,created_at FROM users WHERE id=$1', + [id] + ); res.json({ user }); } catch (e) { res.status(500).json({ error: e.message }); } }); @@ -159,7 +199,7 @@ router.patch('/:id/name', authMiddleware, teamManagerMiddleware, async (req, res // Patch role router.patch('/:id/role', authMiddleware, teamManagerMiddleware, async (req, res) => { const { role } = req.body; - if (!['member','admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' }); + if (!['member','admin','manager'].includes(role)) return res.status(400).json({ error: 'Invalid role' }); try { const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]); if (!target) return res.status(404).json({ error: 'User not found' }); @@ -216,6 +256,10 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, status = 'deleted', email = $1, name = 'Deleted User', + first_name = NULL, + last_name = NULL, + phone = NULL, + is_minor = FALSE, display_name = NULL, avatar = NULL, about_me = NULL, diff --git a/build.sh b/build.sh index 230aaa9..a900a30 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.11}" +VERSION="${1:-0.12.12}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index 80e3eac..6f6cba7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.11", + "version": "0.12.12", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/index.css b/frontend/src/index.css index dd4e26a..f817a8f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -194,6 +194,7 @@ a { color: inherit; text-decoration: none; } font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; } .role-admin { background: #fce8e6; color: #c5221f; } +.role-manager { background: #e6f4ea; color: #1e7e34; } .role-member { background: var(--primary-light); color: var(--primary); } .status-suspended { background: #fff3e0; color: #e65100; } diff --git a/frontend/src/pages/UserManagerPage.jsx b/frontend/src/pages/UserManagerPage.jsx index 42824ba..b9a78a1 100644 --- a/frontend/src/pages/UserManagerPage.jsx +++ b/frontend/src/pages/UserManagerPage.jsx @@ -3,10 +3,16 @@ import { useToast } from '../contexts/ToastContext.jsx'; import { api } from '../utils/api.js'; import Avatar from '../components/Avatar.jsx'; import UserFooter from '../components/UserFooter.jsx'; +import PasswordInput from '../components/PasswordInput.jsx'; const SIDEBAR_W = 320; function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); } +function isValidPhone(p) { + if (!p || !p.trim()) return true; + const digits = p.replace(/[\s\-\(\)\+\.x#]/g, ''); + return /^\d{7,15}$/.test(digits); +} function parseCSV(text) { const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean); @@ -24,38 +30,11 @@ function parseCSV(text) { return { rows, invalid }; } -function UserRow({ u, onUpdated }) { +// ── User Row (accordion list item) ─────────────────────────────────────────── +function UserRow({ u, onUpdated, onEdit }) { const toast = useToast(); const [open, setOpen] = useState(false); - const [resetPw, setResetPw] = useState(''); - const [showReset, setShowReset] = useState(false); - const [editName, setEditName] = useState(false); - const [nameVal, setNameVal] = useState(u.name); - const [roleWarning, setRoleWarning] = useState(false); - // onIF/onIB are no-ops here — UserRow doesn't have access to the page-level - // inputFocused state. The mobile footer is controlled by the parent page only. - const onIF = () => {}; - const onIB = () => {}; - const handleRole = async (role) => { - if (!role) { setRoleWarning(true); return; } - setRoleWarning(false); - try { await api.updateRole(u.id, role); toast('Role updated', 'success'); onUpdated(); } - catch (e) { toast(e.message, 'error'); } - }; - const handleResetPw = async () => { - if (!resetPw || resetPw.length < 6) return toast('Min 6 characters', 'error'); - try { await api.resetPassword(u.id, resetPw); toast('Password reset', 'success'); setShowReset(false); setResetPw(''); onUpdated(); } - catch (e) { toast(e.message, 'error'); } - }; - const handleSaveName = async () => { - if (!nameVal.trim()) return toast('Name cannot be empty', 'error'); - try { - const { name } = await api.updateName(u.id, nameVal.trim()); - toast(name !== nameVal.trim() ? `Saved as "${name}"` : 'Name updated', 'success'); - setEditName(false); onUpdated(); - } catch (e) { toast(e.message, 'error'); } - }; const handleSuspend = async () => { if (!confirm(`Suspend ${u.name}?`)) return; try { await api.suspendUser(u.id); toast('User suspended', 'success'); onUpdated(); } @@ -74,7 +53,7 @@ function UserRow({ u, onUpdated }) { return (
-
{u.email}
-
- Last online: {(() => { - if (!u.last_online) return 'Never'; - const d = new Date(u.last_online); const today = new Date(); today.setHours(0,0,0,0); - const yesterday = new Date(today); yesterday.setDate(yesterday.getDate()-1); - const dd = new Date(d); dd.setHours(0,0,0,0); - if (dd >= today) return 'Today'; if (dd >= yesterday) return 'Yesterday'; - return dd.toISOString().slice(0,10); - })()} -
- {!!u.must_change_password &&
⚠ Must change password
} @@ -106,56 +74,15 @@ function UserRow({ u, onUpdated }) { {open && !u.is_default_admin && ( -
- {editName ? ( -
- setNameVal(e.target.value)} - onKeyDown={e => { if(e.key==='Enter') handleSaveName(); if(e.key==='Escape'){setEditName(false);setNameVal(u.name);} }} - autoComplete="new-password" onFocus={onIF} onBlur={onIB} /> - - -
- ) : ( - - )} -
- - {roleWarning && Role Required} -
- {showReset ? ( -
- setResetPw(e.target.value)} - onKeyDown={e => { if(e.key==='Enter') handleResetPw(); if(e.key==='Escape'){setShowReset(false);setResetPw('');} }} - autoComplete="new-password" onFocus={onIF} onBlur={onIB} /> - - -
- ) : ( - - )} -
- {u.status==='active' ? ( - - ) : u.status==='suspended' ? ( - +
+ +
+ {u.status === 'active' ? ( + + ) : u.status === 'suspended' ? ( + ) : null} - +
)} @@ -163,49 +90,208 @@ function UserRow({ u, onUpdated }) { ); } -function CreateUserForm({ userPass, onCreated, isMobile, onIF, onIB }) { +// ── User Form (create / edit) ───────────────────────────────────────────────── +function UserForm({ user, userPass, onDone, onCancel, isMobile, onIF, onIB }) { const toast = useToast(); - const [form, setForm] = useState({ name:'', email:'', password:'', role:'member' }); - const [saving, setSaving] = useState(false); - const set = k => v => setForm(f => ({ ...f, [k]: v })); - const handle = async () => { - if (!form.name.trim() || !form.email.trim()) return toast('Name and email are required', 'error'); - if (!isValidEmail(form.email)) return toast('Invalid email address', 'error'); - if (!/\S+\s+\S+/.test(form.name.trim())) return toast('Name must be two words (First Last)', 'error'); - setSaving(true); - try { await api.createUser(form); toast('User created', 'success'); setForm({ name:'', email:'', password:'', role:'member' }); onCreated(); } - catch(e) { toast(e.message, 'error'); } - finally { setSaving(false); } + const isEdit = !!user; + + const [firstName, setFirstName] = useState(user?.first_name || ''); + const [lastName, setLastName] = useState(user?.last_name || ''); + const [email, setEmail] = useState(user?.email || ''); + const [phone, setPhone] = useState(user?.phone || ''); + const [role, setRole] = useState(user?.role || 'member'); + const [isMinor, setIsMinor] = useState(!!user?.is_minor); + const [password, setPassword] = useState(''); + const [pwEnabled, setPwEnabled] = useState(!isEdit); + const [saving, setSaving] = useState(false); + + const fmtLastLogin = (ts) => { + if (!ts) return 'Never'; + const d = new Date(ts); const today = new Date(); today.setHours(0,0,0,0); + const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); + const dd = new Date(d); dd.setHours(0,0,0,0); + if (dd >= today) return 'Today'; + if (dd >= yesterday) return 'Yesterday'; + return dd.toISOString().slice(0, 10); }; + + const handleSubmit = async () => { + if (!isEdit && (!email.trim() || !isValidEmail(email.trim()))) + return toast('Valid email address required', 'error'); + if (!firstName.trim()) return toast('First name is required', 'error'); + if (!lastName.trim()) return toast('Last name is required', 'error'); + if (!isValidPhone(phone)) return toast('Invalid phone number', 'error'); + if (!['member', 'admin', 'manager'].includes(role)) return toast('Role is required', 'error'); + if (!isEdit && (!password || password.length < 6)) + return toast('Password must be at least 6 characters', 'error'); + if (isEdit && pwEnabled && (!password || password.length < 6)) + return toast('New password must be at least 6 characters', 'error'); + + setSaving(true); + try { + if (isEdit) { + await api.updateUser(user.id, { + firstName: firstName.trim(), + lastName: lastName.trim(), + phone: phone.trim(), + isMinor, + role, + ...(pwEnabled && password ? { password } : {}), + }); + toast('User updated', 'success'); + } else { + await api.createUser({ + firstName: firstName.trim(), + lastName: lastName.trim(), + email: email.trim(), + phone: phone.trim(), + isMinor, + role, + password, + }); + toast('User created', 'success'); + } + onDone(); + } catch (e) { + toast(e.message, 'error'); + } finally { + setSaving(false); + } + }; + + const colGrid = isMobile ? '1fr' : '1fr 1fr'; + const lbl = (text, required, note) => ( + + ); + return ( -
-
-
- - set('name')(e.target.value)} onFocus={onIF} onBlur={onIB} /> +
+ + {/* Back + title */} +
+ + + {isEdit ? 'Edit User' : 'Create User'} + +
+ + {/* Row 1: Login (email) — full width */} +
+ {lbl('Login (email)', !isEdit)} + setEmail(e.target.value)} + disabled={isEdit} + style={{ width:'100%', ...(isEdit ? { opacity:0.6, cursor:'not-allowed' } : {}) }} + autoComplete="new-password" onFocus={onIF} onBlur={onIB} /> +
+ + {/* Row 2: First Name + Last Name */} +
+
+ {lbl('First Name', true)} + setFirstName(e.target.value)} + autoComplete="new-password" autoCapitalize="words" onFocus={onIF} onBlur={onIB} />
-
- - set('email')(e.target.value)} onFocus={onIF} onBlur={onIB} /> +
+ {lbl('Last Name', true)} + setLastName(e.target.value)} + autoComplete="new-password" autoCapitalize="words" onFocus={onIF} onBlur={onIB} />
-
- - set('password')(e.target.value)} onFocus={onIF} onBlur={onIB} /> +
+ + {/* Row 3: Phone + Role */} +
+
+ {lbl('Phone', false, '(optional)')} + setPhone(e.target.value)} + autoComplete="new-password" onFocus={onIF} onBlur={onIB} />
-
- - setRole(e.target.value)}> +
-

User must change password on first login. Duplicate names get a number suffix.

- + + {/* Row 4: Is minor */} +
+ +
+ + {/* Row 5: Password */} +
+ {lbl('Password', + (!isEdit) || (isEdit && pwEnabled), + isEdit && !pwEnabled ? '(not changing — click Reset Password to set a new one)' : + !isEdit ? `(blank = ${userPass})` : null + )} +
+ setPassword(e.target.value)} + placeholder={isEdit && !pwEnabled ? '••••••••' : 'Min 6 characters'} + disabled={!pwEnabled} + autoComplete="new-password" + onFocus={onIF} onBlur={onIB} + /> +
+
+ + {/* Row 6: Buttons */} +
+ + {isEdit && !pwEnabled && ( + + )} + {isEdit && pwEnabled && ( + + )} + +
+ + {/* Row 7 (edit only): Last login + must change password */} + {isEdit && ( +
+ Last Login: {fmtLastLogin(user.last_online)} + {!!user.must_change_password && ( + ⚠ Must change password + )} +
+ )}
); } +// ── Bulk Import Form ────────────────────────────────────────────────────────── function BulkImportForm({ userPass, onCreated }) { const toast = useToast(); const fileRef = useRef(null); @@ -278,12 +364,13 @@ 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 [tab, setTab] = useState('users'); - 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 [inputFocused, setInputFocused] = useState(false); const onIF = () => setInputFocused(true); const onIB = () => setInputFocused(false); @@ -308,35 +395,39 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o ) .sort((a, b) => a.name.localeCompare(b.name)); - // ── Nav item helper (matches Schedule page style) ───────────────────────── - const navItem = (label, key) => ( - ); + const isFormView = view === 'create' || view === 'edit'; + return (
- {/* ── Left panel ── */} + {/* ── Desktop sidebar ── */} {!isMobile && (
- {/* Title — matches Schedule page */}
User Manager
- {/* Tab navigation */}
View
- {navItem(`All Users${!loading ? ` (${users.length})` : ''}`, 'users')} - {navItem('+ Create User', 'create')} - {navItem('Bulk Import CSV', 'bulk')} + {navItem(`All Users${!loading ? ` (${users.length})` : ''}`, view === 'list' || view === 'edit', goList)} + {navItem('+ Create User', view === 'create', goCreate)} + {navItem('Bulk Import CSV', view === 'bulk', goBulk)}
@@ -346,27 +437,27 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o {/* ── Right panel ── */}
- {/* Mobile tab bar — only on mobile since desktop uses left panel */} + {/* Mobile tab bar */} {isMobile && (
Users - - + +
)} {/* Content */}
- {tab === 'users' && ( + + {/* LIST VIEW */} + {view === 'list' && ( <> - {/* Search — always visible, outside scroll area */}
setSearch(e.target.value)} onFocus={onIF} onBlur={onIB} autoComplete="new-password" autoCorrect="off" spellCheck={false} style={{ width:'100%', maxWidth: isMobile ? '100%' : 400 }} />
- {/* User list — bounded scroll */}
{loading ? ( @@ -381,25 +472,37 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o {search ? 'No users match your search.' : 'No users yet.'}
) : ( - filtered.map(u => ) + filtered.map(u => ) )}
)} - {tab === 'create' && ( + + {/* CREATE / EDIT FORM */} + {isFormView && (
- { load(); setTab('users'); }} isMobile={isMobile} onIF={onIF} onIB={onIB} /> + { load(); goList(); }} + onCancel={goList} + isMobile={isMobile} + onIF={onIF} + onIB={onIB} + />
)} - {tab === 'bulk' && ( + + {/* BULK IMPORT */} + {view === 'bulk' && (
)}
- {/* Mobile footer — fixed, hidden when any input is focused (keyboard open) */} + {/* Mobile footer — fixed, hidden when keyboard is up */} {isMobile && !inputFocused && (
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index cd99f83..2d22c97 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -55,6 +55,7 @@ export const api = { getUsers: () => req('GET', '/users'), searchUsers: (q, groupId) => req('GET', `/users/search?q=${encodeURIComponent(q)}${groupId ? `&groupId=${groupId}` : ''}`), createUser: (body) => req('POST', '/users', body), + updateUser: (id, body) => req('PATCH', `/users/${id}`, body), bulkUsers: (users) => req('POST', '/users/bulk', { users }), updateName: (id, name) => req('PATCH', `/users/${id}/name`, { name }), updateRole: (id, role) => req('PATCH', `/users/${id}/role`, { role }),