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

@@ -106,7 +106,7 @@ class Environment {
static bool hasPermission(String resource, String action) {
final actions = _permissions[resource.toLowerCase()];
if (actions == null || actions.isEmpty) {
return true;
return false;
}
if (actions.contains('all')) {
// all 키워드는 모든 액션 허용을 의미한다.
@@ -114,4 +114,19 @@ class Environment {
}
return actions.contains(action.toLowerCase());
}
/// 테스트에서 환경 권한 맵을 직접 오버라이드하기 위한 헬퍼.
@visibleForTesting
static void setTestPermissions(Map<String, Set<String>> permissions) {
_permissions
..clear()
..addAll(
permissions.map(
(key, value) => MapEntry(
key.toLowerCase(),
value.map((action) => action.toLowerCase()).toSet(),
),
),
);
}
}

View File

@@ -1,196 +0,0 @@
import 'package:flutter/widgets.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import '../permissions/permission_resources.dart';
/// 사이드바/내비게이션용 페이지 정보.
class AppPageDescriptor {
const AppPageDescriptor({
required this.path,
required this.label,
required this.icon,
required this.summary,
this.extraRequiredResources = const [],
});
final String path;
final String label;
final IconData icon;
final String summary;
final List<String> extraRequiredResources;
}
/// 메뉴 섹션을 나타내는 데이터 클래스.
class AppSectionDescriptor {
const AppSectionDescriptor({required this.label, required this.pages});
final String label;
final List<AppPageDescriptor> pages;
}
/// 로그인 라우트 경로.
const loginRoutePath = '/login';
/// 대시보드 라우트 경로.
const dashboardRoutePath = '/dashboard';
/// 재고 현황 라우트 경로.
const inventorySummaryRoutePath = '/inventory/summary';
/// 네비게이션 구성을 정의한 섹션 목록.
const appSections = <AppSectionDescriptor>[
AppSectionDescriptor(
label: '대시보드',
pages: [
AppPageDescriptor(
path: dashboardRoutePath,
label: '대시보드',
icon: lucide.LucideIcons.layoutDashboard,
summary: '오늘 입고/출고, 결재 대기, 최근 트랜잭션을 한 화면에서 확인합니다.',
),
],
),
AppSectionDescriptor(
label: '재고',
pages: [
AppPageDescriptor(
path: inventorySummaryRoutePath,
label: '재고 현황',
icon: lucide.LucideIcons.chartNoAxesColumnIncreasing,
summary: '제품별 총 재고, 창고 잔량, 최근 이벤트를 한 화면에서 확인합니다.',
extraRequiredResources: [PermissionResources.inventoryScope],
),
],
),
AppSectionDescriptor(
label: '입·출고',
pages: [
AppPageDescriptor(
path: '/inventory/inbound',
label: '입고',
icon: lucide.LucideIcons.packagePlus,
summary: '입고 처리 기본정보와 라인 품목을 등록하고 검토합니다.',
),
AppPageDescriptor(
path: '/inventory/outbound',
label: '출고',
icon: lucide.LucideIcons.packageMinus,
summary: '출고 품목, 고객사 연결, 상태 변경을 관리합니다.',
),
AppPageDescriptor(
path: '/inventory/rental',
label: '대여',
icon: lucide.LucideIcons.handshake,
summary: '대여/반납 구분과 반납예정일을 포함한 대여 흐름입니다.',
),
],
),
AppSectionDescriptor(
label: '마스터',
pages: [
AppPageDescriptor(
path: '/masters/vendors',
label: '제조사 관리',
icon: lucide.LucideIcons.factory,
summary: '벤더코드, 명칭, 사용여부 등을 유지합니다.',
),
AppPageDescriptor(
path: '/masters/products',
label: '장비 모델 관리',
icon: lucide.LucideIcons.box,
summary: '제품코드, 제조사, 단위 정보를 관리합니다.',
),
AppPageDescriptor(
path: '/masters/warehouses',
label: '입고지 관리',
icon: lucide.LucideIcons.warehouse,
summary: '창고 주소와 사용여부를 설정합니다.',
),
AppPageDescriptor(
path: '/masters/customers',
label: '회사 관리',
icon: lucide.LucideIcons.building,
summary: '고객사 연락처와 주소 정보를 관리합니다.',
),
AppPageDescriptor(
path: '/masters/users',
label: '사용자 관리',
icon: lucide.LucideIcons.users,
summary: '사번, 그룹, 사용여부를 관리합니다.',
),
AppPageDescriptor(
path: '/masters/groups',
label: '그룹 관리',
icon: lucide.LucideIcons.layers,
summary: '권한 그룹과 설명, 기본여부를 정의합니다.',
),
AppPageDescriptor(
path: '/masters/menus',
label: '메뉴 관리',
icon: lucide.LucideIcons.listTree,
summary: '메뉴 계층과 경로, 노출 순서를 구성합니다.',
),
AppPageDescriptor(
path: '/masters/group-permissions',
label: '그룹 메뉴 권한',
icon: lucide.LucideIcons.shieldCheck,
summary: '그룹별 메뉴 CRUD 권한을 설정합니다.',
),
],
),
AppSectionDescriptor(
label: '결재',
pages: [
AppPageDescriptor(
path: '/approvals/requests',
label: '결재 관리',
icon: lucide.LucideIcons.fileCheck,
summary: '결재 번호, 상태, 상신자를 관리합니다.',
),
AppPageDescriptor(
path: '/approvals/steps',
label: '결재 단계',
icon: lucide.LucideIcons.workflow,
summary: '단계 순서와 승인자 할당을 설정합니다.',
),
AppPageDescriptor(
path: '/approvals/history',
label: '결재 이력',
icon: lucide.LucideIcons.history,
summary: '결재 단계별 변경 이력을 조회합니다.',
),
AppPageDescriptor(
path: '/approvals/templates',
label: '결재 템플릿',
icon: lucide.LucideIcons.fileSpreadsheet,
summary: '반복되는 결재 흐름을 템플릿으로 관리합니다.',
),
],
),
AppSectionDescriptor(
label: '도구',
pages: [
AppPageDescriptor(
path: '/utilities/postal-search',
label: '우편번호 검색',
icon: lucide.LucideIcons.search,
summary: '모달 기반 우편번호 검색 도구입니다.',
),
],
),
AppSectionDescriptor(
label: '보고',
pages: [
AppPageDescriptor(
path: '/reports',
label: '보고서',
icon: lucide.LucideIcons.fileDown,
summary: '조건 필터와 PDF/XLSX 다운로드 기능입니다.',
),
],
),
];
List<AppPageDescriptor> get allAppPages => [
for (final section in appSections) ...section.pages,
];

View 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!;
}
}

