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:
130
lib/core/navigation/menu_catalog.dart
Normal file
130
lib/core/navigation/menu_catalog.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../common/utils/pagination_utils.dart';
|
||||
import '../network/failure.dart';
|
||||
import '../../features/masters/menu/domain/entities/menu.dart';
|
||||
import '../../features/masters/menu/domain/repositories/menu_repository.dart';
|
||||
|
||||
/// 메뉴 목록을 한 번만 로드해 전역에서 재사용하도록 제공하는 카탈로그.
|
||||
///
|
||||
/// - 메뉴 테이블 전체를 페이지네이션 API로 받아 캐시에 저장한다.
|
||||
/// - AppShell/권한 설정 화면 등에서 공통 데이터를 구독해 재렌더링 비용을 줄인다.
|
||||
class MenuCatalog extends ChangeNotifier {
|
||||
MenuCatalog({required MenuRepository repository}) : _repository = repository;
|
||||
|
||||
final MenuRepository _repository;
|
||||
|
||||
List<MenuItem> _menus = const [];
|
||||
bool _isLoading = false;
|
||||
bool _initialized = false;
|
||||
String? _errorMessage;
|
||||
Completer<void>? _pendingLoad;
|
||||
|
||||
/// 최신 메뉴 목록.
|
||||
List<MenuItem> get menus => _menus;
|
||||
|
||||
/// 현재 로딩 상태.
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
/// 최초 로드 여부.
|
||||
bool get isInitialized => _initialized;
|
||||
|
||||
/// 마지막 로드 실패 메시지.
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
/// 캐시된 메뉴를 반환하며 필요 시 자동으로 로딩한다.
|
||||
Future<List<MenuItem>> ensureLoaded({bool forceRefresh = false}) async {
|
||||
if (_initialized && !forceRefresh) {
|
||||
return _menus;
|
||||
}
|
||||
await refresh();
|
||||
return _menus;
|
||||
}
|
||||
|
||||
/// 백엔드에서 메뉴를 다시 불러와 캐시를 갱신한다.
|
||||
Future<void> refresh() async {
|
||||
if (_pendingLoad != null) {
|
||||
await _pendingLoad!.future;
|
||||
if (_errorMessage != null) {
|
||||
throw StateError(_errorMessage!);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final completer = Completer<void>();
|
||||
_pendingLoad = completer;
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final menus = await fetchAllPaginatedItems<MenuItem>(
|
||||
request: (page, pageSize) => _repository.list(
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
includeDeleted: false,
|
||||
),
|
||||
);
|
||||
_menus = List.unmodifiable(menus);
|
||||
_initialized = true;
|
||||
_errorMessage = null;
|
||||
completer.complete();
|
||||
} catch (error, stackTrace) {
|
||||
final failure = Failure.from(error);
|
||||
_errorMessage = failure.describe();
|
||||
completer.completeError(error, stackTrace);
|
||||
rethrow;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
_pendingLoad = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 외부에서 최신 메뉴 셋을 주입해 테스트/동기화를 돕는다.
|
||||
void replaceAll(List<MenuItem> menus) {
|
||||
_menus = List.unmodifiable(menus);
|
||||
_initialized = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 특정 코드에 해당하는 메뉴를 조회한다.
|
||||
MenuItem? findByCode(String menuCode) {
|
||||
for (final menu in _menus) {
|
||||
if (menu.menuCode == menuCode) {
|
||||
return menu;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 캐시를 초기 상태로 되돌린다.
|
||||
void reset() {
|
||||
_menus = const [];
|
||||
_initialized = false;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// [MenuCatalog]를 위젯 트리에 노출하는 Inherited 래퍼.
|
||||
class MenuCatalogScope extends InheritedNotifier<MenuCatalog> {
|
||||
const MenuCatalogScope({
|
||||
super.key,
|
||||
required MenuCatalog catalog,
|
||||
required super.child,
|
||||
}) : super(notifier: catalog);
|
||||
|
||||
/// 현재 컨텍스트에서 [MenuCatalog]를 조회한다.
|
||||
static MenuCatalog of(BuildContext context) {
|
||||
final scope = context
|
||||
.dependOnInheritedWidgetOfExactType<MenuCatalogScope>();
|
||||
assert(
|
||||
scope != null,
|
||||
'MenuCatalogScope.of() called with no MenuCatalogScope ancestor.',
|
||||
);
|
||||
return scope!.notifier!;
|
||||
}
|
||||
}
|
||||
263
lib/core/navigation/menu_route_definitions.dart
Normal file
263
lib/core/navigation/menu_route_definitions.dart
Normal file
@@ -0,0 +1,263 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
|
||||
import '../../features/approvals/history/presentation/pages/approval_history_page.dart';
|
||||
import '../../features/approvals/request/presentation/pages/approval_request_page.dart';
|
||||
import '../../features/approvals/step/presentation/pages/approval_step_page.dart';
|
||||
import '../../features/approvals/template/presentation/pages/approval_template_page.dart';
|
||||
import '../../features/dashboard/presentation/pages/dashboard_page.dart';
|
||||
import '../../features/inventory/inbound/presentation/pages/inbound_page.dart';
|
||||
import '../../features/inventory/outbound/presentation/pages/outbound_page.dart';
|
||||
import '../../features/inventory/rental/presentation/pages/rental_page.dart';
|
||||
import '../../features/inventory/summary/presentation/pages/inventory_summary_page.dart';
|
||||
import '../../features/masters/customer/presentation/pages/customer_page.dart';
|
||||
import '../../features/masters/group/presentation/pages/group_page.dart';
|
||||
import '../../features/masters/group_permission/presentation/pages/group_permission_page.dart';
|
||||
import '../../features/masters/menu/presentation/pages/menu_page.dart';
|
||||
import '../../features/masters/product/presentation/pages/product_page.dart';
|
||||
import '../../features/masters/user/presentation/pages/user_page.dart';
|
||||
import '../../features/masters/vendor/presentation/pages/vendor_page.dart';
|
||||
import '../../features/masters/warehouse/presentation/pages/warehouse_page.dart';
|
||||
import '../../features/reporting/presentation/pages/reporting_page.dart';
|
||||
import '../../features/util/postal_search/presentation/pages/postal_search_page.dart';
|
||||
import '../permissions/permission_manager.dart';
|
||||
import '../permissions/permission_resources.dart';
|
||||
import '../routing/auth_guard.dart';
|
||||
import 'route_paths.dart';
|
||||
|
||||
typedef MenuPageBuilder = Widget Function(BuildContext context, GoRouterState state);
|
||||
|
||||
/// 메뉴 코드 ↔ 라우트 정의를 연결하는 매니페스트.
|
||||
class MenuRouteDefinition {
|
||||
const MenuRouteDefinition({
|
||||
required this.menuCode,
|
||||
this.aliases = const {},
|
||||
required this.routePath,
|
||||
required this.defaultLabel,
|
||||
required this.icon,
|
||||
required this.builder,
|
||||
this.defaultOrder = 0,
|
||||
this.extraRequirements = const [],
|
||||
this.showInNavigation = true,
|
||||
});
|
||||
|
||||
final String menuCode;
|
||||
final Set<String> aliases;
|
||||
final String routePath;
|
||||
final String defaultLabel;
|
||||
final IconData icon;
|
||||
final MenuPageBuilder builder;
|
||||
final int defaultOrder;
|
||||
final List<PermissionRequirement> extraRequirements;
|
||||
final bool showInNavigation;
|
||||
|
||||
Iterable<String> get codes sync* {
|
||||
yield menuCode;
|
||||
for (final alias in aliases) {
|
||||
yield alias;
|
||||
}
|
||||
}
|
||||
|
||||
Iterable<PermissionRequirement> get requirements sync* {
|
||||
yield PermissionRequirement(resource: routePath);
|
||||
for (final requirement in extraRequirements) {
|
||||
yield requirement;
|
||||
}
|
||||
}
|
||||
|
||||
bool canAccess(PermissionManager manager) {
|
||||
for (final requirement in requirements) {
|
||||
if (!manager.can(requirement.resource, requirement.action)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
RouteGuard? buildGuard({String fallback = dashboardRoutePath}) {
|
||||
final guards = requirements.toList(growable: false);
|
||||
if (guards.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return AuthGuard.requireAll(requirements: guards, fallback: fallback);
|
||||
}
|
||||
}
|
||||
|
||||
final List<MenuRouteDefinition> menuRouteDefinitions = [
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'dashboard',
|
||||
routePath: dashboardRoutePath,
|
||||
defaultLabel: '대시보드',
|
||||
icon: lucide.LucideIcons.layoutDashboard,
|
||||
builder: (context, state) => const DashboardPage(),
|
||||
defaultOrder: 10,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'inventory.summary',
|
||||
aliases: {'inventory'},
|
||||
routePath: inventorySummaryRoutePath,
|
||||
defaultLabel: '재고 현황',
|
||||
icon: lucide.LucideIcons.chartNoAxesColumnIncreasing,
|
||||
builder: (context, state) => InventorySummaryPage(routeUri: state.uri),
|
||||
defaultOrder: 20,
|
||||
extraRequirements: const [
|
||||
PermissionRequirement(resource: PermissionResources.inventoryScope),
|
||||
],
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'inventory.receipts',
|
||||
aliases: {'inventory.inbound'},
|
||||
routePath: inventoryReceiptsRoutePath,
|
||||
defaultLabel: '입고',
|
||||
icon: lucide.LucideIcons.packagePlus,
|
||||
builder: (context, state) => InboundPage(routeUri: state.uri),
|
||||
defaultOrder: 21,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'inventory.issues',
|
||||
aliases: {'inventory.outbound'},
|
||||
routePath: inventoryIssuesRoutePath,
|
||||
defaultLabel: '출고',
|
||||
icon: lucide.LucideIcons.packageMinus,
|
||||
builder: (context, state) => OutboundPage(routeUri: state.uri),
|
||||
defaultOrder: 22,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'inventory.rentals',
|
||||
routePath: inventoryRentalsRoutePath,
|
||||
defaultLabel: '대여',
|
||||
icon: lucide.LucideIcons.handshake,
|
||||
builder: (context, state) => RentalPage(routeUri: state.uri),
|
||||
defaultOrder: 23,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'inventory.vendors',
|
||||
aliases: {'inventory.manufacturers', 'masters.vendors'},
|
||||
routePath: inventoryVendorsRoutePath,
|
||||
defaultLabel: '제조사 관리',
|
||||
icon: lucide.LucideIcons.factory,
|
||||
builder: (context, state) => VendorPage(routeUri: state.uri),
|
||||
defaultOrder: 30,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'inventory.products',
|
||||
aliases: {'inventory.models', 'masters.products'},
|
||||
routePath: inventoryProductsRoutePath,
|
||||
defaultLabel: '제품 관리',
|
||||
icon: lucide.LucideIcons.box,
|
||||
builder: (context, state) => ProductPage(routeUri: state.uri),
|
||||
defaultOrder: 31,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'inventory.warehouses',
|
||||
aliases: {'masters.warehouses'},
|
||||
routePath: inventoryWarehousesRoutePath,
|
||||
defaultLabel: '입고지 관리',
|
||||
icon: lucide.LucideIcons.warehouse,
|
||||
builder: (context, state) => WarehousePage(routeUri: state.uri),
|
||||
defaultOrder: 32,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'inventory.customers',
|
||||
aliases: {'masters.customers'},
|
||||
routePath: inventoryCustomersRoutePath,
|
||||
defaultLabel: '회사 관리',
|
||||
icon: lucide.LucideIcons.building,
|
||||
builder: (context, state) => CustomerPage(routeUri: state.uri),
|
||||
defaultOrder: 33,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'settings.users',
|
||||
aliases: {'masters.users'},
|
||||
routePath: settingsUsersRoutePath,
|
||||
defaultLabel: '사용자 관리',
|
||||
icon: lucide.LucideIcons.users,
|
||||
builder: (context, state) => const UserPage(),
|
||||
defaultOrder: 40,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'settings.groups',
|
||||
aliases: {'masters.groups'},
|
||||
routePath: settingsGroupsRoutePath,
|
||||
defaultLabel: '그룹 관리',
|
||||
icon: lucide.LucideIcons.layers,
|
||||
builder: (context, state) => const GroupPage(),
|
||||
defaultOrder: 41,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'settings.menus',
|
||||
aliases: {'masters.menus'},
|
||||
routePath: settingsMenusRoutePath,
|
||||
defaultLabel: '메뉴 관리',
|
||||
icon: lucide.LucideIcons.listTree,
|
||||
builder: (context, state) => const MenuPage(),
|
||||
defaultOrder: 42,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'settings.group_permissions',
|
||||
aliases: {'settings.group-permissions', 'masters.group-permissions'},
|
||||
routePath: settingsGroupPermissionsRoutePath,
|
||||
defaultLabel: '그룹 메뉴 권한',
|
||||
icon: lucide.LucideIcons.shieldCheck,
|
||||
builder: (context, state) => const GroupPermissionPage(),
|
||||
defaultOrder: 43,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'approvals.requests',
|
||||
aliases: {'approvals'},
|
||||
routePath: approvalsRequestsRoutePath,
|
||||
defaultLabel: '결재 관리',
|
||||
icon: lucide.LucideIcons.fileCheck,
|
||||
builder: (context, state) => ApprovalRequestPage(routeUri: state.uri),
|
||||
defaultOrder: 50,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'approvals.steps',
|
||||
routePath: approvalsStepsRoutePath,
|
||||
defaultLabel: '결재 단계',
|
||||
icon: lucide.LucideIcons.workflow,
|
||||
builder: (context, state) => const ApprovalStepPage(),
|
||||
defaultOrder: 51,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'approvals.history',
|
||||
routePath: approvalsHistoryRoutePath,
|
||||
defaultLabel: '결재 이력',
|
||||
icon: lucide.LucideIcons.history,
|
||||
builder: (context, state) => const ApprovalHistoryPage(),
|
||||
defaultOrder: 52,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'approvals.templates',
|
||||
routePath: approvalsTemplatesRoutePath,
|
||||
defaultLabel: '결재 템플릿',
|
||||
icon: lucide.LucideIcons.fileSpreadsheet,
|
||||
builder: (context, state) => const ApprovalTemplatePage(),
|
||||
defaultOrder: 53,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'utilities.zipcodes',
|
||||
aliases: {'utilities.postal-search'},
|
||||
routePath: utilitiesPostalSearchRoutePath,
|
||||
defaultLabel: '우편번호 검색',
|
||||
icon: lucide.LucideIcons.search,
|
||||
builder: (context, state) => const PostalSearchPage(),
|
||||
defaultOrder: 60,
|
||||
),
|
||||
MenuRouteDefinition(
|
||||
menuCode: 'reports.overview',
|
||||
aliases: {'reports'},
|
||||
routePath: reportsOverviewRoutePath,
|
||||
defaultLabel: '보고서',
|
||||
icon: lucide.LucideIcons.fileDown,
|
||||
builder: (context, state) => const ReportingPage(),
|
||||
defaultOrder: 70,
|
||||
),
|
||||
];
|
||||
|
||||
/// menu_code → 정의를 빠르게 조회하기 위한 맵.
|
||||
final Map<String, MenuRouteDefinition> menuRouteDefinitionByCode = {
|
||||
for (final definition in menuRouteDefinitions)
|
||||
for (final code in definition.codes) code: definition,
|
||||
};
|
||||
24
lib/core/navigation/route_paths.dart
Normal file
24
lib/core/navigation/route_paths.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
/// 라우트 경로 상수를 한 곳에서 관리한다.
|
||||
///
|
||||
/// - 메뉴/권한/딥링크에서 동일한 경로 문자열을 참조해 불일치를 줄인다.
|
||||
/// - 로그인/대시보드 등 빈번히 참조되는 경로는 별도의 상수로 노출한다.
|
||||
const loginRoutePath = '/login';
|
||||
const dashboardRoutePath = '/dashboard';
|
||||
const inventorySummaryRoutePath = '/inventory/summary';
|
||||
const inventoryReceiptsRoutePath = '/inventory/receipts';
|
||||
const inventoryIssuesRoutePath = '/inventory/issues';
|
||||
const inventoryRentalsRoutePath = '/inventory/rentals';
|
||||
const inventoryVendorsRoutePath = '/inventory/vendors';
|
||||
const inventoryProductsRoutePath = '/inventory/products';
|
||||
const inventoryWarehousesRoutePath = '/inventory/warehouses';
|
||||
const inventoryCustomersRoutePath = '/inventory/customers';
|
||||
const settingsUsersRoutePath = '/settings/users';
|
||||
const settingsGroupsRoutePath = '/settings/groups';
|
||||
const settingsMenusRoutePath = '/settings/menus';
|
||||
const settingsGroupPermissionsRoutePath = '/settings/group-permissions';
|
||||
const approvalsRequestsRoutePath = '/approvals/requests';
|
||||
const approvalsStepsRoutePath = '/approvals/steps';
|
||||
const approvalsHistoryRoutePath = '/approvals/history';
|
||||
const approvalsTemplatesRoutePath = '/approvals/templates';
|
||||
const utilitiesPostalSearchRoutePath = '/utilities/zipcodes';
|
||||
const reportsOverviewRoutePath = '/reports';
|
||||
Reference in New Issue
Block a user