Files
dashboards/apps/super-admin/src/pages/UsersPage.tsx
T
Alireza HassaniandCursor 6c52ce1247 Polish dashboard page chrome and user access UI.
Rename Customers to Users with FA/EN titles, compact headers and filter bars across apps, and refine manager role copy plus a customer-header link to the business dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 00:00:09 +03:30

1202 lines
40 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 {
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<string, string> = {
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<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 [businessAccess, setBusinessAccess] = useState<BusinessAccess>('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 (
<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}>
{(() => {
const label = businessFilter
? formatBusinessUserRoleLabel(user, teamRoles)
: formatGlobalRoleLabel(user, teamRoles)
return label ? (
<span className={styles.roleBadge}>{label}</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={
businessFilter && user.isBusinessOwner
? 'Business owner cannot be changed'
: 'Change role'
}
aria-label={
businessFilter && user.isBusinessOwner
? 'Business owner cannot be changed'
: 'Change role'
}
disabled={!user.isActive || Boolean(businessFilter && user.isBusinessOwner)}
>
<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('admin')
setRoleError('')
}}
>
{roleError ? (
<p className={tableStyles.alertError} role="alert">
{roleError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
{businessFilter
? `Choose customer or manager access for ${roleUser ? displayName(roleUser) : 'user'} on ${businessFilter.businessName}.`
: `Select a role for ${roleUser ? displayName(roleUser) : 'user'}.`}
</p>
{businessFilter ? (
<>
<div className={styles.roleList}>
<label className={styles.roleOption}>
<input
type="radio"
name="business-access"
value="customer"
checked={businessAccess === 'customer'}
onChange={() => setBusinessAccess('customer')}
disabled={roleSubmitting}
/>
<span className={styles.roleOptionLabel}>Customer</span>
</label>
<label className={styles.roleOption}>
<input
type="radio"
name="business-access"
value="staff"
checked={businessAccess === 'staff'}
onChange={() => {
setBusinessAccess('staff')
if (!selectedTeamRoleSlug) {
setSelectedTeamRoleSlug(orderedTeamRoles[0]?.slug || 'admin')
}
}}
disabled={roleSubmitting}
/>
<span className={styles.roleOptionLabel}>Manager</span>
</label>
</div>
{businessAccess === 'staff' ? (
<>
<p className={styles.roleHint}>
Only a super admin can assign Admin. Admins can assign Editor or Observer.
</p>
<div className={`${styles.roleList} ${styles.roleListNested}`}>
{orderedTeamRoles.map((role) => (
<label key={role.slug} className={styles.roleOption}>
<input
type="radio"
name="team-role"
value={role.slug}
checked={selectedTeamRoleSlug === role.slug}
onChange={() => setSelectedTeamRoleSlug(role.slug)}
disabled={roleSubmitting}
/>
<span className={styles.roleOptionLabel}>
{teamRoleLabel(role.slug, teamRoles) ?? role.name}
</span>
</label>
))}
</div>
</>
) : null}
</>
) : (
<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)}
disabled={roleSubmitting}
/>
<span className={styles.roleOptionLabel}>{role.name}</span>
</label>
))}
</div>
)}
<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>
)
}