From 8dc351ab218e3aa105991ace902c3e16acdb219f Mon Sep 17 00:00:00 2001 From: keyhan Date: Thu, 18 Jun 2026 18:55:24 +0330 Subject: [PATCH] fix(auth): enforce live role + active status from DB on every request JwtStrategy read `role` straight from the JWT payload, so a role change or deactivation stayed ineffective until the 1h access token expired: after a promotion the menus showed (via /users/me) but admin endpoints returned 403 because RolesGuard still saw the old token role; after a demotion the old admin kept API access. Load the user from the DB in validate() and use the current role; reject inactive users. Frontend: poll /users/me in the dashboard layout (+ on window focus) so the sidebar reflects role changes without a hard reload, and redirect away from pages the new role can no longer reach. Co-Authored-By: Claude Opus 4.8 --- backend/src/auth/strategies/jwt.strategy.ts | 24 ++++++++-- frontend/src/app/[lang]/dashboard/layout.tsx | 50 +++++++++++++++++++- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts index e02f5da..1d9bc1a 100644 --- a/backend/src/auth/strategies/jwt.strategy.ts +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -1,7 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; +import { UsersService } from '../../users/users.service'; interface JwtPayload { sub: string; @@ -13,7 +14,10 @@ interface JwtPayload { @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(configService: ConfigService) { + constructor( + configService: ConfigService, + private readonly usersService: UsersService, + ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, @@ -22,10 +26,20 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(payload: JwtPayload) { + // Resolve the role (and active status) from the database on every request + // rather than trusting the token. A token's `role` claim is frozen at login, + // so a role change or deactivation would otherwise stay ineffective until the + // token expires. `payload.sub` is the impersonated user on impersonation + // tokens, so this keeps "login as user" working with the target's live role. + const user = await this.usersService.findById(payload.sub); + if (!user || !user.isActive) { + throw new UnauthorizedException(); + } + return { - id: payload.sub, - email: payload.email, - role: payload.role, + id: user.id, + email: user.email ?? user.phone, + role: user.role, // Non-null only while an admin is impersonating this user. impersonatedBy: payload.act?.sub ?? null, impersonatorRole: payload.act?.role ?? null, diff --git a/frontend/src/app/[lang]/dashboard/layout.tsx b/frontend/src/app/[lang]/dashboard/layout.tsx index b4c5130..20b8aa5 100644 --- a/frontend/src/app/[lang]/dashboard/layout.tsx +++ b/frontend/src/app/[lang]/dashboard/layout.tsx @@ -9,6 +9,7 @@ import { useLocalizedRouter, usePathname } from '@/i18n/navigation'; import { useT } from '@/i18n/I18nProvider'; import { LanguageSwitcher } from '@/i18n/LanguageSwitcher'; import type { Dictionary } from '@/i18n/dictionaries/fa'; +import type { User } from '@/types'; import { DeploymentProgressManager } from '@/components/deployment-progress-manager'; import { ImpersonationBanner } from '@/components/impersonation-banner'; import { useDeployProgressStore } from '@/lib/deploy-progress-store'; @@ -72,9 +73,28 @@ const salesNavItems: NavItem[] = [ { href: '/dashboard/staff/tickets', labelKey: 'salesTickets', icon: }, ]; +/** The nav items a given role is allowed to see — the source of truth for both the sidebar and access control. */ +function navItemsForRole(role: User['role'] | undefined): NavItem[] { + const items = [...userNavItems]; + if (role === 'admin') items.push(...adminNavItems); + if (role === 'technical') items.push(...technicalNavItems); + if (role === 'sales') items.push(...salesNavItems); + return items; +} + +/** Whether `pathname` (locale-stripped) is reachable for the role, matching the sidebar's active-link logic. */ +function isPathAllowed(pathname: string, role: User['role'] | undefined): boolean { + // Admin is a superset and may reach any dashboard page (incl. ones not in its + // sidebar, e.g. staff views), so never redirect an admin. + if (role === 'admin') return true; + return navItemsForRole(role).some( + (item) => pathname === item.href || pathname.startsWith(item.href + '/'), + ); +} + export default function DashboardLayout({ children }: { children: React.ReactNode }) { const t = useT(); - const { user, isAuthenticated, isLoading, logout } = useAuthStore(); + const { user, isAuthenticated, isLoading, logout, setUser } = useAuthStore(); const router = useLocalizedRouter(); const pathname = usePathname(); const [sidebarOpen, setSidebarOpen] = useState(false); @@ -86,6 +106,34 @@ export default function DashboardLayout({ children }: { children: React.ReactNod } }, [isLoading, isAuthenticated, router]); + // Re-validate the session so a role change (or deactivation) made by an admin + // takes effect without a hard reload. Polls in the background and refetches + // when the tab regains focus; a deactivated account 401s and gets logged out + // by the axios interceptor. + const { data: freshUser } = useQuery({ + queryKey: ['current-user'], + queryFn: () => api.get('/users/me').then((r) => r.data), + enabled: isAuthenticated, + refetchInterval: 30000, + refetchOnWindowFocus: true, + }); + + // Push the latest role into the store only when it actually changes, so the + // sidebar and role-gated sections re-render against current permissions. + useEffect(() => { + if (freshUser && freshUser.role !== user?.role) { + setUser(freshUser); + } + }, [freshUser, user?.role, setUser]); + + // If the current role can no longer reach this page (e.g. an admin demoted to + // a regular user while sitting on an admin page), bounce to the dashboard home. + useEffect(() => { + if (!isLoading && isAuthenticated && user && !isPathAllowed(pathname, user.role)) { + router.replace('/dashboard'); + } + }, [pathname, user, isLoading, isAuthenticated, router]); + // Close sidebar on route change (mobile) useEffect(() => { setSidebarOpen(false);