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 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-18 18:55:24 +03:30
parent 04b2f040e0
commit 8dc351ab21
2 changed files with 68 additions and 6 deletions
+49 -1
View File
@@ -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: <Briefcase className="w-4 h-4" /> },
];
/** 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<User>({
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);