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,
})
const useExternal = featureFlags?.learningHubExternal && featureFlags.learningHubExternalUrl
const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/applicant', icon: Home },
{ name: 'Project', href: '/applicant/team', icon: FolderOpen },
@@ -27,7 +29,12 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
...(flags?.hasMentor
? [{ 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 (

View File

@@ -71,13 +71,31 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: session, status: sessionStatus, update: updateSession } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const isImpersonating = !!session?.user?.impersonating
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated,
})
const endImpersonation = trpc.user.endImpersonation.useMutation()
const logNavClick = trpc.learningResource.logNavClick.useMutation()
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
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
useEffect(() => {
if (isAuthenticated) {
@@ -115,7 +133,14 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
)
if (item.external) {
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.name}
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
@@ -211,11 +236,11 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
onClick={handleSignOut}
className="text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
{isImpersonating ? 'Return to Admin' : 'Sign Out'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -257,7 +282,14 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
)
if (item.external) {
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.name}
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
@@ -299,10 +331,10 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
onClick={handleSignOut}
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
{isImpersonating ? 'Return to Admin' : 'Sign Out'}
</Button>
</div>
</nav>

View File

@@ -271,6 +271,24 @@ export const learningResourceRouter = router({
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)
*/