From f49fd5b885c3ee636946b25ed515daf47ce52df6 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Fri, 20 Mar 2026 13:35:22 -0400 Subject: [PATCH] v0.10.4 new UI changes --- frontend/src/components/NavDrawer.jsx | 4 +- frontend/src/pages/Chat.jsx | 75 +++-- frontend/src/pages/GroupManagerPage.jsx | 305 +++++++++++++++++++ frontend/src/pages/UserManagerPage.jsx | 372 ++++++++++++++++++++++++ 4 files changed, 736 insertions(+), 20 deletions(-) create mode 100644 frontend/src/pages/GroupManagerPage.jsx create mode 100644 frontend/src/pages/UserManagerPage.jsx diff --git a/frontend/src/components/NavDrawer.jsx b/frontend/src/components/NavDrawer.jsx index ece9fd2..8c30515 100644 --- a/frontend/src/components/NavDrawer.jsx +++ b/frontend/src/components/NavDrawer.jsx @@ -79,8 +79,8 @@ export default function NavDrawer({ open, onClose, onMessages, onSchedule, onSch {canAccessTools && ( <>
Tools
- {item(NAV_ICON.users, 'User Manager', onUsers)} - {features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager)} + {item(NAV_ICON.users, 'User Manager', onUsers, { active: currentPage === 'users' })} + {features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager, { active: currentPage === 'groups' })} )} diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index db073cd..5dea450 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -6,7 +6,8 @@ import { api } from '../utils/api.js'; import Sidebar from '../components/Sidebar.jsx'; import ChatWindow from '../components/ChatWindow.jsx'; import ProfileModal from '../components/ProfileModal.jsx'; -import UserManagerModal from '../components/UserManagerModal.jsx'; +import UserManagerPage from './UserManagerPage.jsx'; +import GroupManagerPage from './GroupManagerPage.jsx'; import HostPanel from '../components/HostPanel.jsx'; import SettingsModal from '../components/SettingsModal.jsx'; import BrandingModal from '../components/BrandingModal.jsx'; @@ -15,9 +16,7 @@ import GlobalBar from '../components/GlobalBar.jsx'; import AboutModal from '../components/AboutModal.jsx'; import HelpModal from '../components/HelpModal.jsx'; import NavDrawer from '../components/NavDrawer.jsx'; -import GroupManagerModal from '../components/GroupManagerModal.jsx'; import SchedulePage from '../components/SchedulePage.jsx'; -import MobileGroupManager from '../components/MobileGroupManager.jsx'; import './Chat.css'; function urlBase64ToUint8Array(base64String) { @@ -334,6 +333,54 @@ export default function Chat() { const isToolManager = user?.role === 'admin' || (features.teamToolManagers || []).some(gid => (features.userGroupMemberships || []).includes(gid)); + if (page === 'users') { + return ( +
+ setDrawerOpen(true)} /> +
+ +
+ setDrawerOpen(false)} + onMessages={() => { setDrawerOpen(false); setPage('chat'); }} + onSchedule={() => { setDrawerOpen(false); setPage('schedule'); }} + onGroupManager={() => { setDrawerOpen(false); setPage('groups'); }} + onBranding={() => { setDrawerOpen(false); setModal('branding'); }} + onSettings={() => { setDrawerOpen(false); setModal('settings'); }} + onUsers={() => { setDrawerOpen(false); setPage('users'); }} + onHostPanel={() => { setDrawerOpen(false); setPage('hostpanel'); }} + features={features} currentPage={page} isMobile={isMobile} + /> + {modal === 'profile' && setModal(null)} />} + {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} +
+ ); + } + + if (page === 'groups') { + return ( +
+ setDrawerOpen(true)} /> +
+ +
+ setDrawerOpen(false)} + onMessages={() => { setDrawerOpen(false); setPage('chat'); }} + onSchedule={() => { setDrawerOpen(false); setPage('schedule'); }} + onGroupManager={() => { setDrawerOpen(false); setPage('groups'); }} + onBranding={() => { setDrawerOpen(false); setModal('branding'); }} + onSettings={() => { setDrawerOpen(false); setModal('settings'); }} + onUsers={() => { setDrawerOpen(false); setPage('users'); }} + onHostPanel={() => { setDrawerOpen(false); setPage('hostpanel'); }} + features={features} currentPage={page} isMobile={isMobile} + /> + {modal === 'profile' && setModal(null)} />} + {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} +
+ ); + } + if (page === 'hostpanel') { return (
@@ -382,20 +429,19 @@ export default function Chat() { onMessages={() => { setDrawerOpen(false); setPage('chat'); }} onSchedule={() => { setDrawerOpen(false); setPage('schedule'); }} onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }} - onGroupManager={() => { setDrawerOpen(false); if(isMobile) setModal('mobilegroupmanager'); else setModal('groupmanager'); }} + onGroupManager={() => { setDrawerOpen(false); setPage('groups'); }} onBranding={() => { setDrawerOpen(false); setModal('branding'); }} onSettings={() => { setDrawerOpen(false); setModal('settings'); }} - onUsers={() => { setDrawerOpen(false); setModal('users'); }} + onUsers={() => { setDrawerOpen(false); setPage('users'); }} onHostPanel={() => { setDrawerOpen(false); setPage('hostpanel'); }} features={features} currentPage={page} isMobile={isMobile} /> {modal === 'profile' && setModal(null)} />} - {modal === 'users' && setModal(null)} />} {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'branding' && setModal(null)} />} - {modal === 'groupmanager' && setModal(null)} />} + {modal === 'mobilegroupmanager' && (
setModal(null)}/> @@ -422,10 +468,10 @@ export default function Chat() { unreadGroups={unreadGroups} onNewChat={() => setModal('newchat')} onProfile={() => setModal('profile')} - onUsers={() => setModal('users')} + onUsers={() => setPage('users')} onSettings={() => setModal('settings')} onBranding={() => setModal('branding')} - onGroupManager={() => isMobile ? setModal('mobilegroupmanager') : setModal('groupmanager')} + onGroupManager={() => setPage('groups')} features={features} onGroupsUpdated={loadGroups} isMobile={isMobile} @@ -452,25 +498,18 @@ export default function Chat() { onMessages={() => { setDrawerOpen(false); setPage('chat'); }} onSchedule={() => { setDrawerOpen(false); setPage('schedule'); }} onScheduleManager={() => { setDrawerOpen(false); setPage('schedule'); }} - onGroupManager={() => { setDrawerOpen(false); if(isMobile) setModal('mobilegroupmanager'); else setModal('groupmanager'); }} + onGroupManager={() => { setDrawerOpen(false); setPage('groups'); }} onBranding={() => { setDrawerOpen(false); setModal('branding'); }} onSettings={() => { setDrawerOpen(false); setModal('settings'); }} - onUsers={() => { setDrawerOpen(false); setModal('users'); }} + onUsers={() => { setDrawerOpen(false); setPage('users'); }} onHostPanel={() => { setDrawerOpen(false); setPage('hostpanel'); }} features={features} currentPage={page} isMobile={isMobile} /> {modal === 'profile' && setModal(null)} />} - {modal === 'users' && setModal(null)} />} {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'branding' && setModal(null)} />} - {modal === 'groupmanager' && setModal(null)} />} - {modal === 'mobilegroupmanager' && ( -
- setModal(null)}/> -
- )} {modal === 'newchat' && setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />} {modal === 'about' && setModal(null)} />} diff --git a/frontend/src/pages/GroupManagerPage.jsx b/frontend/src/pages/GroupManagerPage.jsx new file mode 100644 index 0000000..125be5a --- /dev/null +++ b/frontend/src/pages/GroupManagerPage.jsx @@ -0,0 +1,305 @@ +import { useState, useEffect, useCallback } from 'react'; +import { api } from '../utils/api.js'; +import { useToast } from '../contexts/ToastContext.jsx'; +import Avatar from '../components/Avatar.jsx'; + +// ── Shared sub-components (identical logic to modal versions) ───────────────── + +function UserCheckList({ allUsers, selectedIds, onChange }) { + const [search, setSearch] = useState(''); + const filtered = allUsers.filter(u => (u.display_name||u.name).toLowerCase().includes(search.toLowerCase())); + return ( +
+ setSearch(e.target.value)} + style={{ marginBottom:8 }} autoComplete="new-password" /> +
+ {filtered.map(u => ( + + ))} + {filtered.length === 0 &&
No users found
} +
+
+ ); +} + +function GroupCheckList({ allGroups, selectedIds, onChange }) { + return ( +
+ {allGroups.map(g => ( + + ))} + {allGroups.length === 0 &&
No user groups yet
} +
+ ); +} + +// ── All Groups tab ──────────────────────────────────────────────────────────── +function AllGroupsTab({ allUsers, onRefresh }) { + const toast = useToast(); + const [groups, setGroups] = useState([]); + const [selected, setSelected] = useState(null); + const [savedMembers, setSavedMembers] = useState(new Set()); + const [members, setMembers] = useState(new Set()); + const [editName, setEditName] = useState(''); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [showDelete, setShowDelete] = useState(false); + + const load = useCallback(() => + api.getUserGroups().then(({ groups }) => setGroups(groups)).catch(() => {}), []); + useEffect(() => { load(); }, [load]); + + const selectGroup = async (g) => { + setShowDelete(false); + const { members: mems } = await api.getUserGroup(g.id); + const ids = new Set(mems.map(m => m.id)); + setSelected(g); setEditName(g.name); setMembers(ids); setSavedMembers(ids); + }; + const clearSelection = () => { setSelected(null); setEditName(''); setMembers(new Set()); setSavedMembers(new Set()); setShowDelete(false); }; + + const handleSave = async () => { + if (!editName.trim()) return toast('Name required', 'error'); + setSaving(true); + try { + if (selected) { + await api.updateUserGroup(selected.id, { name: editName.trim(), memberIds: [...members] }); + toast('Group updated', 'success'); + const { members: fresh } = await api.getUserGroup(selected.id); + const freshIds = new Set(fresh.map(m => m.id)); + setSavedMembers(freshIds); setMembers(freshIds); + setSelected(prev => ({ ...prev, name: editName.trim() })); + } else { + await api.createUserGroup({ name: editName.trim(), memberIds: [...members] }); + toast(`Group "${editName.trim()}" created`, 'success'); + clearSelection(); + } + load(); onRefresh(); + } catch(e) { toast(e.message, 'error'); } + finally { setSaving(false); } + }; + + const handleDelete = async () => { + setDeleting(true); + try { await api.deleteUserGroup(selected.id); toast('Group deleted', 'success'); clearSelection(); load(); onRefresh(); } + catch(e) { toast(e.message, 'error'); } + finally { setDeleting(false); } + }; + + const canDelete = selected && savedMembers.size === 0; + const isCreating = !selected; + + return ( +
+ {/* Sidebar list */} +
+
User Groups
+ + {groups.map(g => ( + + ))} + {groups.length===0 &&
No groups yet
} +
+ + {/* Form */} +
+
+
+ + setEditName(e.target.value)} + placeholder="e.g. Coaches" style={{ marginTop:6 }} autoComplete="new-password" /> + {isCreating &&

A matching Direct Message group will be created automatically.

} +
+
+ +
+

