fix: impersonation logout, applicant learning hub redirect, nav click tracking
- Sign Out button during impersonation now returns to admin instead of destroying the session (fixes multi-click logout issue) - Applicant nav now respects learning_hub_external setting like jury/mentor - Track Learning Hub nav clicks via audit log (LEARNING_HUB_CLICK action) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,8 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const useExternal = featureFlags?.learningHubExternal && featureFlags.learningHubExternalUrl
|
||||||
|
|
||||||
const navigation: NavItem[] = [
|
const navigation: NavItem[] = [
|
||||||
{ name: 'Dashboard', href: '/applicant', icon: Home },
|
{ name: 'Dashboard', href: '/applicant', icon: Home },
|
||||||
{ name: 'Project', href: '/applicant/team', icon: FolderOpen },
|
{ name: 'Project', href: '/applicant/team', icon: FolderOpen },
|
||||||
@@ -27,7 +29,12 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
|
|||||||
...(flags?.hasMentor
|
...(flags?.hasMentor
|
||||||
? [{ name: 'Mentoring', href: '/applicant/mentor', icon: MessageSquare }]
|
? [{ name: 'Mentoring', href: '/applicant/mentor', icon: MessageSquare }]
|
||||||
: []),
|
: []),
|
||||||
{ name: 'Resources', href: '/applicant/resources', icon: BookOpen },
|
{
|
||||||
|
name: 'Learning Hub',
|
||||||
|
href: useExternal ? featureFlags.learningHubExternalUrl : '/applicant/resources',
|
||||||
|
icon: BookOpen,
|
||||||
|
external: !!useExternal,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -71,13 +71,31 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||||
const { data: session, status: sessionStatus, update: updateSession } = useSession()
|
const { data: session, status: sessionStatus, update: updateSession } = useSession()
|
||||||
const isAuthenticated = sessionStatus === 'authenticated'
|
const isAuthenticated = sessionStatus === 'authenticated'
|
||||||
|
const isImpersonating = !!session?.user?.impersonating
|
||||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
})
|
})
|
||||||
|
const endImpersonation = trpc.user.endImpersonation.useMutation()
|
||||||
|
const logNavClick = trpc.learningResource.logNavClick.useMutation()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
useEffect(() => setMounted(true), [])
|
useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
if (isImpersonating) {
|
||||||
|
try {
|
||||||
|
await endImpersonation.mutateAsync()
|
||||||
|
await updateSession({ endImpersonation: true })
|
||||||
|
window.location.href = '/admin/members'
|
||||||
|
} catch {
|
||||||
|
// Fallback: just sign out completely
|
||||||
|
signOut({ callbackUrl: '/login' })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signOut({ callbackUrl: '/login' })
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-refresh session on mount to pick up role changes without requiring re-login
|
// Auto-refresh session on mount to pick up role changes without requiring re-login
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
@@ -115,7 +133,14 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
)
|
)
|
||||||
if (item.external) {
|
if (item.external) {
|
||||||
return (
|
return (
|
||||||
<a key={item.name} href={item.href} target="_blank" rel="noopener noreferrer" className={className}>
|
<a
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={className}
|
||||||
|
onClick={() => logNavClick.mutate({ url: item.href })}
|
||||||
|
>
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
{item.name}
|
{item.name}
|
||||||
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
|
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
|
||||||
@@ -211,11 +236,11 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
onClick={handleSignOut}
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
Sign Out
|
{isImpersonating ? 'Return to Admin' : 'Sign Out'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -257,7 +282,14 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
)
|
)
|
||||||
if (item.external) {
|
if (item.external) {
|
||||||
return (
|
return (
|
||||||
<a key={item.name} href={item.href} target="_blank" rel="noopener noreferrer" onClick={() => setIsMobileMenuOpen(false)} className={className}>
|
<a
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => { logNavClick.mutate({ url: item.href }); setIsMobileMenuOpen(false) }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
{item.name}
|
{item.name}
|
||||||
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
|
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
|
||||||
@@ -299,10 +331,10 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="w-full justify-start text-destructive hover:text-destructive"
|
className="w-full justify-start text-destructive hover:text-destructive"
|
||||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
onClick={handleSignOut}
|
||||||
>
|
>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
Sign Out
|
{isImpersonating ? 'Return to Admin' : 'Sign Out'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -271,6 +271,24 @@ export const learningResourceRouter = router({
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log when a user clicks the Learning Hub nav link (especially external).
|
||||||
|
* Creates an audit log entry so admins can see who accessed it.
|
||||||
|
*/
|
||||||
|
logNavClick: protectedProcedure
|
||||||
|
.input(z.object({ url: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LEARNING_HUB_CLICK',
|
||||||
|
entityType: 'LearningResource',
|
||||||
|
detailsJson: { url: input.url, role: ctx.user.role },
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
return { success: true }
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new resource (admin only)
|
* Create a new resource (admin only)
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user