v0.12.22 User Manager updates
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-frontend",
|
||||
"version": "0.12.21",
|
||||
"version": "0.12.22",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 }) {
|
||||
</label>
|
||||
</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 */}
|
||||
<div style={{ marginBottom:16 }}>
|
||||
{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 (
|
||||
<div style={{ maxWidth:560 }} className="flex-col gap-4">
|
||||
<div className="card" style={{ background:'var(--background)', border:'1px dashed var(--border)' }}>
|
||||
<p className="text-sm font-medium" style={{ marginBottom:6 }}>CSV Format</p>
|
||||
<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>
|
||||
<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>
|
||||
<div style={{ maxWidth:580, display:'flex', flexDirection:'column', gap:16 }}>
|
||||
|
||||
{/* Format info box */}
|
||||
<div style={{ background:'var(--background)', border:'1px dashed var(--border)', borderRadius:'var(--radius)', padding:'12px 14px' }}>
|
||||
<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>
|
||||
|
||||
{/* File picker row */}
|
||||
<div style={{ display:'flex', alignItems:'center', gap:10, flexWrap:'wrap' }}>
|
||||
<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>
|
||||
{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>}
|
||||
{csvRows.length > 0 && <button className="btn btn-primary" onClick={handleImport} disabled={loading}>{loading ? 'Creating…' : `Create ${csvRows.length} User${csvRows.length!==1?'s':''}`}</button>}
|
||||
{csvFile && (
|
||||
<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>
|
||||
|
||||
{/* 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 && (
|
||||
<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>
|
||||
<div style={{ maxHeight:100, 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>)}
|
||||
<p style={{ fontSize:13, fontWeight:600, color:'#e53935', marginBottom:6 }}>{csvInvalid.length} row{csvInvalid.length!==1?'s':''} skipped</p>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{bulkResult && (
|
||||
<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 && (
|
||||
<>
|
||||
<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)' }}>
|
||||
{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 }}>
|
||||
<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>
|
||||
@@ -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
|
||||
<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 ? 'btn-primary' : 'btn-secondary'}`} onClick={goCreate}>+ Create</button>
|
||||
<button className={`btn btn-sm ${view === 'bulk' ? 'btn-primary' : 'btn-secondary'}`} onClick={goBulk}>Bulk</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -485,6 +647,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
|
||||
<UserForm
|
||||
user={view === 'edit' ? editUser : null}
|
||||
userPass={userPass}
|
||||
allUserGroups={allUserGroups}
|
||||
onDone={() => { load(); goList(); }}
|
||||
onCancel={goList}
|
||||
isMobile={isMobile}
|
||||
@@ -497,7 +660,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
|
||||
{/* BULK IMPORT */}
|
||||
{view === 'bulk' && (
|
||||
<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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user