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 { assignBusinessAccess, listTeamRoles, type BusinessAccess, } 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 || '—' } const TEAM_ROLE_ORDER = ['admin', 'editor', 'viewer'] as const const TEAM_ROLE_LABELS: Record = { admin: 'Admin', editor: 'Editor', viewer: 'Observer', } function teamRoleLabel(slug: string | null | undefined, teamRoles: RoleOption[]) { if (!slug) return null return ( TEAM_ROLE_LABELS[slug] ?? teamRoles.find((role) => role.slug === slug)?.name ?? slug ) } function formatGlobalRoleLabel(user: UserListItem, teamRoles: RoleOption[]) { if (user.isBusinessOwner) return 'Business Owner' if (user.teamRole) { const teamName = teamRoleLabel(user.teamRole, teamRoles) return teamName ? `Manager · ${teamName}` : 'Manager' } if (user.roleSlug === 'business_staff' && user.teamRole) { const teamName = teamRoleLabel(user.teamRole, teamRoles) return teamName ? `Manager · ${teamName}` : 'Manager' } return user.roles } function formatBusinessUserRoleLabel(user: UserListItem, teamRoles: RoleOption[]) { if (user.isBusinessOwner) return 'Business Owner' if (user.businessMemberId && user.teamRole) { return teamRoleLabel(user.teamRole, teamRoles) ?? 'Manager' } if (user.businessMemberId) return 'Manager' return 'Customer' } 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 [businessAccess, setBusinessAccess] = useState('customer') const [selectedTeamRoleSlug, setSelectedTeamRoleSlug] = useState('admin') 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 const orderedTeamRoles = useMemo(() => { const bySlug = new Map(teamRoles.map((role) => [role.slug, role])) const ordered = TEAM_ROLE_ORDER.map((slug) => bySlug.get(slug)).filter( (role): role is RoleOption => Boolean(role), ) const extras = teamRoles.filter( (role) => !TEAM_ROLE_ORDER.includes(role.slug as (typeof TEAM_ROLE_ORDER)[number]), ) return [...ordered, ...extras] }, [teamRoles]) function openRoleChange(user: UserListItem) { if (businessFilter && user.isBusinessOwner) return setRoleUser(user) setSelectedRoleSlug(user.roleSlug ?? '') if (businessFilter) { const isStaff = Boolean(user.businessMemberId) && !user.isBusinessOwner setBusinessAccess(isStaff ? 'staff' : 'customer') setSelectedTeamRoleSlug( (isStaff && user.teamRole) || orderedTeamRoles[0]?.slug || 'admin', ) } else { setBusinessAccess('customer') setSelectedTeamRoleSlug(orderedTeamRoles[0]?.slug || 'admin') } setRoleError('') setRoleOpen(true) } const canSaveRole = businessFilter ? !roleUser?.isBusinessOwner && (businessAccess === 'customer' || Boolean(selectedTeamRoleSlug)) : Boolean(selectedRoleSlug) async function submitRoleChange() { if (!roleUser || !canSaveRole) return setRoleSubmitting(true) setRoleError('') try { if (businessFilter) { const result = await assignBusinessAccess(businessFilter.businessId, { userId: roleUser.id, access: businessAccess, roleSlug: businessAccess === 'staff' ? selectedTeamRoleSlug : undefined, }) const accessLabel = businessAccess === 'staff' ? teamRoleLabel(selectedTeamRoleSlug, teamRoles) ?? 'Manager' : 'Customer' showToast(`Access updated to ${accessLabel}.`, 'success') setData((prev) => { if (!prev) return prev return { ...prev, items: prev.items.map((item) => item.id === roleUser.id ? { ...item, roleSlug: businessAccess === 'staff' ? 'business_staff' : 'customer', roles: accessLabel, teamRole: businessAccess === 'staff' ? selectedTeamRoleSlug : null, businessMemberId: businessAccess === 'staff' ? result.member?.id != null ? Number(result.member.id) : item.businessMemberId : null, isBusinessOwner: false, } : item, ), } }) } else { await updateUserRole(roleUser.id, selectedRoleSlug) const roleName = roles.find((role) => role.slug === selectedRoleSlug)?.name ?? selectedRoleSlug showToast(`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: roleName, } : item, ), } }) } setRoleOpen(false) setRoleUser(null) setSelectedTeamRoleSlug('admin') } 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)} {(() => { const label = businessFilter ? formatBusinessUserRoleLabel(user, teamRoles) : formatGlobalRoleLabel(user, teamRoles) return label ? ( {label} ) : ( ) })()} {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'}.