Skip to content

Commit 5158689

Browse files
committed
feat(admin): improve impersonation UI/UX and user search
This commit enhances the admin impersonation feature with better visual design, improved user feedback, and smoother transitions. ## AdminImpersonationButton improvements: - Tighten button spacing and make width content-relative (no overflow) - Update dialog to match app-wide dialog styling patterns - Auto-focus search input when dialog opens - Replace page reload with Next.js router navigation for smoother UX - Show Loader2 icon with "Starting" text during impersonation start ## ImpersonationBanner improvements: - Separate loading states for "switch user" vs "exit" operations - Only show loading state on the button that was clicked - Both buttons disabled during any operation to prevent conflicts - Use Loader2 with "Switching" and "Exiting" text for better feedback - Replace page reloads with router.push('/') for seamless transitions - Align user search popover to right to prevent overflow ## UserSearchAutocomplete enhancements: - Add loading/loadingText props for external loading state control - Add autoFocus prop to focus input when opened - Add align prop (left/right) to control popover positioning - Replace generic icons with better semantic ones: - Building2 for organizations (was FileText) - FolderGit for projects (was BriefcaseBusiness) - Add citizenship badge using CitizenshipBadge component (icon variant) - Add KYC verification badge with verified icon - Add tooltips to all metadata icons for clarity - Show Loader2 icon with "Searching" text during search - Improve spacing and layout of user metadata badges ## Backend changes: - Add hasApprovedKYC field to UserSearchResult interface - Query userKYCUsers in search to determine KYC verification status - Check for APPROVED status with non-expired KYC records ## Navigation improvements: All impersonation transitions now use Next.js router.push('/') instead of window.location reloads, providing: - Smoother transitions without white flash - Faster navigation via client-side routing - Still safe: session update triggers server component refetch - Consistent landing page for all users
1 parent 1a38bbc commit 5158689

File tree

4 files changed

+156
-63
lines changed

4 files changed

+156
-63
lines changed

app/src/components/admin/AdminImpersonationButton.tsx

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
"use client"
22

