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);