Skip to content

Commit 29312ac

Browse files
authored
Merge pull request #120 from flutter-news-app-full-source-code/feature/rbac
Feature/rbac
2 parents 074c3ab + c021c5f commit 29312ac

File tree

5 files changed

+223
-115
lines changed

5 files changed

+223
-115
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ A complete and secure user authentication system is built-in for your editorial
7777
7878
---
7979

80+
### 🛡️ Role-Based Access Control (RBAC)
81+
The dashboard implements a robust RBAC system to ensure team members only access the sections relevant to their role.
82+
- **Protected Navigation:** The system prevents direct URL access to restricted areas, automatically redirecting unauthorized users.
83+
- **Conditional UI:** The navigation sidebar dynamically adapts, showing only the links and tools a user is permitted to see.
84+
> **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.
85+
86+
---
87+
8088
### 🎨 A Personalized Workspace
8189
Empower your team with a dashboard experience they can tailor to their own preferences, improving comfort and productivity.
8290
- **Full Appearance Control:** Each team member can configure their own workspace, including light/dark themes, accent colors, and text styles.

lib/app/view/app_shell.dart

Lines changed: 163 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
33
import 'package:flutter_bloc/flutter_bloc.dart';
44
import 'package:flutter_news_app_web_dashboard_full_source_code/app/bloc/app_bloc.dart';
55
import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart';
6+
import 'package:flutter_news_app_web_dashboard_full_source_code/router/route_permissions.dart';
67
import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.dart';
78
import 'package:go_router/go_router.dart';
89
import 'package:ui_kit/ui_kit.dart';
@@ -24,22 +25,17 @@ class AppShell extends StatelessWidget {
2425

2526
@override
2627
Widget build(BuildContext context) {
27-
final l10n = AppLocalizationsX(context).l10n;
28-
final theme = Theme.of(context);
28+
return BlocBuilder<AppBloc, AppState>(
29+
builder: (context, state) {
30+
final l10n = AppLocalizationsX(context).l10n;
31+
final theme = Theme.of(context);
32+
final userRole = state.user?.dashboardRole;
2933

30-
// Use the same text style as the NavigationRail labels for consistency.
31-
final navRailLabelStyle = theme.textTheme.labelMedium;
34+
// Use the same text style as the NavigationRail labels for consistency.
35+
final navRailLabelStyle = theme.textTheme.labelMedium;
3236

33-
return Scaffold(
34-
body: AdaptiveScaffold(
35-
selectedIndex: navigationShell.currentIndex,
36-
onSelectedIndexChange: (index) {
37-
navigationShell.goBranch(
38-
index,
39-
initialLocation: index == navigationShell.currentIndex,
40-
);
41-
},
42-
destinations: [
37+
// A complete list of all possible navigation destinations.
38+
final allDestinations = [
4339
NavigationDestination(
4440
icon: const Icon(Icons.dashboard_outlined),
4541
selectedIcon: const Icon(Icons.dashboard),
@@ -60,119 +56,172 @@ class AppShell extends StatelessWidget {
6056
selectedIcon: const Icon(Icons.settings_applications),
6157
label: l10n.appConfiguration,
6258
),
63-
],
64-
leadingUnextendedNavRail: Padding(
65-
padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg),
66-
child: Icon(
67-
Icons.newspaper_outlined,
68-
color: theme.colorScheme.primary,
69-
),
70-
),
71-
leadingExtendedNavRail: Padding(
72-
padding: const EdgeInsets.all(AppSpacing.lg),
73-
child: Row(
74-
children: [
75-
Icon(
59+
];
60+
61+
// A parallel list of route names for permission checking, matching the
62+
// order of `allDestinations`.
63+
const allRouteNames = [
64+
Routes.overviewName,
65+
Routes.contentManagementName,
66+
Routes.userManagementName,
67+
Routes.appConfigurationName,
68+
];
69+
70+
// Create a list of records containing the destination, its original
71+
// index, and its route name.
72+
final indexedDestinations = [
73+
for (var i = 0; i < allDestinations.length; i++)
74+
(
75+
destination: allDestinations[i],
76+
originalIndex: i,
77+
routeName: allRouteNames[i],
78+
),
79+
];
80+
81+
// Filter the destinations based on the user's role and allowed routes.
82+
final allowedRoutes = routePermissions[userRole] ?? {};
83+
final accessibleNavItems = indexedDestinations
84+
.where((item) => allowedRoutes.contains(item.routeName))
85+
.toList();
86+
87+
final accessibleDestinations = accessibleNavItems
88+
.map((item) => item.destination)
89+
.toList();
90+
91+
// Find the current index in the list of *accessible* destinations.
92+
final selectedIndex = accessibleNavItems.indexWhere(
93+
(item) => item.originalIndex == navigationShell.currentIndex,
94+
);
95+
96+
return Scaffold(
97+
body: AdaptiveScaffold(
98+
selectedIndex: selectedIndex > -1 ? selectedIndex : 0,
99+
onSelectedIndexChange: (index) {
100+
// Map the index from the accessible list back to the original
101+
// branch index.
102+
final originalBranchIndex =
103+
accessibleNavItems[index].originalIndex;
104+
navigationShell.goBranch(
105+
originalBranchIndex,
106+
initialLocation:
107+
originalBranchIndex == navigationShell.currentIndex,
108+
);
109+
},
110+
destinations: accessibleDestinations,
111+
leadingUnextendedNavRail: Padding(
112+
padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg),
113+
child: Icon(
76114
Icons.newspaper_outlined,
77115
color: theme.colorScheme.primary,
78116
),
79-
const SizedBox(width: AppSpacing.md),
80-
Text(
81-
l10n.dashboardTitle,
82-
style: theme.textTheme.titleLarge,
117+
),
118+
leadingExtendedNavRail: Padding(
119+
padding: const EdgeInsets.all(AppSpacing.lg),
120+
child: Row(
121+
children: [
122+
Icon(
123+
Icons.newspaper_outlined,
124+
color: theme.colorScheme.primary,
125+
),
126+
const SizedBox(width: AppSpacing.md),
127+
Text(
128+
l10n.dashboardTitle,
129+
style: theme.textTheme.titleLarge,
130+
),
131+
],
83132
),
84-
],
85-
),
86-
),
87-
trailingNavRail: Builder(
88-
builder: (context) {
89-
final isExtended =
90-
Breakpoints.mediumLargeAndUp.isActive(context) ||
91-
Breakpoints.small.isActive(context);
92-
return Expanded(
93-
child: Padding(
94-
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
95-
child: Column(
96-
mainAxisAlignment: MainAxisAlignment.end,
97-
children: [
98-
// Settings Tile
99-
InkWell(
100-
onTap: () => context.goNamed(Routes.settingsName),
101-
child: Padding(
102-
padding: EdgeInsets.symmetric(
103-
vertical: AppSpacing.md,
104-
horizontal: isExtended ? 24 : 16,
105-
),
106-
child: Row(
107-
mainAxisAlignment: isExtended
108-
? MainAxisAlignment.start
109-
: MainAxisAlignment.center,
110-
children: [
111-
Icon(
112-
Icons.settings_outlined,
113-
color: theme.colorScheme.onSurfaceVariant,
114-
size: 24,
133+
),
134+
trailingNavRail: Builder(
135+
builder: (context) {
136+
final isExtended =
137+
Breakpoints.mediumLargeAndUp.isActive(context) ||
138+
Breakpoints.small.isActive(context);
139+
return Expanded(
140+
child: Padding(
141+
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
142+
child: Column(
143+
mainAxisAlignment: MainAxisAlignment.end,
144+
children: [
145+
// Settings Tile - universally accessible to all roles.
146+
InkWell(
147+
onTap: () => context.goNamed(Routes.settingsName),
148+
child: Padding(
149+
padding: EdgeInsets.symmetric(
150+
vertical: AppSpacing.md,
151+
horizontal: isExtended ? 24 : 16,
115152
),
116-
if (isExtended) ...[
117-
const SizedBox(width: AppSpacing.lg),
118-
Text(
119-
l10n.settings,
120-
style: navRailLabelStyle,
121-
),
122-
],
123-
],
124-
),
125-
),
126-
),
127-
// Sign Out Tile
128-
InkWell(
129-
onTap: () => context.read<AppBloc>().add(
130-
const AppLogoutRequested(),
131-
),
132-
child: Padding(
133-
padding: EdgeInsets.symmetric(
134-
vertical: AppSpacing.md,
135-
horizontal: isExtended ? 24 : 16,
153+
child: Row(
154+
mainAxisAlignment: isExtended
155+
? MainAxisAlignment.start
156+
: MainAxisAlignment.center,
157+
children: [
158+
Icon(
159+
Icons.settings_outlined,
160+
color: theme.colorScheme.onSurfaceVariant,
161+
size: 24,
162+
),
163+
if (isExtended) ...[
164+
const SizedBox(width: AppSpacing.lg),
165+
Text(
166+
l10n.settings,
167+
style: navRailLabelStyle,
168+
),
169+
],
170+
],
171+
),
172+
),
136173
),
137-
child: Row(
138-
mainAxisAlignment: isExtended
139-
? MainAxisAlignment.start
140-
: MainAxisAlignment.center,
141-
children: [
142-
Icon(
143-
Icons.logout,
144-
color: theme.colorScheme.error,
145-
size: 24,
174+
// Sign Out Tile
175+
InkWell(
176+
onTap: () => context.read<AppBloc>().add(
177+
const AppLogoutRequested(),
178+
),
179+
child: Padding(
180+
padding: EdgeInsets.symmetric(
181+
vertical: AppSpacing.md,
182+
horizontal: isExtended ? 24 : 16,
146183
),
147-
if (isExtended) ...[
148-
const SizedBox(width: AppSpacing.lg),
149-
Text(
150-
l10n.signOut,
151-
style: navRailLabelStyle?.copyWith(
184+
child: Row(
185+
mainAxisAlignment: isExtended
186+
? MainAxisAlignment.start
187+
: MainAxisAlignment.center,
188+
children: [
189+
Icon(
190+
Icons.logout,
152191
color: theme.colorScheme.error,
192+
size: 24,
153193
),
154-
),
155-
],
156-
],
194+
if (isExtended) ...[
195+
const SizedBox(width: AppSpacing.lg),
196+
Text(
197+
l10n.signOut,
198+
style: navRailLabelStyle?.copyWith(
199+
color: theme.colorScheme.error,
200+
),
201+
),
202+
],
203+
],
204+
),
205+
),
157206
),
158-
),
207+
],
159208
),
160-
],
161-
),
209+
),
210+
);
211+
},
212+
),
213+
body: (_) => Padding(
214+
padding: const EdgeInsets.fromLTRB(
215+
0,
216+
AppSpacing.sm,
217+
AppSpacing.sm,
218+
AppSpacing.sm,
162219
),
163-
);
164-
},
165-
),
166-
body: (_) => Padding(
167-
padding: const EdgeInsets.fromLTRB(
168-
0,
169-
AppSpacing.sm,
170-
AppSpacing.sm,
171-
AppSpacing.sm,
220+
child: navigationShell,
221+
),
172222
),
173-
child: navigationShell,
174-
),
175-
),
223+
);
224+
},
176225
);
177226
}
178227
}

lib/router/route_permissions.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import 'package:core/core.dart';
2+
import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.dart';
3+
4+
/// A centralized mapping of dashboard user roles to their permitted routes.
5+
///
6+
/// This map is used by the router's redirect logic to enforce navigation
7+
/// restrictions based on the authenticated user's role.
8+
final Map<DashboardUserRole, Set<String>> routePermissions = {
9+
// Admins have access to all major sections of the dashboard.
10+
DashboardUserRole.admin: {
11+
Routes.overviewName,
12+
Routes.contentManagementName,
13+
Routes.userManagementName,
14+
Routes.appConfigurationName,
15+
},
16+
// Publishers have a more restricted access, focused on content creation
17+
// and management.
18+
DashboardUserRole.publisher: {
19+
Routes.overviewName,
20+
Routes.contentManagementName,
21+
},
22+
};

lib/router/router.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_manage
3838
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';
3939
import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_management/widgets/local_ads_filter_dialog/local_ads_filter_dialog.dart';
4040
import 'package:flutter_news_app_web_dashboard_full_source_code/overview/view/overview_page.dart';
41+
import 'package:flutter_news_app_web_dashboard_full_source_code/router/route_permissions.dart';
4142
import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.dart';
4243
import 'package:flutter_news_app_web_dashboard_full_source_code/settings/view/settings_page.dart';
4344
import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/selection_page/searchable_selection_page.dart';
@@ -95,6 +96,34 @@ GoRouter createRouter({
9596
if (appStatus == AppStatus.authenticated) {
9697
print(' Redirect Decision: User is $appStatus.');
9798

99+
// --- Role-Based Access Control (RBAC) ---
100+
final userRole = context.read<AppBloc>().state.user?.dashboardRole;
101+
final destinationRouteName = state.topRoute?.name;
102+
103+
// Allow navigation if role is not yet determined or route is unknown.
104+
if (userRole == null || destinationRouteName == null) {
105+
return null;
106+
}
107+
108+
final allowedRoutes = routePermissions[userRole];
109+
110+
// Check if the user is trying to access a route they are not
111+
// permitted to view.
112+
final isAuthorized =
113+
allowedRoutes?.contains(destinationRouteName) ?? false;
114+
115+
// Universally allowed routes like 'settings' are exempt from this check.
116+
if (!isAuthorized && destinationRouteName != Routes.settingsName) {
117+
print(
118+
' Action: Unauthorized access to "$destinationRouteName". '
119+
'Redirecting to $overviewPath.',
120+
);
121+
// Redirect unauthorized users to the overview page. This is a safe
122+
// redirect without side effects.
123+
return Routes.overview;
124+
}
125+
// --- End of RBAC ---
126+
98127
// If an authenticated user is on any authentication-related path:
99128
if (isGoingToAuth) {
100129
print(

0 commit comments

Comments
 (0)