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; bool _isSyncing = false; @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, ); } Future _syncFromAuthentik() async { if (_isSyncing) return; setState(() => _isSyncing = true); try { final dio = ref.read(coreApiClientProvider); await dio.post('/auth/users/sync-from-authentik'); if (!mounted) return; ref.read(_gridProvider.notifier).refresh(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('Users synced from Authentik'), behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 2), ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Sync failed: $e'), behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 4), ), ); } finally { if (mounted) setState(() => _isSyncing = false); } } 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(), ), const SizedBox(width: 8), FilledButton.icon( onPressed: _isSyncing ? null : _syncFromAuthentik, icon: _isSyncing ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.sync), label: const Text('Sync from Authentik'), ), ], ); } } 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(), ); } }