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:
Alireza Hassani
2026-08-10 21:31:28 +03:30
co-authored by Cursor
parent 404c008ced
commit f622e6d605
28 changed files with 1280 additions and 241 deletions
@@ -3,13 +3,14 @@
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border-bottom: 1px solid var(--glass-border);
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, #ffffff 42%, transparent);
backdrop-filter: saturate(180%) blur(var(--blur-glass, 28px));
-webkit-backdrop-filter: saturate(180%) blur(var(--blur-glass, 28px));
border-bottom: 1px solid var(--glass-border);
transform: translateZ(0);
}
.left {
@@ -73,3 +73,11 @@
.editor p:last-child {
margin-bottom: 0;
}
.editor blockquote {
margin: 0.35em 0;
margin-inline-start: 1.4em;
margin-inline-end: 0;
padding: 0;
border: 0;
}
@@ -1,7 +1,18 @@
import { useCallback, useEffect, useRef } from 'react'
import { Bold, Italic, List, ListOrdered, Underline } from 'lucide-react'
import {
Bold,
IndentDecrease,
IndentIncrease,
Italic,
List,
ListOrdered,
Underline,
} from 'lucide-react'
import styles from './RichTextEditor.module.css'
const INDENT_STEP_PX = 24
const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4'])
interface RichTextEditorProps {
value: string
onChange: (value: string) => void
@@ -36,6 +47,51 @@ export function RichTextEditor({
syncChange()
}
function findIndentBlock(editor: HTMLElement): HTMLElement | null {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return null
let node: Node | null = selection.anchorNode
while (node && node !== editor) {
if (node instanceof HTMLElement && INDENT_BLOCK_TAGS.has(node.tagName)) {
return node
}
node = node.parentNode
}
return null
}
function changeIndent(direction: 1 | -1) {
const editor = editorRef.current
if (!editor) return
editor.focus()
const block = findIndentBlock(editor)
if (!block) {
document.execCommand(direction > 0 ? 'indent' : 'outdent')
syncChange()
return
}
const current =
Number.parseFloat(block.style.paddingInlineStart) ||
Number.parseFloat(getComputedStyle(block).paddingInlineStart) ||
0
const next = Math.max(0, Math.round(current + direction * INDENT_STEP_PX))
if (next === 0) {
block.style.paddingInlineStart = ''
} else {
block.style.paddingInlineStart = `${next}px`
}
syncChange()
}
function onEditorKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) return
e.preventDefault()
changeIndent(e.shiftKey ? -1 : 1)
}
return (
<div className={styles.wrapper}>
<div className={styles.toolbar}>
@@ -70,6 +126,23 @@ export function RichTextEditor({
>
<ListOrdered size={14} />
</button>
<span className={styles.divider} />
<button
type="button"
onClick={() => changeIndent(-1)}
title="Decrease indent"
aria-label="Decrease indent"
>
<IndentDecrease size={14} />
</button>
<button
type="button"
onClick={() => changeIndent(1)}
title="Increase indent"
aria-label="Increase indent"
>
<IndentIncrease size={14} />
</button>
</div>
<div
ref={editorRef}
@@ -80,6 +153,7 @@ export function RichTextEditor({
aria-multiline="true"
data-placeholder={placeholder}
onInput={syncChange}
onKeyDown={onEditorKeyDown}
suppressContentEditableWarning
/>
</div>
@@ -80,7 +80,15 @@
.roleHint {
margin-top: 12px;
margin-bottom: 8px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.45;
}
.roleListNested {
margin-top: 4px;
margin-inline-start: 12px;
padding-inline-start: 10px;
border-inline-start: 2px solid rgba(var(--primary-rgb) / 0.2);
}
+220 -128
View File
@@ -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
@@ -33,3 +33,40 @@ export async function updateTeamMemberRole(
body: { roleSlug },
})
}
export type BusinessAccess = 'customer' | 'staff'
export interface AssignBusinessAccessPayload {
userId: number | string
access: BusinessAccess
roleSlug?: string
}
export interface AssignBusinessAccessResult {
message: string
access: BusinessAccess
member: {
id: number
userId: number
isOwner: boolean
teamRole: string | null
} | null
}
/** Set customer vs staff (admin/editor/viewer) for a user on one business. */
export async function assignBusinessAccess(
businessId: number | string,
payload: AssignBusinessAccessPayload,
) {
return apiRequest<AssignBusinessAccessResult>(`/businesses/${businessId}/team/access`, {
method: 'PATCH',
auth: true,
body: {
userId: String(payload.userId),
access: payload.access,
...(payload.access === 'staff' && payload.roleSlug
? { roleSlug: payload.roleSlug }
: {}),
},
})
}