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:
@@ -1,22 +1,27 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../core/constants/app_sections.dart';
|
||||
import '../core/navigation/menu_route_definitions.dart';
|
||||
import '../core/navigation/route_paths.dart';
|
||||
import '../core/navigation/menu_catalog.dart';
|
||||
import '../core/permissions/permission_manager.dart';
|
||||
import '../core/network/failure.dart';
|
||||
import '../core/theme/theme_controller.dart';
|
||||
import '../core/validation/password_rules.dart';
|
||||
import '../features/auth/application/auth_service.dart';
|
||||
import '../features/auth/domain/entities/auth_session.dart';
|
||||
import '../features/masters/menu/domain/entities/menu.dart';
|
||||
import '../features/masters/user/domain/entities/user.dart';
|
||||
import '../features/masters/user/domain/repositories/user_repository.dart';
|
||||
import 'components/superport_dialog.dart';
|
||||
|
||||
/// 앱 기본 레이아웃을 제공하는 셸 위젯. 사이드 네비게이션과 AppBar를 구성한다.
|
||||
class AppShell extends StatelessWidget {
|
||||
class AppShell extends StatefulWidget {
|
||||
const AppShell({
|
||||
super.key,
|
||||
required this.child,
|
||||
@@ -26,72 +31,211 @@ class AppShell extends StatelessWidget {
|
||||
final Widget child;
|
||||
final String currentLocation;
|
||||
|
||||
@override
|
||||
State<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends State<AppShell> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= 960;
|
||||
final manager = PermissionScope.of(context);
|
||||
final filteredPages = <AppPageDescriptor>[
|
||||
for (final section in appSections)
|
||||
for (final page in section.pages)
|
||||
if (_hasPageAccess(manager, page)) page,
|
||||
];
|
||||
final pages = filteredPages.isEmpty ? allAppPages : filteredPages;
|
||||
final themeController = ThemeControllerScope.of(context);
|
||||
final authService = GetIt.I<AuthService>();
|
||||
final appBar = _GradientAppBar(
|
||||
title: const _BrandTitle(),
|
||||
actions: [
|
||||
_ThemeMenuButton(
|
||||
mode: themeController.mode,
|
||||
onChanged: themeController.update,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_AccountMenuButton(service: authService),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
);
|
||||
|
||||
if (isWide) {
|
||||
return Scaffold(
|
||||
appBar: appBar,
|
||||
body: Row(
|
||||
children: [
|
||||
_NavigationRail(currentLocation: currentLocation, pages: pages),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: child),
|
||||
final catalog = MenuCatalogScope.of(context);
|
||||
return AnimatedBuilder(
|
||||
animation: catalog,
|
||||
builder: (context, _) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= 960;
|
||||
final manager = PermissionScope.of(context);
|
||||
final pages = _resolveNavigationItems(manager, catalog.menus);
|
||||
final themeController = ThemeControllerScope.of(context);
|
||||
final authService = GetIt.I<AuthService>();
|
||||
final appBar = _GradientAppBar(
|
||||
title: const _BrandTitle(),
|
||||
actions: [
|
||||
_ThemeMenuButton(
|
||||
mode: themeController.mode,
|
||||
onChanged: themeController.update,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_AccountMenuButton(service: authService),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: appBar,
|
||||
drawer: Drawer(
|
||||
child: SafeArea(
|
||||
child: _NavigationList(
|
||||
currentLocation: currentLocation,
|
||||
onTap: (path) {
|
||||
Navigator.of(context).pop();
|
||||
context.go(path);
|
||||
},
|
||||
pages: pages,
|
||||
if (isWide) {
|
||||
return Scaffold(
|
||||
appBar: appBar,
|
||||
body: Row(
|
||||
children: [
|
||||
_NavigationRail(
|
||||
currentLocation: widget.currentLocation,
|
||||
pages: pages,
|
||||
isLoading: catalog.isLoading,
|
||||
errorMessage: catalog.errorMessage,
|
||||
onRetry: () => _handleMenuRefresh(catalog),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: widget.child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: appBar,
|
||||
drawer: Drawer(
|
||||
child: SafeArea(
|
||||
child: _NavigationList(
|
||||
currentLocation: widget.currentLocation,
|
||||
onTap: (path) {
|
||||
Navigator.of(context).pop();
|
||||
context.go(path);
|
||||
},
|
||||
pages: pages,
|
||||
isLoading: catalog.isLoading,
|
||||
errorMessage: catalog.errorMessage,
|
||||
onRetry: () => _handleMenuRefresh(catalog),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: child,
|
||||
body: widget.child,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<_NavigationMenuItem> _resolveNavigationItems(
|
||||
PermissionManager manager,
|
||||
List<MenuItem> menus,
|
||||
) {
|
||||
final addedCodes = <String>{};
|
||||
if (menus.isNotEmpty) {
|
||||
final codeToMenu = {for (final menu in menus) menu.menuCode: menu};
|
||||
final sortedMenus = [...menus]
|
||||
..sort((a, b) {
|
||||
final defA = menuRouteDefinitionByCode[a.menuCode];
|
||||
final defB = menuRouteDefinitionByCode[b.menuCode];
|
||||
final aliasFlagA = _aliasPriority(a, defA);
|
||||
final aliasFlagB = _aliasPriority(b, defB);
|
||||
if (aliasFlagA != aliasFlagB) {
|
||||
return aliasFlagA - aliasFlagB;
|
||||
}
|
||||
final orderA = a.displayOrder ?? defA?.defaultOrder ?? 0;
|
||||
final orderB = b.displayOrder ?? defB?.defaultOrder ?? 0;
|
||||
return orderA.compareTo(orderB);
|
||||
});
|
||||
final items = <_NavigationMenuItem>[];
|
||||
for (final menu in sortedMenus) {
|
||||
if (!menu.isActive || menu.isDeleted) {
|
||||
continue;
|
||||
}
|
||||
final definition = menuRouteDefinitionByCode[menu.menuCode];
|
||||
if (definition == null || !definition.showInNavigation) {
|
||||
continue;
|
||||
}
|
||||
final canonicalCode = definition.menuCode;
|
||||
if (addedCodes.contains(canonicalCode)) {
|
||||
continue;
|
||||
}
|
||||
if (!definition.canAccess(manager)) {
|
||||
continue;
|
||||
}
|
||||
addedCodes.add(canonicalCode);
|
||||
final parentCode = menu.parent?.menuCode ?? _parentCode(menu.menuCode);
|
||||
final parentOrder = parentCode != null
|
||||
? codeToMenu[parentCode]?.displayOrder ?? definition.defaultOrder
|
||||
: definition.defaultOrder;
|
||||
final displayOrder = menu.displayOrder ?? definition.defaultOrder;
|
||||
items.add(
|
||||
_NavigationMenuItem(
|
||||
menuCode: canonicalCode,
|
||||
label: menu.menuName,
|
||||
path: definition.routePath,
|
||||
icon: definition.icon,
|
||||
sortOrder: parentOrder * 1000 + displayOrder,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (items.isNotEmpty) {
|
||||
items.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
final fallbackItems = menuRouteDefinitions
|
||||
.where((definition) => definition.showInNavigation)
|
||||
.where((definition) => definition.canAccess(manager))
|
||||
.map(
|
||||
(definition) => _NavigationMenuItem(
|
||||
menuCode: definition.menuCode,
|
||||
label: definition.defaultLabel,
|
||||
path: definition.routePath,
|
||||
icon: definition.icon,
|
||||
sortOrder: definition.defaultOrder,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
fallbackItems.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
|
||||
return fallbackItems;
|
||||
}
|
||||
|
||||
void _handleMenuRefresh(MenuCatalog catalog) {
|
||||
unawaited(
|
||||
catalog.refresh().catchError((error, stackTrace) {
|
||||
final failure = Failure.from(error);
|
||||
debugPrint('메뉴 갱신 실패: ${failure.describe()}');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
int _aliasPriority(MenuItem menu, MenuRouteDefinition? definition) {
|
||||
if (definition == null) {
|
||||
return 2;
|
||||
}
|
||||
return definition.menuCode == menu.menuCode ? 0 : 1;
|
||||
}
|
||||
|
||||
String? _parentCode(String code) {
|
||||
final separatorIndex = code.lastIndexOf('.');
|
||||
if (separatorIndex == -1) {
|
||||
return null;
|
||||
}
|
||||
return code.substring(0, separatorIndex);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavigationMenuItem {
|
||||
const _NavigationMenuItem({
|
||||
required this.menuCode,
|
||||
required this.label,
|
||||
required this.path,
|
||||
required this.icon,
|
||||
required this.sortOrder,
|
||||
});
|
||||
|
||||
final String menuCode;
|
||||
final String label;
|
||||
final String path;
|
||||
final IconData icon;
|
||||
final int sortOrder;
|
||||
}
|
||||
|
||||
class _NavigationRail extends StatelessWidget {
|
||||
const _NavigationRail({required this.currentLocation, required this.pages});
|
||||
const _NavigationRail({
|
||||
required this.currentLocation,
|
||||
required this.pages,
|
||||
required this.isLoading,
|
||||
required this.errorMessage,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final String currentLocation;
|
||||
final List<AppPageDescriptor> pages;
|
||||
final List<_NavigationMenuItem> pages;
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -106,6 +250,9 @@ class _NavigationRail extends StatelessWidget {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
if (isLoading) const LinearProgressIndicator(minHeight: 2),
|
||||
if (errorMessage != null)
|
||||
_NavigationErrorBanner(message: errorMessage!, onRetry: onRetry),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
@@ -177,11 +324,17 @@ class _NavigationList extends StatelessWidget {
|
||||
required this.currentLocation,
|
||||
required this.onTap,
|
||||
required this.pages,
|
||||
required this.isLoading,
|
||||
required this.errorMessage,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final String currentLocation;
|
||||
final ValueChanged<String> onTap;
|
||||
final List<AppPageDescriptor> pages;
|
||||
final List<_NavigationMenuItem> pages;
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -189,9 +342,21 @@ class _NavigationList extends StatelessWidget {
|
||||
final themeController = ThemeControllerScope.of(context);
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: pages.length + 1,
|
||||
itemCount: pages.length + 2,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == pages.length) {
|
||||
if (index == 0) {
|
||||
return Column(
|
||||
children: [
|
||||
if (isLoading) const LinearProgressIndicator(minHeight: 2),
|
||||
if (errorMessage != null)
|
||||
_NavigationErrorBanner(
|
||||
message: errorMessage!,
|
||||
onRetry: onRetry,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (index == pages.length + 1) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: _ThemeMenuButton(
|
||||
@@ -204,16 +369,11 @@ class _NavigationList extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final page = pages[index];
|
||||
final selected = index == selectedIndex;
|
||||
final page = pages[index - 1];
|
||||
final selected = (index - 1) == selectedIndex;
|
||||
return ListTile(
|
||||
leading: Icon(page.icon),
|
||||
title: Text(page.label),
|
||||
subtitle: Text(
|
||||
page.summary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
selected: selected,
|
||||
selectedColor: Theme.of(context).colorScheme.primary,
|
||||
onTap: () => onTap(page.path),
|
||||
@@ -223,6 +383,50 @@ class _NavigationList extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _NavigationErrorBanner extends StatelessWidget {
|
||||
const _NavigationErrorBanner({required this.message, required this.onRetry});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(lucide.LucideIcons.info, size: 18, color: colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
TextButton(onPressed: onRetry, child: const Text('재시도')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BrandTitle extends StatelessWidget {
|
||||
const _BrandTitle();
|
||||
|
||||
@@ -389,7 +593,7 @@ class _ThemeMenuButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
int _selectedIndex(String location, List<AppPageDescriptor> pages) {
|
||||
int _selectedIndex(String location, List<_NavigationMenuItem> pages) {
|
||||
final normalized = location.toLowerCase();
|
||||
final exact = pages.indexWhere(
|
||||
(page) => normalized == page.path.toLowerCase(),
|
||||
@@ -404,19 +608,6 @@ int _selectedIndex(String location, List<AppPageDescriptor> pages) {
|
||||
return prefix == -1 ? 0 : prefix;
|
||||
}
|
||||
|
||||
bool _hasPageAccess(PermissionManager manager, AppPageDescriptor page) {
|
||||
final requirements = <String>{page.path, ...page.extraRequiredResources};
|
||||
for (final resource in requirements) {
|
||||
if (resource.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
if (!manager.can(resource, PermissionAction.view)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 계정 정보를 확인하고 로그아웃을 수행하는 상단바 버튼.
|
||||
class _AccountMenuButton extends StatelessWidget {
|
||||
const _AccountMenuButton({required this.service});
|
||||
|
||||
Reference in New Issue
Block a user