mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Add customer/manager access UI and tighten customers filters.
Business and super-admin can set Customer vs Manager roles (Admin only for super-admin), and the customers page drops clutter while filtering by all/customers/managers. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
404c008ced
commit
f622e6d605
@@ -8,9 +8,9 @@ import type { RoleOption, UserListItem, UsersListResponse } from '../types/user'
|
||||
import type { ListUsersParams } from '../services/userService'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
addTeamMember,
|
||||
assignBusinessAccess,
|
||||
listTeamRoles,
|
||||
updateTeamMemberRole,
|
||||
type BusinessAccess,
|
||||
} from '../services/teamService'
|
||||
import {
|
||||
adminResetUserPassword,
|
||||
@@ -41,14 +41,45 @@ function displayName(user: UserListItem) {
|
||||
return name || '—'
|
||||
}
|
||||
|
||||
const TEAM_ROLE_ORDER = ['admin', 'editor', 'viewer'] as const
|
||||
|
||||
const TEAM_ROLE_LABELS: Record<string, string> = {
|
||||
admin: 'Admin',
|
||||
editor: 'Editor',
|
||||
viewer: 'Viewer',
|
||||
}
|
||||
|
||||
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 = teamRoles.find((role) => role.slug === user.teamRole)?.name ?? user.teamRole
|
||||
return `Business Staff · ${teamName}`
|
||||
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()
|
||||
@@ -93,7 +124,8 @@ export function UsersPage() {
|
||||
const [roleOpen, setRoleOpen] = useState(false)
|
||||
const [roleUser, setRoleUser] = useState<UserListItem | null>(null)
|
||||
const [selectedRoleSlug, setSelectedRoleSlug] = useState('')
|
||||
const [selectedTeamRoleSlug, setSelectedTeamRoleSlug] = useState('')
|
||||
const [businessAccess, setBusinessAccess] = useState<BusinessAccess>('customer')
|
||||
const [selectedTeamRoleSlug, setSelectedTeamRoleSlug] = useState('admin')
|
||||
const [roleSubmitting, setRoleSubmitting] = useState(false)
|
||||
const [roleError, setRoleError] = useState('')
|
||||
|
||||
@@ -259,84 +291,111 @@ export function UsersPage() {
|
||||
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 ?? '')
|
||||
setSelectedTeamRoleSlug(user.teamRole ?? teamRoles[0]?.slug ?? '')
|
||||
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 needsTeamRole =
|
||||
selectedRoleSlug === 'business_staff' &&
|
||||
businessFilter !== null &&
|
||||
!roleUser?.isBusinessOwner
|
||||
|
||||
const canSaveRole =
|
||||
Boolean(selectedRoleSlug) && (!needsTeamRole || Boolean(selectedTeamRoleSlug))
|
||||
const canSaveRole = businessFilter
|
||||
? !roleUser?.isBusinessOwner &&
|
||||
(businessAccess === 'customer' || Boolean(selectedTeamRoleSlug))
|
||||
: Boolean(selectedRoleSlug)
|
||||
|
||||
async function submitRoleChange() {
|
||||
if (!roleUser || !selectedRoleSlug || !canSaveRole) return
|
||||
if (!roleUser || !canSaveRole) return
|
||||
setRoleSubmitting(true)
|
||||
setRoleError('')
|
||||
try {
|
||||
await updateUserRole(roleUser.id, selectedRoleSlug)
|
||||
if (businessFilter) {
|
||||
const result = await assignBusinessAccess(businessFilter.businessId, {
|
||||
userId: roleUser.id,
|
||||
access: businessAccess,
|
||||
roleSlug: businessAccess === 'staff' ? selectedTeamRoleSlug : undefined,
|
||||
})
|
||||
|
||||
let nextTeamRole = roleUser.teamRole
|
||||
let nextMemberId = roleUser.businessMemberId
|
||||
const accessLabel =
|
||||
businessAccess === 'staff'
|
||||
? teamRoleLabel(selectedTeamRoleSlug, teamRoles) ?? 'Manager'
|
||||
: 'Customer'
|
||||
|
||||
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
|
||||
}
|
||||
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,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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('')
|
||||
setSelectedTeamRoleSlug('admin')
|
||||
} catch (err) {
|
||||
setRoleError(err instanceof ApiError ? err.message : 'Unable to update role.')
|
||||
} finally {
|
||||
@@ -619,13 +678,16 @@ export function UsersPage() {
|
||||
</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>
|
||||
)}
|
||||
{(() => {
|
||||
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>}
|
||||
@@ -637,9 +699,17 @@ export function UsersPage() {
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
onClick={() => openRoleChange(user)}
|
||||
title="Change role"
|
||||
aria-label="Change role"
|
||||
disabled={!user.isActive}
|
||||
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>
|
||||
@@ -901,7 +971,7 @@ export function UsersPage() {
|
||||
onClose={() => {
|
||||
setRoleOpen(false)
|
||||
setRoleUser(null)
|
||||
setSelectedTeamRoleSlug('')
|
||||
setSelectedTeamRoleSlug('admin')
|
||||
setRoleError('')
|
||||
}}
|
||||
>
|
||||
@@ -911,65 +981,87 @@ export function UsersPage() {
|
||||
</p>
|
||||
) : null}
|
||||
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
|
||||
Select a role for {roleUser ? displayName(roleUser) : 'user'}.
|
||||
{businessFilter ? ` Permissions apply to ${businessFilter.businessName}.` : ''}
|
||||
{businessFilter
|
||||
? `Choose customer or manager access for ${roleUser ? displayName(roleUser) : 'user'} on ${businessFilter.businessName}.`
|
||||
: `Select a role for ${roleUser ? displayName(roleUser) : 'user'}.`}
|
||||
</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 && (
|
||||
{businessFilter ? (
|
||||
<>
|
||||
<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 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 Viewer. Admins
|
||||
cannot change other admins.
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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
|
||||
|
||||
Reference in New Issue
Block a user