diff --git a/lib/features/control_room/router.dart b/lib/features/control_room/router.dart index 4aa1712..808941c 100644 --- a/lib/features/control_room/router.dart +++ b/lib/features/control_room/router.dart @@ -16,10 +16,6 @@ abstract class ControlRoomRoutes { static const redirections = '/control-room/redirections'; static const streams = '/control-room/streams'; static const certificates = '/control-room/certificates'; - // Authentik - static const users = '/control-room/users'; - static const groups = '/control-room/groups'; - static const applications = '/control-room/applications'; } /// Control Room navigation items with section grouping. @@ -33,11 +29,7 @@ enum ControlRoomNav { proxyHosts('proxy-hosts', 'Proxy Hosts', Icons.public, 'NPM'), redirections('redirections', 'Redirections', Icons.alt_route, 'NPM'), streams('streams', 'Streams', Icons.stream, 'NPM'), - certificates('certificates', 'SSL Certificates', Icons.verified_user, 'NPM'), - // Authentik section - users('users', 'Users', Icons.people, 'Authentik'), - groups('groups', 'Groups', Icons.group_work, 'Authentik'), - applications('applications', 'Applications', Icons.apps, 'Authentik'); + certificates('certificates', 'SSL Certificates', Icons.verified_user, 'NPM'); const ControlRoomNav(this.id, this.label, this.icon, this.section); diff --git a/lib/features/security/groups/groups_list_page.dart b/lib/features/security/groups/groups_list_page.dart new file mode 100644 index 0000000..af0a9cd --- /dev/null +++ b/lib/features/security/groups/groups_list_page.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tatlock_ui/core/api/api_client.dart'; +import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart'; +import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart'; + +/// Simple group data class for display. +class GroupData { + GroupData({ + required this.id, + required this.name, + required this.isSuperuser, + required this.memberCount, + this.parentName, + }); + + factory GroupData.fromJson(Map json) => GroupData( + id: json['id'] as String, + name: json['name'] as String, + isSuperuser: json['is_superuser'] as bool? ?? false, + memberCount: json['member_count'] as int? ?? 0, + parentName: json['parent_name'] as String?, + ); + + final String id; + final String name; + final bool isSuperuser; + final int memberCount; + final String? parentName; +} + +/// Groups list page using the shared DataGrid component. +class GroupsListPage extends ConsumerStatefulWidget { + const GroupsListPage({super.key}); + + @override + ConsumerState createState() => _GroupsListPageState(); +} + +class _GroupsListPageState extends ConsumerState { + late final StateNotifierProvider, + DataGridState> _gridProvider; + + @override + void initState() { + super.initState(); + // Create provider in initState to ensure stable reference + final dio = ref.read(coreApiClientProvider); + final source = CoreApiDataSource( + dio: dio, + endpoint: '/auth/groups', + fromJson: GroupData.fromJson, + ); + + _gridProvider = dataGridProvider( + source: source, + config: _buildConfig(), + idSelector: (g) => g.id, + ); + } + + DataGridConfig _buildConfig() { + return DataGridConfig( + columns: [ + DataGridColumn( + header: 'Name', + valueBuilder: (g) => g.name, + sortable: true, + searchable: true, + width: const DataGridColumnWidth.flex(2), + ), + DataGridColumn( + header: 'Members', + valueBuilder: (g) => g.memberCount.toString(), + width: const DataGridColumnWidth.fixed(100), + alignment: DataGridColumnAlignment.end, + ), + DataGridColumn( + header: 'Type', + valueBuilder: (g) => g.isSuperuser ? 'Superuser' : 'Standard', + cellBuilder: (context, g) => _GroupTypeBadge(isSuperuser: g.isSuperuser), + width: const DataGridColumnWidth.fixed(120), + ), + DataGridColumn( + header: 'Parent', + valueBuilder: (g) => g.parentName ?? '-', + width: const DataGridColumnWidth.flex(1), + ), + ], + enableSearch: true, + searchHint: 'Search groups...', + showHeader: true, + showFooter: true, + ); + } + + @override + Widget build(BuildContext context) { + return DataGrid( + provider: _gridProvider, + config: _buildConfig(), + idSelector: (g) => g.id, + toolbarActions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + onPressed: () => ref.read(_gridProvider.notifier).refresh(), + ), + ], + ); + } +} + +class _GroupTypeBadge extends StatelessWidget { + const _GroupTypeBadge({required this.isSuperuser}); + + final bool isSuperuser; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isSuperuser + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + isSuperuser ? 'Superuser' : 'Standard', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: isSuperuser + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + ); + } +} diff --git a/lib/features/security/presentation/pages/security_page.dart b/lib/features/security/presentation/pages/security_page.dart new file mode 100644 index 0000000..903d90d --- /dev/null +++ b/lib/features/security/presentation/pages/security_page.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tatlock_ui/features/security/router.dart'; +import 'package:tatlock_ui/features/security/users/users_list_page.dart'; +import 'package:tatlock_ui/features/security/groups/groups_list_page.dart'; +import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart'; + +/// Main Security room page with nav panel and section content. +class SecurityPage extends ConsumerWidget { + const SecurityPage({ + super.key, + this.nav = SecurityNav.users, + }); + + /// The current nav item to display. + final SecurityNav nav; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + body: Row( + children: [ + // Nav Panel - section navigation with grouping + NavPanel( + title: 'Sections', + icon: Icons.security_outlined, + items: SecurityNav.values.map((n) => n.toNavItem()).toList(), + selectedId: nav.id, + onItemSelected: (id) { + final newNav = SecurityNav.values.firstWhere( + (n) => n.id == id, + ); + // Navigate to section URL + context.go(pathForSecurityNav(newNav)); + }, + ), + // Divider + VerticalDivider( + width: 1, + thickness: 1, + color: colorScheme.outlineVariant, + ), + // Section content + Expanded( + child: _SectionContent(nav: nav), + ), + ], + ), + ); + } +} + +/// Renders content for the selected nav item. +class _SectionContent extends StatelessWidget { + const _SectionContent({required this.nav}); + + final SecurityNav nav; + + @override + Widget build(BuildContext context) { + switch (nav) { + case SecurityNav.users: + return const UsersListPage(); + case SecurityNav.groups: + return const GroupsListPage(); + default: + return _PlaceholderSection(nav: nav); + } + } +} + +/// Placeholder for nav items not yet implemented. +class _PlaceholderSection extends StatelessWidget { + const _PlaceholderSection({required this.nav}); + + final SecurityNav nav; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + nav.icon, + size: 64, + color: colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + nav.label, + style: textTheme.headlineSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + '${nav.section} • Coming soon', + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.outline, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/security/router.dart b/lib/features/security/router.dart new file mode 100644 index 0000000..40e91af --- /dev/null +++ b/lib/features/security/router.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tatlock_ui/features/security/presentation/pages/security_page.dart'; +import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart'; + +/// Route paths for Security room. +abstract class SecurityRoutes { + static const base = '/security'; + // User Management (Authentik) + static const users = '/security/users'; + static const groups = '/security/groups'; + // Monitoring (future) + static const cveMonitor = '/security/cve-monitor'; + static const networkMonitor = '/security/network-monitor'; + static const dnsAdblock = '/security/dns-adblock'; +} + +/// Security room navigation items with section grouping. +enum SecurityNav { + // User Management section (moved from Control Room) + users('users', 'Users', Icons.people, 'User Management'), + groups('groups', 'Groups', Icons.group_work, 'User Management'), + // Monitoring section (placeholders for future) + cveMonitor('cve-monitor', 'CVE Monitor', Icons.security, 'Monitoring'), + networkMonitor('network-monitor', 'Network Monitor', Icons.router, 'Monitoring'), + dnsAdblock('dns-adblock', 'DNS / Adblock', Icons.dns, 'Monitoring'); + + const SecurityNav(this.id, this.label, this.icon, this.section); + + final String id; + final String label; + final IconData icon; + final String section; + + NavItem toNavItem() => NavItem( + id: id, + label: label, + icon: icon, + section: section, + ); + + /// Get route path for this nav item. + String get path => '/security/$id'; +} + +/// Get route path for a nav item. +String pathForSecurityNav(SecurityNav nav) => nav.path; + +/// Security room routes for go_router. +List securityRoutes() { + return [ + // Redirect /security to /security/users + GoRoute( + path: SecurityRoutes.base, + name: 'security', + redirect: (context, state) => SecurityRoutes.users, + ), + // Generate routes for all nav items + for (final nav in SecurityNav.values) + GoRoute( + path: nav.path, + name: 'security${_capitalize(nav.id.replaceAll('-', '_'))}', + builder: (context, state) => SecurityPage(nav: nav), + ), + ]; +} + +String _capitalize(String s) { + if (s.isEmpty) return s; + return s + .split('_') + .map((part) => part[0].toUpperCase() + part.substring(1)) + .join(''); +} diff --git a/lib/features/security/users/users_list_page.dart b/lib/features/security/users/users_list_page.dart new file mode 100644 index 0000000..5e531cc --- /dev/null +++ b/lib/features/security/users/users_list_page.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tatlock_ui/core/api/api_client.dart'; +import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart'; +import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart'; + +/// Simple user data class for display. +class UserData { + UserData({ + required this.id, + required this.email, + required this.name, + this.avatarUrl, + required this.createdAt, + this.lastLogin, + this.roles = const [], + }); + + factory UserData.fromJson(Map json) => UserData( + id: json['id'] as String, + email: json['email'] as String, + name: json['name'] as String, + avatarUrl: json['avatar_url'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + lastLogin: json['last_login'] != null + ? DateTime.parse(json['last_login'] as String) + : null, + roles: (json['roles'] as List?)?.cast() ?? [], + ); + + final String id; + final String email; + final String name; + final String? avatarUrl; + final DateTime createdAt; + final DateTime? lastLogin; + final List roles; + + bool get isAdmin => roles.any((r) => r.endsWith(':admin')); + + String get initials { + final parts = name.split(' '); + if (parts.length >= 2) { + return '${parts.first[0]}${parts.last[0]}'.toUpperCase(); + } + return name.substring(0, name.length.clamp(0, 2)).toUpperCase(); + } + + String get lastLoginFormatted { + if (lastLogin == null) return 'Never'; + final diff = DateTime.now().difference(lastLogin!); + if (diff.inDays > 30) { + return '${lastLogin!.day}/${lastLogin!.month}/${lastLogin!.year}'; + } + if (diff.inDays > 0) return '${diff.inDays}d ago'; + if (diff.inHours > 0) return '${diff.inHours}h ago'; + if (diff.inMinutes > 0) return '${diff.inMinutes}m ago'; + return 'Just now'; + } +} + +/// Users list page using the shared DataGrid component. +class UsersListPage extends ConsumerStatefulWidget { + const UsersListPage({super.key}); + + @override + ConsumerState createState() => _UsersListPageState(); +} + +class _UsersListPageState extends ConsumerState { + late final StateNotifierProvider, + DataGridState> _gridProvider; + + @override + void initState() { + super.initState(); + final dio = ref.read(coreApiClientProvider); + final source = CoreApiDataSource( + dio: dio, + endpoint: '/auth/users', + fromJson: UserData.fromJson, + ); + + _gridProvider = dataGridProvider( + source: source, + config: _buildConfig(), + idSelector: (u) => u.id, + ); + } + + DataGridConfig _buildConfig() { + return DataGridConfig( + columns: [ + DataGridColumn( + header: 'User', + valueBuilder: (u) => u.name, + sortable: true, + searchable: true, + width: const DataGridColumnWidth.flex(2), + cellBuilder: (context, u) => _UserCell(user: u), + ), + DataGridColumn( + header: 'Roles', + valueBuilder: (u) => u.roles.join(', '), + width: const DataGridColumnWidth.flex(1), + cellBuilder: (context, u) => _RolesCell(roles: u.roles), + ), + DataGridColumn( + header: 'Last Login', + valueBuilder: (u) => u.lastLoginFormatted, + width: const DataGridColumnWidth.fixed(120), + alignment: DataGridColumnAlignment.end, + ), + ], + enableSearch: true, + searchHint: 'Search users...', + showHeader: true, + showFooter: true, + ); + } + + @override + Widget build(BuildContext context) { + return DataGrid( + provider: _gridProvider, + config: _buildConfig(), + idSelector: (u) => u.id, + toolbarActions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + onPressed: () => ref.read(_gridProvider.notifier).refresh(), + ), + ], + ); + } +} + +class _UserCell extends StatelessWidget { + const _UserCell({required this.user}); + + final UserData user; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Row( + children: [ + CircleAvatar( + radius: 16, + backgroundColor: colorScheme.primaryContainer, + backgroundImage: + user.avatarUrl != null ? NetworkImage(user.avatarUrl!) : null, + child: user.avatarUrl == null + ? Text( + user.initials, + style: TextStyle( + fontSize: 12, + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w500, + ), + ) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: Text( + user.name, + style: const TextStyle(fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + if (user.isAdmin) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Admin', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: colorScheme.onPrimaryContainer, + ), + ), + ), + ], + ], + ), + Text( + user.email, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ); + } +} + +class _RolesCell extends StatelessWidget { + const _RolesCell({required this.roles}); + + final List roles; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + if (roles.isEmpty) { + return Text( + 'No roles', + style: TextStyle( + fontSize: 12, + color: colorScheme.outline, + fontStyle: FontStyle.italic, + ), + ); + } + + return Wrap( + spacing: 4, + runSpacing: 4, + children: roles.take(3).map((role) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + role.split(':').last, + style: TextStyle( + fontSize: 10, + color: colorScheme.onSurfaceVariant, + ), + ), + ); + }).toList(), + ); + } +} diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 986e7b4..19f0119 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:tatlock_ui/features/control_room/router.dart'; import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart'; +import 'package:tatlock_ui/features/security/router.dart'; import 'package:tatlock_ui/shared/layouts/app_scaffold.dart'; part 'app_router.g.dart'; @@ -30,6 +31,7 @@ GoRouter appRouter(AppRouterRef ref) { builder: (context, state) => const FrontHallPage(), ), ...controlRoomRoutes(), + ...securityRoutes(), GoRoute( path: AppRoutes.parlor, name: 'parlor', diff --git a/lib/shared/layouts/app_scaffold.dart b/lib/shared/layouts/app_scaffold.dart index 95a8bc8..df1f34b 100644 --- a/lib/shared/layouts/app_scaffold.dart +++ b/lib/shared/layouts/app_scaffold.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:tatlock_ui/features/control_room/router.dart'; +import 'package:tatlock_ui/features/security/router.dart'; import 'package:tatlock_ui/routing/app_router.dart'; import 'package:tatlock_ui/shared/layouts/widgets/top_header_bar.dart'; @@ -54,7 +55,8 @@ class AppScaffold extends StatelessWidget { final location = GoRouterState.of(context).matchedLocation; if (location.startsWith(ControlRoomRoutes.base)) return 1; - if (location.startsWith(AppRoutes.parlor)) return 2; + if (location.startsWith(SecurityRoutes.base)) return 2; + if (location.startsWith(AppRoutes.parlor)) return 3; // Settings is no longer in main nav (accessed via Profile dropdown) return 0; // Front Hall } @@ -63,7 +65,8 @@ class AppScaffold extends StatelessWidget { final route = switch (index) { 0 => AppRoutes.frontHall, 1 => ControlRoomRoutes.containers, - 2 => AppRoutes.parlor, + 2 => SecurityRoutes.users, + 3 => AppRoutes.parlor, _ => AppRoutes.frontHall, }; context.go(route); diff --git a/lib/shared/layouts/widgets/top_header_bar.dart b/lib/shared/layouts/widgets/top_header_bar.dart index 6170f3e..e5f6530 100644 --- a/lib/shared/layouts/widgets/top_header_bar.dart +++ b/lib/shared/layouts/widgets/top_header_bar.dart @@ -34,6 +34,11 @@ class TopHeaderBar extends StatelessWidget { selectedIcon: Icons.dns, label: 'Control Room', ), + _RoomDestination( + icon: Icons.security_outlined, + selectedIcon: Icons.security, + label: 'Security', + ), _RoomDestination( icon: Icons.lightbulb_outline, selectedIcon: Icons.lightbulb,