From fa310dd87c864930f5d1056d4ac57a636717e9a7 Mon Sep 17 00:00:00 2001 From: SurendarSuri30 Date: Mon, 22 Jun 2026 15:24:35 +0530 Subject: [PATCH] side menu issue fix and user role multi select done --- lib/core/utils/permission_utils.dart | 107 +++++- .../repositories/auth_repository_impl.dart | 12 +- .../screens/master_list_screen.dart | 52 ++- .../screens/masters_hub_screen.dart | 9 +- .../screens/users_role_management_screen.dart | 209 +++++++---- .../presentation/widgets/rbac_widgets.dart | 45 ++- lib/shared/models/user_model.dart | 34 +- .../providers/permissions_provider.dart | 29 ++ lib/shared/routes/menu_config.dart | 47 ++- lib/shared/widgets/app_search_export_bar.dart | 20 +- lib/shared/widgets/app_sidebar.dart | 340 +++++++++++++++++- lib/shared/widgets/can_permission.dart | 29 ++ 12 files changed, 766 insertions(+), 167 deletions(-) create mode 100644 lib/shared/providers/permissions_provider.dart create mode 100644 lib/shared/widgets/can_permission.dart diff --git a/lib/core/utils/permission_utils.dart b/lib/core/utils/permission_utils.dart index d599c59..cb8ea5b 100644 --- a/lib/core/utils/permission_utils.dart +++ b/lib/core/utils/permission_utils.dart @@ -1,31 +1,108 @@ +import '../constants/enums.dart'; import '../errors/failure.dart'; import '../network/api_handler.dart'; -import '../../core/constants/enums.dart'; + +/// Maps UI / route module keys to API permission module codes. +const Map permissionModuleAliases = { + 'master_data': 'MASTERS', + 'masters': 'MASTERS', + 'users': 'USERS', + 'roles': 'ROLES', + 'assets': 'ASSET', + 'asset': 'ASSET', + 'asset_categories': 'ASSET', + 'asset_allocations': 'ASSET', + 'asset_maintenance': 'ASSET', + 'asset_disposal': 'ASSET', + 'vendor': 'VENDOR', + 'purchase_order': 'PURCHASE_ORDER', + 'grn': 'GRN', +}; + +/// Modules that are always visible in navigation (no API permission key). +const Set alwaysVisibleMenuModules = {'dashboard'}; + +String normalizePermissionModule(String module) { + final normalized = module.trim().toLowerCase().replaceAll('-', '_'); + return permissionModuleAliases[normalized] ?? normalized.toUpperCase(); +} + +List permissionActionAliases(PermissionAction action) { + return switch (action) { + PermissionAction.read => const ['read', 'view'], + PermissionAction.update => const ['update', 'edit'], + PermissionAction.create => const ['create'], + PermissionAction.delete => const ['delete'], + PermissionAction.export => const ['export'], + PermissionAction.approve => const ['approve'], + }; +} bool hasPermission({ required List userPermissions, required String module, required PermissionAction action, }) { - final normalizedModule = module.trim().toLowerCase(); - final actionValue = action.value; - - final candidates = { - '$normalizedModule.$actionValue', - '$normalizedModule:$actionValue', - '${normalizedModule.toUpperCase()}:$actionValue', - '${normalizedModule.toUpperCase()}:${actionValue.toUpperCase()}', - '${module.toUpperCase()}:$actionValue', - }; - if (userPermissions.contains('*')) return true; - for (final key in candidates) { + final moduleKey = normalizePermissionModule(module); + final lowerModule = moduleKey.toLowerCase(); + + for (final actionAlias in permissionActionAliases(action)) { + final candidates = { + '$lowerModule.$actionAlias', + '$lowerModule:$actionAlias', + '$moduleKey:$actionAlias', + '$moduleKey:${actionAlias.toUpperCase()}', + '${module.trim().toUpperCase()}:$actionAlias', + '${module.trim().toUpperCase()}:${actionAlias.toUpperCase()}', + }; + + for (final key in candidates) { + if (userPermissions.contains(key)) return true; + } + } + + final wildcards = { + '$lowerModule.*', + '$lowerModule:*', + '$moduleKey:*', + '${module.trim().toUpperCase()}:*', + }; + + for (final key in wildcards) { if (userPermissions.contains(key)) return true; } - return userPermissions.contains('$normalizedModule.*') || - userPermissions.contains('${normalizedModule.toUpperCase()}:*'); + return false; +} + +bool canSeeMenuModule({ + required List permissions, + required String module, + required UserRole role, +}) { + if (alwaysVisibleMenuModules.contains(module)) return true; + if (isSuperAdmin(role) || permissions.contains('*')) return true; + + if (module == 'users') { + return hasPermission( + userPermissions: permissions, + module: 'users', + action: PermissionAction.read, + ) || + hasPermission( + userPermissions: permissions, + module: 'roles', + action: PermissionAction.read, + ); + } + + return hasPermission( + userPermissions: permissions, + module: module, + action: PermissionAction.read, + ); } bool isSuperAdmin(UserRole role) => role == UserRole.superAdmin; diff --git a/lib/modules/auth/data/repositories/auth_repository_impl.dart b/lib/modules/auth/data/repositories/auth_repository_impl.dart index 68b8a47..ede24c5 100644 --- a/lib/modules/auth/data/repositories/auth_repository_impl.dart +++ b/lib/modules/auth/data/repositories/auth_repository_impl.dart @@ -32,7 +32,7 @@ class AuthRepositoryImpl implements AuthRepository { return safeApiCall(() async { final loginResponse = await remote.login(request); await _persistSession(loginResponse); - final user = await remote.resolveCurrentUser(loginResponse.tokens.accessToken); + final user = await remote.getCurrentUser(); return loginResponse.copyWith(user: user); }); } @@ -55,13 +55,7 @@ class AuthRepositoryImpl implements AuthRepository { @override Future> getCurrentUser() async { - return safeApiCall(() async { - final accessToken = await tokenStorage.getAccessToken(); - if (accessToken == null || accessToken.isEmpty) { - throw const FormatException('No access token'); - } - return remote.resolveCurrentUser(accessToken); - }); + return safeApiCall(() => remote.getCurrentUser()); } @override @@ -90,7 +84,7 @@ class AuthRepositoryImpl implements AuthRepository { return safeApiCall(() async { final loginResponse = await remote.verifyOtp(request); await _persistSession(loginResponse); - final user = await remote.resolveCurrentUser(loginResponse.tokens.accessToken); + final user = await remote.getCurrentUser(); return loginResponse.copyWith(user: user); }); } diff --git a/lib/modules/master_data/presentation/screens/master_list_screen.dart b/lib/modules/master_data/presentation/screens/master_list_screen.dart index e95a8a2..8a26265 100644 --- a/lib/modules/master_data/presentation/screens/master_list_screen.dart +++ b/lib/modules/master_data/presentation/screens/master_list_screen.dart @@ -2,9 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../../core/constants/enums.dart'; import '../../../../core/constants/route_constants.dart'; import '../../../../core/errors/failure.dart'; import '../../../../shared/utils/file_download_helper.dart'; +import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../../../../shared/widgets/app_empty_state.dart'; @@ -150,6 +152,10 @@ class _MasterListScreenState extends ConsumerState { final start = total == 0 ? 0 : ((page - 1) * pageSize) + 1; final end = (page * pageSize).clamp(0, total); final notifier = ref.read(masterListProvider(widget.masterId).notifier); + final canCreate = ref.can('masters', PermissionAction.create); + final canExport = ref.can('masters', PermissionAction.export); + final canEdit = ref.can('masters', PermissionAction.update); + final canDelete = ref.can('masters', PermissionAction.delete); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -164,11 +170,12 @@ class _MasterListScreenState extends ConsumerState { label: const Text('All Masters'), ), const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: () => _openFormPanel(), - icon: const Icon(Icons.add), - label: Text('Add ${def.title}'), - ), + if (canCreate) + ElevatedButton.icon( + onPressed: () => _openFormPanel(), + icon: const Icon(Icons.add), + label: Text('Add ${def.title}'), + ), ], ), const SizedBox(height: 16), @@ -194,6 +201,7 @@ class _MasterListScreenState extends ConsumerState { searchController: _searchController, searchHint: _searchHint(def), isExporting: state.isExporting, + showExport: canExport, onSearch: notifier.setSearch, onExport: _exportRecords, ); @@ -223,6 +231,8 @@ class _MasterListScreenState extends ConsumerState { definition: def, items: state.items, isDeleting: state.isDeleting, + canEdit: canEdit, + canDelete: canDelete, onEdit: (id) => _openFormPanel(recordId: id), onDelete: _deleteRecord, ), @@ -303,6 +313,8 @@ class _MasterListTable extends StatelessWidget { required this.definition, required this.items, required this.isDeleting, + required this.canEdit, + required this.canDelete, required this.onEdit, required this.onDelete, }); @@ -310,6 +322,8 @@ class _MasterListTable extends StatelessWidget { final MasterDefinition definition; final List> items; final bool isDeleting; + final bool canEdit; + final bool canDelete; final ValueChanged onEdit; final ValueChanged> onDelete; @@ -444,20 +458,22 @@ class _MasterListTable extends StatelessWidget { actionsCell: Row( mainAxisSize: MainAxisSize.min, children: [ - IconButton( - tooltip: 'Edit', - icon: const Icon(Icons.edit_outlined, size: 20), - onPressed: id == null ? null : () => onEdit(id), - ), - IconButton( - tooltip: 'Delete', - icon: Icon( - Icons.delete_outline, - size: 20, - color: theme.colorScheme.error, + if (canEdit) + IconButton( + tooltip: 'Edit', + icon: const Icon(Icons.edit_outlined, size: 20), + onPressed: id == null ? null : () => onEdit(id), + ), + if (canDelete) + IconButton( + tooltip: 'Delete', + icon: Icon( + Icons.delete_outline, + size: 20, + color: theme.colorScheme.error, + ), + onPressed: isDeleting ? null : () => onDelete(row), ), - onPressed: isDeleting ? null : () => onDelete(row), - ), ], ), ); diff --git a/lib/modules/master_data/presentation/screens/masters_hub_screen.dart b/lib/modules/master_data/presentation/screens/masters_hub_screen.dart index b134b84..2f0f972 100644 --- a/lib/modules/master_data/presentation/screens/masters_hub_screen.dart +++ b/lib/modules/master_data/presentation/screens/masters_hub_screen.dart @@ -1,19 +1,23 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../../core/constants/enums.dart'; import '../../../../core/constants/route_constants.dart'; +import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/widgets/app_hover_effect.dart'; import '../../domain/entities/master_definition.dart'; -class MastersHubScreen extends StatelessWidget { +class MastersHubScreen extends ConsumerWidget { const MastersHubScreen({super.key}); static const double _tileMaxWidth = 120; static const double _tileHeight = 92; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final categories = masterCategories; + final canViewMasters = ref.can('masters', PermissionAction.read); return LayoutBuilder( builder: (context, constraints) { @@ -37,6 +41,7 @@ class MastersHubScreen extends StatelessWidget { ), const SizedBox(height: 24), ...categories.map((category) { + if (!canViewMasters) return const SizedBox.shrink(); final items = masterDefinitions .where((def) => def.category == category) .toList(); diff --git a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart index e9b97a0..33ffb7a 100644 --- a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart +++ b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/utils/formatters.dart'; +import '../../../../core/constants/enums.dart'; import '../../../../core/constants/route_constants.dart'; import '../../../../core/errors/failure.dart'; import '../../../../core/theme/theme_provider.dart'; @@ -9,6 +10,7 @@ import '../../../../core/utils/responsive_utils.dart'; import '../../../../shared/models/permission_matrix_models.dart'; import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/utils/file_download_helper.dart'; +import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/widgets/app_confirmation_dialog.dart'; import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_card.dart'; @@ -142,6 +144,11 @@ class _UsersRoleManagementScreenState summary?.rolesCount ?? state.roles.length; final isCompact = context.isMobile || MediaQuery.sizeOf(context).width < 900; + final canCreateUser = ref.can('users', PermissionAction.create); + final canCreateRole = ref.can('roles', PermissionAction.create); + final canViewUsers = ref.can('users', PermissionAction.read); + final canViewRoles = ref.can('roles', PermissionAction.read); + final canEditRoles = ref.can('roles', PermissionAction.update); return Padding( padding: EdgeInsets.all(isCompact ? 16 : 24), @@ -150,6 +157,8 @@ class _UsersRoleManagementScreenState children: [ _Header( compact: isCompact, + canCreateRole: canCreateRole, + canCreateUser: canCreateUser, onNewRole: _openCreateRole, onAddUser: _openAddUser, ), @@ -217,26 +226,32 @@ class _UsersRoleManagementScreenState state: state, userCount: summary?.totalUsers ?? usersState?.total ?? state.totalUsers, roleCount: roleCount, + canViewUsers: canViewUsers, + canViewRoles: canViewRoles, + canEditRoles: canEditRoles, ), const SizedBox(height: 16), Expanded( child: IndexedStack( - index: switch (state.selectedTab) { - RbacTab.users => 0, - RbacTab.roles => 1, - RbacTab.permissions => 2, - }, + index: _tabIndex( + state.selectedTab, + canViewUsers: canViewUsers, + canViewRoles: canViewRoles, + canEditRoles: canEditRoles, + ), children: [ - _UsersTab( - onAddUser: _openAddUser, - onEditUser: (user) => _openUserPanel(userId: user.id), - ), - _RolesTab( - onNewRole: _openCreateRole, - onEditRole: _editRole, - onDeleteRole: _deleteRole, - ), - const _PermissionMatrixTab(), + if (canViewUsers) + _UsersTab( + onAddUser: _openAddUser, + onEditUser: (user) => _openUserPanel(userId: user.id), + ), + if (canViewRoles) + _RolesTab( + onNewRole: _openCreateRole, + onEditRole: _editRole, + onDeleteRole: _deleteRole, + ), + if (canEditRoles) const _PermissionMatrixTab(), ], ), ), @@ -246,14 +261,33 @@ class _UsersRoleManagementScreenState } } +int _tabIndex( + RbacTab tab, { + required bool canViewUsers, + required bool canViewRoles, + required bool canEditRoles, +}) { + final tabs = [ + if (canViewUsers) RbacTab.users, + if (canViewRoles) RbacTab.roles, + if (canEditRoles) RbacTab.permissions, + ]; + final index = tabs.indexOf(tab); + return index < 0 ? 0 : index; +} + class _Header extends StatelessWidget { const _Header({ required this.compact, + required this.canCreateRole, + required this.canCreateUser, required this.onNewRole, required this.onAddUser, }); final bool compact; + final bool canCreateRole; + final bool canCreateUser; final VoidCallback onNewRole; final VoidCallback onAddUser; @@ -282,16 +316,18 @@ class _Header extends StatelessWidget { spacing: 12, runSpacing: 8, children: [ - OutlinedButton.icon( - onPressed: onNewRole, - icon: const Icon(Icons.add_circle_outline, size: 18), - label: const Text('New Role'), - ), - ElevatedButton.icon( - onPressed: onAddUser, - icon: const Icon(Icons.person_add_outlined, size: 18), - label: const Text('Add User'), - ), + if (canCreateRole) + OutlinedButton.icon( + onPressed: onNewRole, + icon: const Icon(Icons.add_circle_outline, size: 18), + label: const Text('New Role'), + ), + if (canCreateUser) + ElevatedButton.icon( + onPressed: onAddUser, + icon: const Icon(Icons.person_add_outlined, size: 18), + label: const Text('Add User'), + ), ], ); @@ -317,11 +353,17 @@ class _TabBar extends ConsumerWidget { required this.state, required this.userCount, required this.roleCount, + required this.canViewUsers, + required this.canViewRoles, + required this.canEditRoles, }); final RbacState state; final int userCount; final int roleCount; + final bool canViewUsers; + final bool canViewRoles; + final bool canEditRoles; @override Widget build(BuildContext context, WidgetRef ref) { @@ -329,25 +371,28 @@ class _TabBar extends ConsumerWidget { scrollDirection: Axis.horizontal, child: Row( children: [ - _TabButton( - label: 'Users ($userCount)', - icon: Icons.people_outline, - selected: state.selectedTab == RbacTab.users, - onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.users), - ), - _TabButton( - label: 'Roles ($roleCount)', - icon: Icons.shield_outlined, - selected: state.selectedTab == RbacTab.roles, - onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.roles), - ), - _TabButton( - label: 'Permission Matrix', - icon: Icons.vpn_key_outlined, - selected: state.selectedTab == RbacTab.permissions, - onTap: () => - ref.read(rbacProvider.notifier).setTab(RbacTab.permissions), - ), + if (canViewUsers) + _TabButton( + label: 'Users ($userCount)', + icon: Icons.people_outline, + selected: state.selectedTab == RbacTab.users, + onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.users), + ), + if (canViewRoles) + _TabButton( + label: 'Roles ($roleCount)', + icon: Icons.shield_outlined, + selected: state.selectedTab == RbacTab.roles, + onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.roles), + ), + if (canEditRoles) + _TabButton( + label: 'Permission Matrix', + icon: Icons.vpn_key_outlined, + selected: state.selectedTab == RbacTab.permissions, + onTap: () => + ref.read(rbacProvider.notifier).setTab(RbacTab.permissions), + ), ], ), ); @@ -596,6 +641,9 @@ class _UsersTabState extends ConsumerState<_UsersTab> { ), data: (usersState) { final filters = usersState.filters; + final canExport = ref.can('users', PermissionAction.export); + final canEditUser = ref.can('users', PermissionAction.update); + final canDeleteUser = ref.can('users', PermissionAction.delete); final roles = ['All roles', ...?filters?.roles.map((r) => r.name)]; final departments = [ 'All departments', @@ -640,6 +688,7 @@ class _UsersTabState extends ConsumerState<_UsersTab> { departments: departments, statuses: statuses, isExporting: usersState.isExporting, + showExport: canExport, onExport: _exportUsers, onSearch: ref.read(usersListProvider.notifier).setSearch, onRoleChanged: (value) { @@ -758,6 +807,9 @@ class _UsersTabState extends ConsumerState<_UsersTab> { UserTableActionsCell( child: UserTableActions( user: user, + canEdit: canEditUser, + canResetPassword: canEditUser, + canDeactivate: canDeleteUser, onEdit: () => _editUser(user), onResetPassword: () => _resetPassword(user), @@ -854,6 +906,7 @@ class _UsersFilterBar extends StatelessWidget { required this.onSearch, required this.onExport, this.isExporting = false, + this.showExport = true, required this.onRoleChanged, required this.onDepartmentChanged, required this.onStatusChanged, @@ -869,6 +922,7 @@ class _UsersFilterBar extends StatelessWidget { final ValueChanged onSearch; final VoidCallback onExport; final bool isExporting; + final bool showExport; final ValueChanged onRoleChanged; final ValueChanged onDepartmentChanged; final ValueChanged onStatusChanged; @@ -916,13 +970,18 @@ class _UsersFilterBar extends StatelessWidget { ), ]; + final visibleFilters = [ + ...filters.take(3), + if (showExport) filters[3], + ]; + if (wrapped) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ searchField, const SizedBox(height: 12), - Wrap(spacing: 12, runSpacing: 12, children: filters), + Wrap(spacing: 12, runSpacing: 12, children: visibleFilters), ], ); } @@ -936,8 +995,10 @@ class _UsersFilterBar extends StatelessWidget { Expanded(flex: 2, child: filters[1]), const SizedBox(width: 12), Expanded(child: filters[2]), - const SizedBox(width: 12), - filters[3], + if (showExport) ...[ + const SizedBox(width: 12), + filters[3], + ], ], ); } @@ -987,6 +1048,9 @@ class _RolesTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final rolesAsync = ref.watch(rolesListProvider); + final canCreateRole = ref.can('roles', PermissionAction.create); + final canEditRole = ref.can('roles', PermissionAction.update); + final canDeleteRole = ref.can('roles', PermissionAction.delete); // Text styles are resolved at build time; rebuild cards when theme toggles. final themeMode = ref.watch(themeModeProvider); final brightness = Theme.of(context).brightness; @@ -1014,9 +1078,9 @@ class _RolesTab extends ConsumerWidget { crossAxisSpacing: 16, mainAxisSpacing: 16, ), - itemCount: roles.length + 1, + itemCount: roles.length + (canCreateRole ? 1 : 0), itemBuilder: (context, index) { - if (index == roles.length) { + if (canCreateRole && index == roles.length) { return AppHoverEffect( onTap: onNewRole, showHoverBorder: false, @@ -1069,12 +1133,13 @@ class _RolesTab extends ConsumerWidget { child: Icon(appearance.icon, color: appearance.color, size: 20), ), const Spacer(), - IconButton( - icon: const Icon(Icons.edit_outlined, size: 18), - onPressed: () => onEditRole(role), - visualDensity: VisualDensity.compact, - ), - if (!isProtectedRole(role)) + if (canEditRole) + IconButton( + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: () => onEditRole(role), + visualDensity: VisualDensity.compact, + ), + if (canDeleteRole && !isProtectedRole(role)) IconButton( icon: const Icon(Icons.delete_outline, size: 18), onPressed: () => onDeleteRole(role), @@ -1202,6 +1267,7 @@ class _PermissionMatrixTabState extends ConsumerState<_PermissionMatrixTab> { Widget build(BuildContext context) { final rolesAsync = ref.watch(rolesListProvider); final catalogAsync = ref.watch(permissionCatalogProvider); + final canEditRoles = ref.can('roles', PermissionAction.update); final selectedRoleId = _selectedRoleId; final matrixAsync = selectedRoleId == null ? null @@ -1272,22 +1338,23 @@ class _PermissionMatrixTabState extends ConsumerState<_PermissionMatrixTab> { ], ), ), - ElevatedButton.icon( - onPressed: selectedRoleId == null || _isSaving - ? null - : () => _save(selectedRole?.name ?? 'role'), - icon: _isSaving - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.save_outlined, size: 18), - label: Text(_isSaving ? 'Saving...' : 'Save changes'), - ), + if (canEditRoles) + ElevatedButton.icon( + onPressed: selectedRoleId == null || _isSaving + ? null + : () => _save(selectedRole?.name ?? 'role'), + icon: _isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.save_outlined, size: 18), + label: Text(_isSaving ? 'Saving...' : 'Save changes'), + ), ], ), ), diff --git a/lib/modules/rbac/presentation/widgets/rbac_widgets.dart b/lib/modules/rbac/presentation/widgets/rbac_widgets.dart index accc1d6..9a9052f 100644 --- a/lib/modules/rbac/presentation/widgets/rbac_widgets.dart +++ b/lib/modules/rbac/presentation/widgets/rbac_widgets.dart @@ -192,6 +192,9 @@ class UserTableActions extends StatelessWidget { required this.onEdit, required this.onResetPassword, required this.onDeactivate, + this.canEdit = true, + this.canResetPassword = true, + this.canDeactivate = true, }); static const columnWidth = 120.0; @@ -200,6 +203,9 @@ class UserTableActions extends StatelessWidget { final VoidCallback onEdit; final VoidCallback onResetPassword; final VoidCallback onDeactivate; + final bool canEdit; + final bool canResetPassword; + final bool canDeactivate; @override Widget build(BuildContext context) { @@ -209,24 +215,27 @@ class UserTableActions extends StatelessWidget { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, children: [ - _UserActionIcon( - tooltip: 'Edit user', - icon: Icons.edit_outlined, - color: muted, - onPressed: onEdit, - ), - _UserActionIcon( - tooltip: 'Reset password', - icon: Icons.vpn_key_outlined, - color: muted, - onPressed: onResetPassword, - ), - _UserActionIcon( - tooltip: 'Deactivate user', - icon: Icons.person_off_outlined, - color: muted, - onPressed: onDeactivate, - ), + if (canEdit) + _UserActionIcon( + tooltip: 'Edit user', + icon: Icons.edit_outlined, + color: muted, + onPressed: onEdit, + ), + if (canResetPassword) + _UserActionIcon( + tooltip: 'Reset password', + icon: Icons.vpn_key_outlined, + color: muted, + onPressed: onResetPassword, + ), + if (canDeactivate) + _UserActionIcon( + tooltip: 'Deactivate user', + icon: Icons.person_off_outlined, + color: muted, + onPressed: onDeactivate, + ), ], ); } diff --git a/lib/shared/models/user_model.dart b/lib/shared/models/user_model.dart index c8cd12b..49e2b74 100644 --- a/lib/shared/models/user_model.dart +++ b/lib/shared/models/user_model.dart @@ -28,10 +28,14 @@ class UserModel with _$UserModel { /// Parses user objects returned by auth endpoints (`/auth/login`, `/auth/me`). factory UserModel.fromLoginJson(Map json) { - final role = json['role_name'] ?? + final roleRaw = json['role_name'] ?? json['role'] ?? json['role_slug'] ?? + _firstRoleFromList(json['roles']) ?? 'employee'; + final role = roleRaw is Map + ? (roleRaw['name'] as String? ?? roleRaw['slug'] as String? ?? 'employee') + : roleRaw.toString(); final name = json['full_name'] ?? json['name'] ?? [ @@ -41,12 +45,13 @@ class UserModel with _$UserModel { return UserModel( id: json['id']?.toString() ?? '', - employeeId: (json['employee_code'] ?? json['employee_id'] ?? '') as String, + employeeId: (json['employee_code'] ?? json['employee_id'] ?? '').toString(), name: name is String && name.isNotEmpty ? name : (json['email'] as String? ?? ''), email: json['email'] as String? ?? '', mobile: json['mobile']?.toString() ?? '', - role: role is String ? role : role.toString(), - department: json['department_name'] as String? ?? json['department'] as String?, + role: role, + department: _nestedLabel(json['department_name']) ?? + _nestedLabel(json['department']), status: json['status'] as String? ?? 'active', companyId: json['company_id']?.toString(), branchId: json['branch_id']?.toString(), @@ -56,15 +61,32 @@ class UserModel with _$UserModel { .toList() ?? const [], createdAt: json['created_at'] != null - ? DateTime.tryParse(json['created_at'] as String) + ? DateTime.tryParse(json['created_at'].toString()) : null, updatedAt: json['updated_at'] != null - ? DateTime.tryParse(json['updated_at'] as String) + ? DateTime.tryParse(json['updated_at'].toString()) : null, ); } } +Object? _firstRoleFromList(Object? roles) { + if (roles is! List || roles.isEmpty) return null; + return roles.first; +} + +String? _nestedLabel(Object? value) { + if (value == null) return null; + if (value is String) return value.isEmpty ? null : value; + if (value is Map) { + final name = value['name']; + if (name is String && name.isNotEmpty) return name; + final code = value['code']; + if (code is String && code.isNotEmpty) return code; + } + return null; +} + extension UserModelX on UserModel { UserRole get userRole => UserRole.fromValue(role); EntityStatus get entityStatus => EntityStatus.fromValue(status); diff --git a/lib/shared/providers/permissions_provider.dart b/lib/shared/providers/permissions_provider.dart new file mode 100644 index 0000000..47f45e5 --- /dev/null +++ b/lib/shared/providers/permissions_provider.dart @@ -0,0 +1,29 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/constants/enums.dart'; +import '../../core/utils/permission_utils.dart'; +import 'auth_provider.dart'; + +final userPermissionsProvider = Provider>((ref) { + return ref.watch(authStateProvider).user?.permissions ?? const []; +}); + +extension PermissionCheck on WidgetRef { + bool can(String module, PermissionAction action) { + return hasPermission( + userPermissions: read(userPermissionsProvider), + module: module, + action: action, + ); + } +} + +extension PermissionCheckReader on Ref { + bool can(String module, PermissionAction action) { + return hasPermission( + userPermissions: read(userPermissionsProvider), + module: module, + action: action, + ); + } +} diff --git a/lib/shared/routes/menu_config.dart b/lib/shared/routes/menu_config.dart index cbf3a4b..314adeb 100644 --- a/lib/shared/routes/menu_config.dart +++ b/lib/shared/routes/menu_config.dart @@ -132,14 +132,47 @@ List getVisibleMenuItems({ return appMenuItems; } - return appMenuItems.where((item) { + final visible = []; + + for (final item in appMenuItems) { if (item.requiredRole != null && !isSuperAdmin(role) && role != item.requiredRole) { - return false; + continue; } - return hasPermission( - userPermissions: permissions, + + if (item.children.isNotEmpty) { + final visibleChildren = item.children + .where( + (child) => canSeeMenuModule( + permissions: permissions, + module: child.module, + role: role, + ), + ) + .toList(); + if (visibleChildren.isEmpty) continue; + visible.add( + MenuItem( + label: item.label, + icon: item.icon, + route: visibleChildren.first.route, + module: item.module, + children: visibleChildren, + requiredRole: item.requiredRole, + ), + ); + continue; + } + + if (!canSeeMenuModule( + permissions: permissions, module: item.module, - action: PermissionAction.read, - ) || isAdmin(role); - }).toList(); + role: role, + )) { + continue; + } + + visible.add(item); + } + + return visible; } diff --git a/lib/shared/widgets/app_search_export_bar.dart b/lib/shared/widgets/app_search_export_bar.dart index 5ba1d1f..a4a0b61 100644 --- a/lib/shared/widgets/app_search_export_bar.dart +++ b/lib/shared/widgets/app_search_export_bar.dart @@ -11,6 +11,7 @@ class AppSearchExportBar extends StatelessWidget { this.wrapped = false, this.searchController, this.searchWidth = 320, + this.showExport = true, }); final String searchHint; @@ -20,6 +21,7 @@ class AppSearchExportBar extends StatelessWidget { final bool wrapped; final TextEditingController? searchController; final double searchWidth; + final bool showExport; @override Widget build(BuildContext context) { @@ -50,13 +52,15 @@ class AppSearchExportBar extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ searchField, - const SizedBox(height: 12), - Row( - children: [ - const Spacer(), - exportButton, - ], - ), + if (showExport) ...[ + const SizedBox(height: 12), + Row( + children: [ + const Spacer(), + exportButton, + ], + ), + ], ], ); } @@ -65,7 +69,7 @@ class AppSearchExportBar extends StatelessWidget { children: [ SizedBox(width: searchWidth, child: searchField), const Spacer(), - exportButton, + if (showExport) exportButton, ], ); } diff --git a/lib/shared/widgets/app_sidebar.dart b/lib/shared/widgets/app_sidebar.dart index 1d8e986..3a4dc28 100644 --- a/lib/shared/widgets/app_sidebar.dart +++ b/lib/shared/widgets/app_sidebar.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -93,7 +95,8 @@ class _AppSidebarState extends ConsumerState { (themeMode == ThemeModeOption.system && !isDark); return AnimatedContainer( - duration: AppConstants.animationDuration, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, width: widget.isDrawer ? double.infinity : (widget.collapsed ? _sidebarCollapsedWidth : _sidebarExpandedWidth), @@ -141,6 +144,15 @@ class _AppSidebarState extends ConsumerState { else const SizedBox(height: 4), ..._mainMenuItems.map((item) { + if (isNarrow && item.children.isNotEmpty) { + return _CollapsedFlyoutNavItem( + item: item, + selected: _isGroupActive(item), + isChildSelected: _isSelected, + onChildTap: widget.onItemTap, + ); + } + if (item.children.isNotEmpty && !isNarrow) { final expanded = _isGroupExpanded(item); final active = _isGroupActive(item); @@ -178,10 +190,6 @@ class _AppSidebarState extends ConsumerState { (isNarrow && _isGroupActive(item)), collapsed: isNarrow, onTap: () { - if (item.children.isNotEmpty && isNarrow) { - widget.onItemTap(item.children.first.route); - return; - } widget.onItemTap(item.route); }, ); @@ -330,7 +338,11 @@ class _AppSidebarState extends ConsumerState { return Padding( padding: const EdgeInsets.all(12), child: Center( - child: _UserAvatar(name: name), + child: _UserProfileMenu( + userName: name, + menuOffset: const Offset(-8, -210), + child: _UserAvatar(name: name), + ), ), ); } @@ -378,6 +390,296 @@ class _AppSidebarState extends ConsumerState { } } +class _CollapsedFlyoutNavItem extends StatefulWidget { + const _CollapsedFlyoutNavItem({ + required this.item, + required this.selected, + required this.isChildSelected, + required this.onChildTap, + }); + + final menu.MenuItem item; + final bool selected; + final bool Function(String route) isChildSelected; + final void Function(String route) onChildTap; + + @override + State<_CollapsedFlyoutNavItem> createState() => + _CollapsedFlyoutNavItemState(); +} + +class _CollapsedFlyoutNavItemState extends State<_CollapsedFlyoutNavItem> { + final _anchorKey = GlobalKey(); + OverlayEntry? _overlayEntry; + Timer? _hideTimer; + + @override + void dispose() { + _hideTimer?.cancel(); + _removeOverlay(); + super.dispose(); + } + + void _cancelHide() { + _hideTimer?.cancel(); + _hideTimer = null; + } + + void _scheduleHide() { + _cancelHide(); + _hideTimer = Timer(const Duration(milliseconds: 50), () { + if (!mounted) return; + _removeOverlay(); + }); + } + + void _removeOverlay() { + _overlayEntry?.remove(); + _overlayEntry = null; + if (mounted) setState(() {}); + } + + void _showOverlay() { + if (_overlayEntry != null) return; + _insertOverlay(); + if (_overlayEntry == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _overlayEntry != null) return; + _insertOverlay(); + }); + } + } + + void _insertOverlay() { + if (_overlayEntry != null) return; + + final renderBox = + _anchorKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + final anchorTopLeft = renderBox.localToGlobal(Offset.zero); + final anchorSize = renderBox.size; + const panelWidth = 220.0; + + _overlayEntry = OverlayEntry( + builder: (overlayContext) { + final theme = Theme.of(overlayContext); + final primary = theme.colorScheme.primary; + + return Positioned( + left: anchorTopLeft.dx, + top: anchorTopLeft.dy, + child: TapRegion( + onTapOutside: (_) => _removeOverlay(), + child: MouseRegion( + onEnter: (_) => _cancelHide(), + onExit: (_) => _scheduleHide(), + child: _SidebarFlyoutFade( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: anchorSize.width, + height: anchorSize.height, + ), + Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + shadowColor: Colors.black45, + child: SizedBox( + width: panelWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 8), + child: Text( + widget.item.label, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.4, + ), + ), + ), + const Divider(height: 1), + ...widget.item.children.map((child) { + final childSelected = + widget.isChildSelected(child.route); + return InkWell( + onTap: () { + _removeOverlay(); + widget.onChildTap(child.route); + }, + child: Container( + height: _sidebarItemHeight, + padding: const EdgeInsets.symmetric( + horizontal: _sidebarItemPadding, + ), + color: childSelected + ? primary.withValues(alpha: 0.1) + : null, + child: Row( + children: [ + Icon( + child.icon, + size: _sidebarIconSize, + color: childSelected + ? primary + : theme + .colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + child.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium + ?.copyWith( + color: childSelected + ? primary + : theme.colorScheme + .onSurfaceVariant, + fontWeight: childSelected + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + + Overlay.of(context).insert(_overlayEntry!); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final primary = theme.colorScheme.primary; + + return MouseRegion( + onEnter: (_) { + _cancelHide(); + _showOverlay(); + }, + onExit: (_) { + if (_overlayEntry != null) return; + _scheduleHide(); + }, + child: KeyedSubtree( + key: _anchorKey, + child: Padding( + padding: const EdgeInsets.only(bottom: 4), + child: SizedBox( + width: double.infinity, + child: InkWell( + onTap: _showOverlay, + borderRadius: BorderRadius.circular(12), + child: Container( + height: _sidebarItemHeight, + alignment: Alignment.center, + decoration: BoxDecoration( + color: widget.selected || _overlayEntry != null + ? primary.withValues(alpha: 0.12) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + widget.item.icon, + size: 22, + color: widget.selected || _overlayEntry != null + ? primary + : theme.colorScheme.onSurfaceVariant, + ), + Icon( + Icons.chevron_right, + size: 12, + color: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.7), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _SidebarFlyoutFade extends StatefulWidget { + const _SidebarFlyoutFade({required this.child}); + + final Widget child; + + @override + State<_SidebarFlyoutFade> createState() => _SidebarFlyoutFadeState(); +} + +class _SidebarFlyoutFadeState extends State<_SidebarFlyoutFade> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _fade; + late final Animation _slide; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 120), + ); + final curve = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic); + _fade = curve; + _slide = Tween( + begin: const Offset(-0.04, 0), + end: Offset.zero, + ).animate(curve); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _fade, + child: SlideTransition( + position: _slide, + child: widget.child, + ), + ); + } +} + class _SectionLabel extends StatelessWidget { const _SectionLabel({required this.label}); @@ -437,7 +739,7 @@ class _SidebarNavItem extends StatelessWidget { width: double.infinity, child: Tooltip( message: label, - waitDuration: const Duration(milliseconds: 500), + waitDuration: const Duration(milliseconds: 250), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), @@ -589,19 +891,25 @@ class _UserAvatar extends StatelessWidget { } class _UserProfileMenu extends ConsumerWidget { - const _UserProfileMenu({this.userName}); + const _UserProfileMenu({ + this.userName, + this.child, + this.menuOffset, + }); final String? userName; + final Widget? child; + final Offset? menuOffset; @override Widget build(BuildContext context, WidgetRef ref) { return PopupMenuButton( - icon: Icon( - Icons.more_vert, - size: 20, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + tooltip: userName ?? 'Account menu', padding: EdgeInsets.zero, + offset: menuOffset ?? Offset.zero, + position: child != null + ? PopupMenuPosition.over + : PopupMenuPosition.under, onSelected: (value) async { switch (value) { case 'profile': @@ -622,6 +930,12 @@ class _UserProfileMenu extends ConsumerWidget { const PopupMenuDivider(), const PopupMenuItem(value: 'logout', child: Text('Logout')), ], + child: child ?? + Icon( + Icons.more_vert, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ); } } diff --git a/lib/shared/widgets/can_permission.dart b/lib/shared/widgets/can_permission.dart new file mode 100644 index 0000000..648beb6 --- /dev/null +++ b/lib/shared/widgets/can_permission.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/constants/enums.dart'; +import '../providers/permissions_provider.dart'; + +/// Shows [child] only when the logged-in user has the given permission. +class CanPermission extends ConsumerWidget { + const CanPermission({ + super.key, + required this.module, + required this.action, + required this.child, + this.fallback, + }); + + final String module; + final PermissionAction action; + final Widget child; + final Widget? fallback; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (ref.can(module, action)) { + return child; + } + return fallback ?? const SizedBox.shrink(); + } +}