Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
277 changes: 163 additions & 114 deletions lib/app/view/app_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<AppBloc, AppState>(
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),
Expand All @@ -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<AppBloc>().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<AppBloc>().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,
),
),
);
},
);
}
}
22 changes: 22 additions & 0 deletions lib/router/route_permissions.dart
Original file line number Diff line number Diff line change
@@ -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<DashboardUserRole, Set<String>> 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,
},
};
29 changes: 29 additions & 0 deletions lib/router/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<AppBloc>().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(
Expand Down
Loading
Loading