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:
2026-03-05 14:15:59 +01:00
parent abb6e6df83
commit 12e4864d36
3 changed files with 64 additions and 7 deletions

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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)
*/ */