Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
254 changes: 140 additions & 114 deletions lib/app/view/app_shell.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:core/core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
import 'package:flutter_bloc/flutter_bloc.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,149 @@ 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(
];

// Filter the destinations based on the user's role.
final accessibleDestinations = allDestinations.where((destination) {
if (userRole == null) return false;

switch (userRole) {
case DashboardUserRole.admin:
// Admin can see all destinations.
return true;
case DashboardUserRole.publisher:
// Publisher can only see Overview and Content Management.
return destination.label == l10n.overview ||
destination.label == l10n.contentManagement;
case DashboardUserRole.none:
return false;
}
}).toList();

return Scaffold(
body: AdaptiveScaffold(
selectedIndex: navigationShell.currentIndex,
onSelectedIndexChange: (index) {
navigationShell.goBranch(
index,
initialLocation: index == 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,
),
),
);
},
);
}
}
6 changes: 6 additions & 0 deletions lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2977,6 +2977,12 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Premium'**
String get subscriptionPremium;

/// Snackbar message shown when a user is redirected due to lack of permissions.
///
/// In en, this message translates to:
/// **'Redirecting: You do not have permission to access this page.'**
String get unauthorizedAccessRedirect;
}

class _AppLocalizationsDelegate
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_localizations_ar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1582,4 +1582,8 @@ class AppLocalizationsAr extends AppLocalizations {

@override
String get subscriptionPremium => 'مميز';

@override
String get unauthorizedAccessRedirect =>
'إعادة التوجيه: ليس لديك إذن للوصول إلى هذه الصفحة.';
}
4 changes: 4 additions & 0 deletions lib/l10n/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1588,4 +1588,8 @@ class AppLocalizationsEn extends AppLocalizations {

@override
String get subscriptionPremium => 'Premium';

@override
String get unauthorizedAccessRedirect =>
'Redirecting: You do not have permission to access this page.';
}
4 changes: 4 additions & 0 deletions lib/l10n/arb/app_ar.arb
Original file line number Diff line number Diff line change
Expand Up @@ -2011,5 +2011,9 @@
"subscriptionPremium": "مميز",
"@subscriptionPremium": {
"description": "حالة الاشتراك لمستخدم مميز"
},
"unauthorizedAccessRedirect": "إعادة التوجيه: ليس لديك إذن للوصول إلى هذه الصفحة.",
"@unauthorizedAccessRedirect": {
"description": "رسالة شريط التنبيه التي تظهر عند إعادة توجيه المستخدم بسبب نقص الأذونات."
}
}
4 changes: 4 additions & 0 deletions lib/l10n/arb/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -2007,5 +2007,9 @@
"subscriptionPremium": "Premium",
"@subscriptionPremium": {
"description": "Subscription status for a premium user"
},
"unauthorizedAccessRedirect": "Redirecting: You do not have permission to access this page.",
"@unauthorizedAccessRedirect": {
"description": "Snackbar message shown when a user is redirected due to lack of permissions."
}
}
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,
},
};
Loading
Loading