{members.size} selected

+
+
+ + {!isCreating && } + {!isCreating && ( + + )} +
+ {showDelete && ( +
+

Delete {selected?.name}? This also deletes the associated direct message group.

+
+ + +
+
+ )} +
+
+
+ ); +} + +// ── Direct Messages tab ─────────────────────────────────────────────────────── +function DirectMessagesTab({ allUserGroups, onRefresh, refreshKey }) { + const toast = useToast(); + const [dms, setDms] = useState([]); + const [selected, setSelected] = useState(null); + const [savedGroupIds, setSavedGroupIds] = useState(new Set()); + const [groupIds, setGroupIds] = useState(new Set()); + const [dmName, setDmName] = useState(''); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [showDelete, setShowDelete] = useState(false); + + const load = useCallback(() => + api.getMultiGroupDms().then(({ dms }) => setDms(dms||[])).catch(() => {}), []); + useEffect(() => { load(); }, [load, refreshKey]); + + const clearSelection = () => { setSelected(null); setDmName(''); setGroupIds(new Set()); setSavedGroupIds(new Set()); setShowDelete(false); }; + const selectDm = (dm) => { + setShowDelete(false); setSelected(dm); setDmName(dm.name); + const ids = new Set(dm.memberGroupIds||[]); setGroupIds(ids); setSavedGroupIds(ids); + }; + + const handleSave = async () => { + if (!dmName.trim()) return toast('Name required', 'error'); + if (!selected && groupIds.size < 2) return toast('Select at least two user groups', 'error'); + setSaving(true); + try { + if (selected) { + await api.updateMultiGroupDm(selected.id, { name:dmName.trim(), userGroupIds:[...groupIds] }); + toast('Multi-group DM updated', 'success'); + const freshDms = await api.getMultiGroupDms(); + const fresh = freshDms.dms.find(d => d.id===selected.id); + if (fresh) { const ids=new Set(fresh.memberGroupIds||[]); setSavedGroupIds(ids); setGroupIds(ids); setSelected(fresh); } + } else { + await api.createMultiGroupDm({ name:dmName.trim(), userGroupIds:[...groupIds] }); + toast(`"${dmName.trim()}" created`, 'success'); + clearSelection(); + } + load(); onRefresh(); + } catch(e) { toast(e.message, 'error'); } + finally { setSaving(false); } + }; + + const handleDelete = async () => { + setDeleting(true); + try { await api.deleteMultiGroupDm(selected.id); toast('Deleted', 'success'); clearSelection(); load(); onRefresh(); } + catch(e) { toast(e.message, 'error'); } + finally { setDeleting(false); } + }; + + const canDelete = selected && savedGroupIds.size === 0; + const isCreating = !selected; + + return ( +
+
+
Multi-Group DMs
+ + {dms.map(dm => ( + + ))} + {dms.length===0 &&
No multi-group DMs yet
} +
+
+
+
+ + setDmName(e.target.value)} placeholder="e.g. Coaches + Players" style={{ marginTop:6 }} autoComplete="new-password" /> +
+
+ +

