diff --git a/README.md b/README.md index 218f918e..0f0d2ed7 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,14 @@ A complete and secure user authentication system is built-in for your editorial --- +### 🛡️ Role-Based Access Control (RBAC) +The dashboard implements a robust RBAC system to ensure team members only access the sections relevant to their role. +- **Protected Navigation:** The system prevents direct URL access to restricted areas, automatically redirecting unauthorized users. +- **Conditional UI:** The navigation sidebar dynamically adapts, showing only the links and tools a user is permitted to see. +> **Your Advantage:** Enforce a clear separation of duties within your team. Administrators maintain full control, while Publishers can focus solely on content management, creating a secure and efficient workflow. + +--- + ### 🎨 A Personalized Workspace Empower your team with a dashboard experience they can tailor to their own preferences, improving comfort and productivity. - **Full Appearance Control:** Each team member can configure their own workspace, including light/dark themes, accent colors, and text styles. diff --git a/lib/app/view/app_shell.dart b/lib/app/view/app_shell.dart index 7b82dcbf..88ea06e9 100644 --- a/lib/app/view/app_shell.dart +++ b/lib/app/view/app_shell.dart @@ -3,6 +3,7 @@ import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/router/route_permissions.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.dart'; import 'package:go_router/go_router.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -24,22 +25,17 @@ class AppShell extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - final theme = Theme.of(context); + return BlocBuilder( + builder: (context, state) { + final l10n = AppLocalizationsX(context).l10n; + final theme = Theme.of(context); + final userRole = state.user?.dashboardRole; - // Use the same text style as the NavigationRail labels for consistency. - final navRailLabelStyle = theme.textTheme.labelMedium; + // Use the same text style as the NavigationRail labels for consistency. + final navRailLabelStyle = theme.textTheme.labelMedium; - return Scaffold( - body: AdaptiveScaffold( - selectedIndex: navigationShell.currentIndex, - onSelectedIndexChange: (index) { - navigationShell.goBranch( - index, - initialLocation: index == navigationShell.currentIndex, - ); - }, - destinations: [ + // A complete list of all possible navigation destinations. + final allDestinations = [ NavigationDestination( icon: const Icon(Icons.dashboard_outlined), selectedIcon: const Icon(Icons.dashboard), @@ -60,119 +56,172 @@ class AppShell extends StatelessWidget { selectedIcon: const Icon(Icons.settings_applications), label: l10n.appConfiguration, ), - ], - leadingUnextendedNavRail: Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), - child: Icon( - Icons.newspaper_outlined, - color: theme.colorScheme.primary, - ), - ), - leadingExtendedNavRail: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Row( - children: [ - Icon( + ]; + + // A parallel list of route names for permission checking, matching the + // order of `allDestinations`. + const allRouteNames = [ + Routes.overviewName, + Routes.contentManagementName, + Routes.userManagementName, + Routes.appConfigurationName, + ]; + + // Create a list of records containing the destination, its original + // index, and its route name. + final indexedDestinations = [ + for (var i = 0; i < allDestinations.length; i++) + ( + destination: allDestinations[i], + originalIndex: i, + routeName: allRouteNames[i], + ), + ]; + + // Filter the destinations based on the user's role and allowed routes. + final allowedRoutes = routePermissions[userRole] ?? {}; + final accessibleNavItems = indexedDestinations + .where((item) => allowedRoutes.contains(item.routeName)) + .toList(); + + final accessibleDestinations = accessibleNavItems + .map((item) => item.destination) + .toList(); + + // Find the current index in the list of *accessible* destinations. + final selectedIndex = accessibleNavItems.indexWhere( + (item) => item.originalIndex == navigationShell.currentIndex, + ); + + return Scaffold( + body: AdaptiveScaffold( + selectedIndex: selectedIndex > -1 ? selectedIndex : 0, + onSelectedIndexChange: (index) { + // Map the index from the accessible list back to the original + // branch index. + final originalBranchIndex = + accessibleNavItems[index].originalIndex; + navigationShell.goBranch( + originalBranchIndex, + initialLocation: + originalBranchIndex == navigationShell.currentIndex, + ); + }, + destinations: accessibleDestinations, + leadingUnextendedNavRail: Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), + child: Icon( Icons.newspaper_outlined, color: theme.colorScheme.primary, ), - const SizedBox(width: AppSpacing.md), - Text( - l10n.dashboardTitle, - style: theme.textTheme.titleLarge, + ), + leadingExtendedNavRail: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Row( + children: [ + Icon( + Icons.newspaper_outlined, + color: theme.colorScheme.primary, + ), + const SizedBox(width: AppSpacing.md), + Text( + l10n.dashboardTitle, + style: theme.textTheme.titleLarge, + ), + ], ), - ], - ), - ), - trailingNavRail: Builder( - builder: (context) { - final isExtended = - Breakpoints.mediumLargeAndUp.isActive(context) || - Breakpoints.small.isActive(context); - return Expanded( - child: Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.lg), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - // Settings Tile - InkWell( - onTap: () => context.goNamed(Routes.settingsName), - child: Padding( - padding: EdgeInsets.symmetric( - vertical: AppSpacing.md, - horizontal: isExtended ? 24 : 16, - ), - child: Row( - mainAxisAlignment: isExtended - ? MainAxisAlignment.start - : MainAxisAlignment.center, - children: [ - Icon( - Icons.settings_outlined, - color: theme.colorScheme.onSurfaceVariant, - size: 24, + ), + trailingNavRail: Builder( + builder: (context) { + final isExtended = + Breakpoints.mediumLargeAndUp.isActive(context) || + Breakpoints.small.isActive(context); + return Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Settings Tile - universally accessible to all roles. + InkWell( + onTap: () => context.goNamed(Routes.settingsName), + child: Padding( + padding: EdgeInsets.symmetric( + vertical: AppSpacing.md, + horizontal: isExtended ? 24 : 16, ), - if (isExtended) ...[ - const SizedBox(width: AppSpacing.lg), - Text( - l10n.settings, - style: navRailLabelStyle, - ), - ], - ], - ), - ), - ), - // Sign Out Tile - InkWell( - onTap: () => context.read().add( - const AppLogoutRequested(), - ), - child: Padding( - padding: EdgeInsets.symmetric( - vertical: AppSpacing.md, - horizontal: isExtended ? 24 : 16, + child: Row( + mainAxisAlignment: isExtended + ? MainAxisAlignment.start + : MainAxisAlignment.center, + children: [ + Icon( + Icons.settings_outlined, + color: theme.colorScheme.onSurfaceVariant, + size: 24, + ), + if (isExtended) ...[ + const SizedBox(width: AppSpacing.lg), + Text( + l10n.settings, + style: navRailLabelStyle, + ), + ], + ], + ), + ), ), - child: Row( - mainAxisAlignment: isExtended - ? MainAxisAlignment.start - : MainAxisAlignment.center, - children: [ - Icon( - Icons.logout, - color: theme.colorScheme.error, - size: 24, + // Sign Out Tile + InkWell( + onTap: () => context.read().add( + const AppLogoutRequested(), + ), + child: Padding( + padding: EdgeInsets.symmetric( + vertical: AppSpacing.md, + horizontal: isExtended ? 24 : 16, ), - if (isExtended) ...[ - const SizedBox(width: AppSpacing.lg), - Text( - l10n.signOut, - style: navRailLabelStyle?.copyWith( + child: Row( + mainAxisAlignment: isExtended + ? MainAxisAlignment.start + : MainAxisAlignment.center, + children: [ + Icon( + Icons.logout, color: theme.colorScheme.error, + size: 24, ), - ), - ], - ], + if (isExtended) ...[ + const SizedBox(width: AppSpacing.lg), + Text( + l10n.signOut, + style: navRailLabelStyle?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ], + ), + ), ), - ), + ], ), - ], - ), + ), + ); + }, + ), + body: (_) => Padding( + padding: const EdgeInsets.fromLTRB( + 0, + AppSpacing.sm, + AppSpacing.sm, + AppSpacing.sm, ), - ); - }, - ), - body: (_) => Padding( - padding: const EdgeInsets.fromLTRB( - 0, - AppSpacing.sm, - AppSpacing.sm, - AppSpacing.sm, + child: navigationShell, + ), ), - child: navigationShell, - ), - ), + ); + }, ); } } diff --git a/lib/router/route_permissions.dart b/lib/router/route_permissions.dart new file mode 100644 index 00000000..a9048eba --- /dev/null +++ b/lib/router/route_permissions.dart @@ -0,0 +1,22 @@ +import 'package:core/core.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.dart'; + +/// A centralized mapping of dashboard user roles to their permitted routes. +/// +/// This map is used by the router's redirect logic to enforce navigation +/// restrictions based on the authenticated user's role. +final Map> routePermissions = { + // Admins have access to all major sections of the dashboard. + DashboardUserRole.admin: { + Routes.overviewName, + Routes.contentManagementName, + Routes.userManagementName, + Routes.appConfigurationName, + }, + // Publishers have a more restricted access, focused on content creation + // and management. + DashboardUserRole.publisher: { + Routes.overviewName, + Routes.contentManagementName, + }, +}; diff --git a/lib/router/router.dart b/lib/router/router.dart index ec9238a6..bbe86af7 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -38,6 +38,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_manage import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_management/widgets/local_ads_filter_dialog/bloc/local_ads_filter_dialog_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_management/widgets/local_ads_filter_dialog/local_ads_filter_dialog.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/overview/view/overview_page.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/router/route_permissions.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/settings/view/settings_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/selection_page/searchable_selection_page.dart'; @@ -95,6 +96,34 @@ GoRouter createRouter({ if (appStatus == AppStatus.authenticated) { print(' Redirect Decision: User is $appStatus.'); + // --- Role-Based Access Control (RBAC) --- + final userRole = context.read().state.user?.dashboardRole; + final destinationRouteName = state.topRoute?.name; + + // Allow navigation if role is not yet determined or route is unknown. + if (userRole == null || destinationRouteName == null) { + return null; + } + + final allowedRoutes = routePermissions[userRole]; + + // Check if the user is trying to access a route they are not + // permitted to view. + final isAuthorized = + allowedRoutes?.contains(destinationRouteName) ?? false; + + // Universally allowed routes like 'settings' are exempt from this check. + if (!isAuthorized && destinationRouteName != Routes.settingsName) { + print( + ' Action: Unauthorized access to "$destinationRouteName". ' + 'Redirecting to $overviewPath.', + ); + // Redirect unauthorized users to the overview page. This is a safe + // redirect without side effects. + return Routes.overview; + } + // --- End of RBAC --- + // If an authenticated user is on any authentication-related path: if (isGoingToAuth) { print( diff --git a/lib/user_management/bloc/user_management_bloc.dart b/lib/user_management/bloc/user_management_bloc.dart index 0ba3f473..9a50b77d 100644 --- a/lib/user_management/bloc/user_management_bloc.dart +++ b/lib/user_management/bloc/user_management_bloc.dart @@ -1,12 +1,12 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; -import 'package:logging/logging.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/constants/app_constants.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_filter/user_filter_bloc.dart'; +import 'package:logging/logging.dart'; import 'package:ui_kit/ui_kit.dart'; part 'user_management_event.dart';