Files
Nils-Johan Gynther 98ee8a3ad6
Test Suite / backend-pr-quick (24.15.0) (push) Has been cancelled
Test Suite / backend-full (24.15.0) (push) Has been cancelled
Test Suite / flutter-quality (push) Has been cancelled
feat: implement real-time database synchronization with SSE and update backend modules
2026-05-12 16:57:05 +02:00

811 lines
27 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:async';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/realtime/realtime_sync.dart';
import '../data/admin_repository.dart';
import '../domain/user_admin.dart';
enum _UserSort { newest, usernameAsc, usernameDesc, roleAdminFirst }
class AdminUsersPanel extends ConsumerStatefulWidget {
final bool embedded;
const AdminUsersPanel({super.key, this.embedded = false});
@override
ConsumerState<AdminUsersPanel> createState() => _AdminUsersPanelState();
}
class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
bool _isLoading = true;
String? _error;
String _search = '';
late final TextEditingController _searchCtrl;
_UserSort _sort = _UserSort.newest;
bool _filterAdminOnly = false;
bool _filterPremiumOnly = false;
bool _filterSharingOffOnly = false;
List<UserAdmin> _users = [];
ProviderSubscription<int>? _realtimeTickSubscription;
Timer? _realtimeDebounce;
String _sortLabel(_UserSort sort) => switch (sort) {
_UserSort.newest => 'Nyast',
_UserSort.usernameAsc => 'Användarnamn A-Ö',
_UserSort.usernameDesc => 'Användarnamn Ö-A',
_UserSort.roleAdminFirst => 'Admin först',
};
List<UserAdmin> get _visibleUsers {
final query = _search.trim().toLowerCase();
final filtered = _users.where((user) {
if (_filterAdminOnly && !user.isAdmin) return false;
if (_filterPremiumOnly && !user.isPremium) return false;
if (_filterSharingOffOnly && user.canShareRecipes) return false;
if (query.isEmpty) return true;
return user.username.toLowerCase().contains(query) ||
user.displayName.toLowerCase().contains(query) ||
user.email.toLowerCase().contains(query) ||
user.id.toString().contains(query);
}).toList();
filtered.sort((a, b) {
switch (_sort) {
case _UserSort.newest:
return b.id.compareTo(a.id);
case _UserSort.usernameAsc:
return a.username.toLowerCase().compareTo(b.username.toLowerCase());
case _UserSort.usernameDesc:
return b.username.toLowerCase().compareTo(a.username.toLowerCase());
case _UserSort.roleAdminFirst:
if (a.isAdmin == b.isAdmin) {
return a.username.toLowerCase().compareTo(b.username.toLowerCase());
}
return a.isAdmin ? -1 : 1;
}
});
return filtered;
}
@override
void initState() {
super.initState();
_searchCtrl = TextEditingController();
_realtimeTickSubscription = ref.listenManual<int>(
realtimeRefreshTickProvider,
(_, __) {
if (!mounted) return;
_realtimeDebounce?.cancel();
_realtimeDebounce = Timer(const Duration(milliseconds: 600), () {
if (!mounted) return;
_load();
});
},
);
_load();
}
@override
void dispose() {
_realtimeDebounce?.cancel();
_realtimeTickSubscription?.close();
_searchCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final users = await ref.read(adminRepositoryProvider).listUsers();
if (!mounted) return;
setState(() => _users = users);
} catch (e) {
if (!mounted) return;
setState(() => _error = mapErrorToUserMessage(e, context));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _changeRole(UserAdmin user) async {
final newRole = user.isAdmin ? 'user' : 'admin';
final confirmed = await _confirm(
context,
context.l10n.adminChangeRole,
context.l10n.adminChangeRoleConfirm(user.username, newRole),
);
if (!confirmed || !mounted) return;
try {
await ref.read(adminRepositoryProvider).setRole(user.id, newRole);
if (!mounted) return;
_load();
} catch (e) {
if (!mounted) return;
_showError(e);
}
}
Future<void> _togglePremium(UserAdmin user) async {
final newValue = !user.isPremium;
final confirmed = await _confirm(
context,
newValue ? context.l10n.adminGivePremium : context.l10n.adminRemovePremium,
context.l10n.adminPremiumConfirm(newValue ? context.l10n.adminGivePremium : context.l10n.adminRemovePremium, user.username),
);
if (!confirmed || !mounted) return;
try {
await ref
.read(adminRepositoryProvider)
.setPremium(user.id, isPremium: newValue);
if (!mounted) return;
_load();
} catch (e) {
if (!mounted) return;
_showError(e);
}
}
Future<void> _toggleRecipeSharing(UserAdmin user) async {
final newValue = !user.canShareRecipes;
final confirmed = await _confirm(
context,
newValue ? context.l10n.adminAllowSharing : context.l10n.adminBlockSharing,
context.l10n.adminSharingConfirm(newValue ? context.l10n.adminAllowSharing : context.l10n.adminBlockSharing, user.username),
);
if (!confirmed || !mounted) return;
try {
await ref
.read(adminRepositoryProvider)
.setRecipeSharing(user.id, canShareRecipes: newValue);
if (!mounted) return;
_load();
} catch (e) {
if (!mounted) return;
_showError(e);
}
}
Future<void> _resetPassword(UserAdmin user) async {
final confirmed = await _confirm(
context,
context.l10n.adminResetPassword,
context.l10n.adminResetPasswordConfirm(user.username),
);
if (!confirmed || !mounted) return;
try {
final result = await ref.read(adminRepositoryProvider).resetPassword(user.id);
if (!mounted) return;
final tempPw = result['temporaryPassword'] as String? ?? '';
await showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: Text(context.l10n.adminTempPasswordTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.adminTempPasswordForUser(user.username)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: SelectableText(
tempPw,
style: const TextStyle(
fontFamily: 'monospace',
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: context.l10n.adminCopyAction,
onPressed: () => Clipboard.setData(
ClipboardData(text: tempPw),
),
),
],
),
const SizedBox(height: 8),
const Text(
'Användaren måste byta lösenord vid nästa inloggning.',
style: TextStyle(fontSize: 12),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.adminCloseAction),
),
],
),
);
} catch (e) {
if (!mounted) return;
_showError(e);
}
}
Future<void> _editEmail(UserAdmin user) async {
final controller = TextEditingController(text: user.email);
try {
final newEmail = await showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.adminEmailEditTitle(user.username)),
content: TextField(
controller: controller,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: context.l10n.adminEmailLabel,
border: const OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, controller.text.trim()),
child: Text(context.l10n.saveAction),
),
],
),
);
if (newEmail == null || newEmail.isEmpty || !mounted) return;
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(newEmail)) {
_showError(context.l10n.adminEmailInvalid);
return;
}
await ref.read(adminRepositoryProvider).updateEmail(user.id, newEmail);
if (!mounted) return;
_load();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.adminEmailUpdated)),
);
} catch (e) {
if (!mounted) return;
_showError(e);
} finally {
controller.dispose();
}
}
Future<void> _deleteUser(UserAdmin user) async {
final confirmed = await _confirm(
context,
context.l10n.adminDeleteUser,
context.l10n.adminDeleteUserConfirm,
destructive: true,
);
if (!confirmed || !mounted) return;
try {
await ref.read(adminRepositoryProvider).deleteUser(user.id);
if (!mounted) return;
_load();
} catch (e) {
if (!mounted) return;
_showError(e);
}
}
Future<void> _createUser() async {
final result = await showDialog<Map<String, String>>(
context: context,
builder: (_) => const _CreateUserDialog(),
);
if (result == null || !mounted) return;
try {
await ref.read(adminRepositoryProvider).createUser(
username: result['username']!,
email: result['email']!,
password: result['password']!,
role: result['role'] ?? 'user',
);
if (!mounted) return;
_load();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.adminUserCreated(result['username']!))),
);
} catch (e) {
if (!mounted) return;
_showError(e);
}
}
void _showError(Object e) {
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
Future<bool> _confirm(
BuildContext ctx,
String title,
String body, {
bool destructive = false,
}) async {
final result = await showDialog<bool>(
context: ctx,
builder: (_) => AlertDialog(
title: Text(title),
content: Text(body),
actions: [
TextButton(
onPressed: () => Navigator.pop(_, false),
child: const Text('Avbryt'),
),
TextButton(
onPressed: () => Navigator.pop(_, true),
style: destructive
? TextButton.styleFrom(
foregroundColor: Theme.of(ctx).colorScheme.error,
)
: null,
child: Text(context.l10n.adminConfirmAction),
),
],
),
);
return result ?? false;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return buildCopyableErrorPanel(
context: context,
message: _error!,
onRetry: _load,
title: 'Kunde inte läsa användare',
);
}
final visibleUsers = _visibleUsers;
if (_users.isEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.embedded) ...[
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Användarkonton', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Här styr du konton, roller, premium och delning. När listan är tom kan du skapa den första användaren direkt.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _createUser,
icon: const Icon(Icons.person_add_outlined),
label: Text(context.l10n.adminNewUser),
),
],
),
),
),
const SizedBox(height: 16),
],
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(context.l10n.adminNoUsers),
),
),
],
);
}
final list = ListView.builder(
shrinkWrap: false,
physics: const AlwaysScrollableScrollPhysics(),
padding: widget.embedded
? EdgeInsets.zero
: const EdgeInsets.fromLTRB(16, 8, 16, 80),
itemCount: visibleUsers.length,
itemBuilder: (ctx, i) => _UserTile(
user: visibleUsers[i],
onChangeRole: () => _changeRole(visibleUsers[i]),
onTogglePremium: () => _togglePremium(visibleUsers[i]),
onToggleRecipeSharing: () => _toggleRecipeSharing(visibleUsers[i]),
onEditEmail: () => _editEmail(visibleUsers[i]),
onResetPassword: () => _resetPassword(visibleUsers[i]),
onDelete: () => _deleteUser(visibleUsers[i]),
),
);
if (!widget.embedded) {
return list;
}
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
FilterChip(
label: const Text('Endast admin'),
selected: _filterAdminOnly,
onSelected: (value) => setState(() => _filterAdminOnly = value),
),
const SizedBox(width: 8),
FilterChip(
label: const Text('Endast premium'),
selected: _filterPremiumOnly,
onSelected: (value) => setState(() => _filterPremiumOnly = value),
),
const SizedBox(width: 8),
FilterChip(
label: const Text('Delning avstängd'),
selected: _filterSharingOffOnly,
onSelected: (value) => setState(() => _filterSharingOffOnly = value),
),
if (_filterAdminOnly || _filterPremiumOnly || _filterSharingOffOnly) ...[
const SizedBox(width: 8),
TextButton.icon(
onPressed: () {
setState(() {
_filterAdminOnly = false;
_filterPremiumOnly = false;
_filterSharingOffOnly = false;
});
},
icon: const Icon(Icons.clear_all),
label: const Text('Rensa filter'),
),
],
],
),
),
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 260,
child: DropdownButtonFormField<_UserSort>(
initialValue: _sort,
decoration: const InputDecoration(
labelText: 'Sortering',
border: OutlineInputBorder(),
isDense: true,
),
items: _UserSort.values
.map(
(value) => DropdownMenuItem<_UserSort>(
value: value,
child: Text(_sortLabel(value)),
),
)
.toList(),
onChanged: (value) {
if (value == null) return;
setState(() => _sort = value);
},
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchCtrl,
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
prefixIcon: Icon(Icons.search),
hintText: 'Sök användare, e-post eller id',
),
onChanged: (value) => setState(() => _search = value),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Uppdatera',
onPressed: _load,
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
'Visar ${visibleUsers.length} av ${_users.length} användare',
style: theme.textTheme.bodySmall,
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _createUser,
icon: const Icon(Icons.person_add_outlined),
label: Text(context.l10n.adminNewUser),
),
],
),
const SizedBox(height: 12),
Expanded(child: list),
],
),
);
}
}
class _UserTile extends StatelessWidget {
final UserAdmin user;
final VoidCallback onChangeRole;
final VoidCallback onTogglePremium;
final VoidCallback onToggleRecipeSharing;
final VoidCallback onEditEmail;
final VoidCallback onResetPassword;
final VoidCallback onDelete;
const _UserTile({
required this.user,
required this.onChangeRole,
required this.onTogglePremium,
required this.onToggleRecipeSharing,
required this.onEditEmail,
required this.onResetPassword,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: user.isAdmin
? theme.colorScheme.primaryContainer
: theme.colorScheme.secondaryContainer,
child: Icon(
user.isAdmin ? Icons.shield_outlined : Icons.person_outline,
color: user.isAdmin
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSecondaryContainer,
size: 20,
),
),
title: Text(user.displayName),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(user.email, style: theme.textTheme.bodySmall),
Wrap(
spacing: 4,
runSpacing: 2,
children: [
ActionChip(
label: Text(user.isAdmin ? 'Admin' : 'User'),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
labelStyle: theme.textTheme.labelSmall,
tooltip: user.isAdmin ? context.l10n.adminDowngradeToUser : context.l10n.adminUpgradeToAdmin,
onPressed: onChangeRole,
),
ActionChip(
label: Text(user.isPremium ? context.l10n.adminPremiumLabel : context.l10n.adminFreeLabel),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
labelStyle: theme.textTheme.labelSmall,
backgroundColor: user.isPremium
? theme.colorScheme.tertiaryContainer
: theme.colorScheme.surfaceContainerHighest,
tooltip: user.isPremium ? context.l10n.adminRemovePremium : context.l10n.adminGivePremium,
onPressed: onTogglePremium,
),
ActionChip(
label: Text(user.canShareRecipes ? context.l10n.adminSharingOn : context.l10n.adminSharingOff),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
labelStyle: theme.textTheme.labelSmall,
backgroundColor: user.canShareRecipes
? theme.colorScheme.secondaryContainer
: theme.colorScheme.errorContainer,
tooltip: user.canShareRecipes ? context.l10n.adminBlockSharing : context.l10n.adminAllowSharing,
onPressed: onToggleRecipeSharing,
),
],
),
],
),
isThreeLine: true,
trailing: PopupMenuButton<String>(
onSelected: (action) {
switch (action) {
case 'role':
onChangeRole();
break;
case 'premium':
onTogglePremium();
break;
case 'sharing':
onToggleRecipeSharing();
break;
case 'email':
onEditEmail();
break;
case 'reset':
onResetPassword();
break;
case 'delete':
onDelete();
break;
}
},
itemBuilder: (_) => [
PopupMenuItem(
value: 'role',
child: Text(
user.isAdmin ? context.l10n.adminDowngradeToUser : context.l10n.adminUpgradeToAdmin,
),
),
PopupMenuItem(
value: 'premium',
child: Text(user.isPremium ? context.l10n.adminRemovePremium : context.l10n.adminGivePremium),
),
PopupMenuItem(
value: 'sharing',
child: Text(
user.canShareRecipes
? context.l10n.adminBlockSharing
: context.l10n.adminAllowSharing,
),
),
PopupMenuItem(
value: 'email',
child: Text(context.l10n.adminEmailAction),
),
PopupMenuItem(
value: 'reset',
child: Text(context.l10n.adminResetPassword),
),
const PopupMenuDivider(),
PopupMenuItem(
value: 'delete',
child: Text(
context.l10n.deleteAction,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
),
),
);
}
}
class _CreateUserDialog extends StatefulWidget {
const _CreateUserDialog();
@override
State<_CreateUserDialog> createState() => _CreateUserDialogState();
}
class _CreateUserDialogState extends State<_CreateUserDialog> {
final _formKey = GlobalKey<FormState>();
final _usernameCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
String _role = 'user';
bool _obscure = true;
@override
void dispose() {
_usernameCtrl.dispose();
_emailCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(context.l10n.adminCreateUserTitle),
content: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Skapa ett nytt konto med tydlig roll direkt från adminvyn. Du väljer bara uppgifter som krävs för inloggning och åtkomst.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
TextFormField(
controller: _usernameCtrl,
decoration: InputDecoration(labelText: context.l10n.profileUsernameLabel),
validator: (v) =>
(v == null || v.length < 2) ? context.l10n.adminMinChars2 : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _emailCtrl,
decoration: InputDecoration(labelText: context.l10n.adminEmailLabel),
keyboardType: TextInputType.emailAddress,
validator: (v) {
if (v == null || v.isEmpty) return context.l10n.required;
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(v)) {
return context.l10n.adminEmailInvalid;
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordCtrl,
decoration: InputDecoration(
labelText: context.l10n.adminPasswordLabel,
suffixIcon: IconButton(
icon: Icon(
_obscure
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
onPressed: () => setState(() => _obscure = !_obscure),
),
),
obscureText: _obscure,
validator: (v) =>
(v == null || v.length < 8) ? context.l10n.adminMinChars8 : null,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _role,
decoration: InputDecoration(labelText: context.l10n.adminRoleLabel),
items: [
DropdownMenuItem(value: 'user', child: Text(context.l10n.adminUserRole)),
DropdownMenuItem(value: 'admin', child: Text(context.l10n.adminAdminRole)),
],
onChanged: (v) => setState(() => _role = v ?? 'user'),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
Navigator.pop(context, {
'username': _usernameCtrl.text.trim(),
'email': _emailCtrl.text.trim(),
'password': _passwordCtrl.text,
'role': _role,
});
}
},
child: Text(context.l10n.adminCreateAction),
),
],
);
}
}