Files
dashboards/apps/super-admin/src/pages/UsersPage.tsx
T

1111 lines
37 KiB
TypeScript

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<UsersListResponse | null>(null)
const [roles, setRoles] = useState<RoleOption[]>([])
const [teamRoles, setTeamRoles] = useState<RoleOption[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [appliedFilters, setAppliedFilters] = useState<Omit<ListUsersParams, 'page' | 'pageSize'>>({})
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<UserListItem | null>(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<UserListItem | null>(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<UserListItem | null>(null)
const [messageText, setMessageText] = useState('')
const [messageSubmitting, setMessageSubmitting] = useState(false)
const [messageError, setMessageError] = useState('')
const [removeTarget, setRemoveTarget] = useState<UserListItem | null>(null)
const [roleOpen, setRoleOpen] = useState(false)
const [roleUser, setRoleUser] = useState<UserListItem | null>(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 (
<main className={pageStyles.content}>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Users</h2>
<p className={pageStyles.pageSubtitle}>
View and manage all platform users and their access levels.
</p>
</div>
</div>
{businessFilter && (
<div className={styles.businessBanner}>
<span>
Showing {businessFilter.membership === 'staff' ? 'staff' : 'all users'} for{' '}
<strong>{businessFilter.businessName}</strong>
</span>
<button
type="button"
className={styles.businessBannerClear}
onClick={clearBusinessFilter}
aria-label="Clear business filter"
title="Clear business filter"
>
<X size={16} />
</button>
</div>
)}
<div className={tableStyles.filtersPanel}>
<div className={tableStyles.filtersTitle}>Filters</div>
<form className={tableStyles.filtersGrid} onSubmit={(e) => { e.preventDefault(); applyFilters() }}>
<div className={tableStyles.filtersInputs}>
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
<label htmlFor="filter-user-name">Name</label>
<input
id="filter-user-name"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="Search by name"
/>
</div>
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
<label htmlFor="filter-user-cell">Cell number</label>
<input
id="filter-user-cell"
value={draftCell}
onChange={(e) => setDraftCell(e.target.value)}
placeholder="0912..."
autoComplete="off"
/>
</div>
<div className={`${tableStyles.field} ${tableStyles.fieldCol3}`}>
<label htmlFor="filter-user-role">Role</label>
<select
id="filter-user-role"
value={draftRole}
onChange={(e) => setDraftRole(e.target.value)}
>
<option value="">All roles</option>
{roles.map((role) => (
<option key={role.slug} value={role.slug}>
{role.name}
</option>
))}
</select>
</div>
</div>
<div className={tableStyles.filterActions}>
<button
type="submit"
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnPrimary}`}
disabled={loading}
aria-label="Search"
title="Search"
>
<Search size={18} />
</button>
<button
type="button"
className={`${tableStyles.iconActionBtn} ${tableStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={loading}
aria-label="Clear filters"
title="Clear filters"
>
<RotateCcw size={18} />
</button>
</div>
</form>
</div>
<div className={tableStyles.tablePanel}>
<div className={tableStyles.tableWrap}>
<div className={tableStyles.tableHeader}>
<div className={tableStyles.tableHeaderTitle}>User list</div>
<div className={tableStyles.meta}>
{data ? (
<>
Showing {showingFrom} - {showingTo} of {data.total}
</>
) : (
' '
)}
</div>
</div>
{error && (
<div style={{ padding: 16 }}>
<div className={tableStyles.meta} style={{ color: '#b91c1c' }}>
{error}
</div>
</div>
)}
<div style={{ overflowX: 'auto' }}>
<table className={tableStyles.table}>
<thead>
<tr>
<th className={tableStyles.th}>Name</th>
<th className={tableStyles.th}>Cell number</th>
<th className={tableStyles.th}>Role</th>
<th className={tableStyles.th}>Business</th>
<th className={tableStyles.th}>Date joined</th>
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td className={tableStyles.td} colSpan={6}>
Loading...
</td>
</tr>
)}
{!loading && data?.items?.length === 0 && (
<tr>
<td className={tableStyles.td} colSpan={6}>
No results found.
</td>
</tr>
)}
{!loading &&
data?.items?.map((user) => (
<tr key={user.id} className={!user.isActive ? styles.inactiveRow : undefined}>
<td className={tableStyles.td}>
<div>{displayName(user)}</div>
{!user.isActive && <div className={styles.statusInactive}>Inactive</div>}
</td>
<td className={tableStyles.td}>{formatCellForDisplay(user.cellNumber)}</td>
<td className={tableStyles.td}>
{user.roles ? (
<span className={styles.roleBadge}>
{formatGlobalRoleLabel(user, teamRoles)}
</span>
) : (
<span className={tableStyles.subText}></span>
)}
</td>
<td className={tableStyles.td}>
{user.businesses ?? <span className={tableStyles.subText}></span>}
</td>
<td className={tableStyles.td}>{formatDate(user.createdAt)}</td>
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
<div className={tableStyles.rowActions}>
<button
type="button"
className={tableStyles.controlBtn}
onClick={() => openRoleChange(user)}
title="Change role"
aria-label="Change role"
disabled={!user.isActive}
>
<Shield size={16} />
</button>
<button
type="button"
className={tableStyles.controlBtn}
onClick={() => openResetPassword(user)}
title="Reset password"
aria-label="Reset password"
disabled={!user.isActive}
>
<KeyRound size={16} />
</button>
<button
type="button"
className={tableStyles.controlBtn}
onClick={() => openEdit(user)}
title="Edit"
aria-label="Edit"
disabled={!user.isActive}
>
<Pencil size={16} />
</button>
<button
type="button"
className={tableStyles.controlBtn}
onClick={() => openSendMessage(user)}
title="Send message"
aria-label="Send message"
disabled={!user.isActive}
>
<MessageSquare size={16} />
</button>
<button
type="button"
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
onClick={() => setRemoveTarget(user)}
title="Remove"
aria-label="Remove"
disabled={!user.isActive}
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className={tableStyles.pagination}>
<div>
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
</div>
</div>
<Modal
open={editOpen}
title="Edit user"
onClose={() => {
setEditOpen(false)
setEditUser(null)
setEditError('')
}}
>
{editError ? (
<p className={tableStyles.alertError} role="alert">
{editError}
</p>
) : null}
<div className={tableStyles.formGrid}>
<div className={tableStyles.field}>
<label htmlFor="edit-first-name">First name</label>
<input
id="edit-first-name"
value={editFirstName}
onChange={(e) => setEditFirstName(e.target.value)}
disabled={editSubmitting}
/>
</div>
<div className={tableStyles.field}>
<label htmlFor="edit-last-name">Last name</label>
<input
id="edit-last-name"
value={editLastName}
onChange={(e) => setEditLastName(e.target.value)}
disabled={editSubmitting}
/>
</div>
<div className={`${tableStyles.field} ${tableStyles.formFull}`}>
<label htmlFor="edit-cell">Cell number</label>
<input
id="edit-cell"
value={editCell}
onChange={(e) => setEditCell(e.target.value)}
autoComplete="off"
disabled={editSubmitting}
/>
</div>
<div className={`${tableStyles.field} ${tableStyles.formFull}`}>
<label htmlFor="edit-email">Email (optional)</label>
<input
id="edit-email"
type="email"
value={editEmail}
onChange={(e) => setEditEmail(e.target.value)}
disabled={editSubmitting}
/>
</div>
</div>
<div style={{ height: 12 }} />
<div className={tableStyles.actionsRow}>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={() => setEditOpen(false)}
disabled={editSubmitting}
>
Cancel
</button>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
onClick={() => void submitEdit()}
disabled={
editSubmitting ||
!editFirstName.trim() ||
!editLastName.trim() ||
!editCell.trim()
}
>
Save
</button>
</div>
</Modal>
<Modal
open={resetOpen}
title="Reset password"
onClose={() => {
setResetOpen(false)
setResetUser(null)
setResetError('')
}}
>
{resetError ? (
<p className={tableStyles.alertError} role="alert">
{resetError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Set a new password for {resetUser ? displayName(resetUser) : 'user'}.
</p>
<div className={tableStyles.field}>
<label htmlFor="reset-password">New password</label>
<input
id="reset-password"
type="password"
autoComplete="new-password"
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
disabled={resetSubmitting}
/>
</div>
<div style={{ height: 10 }} />
<div className={tableStyles.field}>
<label htmlFor="reset-confirm">Confirm password</label>
<input
id="reset-confirm"
type="password"
autoComplete="new-password"
value={resetConfirm}
onChange={(e) => setResetConfirm(e.target.value)}
disabled={resetSubmitting}
/>
</div>
<div style={{ height: 12 }} />
<div className={tableStyles.actionsRow}>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={() => setResetOpen(false)}
disabled={resetSubmitting}
>
Cancel
</button>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
onClick={() => void submitResetPassword()}
disabled={resetSubmitting || !canResetPassword}
>
Reset
</button>
</div>
</Modal>
<Modal
open={messageOpen}
title="Send SMS message"
onClose={() => {
setMessageOpen(false)
setMessageUser(null)
setMessageError('')
}}
>
{messageError ? (
<p className={tableStyles.alertError} role="alert">
{messageError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Send an SMS to {messageUser ? formatCellForDisplay(messageUser.cellNumber) : 'user'}.
</p>
<div className={tableStyles.field}>
<label htmlFor="sms-message">Message</label>
<textarea
id="sms-message"
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
disabled={messageSubmitting}
rows={4}
/>
</div>
<div style={{ height: 12 }} />
<div className={tableStyles.actionsRow}>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={() => setMessageOpen(false)}
disabled={messageSubmitting}
>
Cancel
</button>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
onClick={() => void submitSendMessage()}
disabled={messageSubmitting || !messageText.trim()}
>
Send
</button>
</div>
</Modal>
<Modal
open={roleOpen}
title="Change role"
onClose={() => {
setRoleOpen(false)
setRoleUser(null)
setSelectedTeamRoleSlug('')
setRoleError('')
}}
>
{roleError ? (
<p className={tableStyles.alertError} role="alert">
{roleError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Select a role for {roleUser ? displayName(roleUser) : 'user'}.
{businessFilter ? ` Permissions apply to ${businessFilter.businessName}.` : ''}
</p>
<div className={styles.roleList}>
{roles.map((role) => (
<label key={role.slug} className={styles.roleOption}>
<input
type="radio"
name="user-role"
value={role.slug}
checked={selectedRoleSlug === role.slug}
onChange={() => {
setSelectedRoleSlug(role.slug)
if (role.slug === 'business_staff') {
setSelectedTeamRoleSlug(
roleUser?.teamRole ?? (selectedTeamRoleSlug || teamRoles[0]?.slug || ''),
)
}
}}
disabled={roleSubmitting}
/>
<span className={styles.roleOptionLabel}>{role.name}</span>
</label>
))}
</div>
{selectedRoleSlug === 'business_staff' && businessFilter && !roleUser?.isBusinessOwner && (
<>
<div style={{ height: 12 }} />
<div className={tableStyles.field}>
<label htmlFor="team-role">Team permissions</label>
<select
id="team-role"
value={selectedTeamRoleSlug}
onChange={(e) => setSelectedTeamRoleSlug(e.target.value)}
disabled={roleSubmitting}
>
<option value="">Select team role</option>
{teamRoles.map((role) => (
<option key={role.slug} value={role.slug}>
{role.name}
</option>
))}
</select>
</div>
</>
)}
{selectedRoleSlug === 'business_staff' && !businessFilter && (
<p className={styles.roleHint}>
Open users from a business to assign team permissions (admin, editor, viewer).
</p>
)}
{selectedRoleSlug === 'business_staff' && roleUser?.isBusinessOwner && (
<p className={styles.roleHint}>
Business owners already have full access. Team permissions do not apply to owners.
</p>
)}
<div style={{ height: 12 }} />
<div className={tableStyles.actionsRow}>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={() => setRoleOpen(false)}
disabled={roleSubmitting}
>
Cancel
</button>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
onClick={() => void submitRoleChange()}
disabled={roleSubmitting || !canSaveRole}
>
Save
</button>
</div>
</Modal>
<ConfirmDeleteModal
open={removeTarget !== null}
title="Remove user?"
message={
removeTarget
? `Are you sure you want to remove "${displayName(removeTarget)}"? They will no longer be able to sign in.`
: ''
}
onCancel={() => setRemoveTarget(null)}
onConfirm={() => void confirmRemove()}
/>
{businessFilter?.membership === 'all' && (
<button
type="button"
className={tableStyles.fab}
onClick={openCreate}
aria-label="Add user to business"
title="Add user to business"
>
<Plus size={24} />
</button>
)}
<Modal
open={createOpen}
title={`Add user to ${businessFilter?.businessName ?? 'business'}`}
onClose={() => {
setCreateOpen(false)
resetCreateForm()
}}
>
{createError ? (
<p className={tableStyles.alertError} role="alert">
{createError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Creates a new account or links an existing user as a customer of this business.
Password is required only for new accounts.
</p>
<div className={tableStyles.formGrid}>
<div className={tableStyles.field}>
<label htmlFor="create-user-first-name">First name</label>
<input
id="create-user-first-name"
value={createFirstName}
onChange={(e) => setCreateFirstName(e.target.value)}
disabled={createSubmitting}
/>
</div>
<div className={tableStyles.field}>
<label htmlFor="create-user-last-name">Last name</label>
<input
id="create-user-last-name"
value={createLastName}
onChange={(e) => setCreateLastName(e.target.value)}
disabled={createSubmitting}
/>
</div>
<div className={`${tableStyles.field} ${tableStyles.formFull}`}>
<label htmlFor="create-user-cell">Cell number</label>
<input
id="create-user-cell"
value={createCell}
onChange={(e) => setCreateCell(e.target.value)}
placeholder="0912..."
autoComplete="off"
disabled={createSubmitting}
/>
</div>
<div className={`${tableStyles.field} ${tableStyles.formFull}`}>
<label htmlFor="create-user-password">Password (new users)</label>
<input
id="create-user-password"
type="password"
autoComplete="new-password"
value={createPassword}
onChange={(e) => setCreatePassword(e.target.value)}
placeholder="Min. 8 characters"
disabled={createSubmitting}
/>
</div>
<div className={`${tableStyles.field} ${tableStyles.formFull}`}>
<label htmlFor="create-user-email">Email (optional)</label>
<input
id="create-user-email"
type="email"
value={createEmail}
onChange={(e) => setCreateEmail(e.target.value)}
disabled={createSubmitting}
/>
</div>
</div>
<div style={{ height: 12 }} />
<div className={tableStyles.actionsRow}>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={() => setCreateOpen(false)}
disabled={createSubmitting}
>
Cancel
</button>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
onClick={() => void submitCreate()}
disabled={createSubmitting || !canCreateUser}
>
Add user
</button>
</div>
</Modal>
</main>
)
}