Select two or more user groups. All their members get access to this conversation.

+ +

{groupIds.size} group{groupIds.size!==1?'s':''} selected

+
+
+ + {!isCreating && } + {!isCreating && ( + + )} +
+ {showDelete && ( +
+

Delete {selected?.name}? Also deletes the associated DM group.

+
+ + +
+
+ )} +
+
+
+ ); +} + +// ── Main page ───────────────────────────────────────────────────────────────── +export default function GroupManagerPage() { + const [tab, setTab] = useState('all'); + const [allUsers, setAllUsers] = useState([]); + const [allUserGroups, setAllUserGroups] = useState([]); + const [refreshKey, setRefreshKey] = useState(0); + const onRefresh = () => setRefreshKey(k => k+1); + + useEffect(() => { + api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status==='active'))).catch(() => {}); + api.getUserGroups().then(({ groups }) => setAllUserGroups(groups)).catch(() => {}); + }, [refreshKey]); + + return ( +
+ {/* Page header */} +
+
+
+ + Group Manager +
+
+ + +
+
+
+ + {/* Content */} +
+ {tab==='all' && } + {tab==='dm' && } +
+
+ ); +} diff --git a/frontend/src/pages/UserManagerPage.jsx b/frontend/src/pages/UserManagerPage.jsx new file mode 100644 index 0000000..5a1c04b --- /dev/null +++ b/frontend/src/pages/UserManagerPage.jsx @@ -0,0 +1,372 @@ +import { useState, useEffect, useRef } from 'react'; +import { useToast } from '../contexts/ToastContext.jsx'; +import { api } from '../utils/api.js'; +import Avatar from '../components/Avatar.jsx'; + +function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); } + +function parseCSV(text) { + const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean); + const rows = [], invalid = []; + 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() }); + } + return { rows, invalid }; +} + +// ── User row (accordion) ────────────────────────────────────────────────────── +function UserRow({ u, onUpdated }) { + 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); + + 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(); } + catch (e) { toast(e.message, 'error'); } + }; + const handleActivate = async () => { + try { await api.activateUser(u.id); toast('User activated', 'success'); onUpdated(); } + catch (e) { toast(e.message, 'error'); } + }; + const handleDelete = async () => { + if (u.role === 'admin') return toast('Demote to member before deleting an admin', 'error'); + if (!confirm(`Delete ${u.name}? Their messages will remain but they cannot log in.`)) return; + try { await api.deleteUser(u.id); toast('User deleted', 'success'); onUpdated(); } + catch (e) { toast(e.message, 'error'); } + }; + + return ( +
+ + + {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" /> + + +
+ ) : ( + + )} + +
+ + {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" /> + + +
+ ) : ( + + )} + +
+ {u.status==='active' ? ( + + ) : u.status==='suspended' ? ( + + ) : null} + +
+
+ )} +
+ ); +} + +// ── Create user form ────────────────────────────────────────────────────────── +function CreateUserForm({ userPass, onCreated }) { + 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); } + }; + + return ( +
+
+
+ + set('name')(e.target.value)} /> +
+
+ + set('email')(e.target.value)} /> +
+
+ + set('password')(e.target.value)} /> +
+
+ + +
+
+

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

