import { useEffect, useMemo, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { KeyRound, MessageSquare, Pencil, Plus, RotateCcw, Search, Shield, Trash2, X } from 'lucide-react' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { Modal } from '../components/Modal' import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber' import type { RoleOption, UserListItem, UsersListResponse } from '../types/user' import type { ListUsersParams } from '../services/userService' import { ApiError, isAbortError } from '../lib/api' import { addTeamMember, listTeamRoles, updateTeamMemberRole, } from '../services/teamService' import { adminResetUserPassword, createUser, listUserRoles, listUsers, removeUser, sendUserMessage, updateUser, updateUserRole, } from '../services/userService' import { useToast } from '../context/ToastContext' import { Pagination } from '@meshkee/dashboard-ui' import pageStyles from '../components/PageContent.module.css' import tableStyles from './BusinessesPage.module.css' import styles from './UsersPage.module.css' const PAGE_SIZE = 24 function formatDate(value: string) { const d = new Date(value) if (Number.isNaN(d.getTime())) return value return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }) } function displayName(user: UserListItem) { const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim() return name || '—' } function formatGlobalRoleLabel(user: UserListItem, teamRoles: RoleOption[]) { if (user.roleSlug === 'business_staff' && user.teamRole) { const teamName = teamRoles.find((role) => role.slug === user.teamRole)?.name ?? user.teamRole return `Business Staff · ${teamName}` } return user.roles } export function UsersPage() { const { showToast } = useToast() const navigate = useNavigate() const [searchParams] = useSearchParams() const [data, setData] = useState(null) const [roles, setRoles] = useState([]) const [teamRoles, setTeamRoles] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [appliedFilters, setAppliedFilters] = useState>({}) const [page, setPage] = useState(1) const [draftName, setDraftName] = useState('') const [draftCell, setDraftCell] = useState('') const [draftRole, setDraftRole] = useState('') const [editOpen, setEditOpen] = useState(false) const [editUser, setEditUser] = useState(null) const [editFirstName, setEditFirstName] = useState('') const [editLastName, setEditLastName] = useState('') const [editEmail, setEditEmail] = useState('') const [editCell, setEditCell] = useState('') const [editSubmitting, setEditSubmitting] = useState(false) const [editError, setEditError] = useState('') const [resetOpen, setResetOpen] = useState(false) const [resetUser, setResetUser] = useState(null) const [resetPassword, setResetPassword] = useState('') const [resetConfirm, setResetConfirm] = useState('') const [resetSubmitting, setResetSubmitting] = useState(false) const [resetError, setResetError] = useState('') const [messageOpen, setMessageOpen] = useState(false) const [messageUser, setMessageUser] = useState(null) const [messageText, setMessageText] = useState('') const [messageSubmitting, setMessageSubmitting] = useState(false) const [messageError, setMessageError] = useState('') const [removeTarget, setRemoveTarget] = useState(null) const [roleOpen, setRoleOpen] = useState(false) const [roleUser, setRoleUser] = useState(null) const [selectedRoleSlug, setSelectedRoleSlug] = useState('') const [selectedTeamRoleSlug, setSelectedTeamRoleSlug] = useState('') const [roleSubmitting, setRoleSubmitting] = useState(false) const [roleError, setRoleError] = useState('') const [createOpen, setCreateOpen] = useState(false) const [createFirstName, setCreateFirstName] = useState('') const [createLastName, setCreateLastName] = useState('') const [createCell, setCreateCell] = useState('') const [createPassword, setCreatePassword] = useState('') const [createEmail, setCreateEmail] = useState('') const [createSubmitting, setCreateSubmitting] = useState(false) const [createError, setCreateError] = useState('') const [listVersion, setListVersion] = useState(0) const businessFilter = useMemo(() => { const businessIdRaw = searchParams.get('businessId') const membershipRaw = searchParams.get('membership') if (!businessIdRaw || (membershipRaw !== 'staff' && membershipRaw !== 'all')) { return null } const businessId = Number(businessIdRaw) if (!Number.isFinite(businessId)) return null return { businessId, membership: membershipRaw as 'staff' | 'all', businessName: searchParams.get('businessName') ?? 'Business', } }, [searchParams]) useEffect(() => { const controller = new AbortController() async function load() { setLoading(true) setError('') try { const [result, roleResult, teamRoleResult] = await Promise.all([ listUsers( { page, pageSize: PAGE_SIZE, ...appliedFilters, ...(businessFilter ? { businessId: businessFilter.businessId, membership: businessFilter.membership } : {}), }, controller.signal, ), listUserRoles(controller.signal), listTeamRoles(controller.signal), ]) if (controller.signal.aborted) return setData(result) setRoles(roleResult.items) setTeamRoles(teamRoleResult.items) } catch (err) { if (isAbortError(err) || controller.signal.aborted) return setError(err instanceof ApiError ? err.message : 'Unable to load users.') } finally { if (!controller.signal.aborted) setLoading(false) } } void load() return () => { controller.abort() } }, [ page, appliedFilters.name, appliedFilters.cellNumber, appliedFilters.role, businessFilter?.businessId, businessFilter?.membership, listVersion, ]) useEffect(() => { setPage(1) }, [businessFilter?.businessId, businessFilter?.membership]) const totalPages = useMemo(() => { const total = data?.total ?? 0 return Math.max(1, Math.ceil(total / PAGE_SIZE)) }, [data?.total]) const showingFrom = useMemo(() => { if (!data) return 0 return (page - 1) * PAGE_SIZE + 1 }, [data, page]) const showingTo = useMemo(() => { if (!data) return 0 return Math.min(data.total, page * PAGE_SIZE) }, [data, page]) function applyFilters() { setPage(1) setAppliedFilters({ ...(draftName.trim() ? { name: draftName.trim() } : {}), ...(draftCell.trim() ? { cellNumber: draftCell.trim() } : {}), ...(draftRole ? { role: draftRole } : {}), }) } function clearFilters() { setDraftName('') setDraftCell('') setDraftRole('') setPage(1) setAppliedFilters({}) } function clearBusinessFilter() { setPage(1) navigate('/users') } function resetCreateForm() { setCreateFirstName('') setCreateLastName('') setCreateCell('') setCreatePassword('') setCreateEmail('') setCreateError('') } function openCreate() { resetCreateForm() setCreateOpen(true) } async function submitCreate() { if (!businessFilter || businessFilter.membership !== 'all') return const cellNumber = toE164CellNumber(createCell.trim()) if (!cellNumber) return setCreateSubmitting(true) setCreateError('') try { await createUser({ businessId: businessFilter.businessId, cellNumber, firstName: createFirstName.trim(), lastName: createLastName.trim(), ...(createPassword.trim() ? { password: createPassword.trim() } : {}), ...(createEmail.trim() ? { email: createEmail.trim() } : {}), }) showToast('User added to business.', 'success') setCreateOpen(false) resetCreateForm() setPage(1) setListVersion((v) => v + 1) } catch (err) { setCreateError(err instanceof ApiError ? err.message : 'Unable to add user.') } finally { setCreateSubmitting(false) } } const canCreateUser = createFirstName.trim().length >= 2 && createLastName.trim().length >= 2 && toE164CellNumber(createCell.trim()).length > 0 function openRoleChange(user: UserListItem) { setRoleUser(user) setSelectedRoleSlug(user.roleSlug ?? '') setSelectedTeamRoleSlug(user.teamRole ?? teamRoles[0]?.slug ?? '') setRoleError('') setRoleOpen(true) } const needsTeamRole = selectedRoleSlug === 'business_staff' && businessFilter !== null && !roleUser?.isBusinessOwner const canSaveRole = Boolean(selectedRoleSlug) && (!needsTeamRole || Boolean(selectedTeamRoleSlug)) async function submitRoleChange() { if (!roleUser || !selectedRoleSlug || !canSaveRole) return setRoleSubmitting(true) setRoleError('') try { await updateUserRole(roleUser.id, selectedRoleSlug) let nextTeamRole = roleUser.teamRole let nextMemberId = roleUser.businessMemberId if (needsTeamRole && businessFilter && selectedTeamRoleSlug) { if (roleUser.businessMemberId) { await updateTeamMemberRole( businessFilter.businessId, roleUser.businessMemberId, selectedTeamRoleSlug, ) nextTeamRole = selectedTeamRoleSlug } else { const result = (await addTeamMember(businessFilter.businessId, { cellNumber: roleUser.cellNumber, firstName: roleUser.firstName ?? 'User', lastName: roleUser.lastName ?? 'User', roleSlug: selectedTeamRoleSlug, })) as { member?: { id: number; teamRole: string } } nextTeamRole = result.member?.teamRole ?? selectedTeamRoleSlug nextMemberId = result.member?.id ?? null } } const roleName = roles.find((role) => role.slug === selectedRoleSlug)?.name ?? selectedRoleSlug const teamName = selectedRoleSlug === 'business_staff' && nextTeamRole ? teamRoles.find((role) => role.slug === nextTeamRole)?.name ?? nextTeamRole : null showToast( teamName ? `Role updated to ${roleName} (${teamName}).` : `Role updated to ${roleName}.`, 'success', ) setData((prev) => { if (!prev) return prev return { ...prev, items: prev.items.map((item) => item.id === roleUser.id ? { ...item, roleSlug: selectedRoleSlug, roles: teamName ? `${roleName} · ${teamName}` : roleName, teamRole: selectedRoleSlug === 'business_staff' ? nextTeamRole : item.teamRole, businessMemberId: selectedRoleSlug === 'business_staff' ? nextMemberId : item.businessMemberId, } : item, ), } }) setRoleOpen(false) setRoleUser(null) setSelectedTeamRoleSlug('') } catch (err) { setRoleError(err instanceof ApiError ? err.message : 'Unable to update role.') } finally { setRoleSubmitting(false) } } function openEdit(user: UserListItem) { setEditUser(user) setEditFirstName(user.firstName ?? '') setEditLastName(user.lastName ?? '') setEditEmail('') setEditCell(user.cellNumber) setEditError('') setEditOpen(true) } async function submitEdit() { if (!editUser) return setEditSubmitting(true) setEditError('') try { await updateUser(editUser.id, { firstName: editFirstName.trim(), lastName: editLastName.trim(), cellNumber: editCell.trim(), ...(editEmail.trim() ? { email: editEmail.trim() } : {}), }) setEditOpen(false) setEditUser(null) showToast(`"${displayName(editUser)}" has been updated.`, 'success') setData((prev) => { if (!prev) return prev return { ...prev, items: prev.items.map((item) => item.id === editUser.id ? { ...item, firstName: editFirstName.trim(), lastName: editLastName.trim(), cellNumber: editCell.trim(), } : item, ), } }) } catch (err) { setEditError(err instanceof ApiError ? err.message : 'Unable to update user.') } finally { setEditSubmitting(false) } } function openResetPassword(user: UserListItem) { setResetUser(user) setResetPassword('') setResetConfirm('') setResetError('') setResetOpen(true) } async function submitResetPassword() { if (!resetUser) return if (resetPassword !== resetConfirm) { setResetError('Passwords do not match.') return } setResetSubmitting(true) setResetError('') try { await adminResetUserPassword(resetUser.id, resetPassword) setResetOpen(false) setResetUser(null) showToast(`Password reset for "${displayName(resetUser)}".`, 'success') } catch (err) { setResetError(err instanceof ApiError ? err.message : 'Unable to reset password.') } finally { setResetSubmitting(false) } } function openSendMessage(user: UserListItem) { setMessageUser(user) setMessageText('') setMessageError('') setMessageOpen(true) } async function submitSendMessage() { if (!messageUser) return setMessageSubmitting(true) setMessageError('') try { const result = await sendUserMessage(messageUser.id, messageText.trim()) setMessageOpen(false) setMessageUser(null) showToast(result.message, result.enabled ? 'success' : 'info') } catch (err) { setMessageError(err instanceof ApiError ? err.message : 'Unable to send message.') } finally { setMessageSubmitting(false) } } async function confirmRemove() { if (!removeTarget) return setError('') try { await removeUser(removeTarget.id) setRemoveTarget(null) showToast(`"${displayName(removeTarget)}" has been removed.`, 'success') setData((prev) => { if (!prev) return prev return { ...prev, items: prev.items.map((item) => item.id === removeTarget.id ? { ...item, isActive: false } : item, ), } }) } catch (err) { setError(err instanceof ApiError ? err.message : 'Unable to remove user.') } } const canResetPassword = resetPassword.length >= 8 && resetConfirm.length >= 8 && resetPassword === resetConfirm return (

Users

View and manage all platform users and their access levels.

{businessFilter && (
Showing {businessFilter.membership === 'staff' ? 'staff' : 'all users'} for{' '} {businessFilter.businessName}
)}
Filters
{ e.preventDefault(); applyFilters() }}>
setDraftName(e.target.value)} placeholder="Search by name" />
setDraftCell(e.target.value)} placeholder="0912..." autoComplete="off" />
User list
{data ? ( <> Showing {showingFrom} - {showingTo} of {data.total} ) : ( ' ' )}
{error && (
{error}
)}
{loading && ( )} {!loading && data?.items?.length === 0 && ( )} {!loading && data?.items?.map((user) => ( ))}
Name Cell number Role Business Date joined Actions
Loading...
No results found.
{displayName(user)}
{!user.isActive &&
Inactive
}
{formatCellForDisplay(user.cellNumber)} {user.roles ? ( {formatGlobalRoleLabel(user, teamRoles)} ) : ( )} {user.businesses ?? } {formatDate(user.createdAt)}
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
{ setEditOpen(false) setEditUser(null) setEditError('') }} > {editError ? (

{editError}

) : null}
setEditFirstName(e.target.value)} disabled={editSubmitting} />
setEditLastName(e.target.value)} disabled={editSubmitting} />
setEditCell(e.target.value)} autoComplete="off" disabled={editSubmitting} />
setEditEmail(e.target.value)} disabled={editSubmitting} />
{ setResetOpen(false) setResetUser(null) setResetError('') }} > {resetError ? (

{resetError}

) : null}

Set a new password for {resetUser ? displayName(resetUser) : 'user'}.

setResetPassword(e.target.value)} disabled={resetSubmitting} />
setResetConfirm(e.target.value)} disabled={resetSubmitting} />
{ setMessageOpen(false) setMessageUser(null) setMessageError('') }} > {messageError ? (

{messageError}

) : null}

Send an SMS to {messageUser ? formatCellForDisplay(messageUser.cellNumber) : 'user'}.