Files
tatlock-ui/lib/features/security/users/users_list_page.dart
T
Jeroen SchweitzerandClaude Opus 4.5 6989241ae7 chore: upgrade to riverpod 3.x and freezed 3.x
- Upgrade flutter_riverpod to 3.1.0, riverpod_annotation to 4.0.0
- Upgrade freezed to 3.2.3, freezed_annotation to 3.1.0
- Migrate freezed classes to use sealed keyword (freezed 3.x)
- Update provider naming (*NotifierProvider → *Provider)
- Add legacy.dart import for StateNotifierProvider compatibility
- Fix valueOrNull → value for AsyncValue
- Remove unused imports and fields
- Add sync from Authentik button to users/groups pages
- Suppress invalid_annotation_target warning in analysis_options

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 00:07:01 +01:00

307 lines
8.9 KiB
Dart

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<String, dynamic> 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<dynamic>?)?.cast<String>() ?? [],
);
final String id;
final String email;
final String name;
final String? avatarUrl;
final DateTime createdAt;
final DateTime? lastLogin;
final List<String> 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<UsersListPage> createState() => _UsersListPageState();
}
class _UsersListPageState extends ConsumerState<UsersListPage> {
late final StateNotifierProvider<DataGridController<UserData>,
DataGridState<UserData>> _gridProvider;
bool _isSyncing = false;
@override
void initState() {
super.initState();
final dio = ref.read(coreApiClientProvider);
final source = CoreApiDataSource<UserData>(
dio: dio,
endpoint: '/auth/users',
fromJson: UserData.fromJson,
);
_gridProvider = dataGridProvider<UserData>(
source: source,
config: _buildConfig(),
idSelector: (u) => u.id,
);
}
Future<void> _syncFromAuthentik() async {
if (_isSyncing) return;
setState(() => _isSyncing = true);
try {
final dio = ref.read(coreApiClientProvider);
await dio.post<void>('/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<UserData> _buildConfig() {
return DataGridConfig<UserData>(
columns: [
DataGridColumn<UserData>(
header: 'User',
valueBuilder: (u) => u.name,
sortable: true,
searchable: true,
width: const DataGridColumnWidth.flex(2),
cellBuilder: (context, u) => _UserCell(user: u),
),
DataGridColumn<UserData>(
header: 'Roles',
valueBuilder: (u) => u.roles.join(', '),
width: const DataGridColumnWidth.flex(1),
cellBuilder: (context, u) => _RolesCell(roles: u.roles),
),
DataGridColumn<UserData>(
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<UserData>(
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<String> 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(),
);
}
}