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

@@ -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;
}