+ +
+ ); +} + +// ── Bulk import form ────────────────────────────────────────────────────────── +function BulkImportForm({ userPass, 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 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.readAsText(file); + }; + + const handleImport = async () => { + if (!csvRows.length) return; + setLoading(true); + try { + const result = await api.bulkUsers(csvRows); + setBulkResult(result); setCsvRows([]); setCsvFile(null); setCsvInvalid([]); + if (fileRef.current) fileRef.current.value = ''; + onCreated(); + } catch(e) { toast(e.message, 'error'); } + finally { setLoading(false); } + }; + + 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. +

+
+
+ + {csvFile && {csvFile.name}{csvRows.length > 0 && ({csvRows.length} valid)}} + {csvRows.length > 0 && ( + + )} +
+ {csvInvalid.length > 0 && ( +
+

{csvInvalid.length} line{csvInvalid.length!==1?'s':''} skipped

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

✓ {bulkResult.created.length} user{bulkResult.created.length!==1?'s':''} created

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

{bulkResult.skipped.length} skipped:

+
+ {bulkResult.skipped.map((s,i) => ( +
+ {s.email}{s.reason} +
+ ))} +
+ + )} + +
+ )} +
+ ); +} + +// ── Main page ───────────────────────────────────────────────────────────────── +export default function UserManagerPage() { + const isMobile = window.innerWidth < 768; + 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 load = async () => { + setLoadError(''); setLoading(true); + try { + const { users } = await api.getUsers(); + setUsers(users || []); + } catch(e) { setLoadError(e.message || 'Failed to load users'); } + finally { setLoading(false); } + }; + + useEffect(() => { + load(); + api.getSettings().then(({ settings }) => { if (settings.user_pass) setUserPass(settings.user_pass); }).catch(() => {}); + }, []); + + const filtered = users.filter(u => + !search || u.name?.toLowerCase().includes(search.toLowerCase()) || + u.display_name?.toLowerCase().includes(search.toLowerCase()) || + u.email?.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+ {/* Page header */} +
+
+
+ + User Manager + {!loading && {users.length} user{users.length!==1?'s':''}} +
+
+ + + {!isMobile && } +
+
+
+ + {/* Content */} +
+ {tab === 'users' && ( + <> + setSearch(e.target.value)} + autoComplete="new-password" autoCorrect="off" spellCheck={false} + style={{ marginBottom:16, maxWidth:400 }} /> +
+ {loading ? ( +
+ ) : loadError ? ( +
+
⚠ {loadError}
+ +
+ ) : filtered.length === 0 ? ( +
+ {search ? 'No users match your search.' : 'No users yet.'} +
+ ) : ( + filtered.map(u => ) + )} +
+ + )} + {tab === 'create' && { load(); setTab('users'); }} />} + {tab === 'bulk' && } +
+
+ ); +}