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