feat: impersonation system, semi-finalist detail page, tRPC resilience
- Add super-admin impersonation: "Login As" from user list, red banner with "Return to Admin", audit logged start/end, nested impersonation blocked, onboarding gate skipped during impersonation - Fix semi-finalist stats: check latest terminal state (not any PASSED), use passwordHash OR status=ACTIVE for activation check - Add /admin/semi-finalists detail page with search, category/status filters - Add account_reminder_days setting to notifications tab - Add tRPC resilience: retry on 503/HTML responses, custom fetch detects nginx error pages, exponential backoff (2s/4s/8s) - Reduce dashboard polling intervals (60s stats, 30s activity, 120s semi) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -33,6 +36,7 @@ import {
|
||||
Trash2,
|
||||
Loader2,
|
||||
Shield,
|
||||
LogIn,
|
||||
} from 'lucide-react'
|
||||
|
||||
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||
@@ -54,9 +58,21 @@ interface UserActionsProps {
|
||||
currentUserRole?: Role
|
||||
}
|
||||
|
||||
function getRoleHomePath(role: string): string {
|
||||
switch (role) {
|
||||
case 'JURY_MEMBER': return '/jury'
|
||||
case 'APPLICANT': return '/applicant'
|
||||
case 'MENTOR': return '/mentor'
|
||||
case 'OBSERVER': return '/observer'
|
||||
default: return '/admin'
|
||||
}
|
||||
}
|
||||
|
||||
export function UserActions({ userId, userEmail, userStatus, userRole, userRoles, currentUserRole }: UserActionsProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const { data: session, update } = useSession()
|
||||
const router = useRouter()
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||
@@ -65,6 +81,7 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles
|
||||
utils.user.list.invalidate()
|
||||
},
|
||||
})
|
||||
const startImpersonation = trpc.user.startImpersonation.useMutation()
|
||||
const updateRoles = trpc.user.updateRoles.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.user.list.invalidate()
|
||||
@@ -105,6 +122,18 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles
|
||||
updateRoles.mutate({ userId, roles: newRoles })
|
||||
}
|
||||
|
||||
const handleImpersonate = async () => {
|
||||
try {
|
||||
const result = await startImpersonation.mutateAsync({ targetUserId: userId })
|
||||
await update({ impersonate: userId })
|
||||
toast.success(`Now impersonating ${userEmail}`)
|
||||
router.push(getRoleHomePath(result.targetRole) as Route)
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to start impersonation')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendInvitation = async () => {
|
||||
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
|
||||
toast.error('User has already accepted their invitation')
|
||||
@@ -154,6 +183,19 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isSuperAdmin && session?.user?.id !== userId && (
|
||||
<DropdownMenuItem
|
||||
onClick={handleImpersonate}
|
||||
disabled={startImpersonation.isPending}
|
||||
>
|
||||
{startImpersonation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Login As
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canChangeRole && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={updateRoles.isPending}>
|
||||
@@ -237,8 +279,11 @@ export function UserMobileActions({
|
||||
currentUserRole,
|
||||
}: UserMobileActionsProps) {
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const { data: session, update } = useSession()
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||
const startImpersonation = trpc.user.startImpersonation.useMutation()
|
||||
const updateRoles = trpc.user.updateRoles.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.user.list.invalidate()
|
||||
@@ -253,6 +298,18 @@ export function UserMobileActions({
|
||||
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
|
||||
const currentRoles: Role[] = userRoles?.length ? userRoles : [userRole]
|
||||
|
||||
const handleImpersonateMobile = async () => {
|
||||
try {
|
||||
const result = await startImpersonation.mutateAsync({ targetUserId: userId })
|
||||
await update({ impersonate: userId })
|
||||
toast.success(`Now impersonating ${userEmail}`)
|
||||
router.push(getRoleHomePath(result.targetRole) as Route)
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to start impersonation')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendInvitation = async () => {
|
||||
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
|
||||
toast.error('User has already accepted their invitation')
|
||||
@@ -280,6 +337,22 @@ export function UserMobileActions({
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
{isSuperAdmin && session?.user?.id !== userId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={handleImpersonateMobile}
|
||||
disabled={startImpersonation.isPending}
|
||||
>
|
||||
{startImpersonation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Login As
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
Reference in New Issue
Block a user