View 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,
};

View 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';

View File

@@ -1,3 +1,4 @@
import 'package:superport_v2/core/navigation/menu_catalog.dart';
import 'package:superport_v2/core/permissions/permission_manager.dart';
import '../../features/auth/domain/entities/auth_session.dart';
@@ -12,13 +13,16 @@ class PermissionBootstrapper {
required PermissionManager manager,
required GroupRepository groupRepository,
required GroupPermissionRepository groupPermissionRepository,
MenuCatalog? menuCatalog,
}) : _manager = manager,
_groupRepository = groupRepository,
_groupPermissionRepository = groupPermissionRepository;
_groupPermissionRepository = groupPermissionRepository,
_menuCatalog = menuCatalog;
final PermissionManager _manager;
final GroupRepository _groupRepository;
final GroupPermissionRepository _groupPermissionRepository;
final MenuCatalog? _menuCatalog;
/// 세션의 권한 목록과 그룹 권한을 적용한다.
Future<void> apply(AuthSession session) async {
@@ -71,6 +75,7 @@ class PermissionBootstrapper {
final synchronizer = PermissionSynchronizer(
repository: _groupPermissionRepository,
manager: _manager,
menuCatalog: _menuCatalog,
);
await synchronizer.syncForGroup(targetGroupId);
}
@@ -85,6 +90,7 @@ class PermissionBootstrapper {
final synchronizer = PermissionSynchronizer(
repository: _groupPermissionRepository,
manager: _manager,
menuCatalog: _menuCatalog,
);
return synchronizer.fetchPermissionMap(targetGroupId);
}

View File

@@ -45,7 +45,13 @@ class PermissionManager extends ChangeNotifier {
return false;
}
return Environment.hasPermission(key, action.name);
// 서버/오버라이드 권한이 없으면 기본적으로 거부하고,
// .env에 명시된 PERMISSION__ 항목만 허용한다.
final fallbackAllowed = Environment.hasPermission(key, action.name);
if (!fallbackAllowed) {
return false;
}
return true;
}
/// 개발/테스트 환경에서 사용할 임시 오버라이드 값을 설정한다.

