feat(menu-permissions): 메뉴 API 연동으로 사이드바 권한 정비

- .env.development.example과 lib/core/config/environment.dart, lib/core/permissions/permission_manager.dart에서 PERMISSION__ 폴백을 view 전용으로 좁히고 기본 정책을 명시적으로 거부하도록 재정비했다

- lib/core/navigation/*, lib/core/routing/app_router.dart, lib/widgets/app_shell.dart, lib/main.dart에서 메뉴 매니페스트·카탈로그를 도입해 /menus 응답을 캐싱하고 라우터·사이드바·Breadcrumb가 동일 menu_code/route_path를 쓰도록 리팩터링했다

- lib/core/permissions/permission_resources.dart와 그룹 권한/메뉴 마스터 모듈을 menu_code 기반 CRUD 및 Catalog 경로 정합성 검사로 전환하고 PermissionSynchronizer·PermissionBootstrapper를 확장했다

- test/helpers/test_permissions.dart, test/widgets/app_shell_test.dart 등 신규 구조를 반영하는 테스트·골든과 doc/frontend_menu_permission_tasks.md 문서를 보강했다
This commit is contained in:
JiWoong Sul
2025-11-12 18:29:03 +09:00
parent f767c44573
commit 753f76e952
72 changed files with 1914 additions and 704 deletions

View File

@@ -1,4 +1,6 @@
import 'package:superport_v2/core/navigation/menu_catalog.dart';
import 'package:superport_v2/core/permissions/permission_manager.dart';
import 'package:superport_v2/core/permissions/permission_resources.dart';
import '../domain/entities/group_permission.dart';
import '../domain/mappers/group_permission_mapper.dart';
@@ -9,12 +11,15 @@ class PermissionSynchronizer {
PermissionSynchronizer({
required GroupPermissionRepository repository,
required PermissionManager manager,
MenuCatalog? menuCatalog,
this.pageSize = 200,
}) : _repository = repository,
_manager = manager;
_manager = manager,
_menuCatalog = menuCatalog;
final GroupPermissionRepository _repository;
final PermissionManager _manager;
final MenuCatalog? _menuCatalog;
final int pageSize;
/// 지정한 [groupId]의 메뉴 권한을 조회해 [PermissionManager]에 적용한다.
@@ -31,7 +36,8 @@ class PermissionSynchronizer {
if (collected.isEmpty) {
return const {};
}
return buildPermissionMap(collected);
final synchronized = await _alignMenuRoutes(collected);
return buildPermissionMap(synchronized);
}
Future<List<GroupPermission>> _collectPermissions(int groupId) async {
@@ -63,4 +69,85 @@ class PermissionSynchronizer {
return collected;
}
Future<List<GroupPermission>> _alignMenuRoutes(
List<GroupPermission> permissions,
) async {
final catalog = _menuCatalog;
if (catalog == null || permissions.isEmpty) {
return permissions;
}
final codeToRoute = await _buildMenuRouteMap(catalog);
if (codeToRoute.isEmpty) {
return permissions;
}
var mutated = false;
final adjusted = <GroupPermission>[];
for (final permission in permissions) {
final resolvedPath = _resolveRouteForPermission(
permission.menu.menuCode,
permission.menu.path,
codeToRoute,
);
if (resolvedPath == null) {
adjusted.add(permission);
continue;
}
final normalizedCurrent = PermissionResources.normalize(
permission.menu.path ?? '',
);
final normalizedResolved = PermissionResources.normalize(resolvedPath);
if (normalizedCurrent == normalizedResolved) {
adjusted.add(permission);
continue;
}
final updatedMenu = GroupPermissionMenu(
id: permission.menu.id,
menuCode: permission.menu.menuCode,
menuName: permission.menu.menuName,
path: resolvedPath,
);
adjusted.add(permission.copyWith(menu: updatedMenu));
mutated = true;
}
return mutated ? adjusted : permissions;
}
Future<Map<String, String>> _buildMenuRouteMap(MenuCatalog catalog) async {
try {
final menus = await catalog.ensureLoaded();
final map = <String, String>{};
for (final menu in menus) {
final code = menu.menuCode;
final path = menu.path;
if (code.isEmpty || path == null || path.isEmpty) {
continue;
}
map[code] = path;
}
return map;
} catch (_) {
return const {};
}
}
String? _resolveRouteForPermission(
String menuCode,
String? currentPath,
Map<String, String> codeToRoute,
) {
final expectedRaw = codeToRoute[menuCode];
final normalizedCurrent = PermissionResources.normalize(currentPath ?? '');
if (normalizedCurrent.isNotEmpty) {
if (expectedRaw == null || expectedRaw.isEmpty) {
return null;
}
final normalizedExpected = PermissionResources.normalize(expectedRaw);
return normalizedCurrent == normalizedExpected ? null : expectedRaw;
}
if (expectedRaw == null || expectedRaw.isEmpty) {
return null;
}
return expectedRaw;
}
}

View File

@@ -22,17 +22,19 @@ class GroupPermissionRepositoryRemote implements GroupPermissionRepository {
int page = 1,
int pageSize = 20,
int? groupId,
int? menuId,
String? menuCode,
bool? isActive,
bool includeDeleted = false,
}) async {
final normalizedMenuCode = menuCode?.trim();
final response = await _api.get<Map<String, dynamic>>(
_basePath,
query: {
'page': page,
'page_size': pageSize,
if (groupId != null) 'group_id': groupId,
if (menuId != null) 'menu_id': menuId,
if (normalizedMenuCode != null && normalizedMenuCode.isNotEmpty)
'menu_code': normalizedMenuCode,
if (isActive != null) 'active': isActive,
if (includeDeleted) 'include_deleted': true,
'include': 'group,menu',

View File

@@ -87,7 +87,7 @@ class GroupPermissionMenu {
class GroupPermissionInput {
GroupPermissionInput({
required this.groupId,
required this.menuId,
required this.menuCode,
this.canCreate = false,
this.canRead = true,
this.canUpdate = false,
@@ -97,7 +97,7 @@ class GroupPermissionInput {
});
final int groupId;
final int menuId;
final String menuCode;
final bool canCreate;
final bool canRead;
final bool canUpdate;
@@ -106,9 +106,10 @@ class GroupPermissionInput {
final String? note;
Map<String, dynamic> toPayload() {
final normalizedCode = menuCode.trim();
return {
'group_id': groupId,
'menu_id': menuId,
'menu_code': normalizedCode,
'can_create': canCreate,
'can_read': canRead,
'can_update': canUpdate,

View File

@@ -9,7 +9,7 @@ abstract class GroupPermissionRepository {
int page = 1,
int pageSize = 20,
int? groupId,
int? menuId,
String? menuCode,
bool? isActive,
bool includeDeleted = false,
});

View File

@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:superport_v2/core/navigation/menu_catalog.dart';
import 'package:superport_v2/core/network/failure.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/common/utils/pagination_utils.dart';
@@ -24,15 +25,18 @@ class GroupPermissionController extends ChangeNotifier {
required GroupPermissionRepository permissionRepository,
required GroupRepository groupRepository,
required MenuRepository menuRepository,
MenuCatalog? menuCatalog,
PermissionManager? permissionManager,
}) : _permissionRepository = permissionRepository,
_groupRepository = groupRepository,
_menuRepository = menuRepository,
_menuCatalog = menuCatalog,
_permissionManager = permissionManager;
final GroupPermissionRepository _permissionRepository;
final GroupRepository _groupRepository;
final MenuRepository _menuRepository;
final MenuCatalog? _menuCatalog;
final PermissionManager? _permissionManager;
PaginatedResult<GroupPermission>? _result;
@@ -43,7 +47,7 @@ class GroupPermissionController extends ChangeNotifier {
String? _errorMessage;
GroupPermissionStatusFilter _statusFilter = GroupPermissionStatusFilter.all;
int? _groupFilter;
int? _menuFilter;
String? _menuFilter;
bool _includeDeleted = false;
final List<Group> _groups = [];
final List<MenuItem> _menus = [];
@@ -56,7 +60,7 @@ class GroupPermissionController extends ChangeNotifier {
String? get errorMessage => _errorMessage;
GroupPermissionStatusFilter get statusFilter => _statusFilter;
int? get groupFilter => _groupFilter;
int? get menuFilter => _menuFilter;
String? get menuFilter => _menuFilter;
bool get includeDeleted => _includeDeleted;
List<Group> get groups => List.unmodifiable(_groups);
List<MenuItem> get menus => List.unmodifiable(_menus);
@@ -87,13 +91,7 @@ class GroupPermissionController extends ChangeNotifier {
_isLoadingMenus = true;
notifyListeners();
try {
final menus = await fetchAllPaginatedItems<MenuItem>(
request: (page, pageSize) => _menuRepository.list(
page: page,
pageSize: pageSize,
includeDeleted: false,
),
);
final menus = _sortMenus(await _resolveMenus());
_menus
..clear()
..addAll(menus);
@@ -132,7 +130,7 @@ class GroupPermissionController extends ChangeNotifier {
page: resolvedPage,
pageSize: _result?.pageSize ?? 20,
groupId: _groupFilter,
menuId: _menuFilter,
menuCode: _menuFilter,
isActive: isActive,
includeDeleted: _includeDeleted,
);
@@ -153,8 +151,9 @@ class GroupPermissionController extends ChangeNotifier {
}
/// 메뉴 필터를 변경한다.
void updateMenuFilter(int? menuId) {
_menuFilter = menuId;
void updateMenuFilter(String? menuCode) {
final trimmed = menuCode?.trim();
_menuFilter = trimmed?.isEmpty ?? true ? null : trimmed;
notifyListeners();
}
@@ -266,6 +265,7 @@ class GroupPermissionController extends ChangeNotifier {
final synchronizer = PermissionSynchronizer(
repository: _permissionRepository,
manager: manager,
menuCatalog: _menuCatalog,
);
await synchronizer.syncForGroup(groupId);
} catch (_) {
@@ -285,4 +285,53 @@ class GroupPermissionController extends ChangeNotifier {
}
return null;
}
List<MenuItem> _sortMenus(List<MenuItem> menus) {
// 백엔드 menus 테이블 순서를 재현하기 위해 display_order → 메뉴명 순으로 정렬한다.
final visibleMenus = menus.where((menu) => !menu.isDeleted).toList();
visibleMenus.sort(_compareMenuItems);
return visibleMenus;
}
int _compareMenuItems(MenuItem a, MenuItem b) {
final parentCompare = (a.parent?.menuName ?? '').compareTo(
b.parent?.menuName ?? '',
);
if (parentCompare != 0) {
return parentCompare;
}
final orderCompare = _compareDisplayOrder(a.displayOrder, b.displayOrder);
if (orderCompare != 0) {
return orderCompare;
}
return a.menuName.compareTo(b.menuName);
}
int _compareDisplayOrder(int? a, int? b) {
if (a == null && b == null) {
return 0;
}
if (a == null) {
return 1;
}
if (b == null) {
return -1;
}
return a.compareTo(b);
}
Future<List<MenuItem>> _resolveMenus() async {
final catalog = _menuCatalog;
if (catalog != null) {
final menus = await catalog.ensureLoaded();
return List<MenuItem>.from(menus);
}
return fetchAllPaginatedItems<MenuItem>(
request: (page, pageSize) => _menuRepository.list(
page: page,
pageSize: pageSize,
includeDeleted: false,
),
);
}
}

View File

@@ -366,7 +366,7 @@ class _GroupPermissionForm extends StatefulWidget {
class _GroupPermissionFormState extends State<_GroupPermissionForm> {
int? _selectedGroup;
int? _selectedMenu;
String? _selectedMenu;
late bool _canCreate;
late bool _canRead;
late bool _canUpdate;
@@ -385,7 +385,7 @@ class _GroupPermissionFormState extends State<_GroupPermissionForm> {
super.initState();
final permission = widget.permission;
_selectedGroup = permission?.group.id;
_selectedMenu = permission?.menu.id;
_selectedMenu = permission?.menu.menuCode;
_canCreate = permission?.canCreate ?? false;
_canRead = permission?.canRead ?? true;
_canUpdate = permission?.canUpdate ?? false;
@@ -457,7 +457,7 @@ class _GroupPermissionFormState extends State<_GroupPermissionForm> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShadSelect<int>(
ShadSelect<String>(
initialValue: _selectedMenu,
placeholder: Text(
widget.isLoadingMenus ? '메뉴 로딩중...' : '메뉴 선택',
@@ -475,9 +475,10 @@ class _GroupPermissionFormState extends State<_GroupPermissionForm> {
});
},
options: widget.menus
.where((menu) => menu.menuCode.isNotEmpty)
.map(
(menu) => ShadOption<int>(
value: menu.id!,
(menu) => ShadOption<String>(
value: menu.menuCode,
child: Text(menu.menuName),
),
)
@@ -612,7 +613,7 @@ class _GroupPermissionFormState extends State<_GroupPermissionForm> {
final note = _noteController.text.trim();
final input = GroupPermissionInput(
groupId: _selectedGroup!,
menuId: _selectedMenu!,
menuCode: _selectedMenu!,
canCreate: _canCreate,
canRead: _canRead,
canUpdate: _canUpdate,
@@ -648,13 +649,13 @@ class _GroupPermissionFormState extends State<_GroupPermissionForm> {
return group.groupName;
}
String _resolveMenuLabel(int? id) {
if (id == null) {
String _resolveMenuLabel(String? code) {
if (code == null) {
return widget.isLoadingMenus ? '메뉴 로딩중...' : '메뉴 선택';
}
final menu = widget.menus.firstWhere(
(item) => item.id == id,
orElse: () => MenuItem(id: id, menuCode: '', menuName: '알 수 없음'),
(item) => item.menuCode == code,
orElse: () => MenuItem(menuCode: code, menuName: '알 수 없음'),
);
return menu.menuName;
}

View File

@@ -3,7 +3,9 @@ import 'package:get_it/get_it.dart';
import 'package:intl/intl.dart' as intl;
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/core/constants/app_sections.dart';
import 'package:superport_v2/core/navigation/menu_catalog.dart';
import 'package:superport_v2/core/navigation/menu_route_definitions.dart';
import 'package:superport_v2/core/navigation/route_paths.dart';
import 'package:superport_v2/widgets/app_layout.dart';
import 'package:superport_v2/widgets/components/filter_bar.dart';
import 'package:superport_v2/widgets/components/superport_pagination_controls.dart';
@@ -24,9 +26,9 @@ import '../dialogs/group_permission_detail_dialog.dart';
String _menuDisplayLabelFromPath(String? path, String fallback) {
if (path != null && path.isNotEmpty) {
final normalized = path.toLowerCase();
for (final page in allAppPages) {
if (page.path.toLowerCase() == normalized) {
return page.label;
for (final definition in menuRouteDefinitions) {
if (definition.routePath.toLowerCase() == normalized) {
return definition.defaultLabel;
}
}
}
@@ -147,10 +149,12 @@ class _GroupPermissionEnabledPageState
return;
}
final permissionManager = PermissionScope.of(context);
final menuCatalog = MenuCatalogScope.of(context);
_controller = GroupPermissionController(
permissionRepository: GetIt.I<GroupPermissionRepository>(),
groupRepository: GetIt.I<GroupRepository>(),
menuRepository: GetIt.I<MenuRepository>(),
menuCatalog: menuCatalog,
permissionManager: permissionManager,
)..addListener(_handleControllerUpdate);
_initialized = true;
@@ -209,7 +213,10 @@ class _GroupPermissionEnabledPageState
subtitle: '그룹별 메뉴 CRUD 권한을 체크박스로 관리합니다.',
breadcrumbs: const [
AppBreadcrumbItem(label: '대시보드', path: dashboardRoutePath),
AppBreadcrumbItem(label: '마스터', path: '/masters/group-permissions'),
AppBreadcrumbItem(
label: '마스터',
path: settingsGroupPermissionsRoutePath,
),
AppBreadcrumbItem(label: '그룹 권한'),
],
actions: [
@@ -292,8 +299,8 @@ class _GroupPermissionEnabledPageState
),
SizedBox(
width: 220,
child: ShadSelect<int?>(
key: ValueKey(_controller.menuFilter),
child: ShadSelect<String?>(
key: ValueKey<String?>(_controller.menuFilter),
initialValue: _controller.menuFilter,
placeholder: Text(
_controller.menus.isEmpty ? '메뉴 로딩중...' : '메뉴 전체',
@@ -305,9 +312,9 @@ class _GroupPermissionEnabledPageState
);
}
final menuItem = _controller.menus.firstWhere(
(m) => m.id == value,
(m) => m.menuCode == value,
orElse: () =>
MenuItem(id: value, menuCode: '', menuName: ''),
MenuItem(menuCode: value, menuName: '알 수 없음'),
);
return Text(_menuDisplayLabel(menuItem));
},
@@ -315,10 +322,13 @@ class _GroupPermissionEnabledPageState
_controller.updateMenuFilter(value);
},
options: [
const ShadOption<int?>(value: null, child: Text('메뉴 전체')),
const ShadOption<String?>(
value: null,
child: Text('메뉴 전체'),
),
..._controller.menus.map(
(menuItem) => ShadOption<int?>(
value: menuItem.id,
(menuItem) => ShadOption<String?>(
value: menuItem.menuCode,
child: Text(_menuDisplayLabel(menuItem)),
),
),