33
import { useSession } from "next-auth/react"
4+
import { useRouter } from "next/navigation"
45
import { useEffect, useState } from "react"
56
import { Button } from "@/components/ui/button"
67
import {
78
Dialog,
89
DialogContent,
9-
DialogDescription,
10-
DialogHeader,
11-
DialogTitle,
1210
DialogTrigger,
1311
} from "@/components/ui/dialog"
1412
import { Eye } from "lucide-react"
@@ -24,6 +22,7 @@ type ImpersonationStatus = {
2422

2523
export function AdminImpersonationButton() {
2624
const { data: session, status, update } = useSession()
25+
const router = useRouter()
2726
const [open, setOpen] = useState(false)
2827
const [starting, setStarting] = useState(false)
2928
const [checkingAccess, setCheckingAccess] = useState(true)
@@ -110,9 +109,9 @@ export function AdminImpersonationButton() {
110109
impersonation: data.impersonation,
111110
})
112111

113-
// Close dialog and reload
112+
// Close dialog and navigate to home
114113
setOpen(false)
115-
window.location.reload()
114+
router.push('/')
116115
} catch (error) {
117116
console.error('Failed to start impersonation:', error)
118117
alert(error instanceof Error ? error.message : 'Failed to start impersonation')
@@ -127,7 +126,7 @@ export function AdminImpersonationButton() {
127126
<Button
128127
variant="outline"
129128
size="sm"
130-
className="gap-2 px-2"
129+
className="gap-1.5 px-2 w-auto whitespace-nowrap"
131130
aria-label="View as user"
132131
disabled={starting}
133132
>
@@ -136,26 +135,31 @@ export function AdminImpersonationButton() {
136135
<span className="sm:hidden text-xs font-medium">View</span>
137136
</Button>
138137
</DialogTrigger>
139-
<DialogContent>
140-
<DialogHeader>
141-
<DialogTitle>Admin: View as User</DialogTitle>
142-
<DialogDescription>
138+
<DialogContent className="max-w-md">
139+
<div className="flex flex-col text-center">
140+
<div className="font-semibold text-xl">
141+
Admin: View as User
142+
</div>
143+
144+
<div className="text-base text-secondary-foreground mt-2">
143145
Search for a user to view the app from their perspective.
144146
You&apos;ll be using yesterday&apos;s data snapshot - changes won&apos;t affect production.
145-
</DialogDescription>
146-
</DialogHeader>
147-
148-
<div className="py-4">
149-
<UserSearchAutocomplete
150-
onSelectUser={handleStartImpersonation}
151-
disabled={starting}
152-
placeholder={starting ? "Starting..." : "Search for user..."}
153-
/>
154-
</div>
155-
156-
<div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
157-
<strong>Note:</strong> External services (emails, KYC, payments) will be mocked during impersonation.
158-
All actions are logged for audit purposes.
147+
</div>
148+
149+
<div className="mt-6">
150+
<UserSearchAutocomplete
151+
onSelectUser={handleStartImpersonation}
152+
disabled={starting}
153+
placeholder="Search for user"
154+
loading={starting}
155+
loadingText="Starting"
156+
autoFocus={open}
157+
/>
158+
</div>
159+
160+
<div className="text-sm text-secondary-foreground bg-muted p-3 rounded-md mt-4">
161+
<strong>Note:</strong> External services (emails, KYC, payments) will be mocked during impersonation.
162+
</div>
159163
</div>
160164
</DialogContent>
161165
</Dialog>

app/src/components/admin/ImpersonationBanner.tsx

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
"use client"
22

33
import { useSession } from "next-auth/react"
4+
import { useRouter } from "next/navigation"
45
import { useEffect, useRef, useState } from "react"
56
import { Alert, AlertDescription } from "@/components/ui/alert"
67
import { Button } from "@/components/ui/button"
7-
import { TriangleAlert, X, Eye } from "lucide-react"
8+
import { X, Eye, Loader2 } from "lucide-react"
89
import { UserSearchAutocomplete } from "./UserSearchAutocomplete"
910

1011
export function ImpersonationBanner() {
1112
const { data: session, update } = useSession()
12-
const [isSwitching, setIsSwitching] = useState(false)
13+
const router = useRouter()
14+
const [isSwitchingUser, setIsSwitchingUser] = useState(false)
15+
const [isExiting, setIsExiting] = useState(false)
1316
const bannerRef = useRef<HTMLDivElement | null>(null)
1417

1518
useEffect(() => {
@@ -70,7 +73,7 @@ export function ImpersonationBanner() {
7073

7174
const handleStopImpersonation = async () => {
7275
try {
73-
setIsSwitching(true)
76+
setIsExiting(true)
7477

7578
const response = await fetch('/api/admin/impersonate', {
7679
method: 'DELETE',
@@ -83,19 +86,19 @@ export function ImpersonationBanner() {
8386
// CRITICAL: Clear impersonation from session (triggers new JWT without impersonation)
8487
await update({ impersonation: null })
8588

86-
// Reload to return to admin view
87-
window.location.href = '/'
89+
// Navigate to home to return to admin view
90+
router.push('/')
8891
} catch (error) {
8992
console.error('Failed to stop impersonation:', error)
9093
alert('Failed to stop impersonation. Please try again or refresh the page.')
9194
} finally {
92-
setIsSwitching(false)
95+
setIsExiting(false)
9396
}
9497
}
9598

9699
const handleSwitchUser = async (targetUserId: string) => {
97100
try {
98-
setIsSwitching(true)
101+
setIsSwitchingUser(true)
99102

100103
const response = await fetch('/api/admin/impersonate', {
101104
method: 'POST',
@@ -112,24 +115,23 @@ export function ImpersonationBanner() {
112115
// Update session with new impersonation data
113116
await update({ impersonation: data.impersonation })
114117

115-
// Reload to see new user's view
116-
window.location.href = '/'
118+
// Navigate to home to see new user's view
119+
router.push('/')
117120
} catch (error) {
118121
console.error('Failed to switch user:', error)
119122
alert(error instanceof Error ? error.message : 'Failed to switch user')
120123
} finally {
121-
setIsSwitching(false)
124+
setIsSwitchingUser(false)
122125
}
123126
}
124127

125128
return (
126129
<div ref={bannerRef} className="sticky top-0 z-[320]">
127130
<Alert className="border-yellow-500 bg-yellow-50 dark:bg-yellow-950 rounded-none mb-0 shadow-md px-4 sm:px-6">
128-
<TriangleAlert className="h-4 w-4" />
131+
<Eye className="h-4 w-4" />
129132
<AlertDescription className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 w-full">
130133
<div className="flex flex-col gap-1">
131134
<div className="flex items-center gap-2">
132-
<Eye className="h-4 w-4" />
133135
<strong className="text-sm">Admin Mode: Viewing as {session.impersonation.targetUserName}</strong>
134136
</div>
135137
<span className="text-xs text-muted-foreground">
@@ -144,21 +146,33 @@ export function ImpersonationBanner() {
144146
<div className="w-full sm:w-auto">
145147
<UserSearchAutocomplete
146148
onSelectUser={handleSwitchUser}
147-
disabled={isSwitching}
148-
placeholder="Switch user..."
149+
disabled={isSwitchingUser || isExiting}
150+
placeholder="Switch user"
151+
loading={isSwitchingUser}
152+
loadingText="Switching"
149153
currentUserId={session.impersonation.targetUserId}
154+
align="right"
150155
/>
151156
</div>
152157

153158
<Button
154159
onClick={handleStopImpersonation}
155-
disabled={isSwitching}
160+
disabled={isSwitchingUser || isExiting}
156161
variant="outline"
157162
size="sm"
158163
className="whitespace-nowrap w-full sm:w-auto"
159164
>
160-
<X className="h-4 w-4 mr-1" />
161-
{isSwitching ? 'Stopping...' : 'Exit Admin Mode'}
165+
{isExiting ? (
166+
<>
167+
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
168+
Exiting
169+
</>
170+
) : (
171+
<>
172+
<X className="h-4 w-4 mr-1" />
173+
Exit Admin Mode
174+
</>
175+
)}
162176
</Button>
163177
</div>
164178
</AlertDescription>

0 commit comments

Comments
 (0)