Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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,
),
),
);
},
);
}
}
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:
/// **'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 =>
'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": "You do not have permission to access this page.",
"@unauthorizedAccessRedirect": {
"description": "Snackbar message shown when a user is redirected due to lack of permissions."
}
}
Loading
Loading