View File

@@ -33,6 +33,9 @@ class PermissionResources {
'/inventory/inbound': stockTransactions,
'/inventory/outbound': stockTransactions,
'/inventory/rental': stockTransactions,
'/inventory/rentals': stockTransactions,
'/inventory/receipts': stockTransactions,
'/inventory/issues': stockTransactions,
'/approvals/requests': approvals,
'/approvals': approvals,
'/approvals/steps': approvalSteps,
@@ -45,22 +48,33 @@ class PermissionResources {
'/approval-templates': approvalTemplates,
'/inventory/summary': inventorySummary,
'/masters/group-permissions': groupMenuPermissions,
'/settings/group-permissions': groupMenuPermissions,
'/group-menu-permissions': groupMenuPermissions,
'/masters/vendors': vendors,
'/inventory/vendors': vendors,
'/inventory/manufacturers': vendors,
'/vendors': vendors,
'/masters/products': products,
'/inventory/products': products,
'/inventory/models': products,
'/products': products,
'/masters/warehouses': warehouses,
'/inventory/warehouses': warehouses,
'/warehouses': warehouses,
'/masters/customers': customers,
'/inventory/customers': customers,
'/customers': customers,
'/masters/users': users,
'/settings/users': users,
'/users': users,
'/masters/groups': groups,
'/settings/groups': groups,
'/groups': groups,
'/masters/menus': menus,
'/settings/menus': menus,
'/menus': menus,
'/utilities/postal-search': postalSearch,
'/utilities/zipcodes': postalSearch,
'/zipcodes': postalSearch,
'/reports': reports,
'/reports/transactions': reportsTransactions,

View File

@@ -2,32 +2,11 @@ import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import '../../features/approvals/history/presentation/pages/approval_history_page.dart';
import '../../features/auth/application/auth_service.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/login/presentation/pages/login_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 '../../widgets/app_shell.dart';
import '../constants/app_sections.dart';
import '../permissions/permission_manager.dart';
import '../permissions/permission_resources.dart';
import 'auth_guard.dart';
import '../navigation/menu_route_definitions.dart';
import '../navigation/route_paths.dart';
/// 전역 네비게이터 키(로그인/셸 라우터 공용).
final _rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root');
@@ -63,131 +42,13 @@ final appRouter = GoRouter(
builder: (context, state, child) =>
AppShell(currentLocation: state.uri.toString(), child: child),
routes: [
GoRoute(
path: dashboardRoutePath,
name: 'dashboard',
builder: (context, state) => const DashboardPage(),
),
GoRoute(
path: inventorySummaryRoutePath,
name: 'inventory-summary',
redirect: (context, state) {
if (!AuthGuard.can(inventorySummaryRoutePath)) {
return dashboardRoutePath;
}
if (!AuthGuard.can(PermissionResources.inventoryScope)) {
return dashboardRoutePath;
}
return null;
},
builder: (context, state) =>
InventorySummaryPage(routeUri: state.uri),
),
GoRoute(
path: '/inventory/inbound',
name: 'inventory-inbound',
builder: (context, state) => InboundPage(routeUri: state.uri),
),
GoRoute(
path: '/inventory/outbound',
name: 'inventory-outbound',
builder: (context, state) => OutboundPage(routeUri: state.uri),
),
GoRoute(
path: '/inventory/rental',
name: 'inventory-rental',
builder: (context, state) => RentalPage(routeUri: state.uri),
),
GoRoute(
path: '/masters/vendors',
name: 'masters-vendors',
builder: (context, state) => VendorPage(routeUri: state.uri),
),
GoRoute(
path: '/masters/products',
name: 'masters-products',
builder: (context, state) => ProductPage(routeUri: state.uri),
),
GoRoute(
path: '/masters/warehouses',
name: 'masters-warehouses',
builder: (context, state) => WarehousePage(routeUri: state.uri),
),
GoRoute(
path: '/masters/customers',
name: 'masters-customers',
builder: (context, state) => CustomerPage(routeUri: state.uri),
),
GoRoute(
path: '/masters/users',
name: 'masters-users',
builder: (context, state) => const UserPage(),
),
GoRoute(
path: '/masters/groups',
name: 'masters-groups',
builder: (context, state) => const GroupPage(),
),
GoRoute(
path: '/masters/menus',
name: 'masters-menus',
builder: (context, state) => const MenuPage(),
),
GoRoute(
path: '/masters/group-permissions',
name: 'masters-group-permissions',
builder: (context, state) => const GroupPermissionPage(),
),
GoRoute(
path: '/approvals/requests',
name: 'approvals-requests',
redirect: AuthGuard.require(
resource: '/approvals/requests',
action: PermissionAction.view,
fallback: dashboardRoutePath,
for (final definition in menuRouteDefinitions)
GoRoute(
path: definition.routePath,
name: definition.menuCode,
redirect: definition.buildGuard(),
builder: definition.builder,
),
builder: (context, state) => ApprovalRequestPage(routeUri: state.uri),
),
GoRoute(
path: '/approvals/steps',
name: 'approvals-steps',
redirect: AuthGuard.require(
resource: '/approvals/steps',
action: PermissionAction.view,
fallback: dashboardRoutePath,
),
builder: (context, state) => const ApprovalStepPage(),
),
GoRoute(
path: '/approvals/history',
name: 'approvals-history',
redirect: AuthGuard.require(
resource: '/approvals/history',
action: PermissionAction.view,
fallback: dashboardRoutePath,
),
builder: (context, state) => const ApprovalHistoryPage(),
),
GoRoute(
path: '/approvals/templates',
name: 'approvals-templates',
redirect: AuthGuard.require(
resource: '/approvals/templates',
action: PermissionAction.view,
fallback: dashboardRoutePath,
),
builder: (context, state) => const ApprovalTemplatePage(),
),
GoRoute(
path: '/utilities/postal-search',
name: 'utilities-postal-search',
builder: (context, state) => const PostalSearchPage(),
),
GoRoute(
path: '/reports',
name: 'reports',
builder: (context, state) => const ReportingPage(),
),
],
),
],

View File

@@ -27,13 +27,46 @@ class AuthGuard {
PermissionAction action = PermissionAction.view,
required String fallback,
}) {
return requireAll(
requirements: [
PermissionRequirement(resource: resource, action: action),
],
fallback: fallback,
);
}
/// 여러 권한 요구사항을 모두 만족해야 통과시키는 가드를 생성한다.
static RouteGuard requireAll({
required Iterable<PermissionRequirement> requirements,
required String fallback,
}) {
final guards = requirements.toList(growable: false);
if (guards.isEmpty) {
return (context, state) => null;
}
return (context, state) {
if (!GetIt.I.isRegistered<PermissionManager>()) {
return null;
}
final manager = GetIt.I<PermissionManager>();
final allowed = manager.can(resource, action);
return allowed ? null : fallback;
for (final requirement in guards) {
final allowed = manager.can(requirement.resource, requirement.action);
if (!allowed) {
return fallback;
}
}
return null;
};
}
}
/// 라우트 접근 시 필요한 권한 정보를 표현한다.
class PermissionRequirement {
const PermissionRequirement({
required this.resource,
this.action = PermissionAction.view,
});
final String resource;
final PermissionAction action;
}