feat: user status toggle, temp-pw change, multi-file upload
User Management: - Add is_active column to users table (migration-safe) - Block deactivated users at login with clear error message - Toggle Active/Inactive button replaces Delete in detail modal - Delete button and _DeleteConfirmationDialog fully removed - Status chip added to desktop table and detail modal - Temp password changed from random to username+123 - Create user note now shows the exact password format - Credentials dialog updated with security reminder Documents: - Complete multi-file upload redesign - file_picker allows multiple files (Android + Windows) - Auto-detect document type from filename similarity - Per-file manual dropdown override - Sequential upload with per-file progress bar - Done/error summary banner
This commit is contained in:
1 parent
4a27cf4853
commit
3ed477327c
9 files changed
+918
-743
No files matched your search
@@ -8,6 +8,7 @@ class SystemUser {
|
||||
final String role;
|
||||
final String? email;
|
||||
final String? phone;
|
||||
final bool isActive;
|
||||
final String? createdAt;
|
||||
final String? addedByUsername;
|
||||
final String? addedByName;
|
||||
@@ -22,6 +23,7 @@ class SystemUser {
|
||||
required this.role,
|
||||
this.email,
|
||||
this.phone,
|
||||
this.isActive = true,
|
||||
this.createdAt,
|
||||
this.addedByUsername,
|
||||
this.addedByName,
|
||||
@@ -52,6 +54,7 @@ class SystemUser {
|
||||
role: json['role'] as String,
|
||||
email: json['email'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
isActive: (json['is_active'] as int? ?? 1) == 1,
|
||||
createdAt: json['created_at'] as String?,
|
||||
addedByUsername: json['added_by_username'] as String?,
|
||||
addedByName: json['added_by_name'] as String?,
|
||||
|
||||
@@ -125,4 +125,16 @@ class UserRepository {
|
||||
throw Exception(e.response?.data['message'] ?? 'Failed to delete user.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> toggleStatus(int id) async {
|
||||
try {
|
||||
final options = await _getAuthOptions();
|
||||
final response = await _dio.put('/users/$id/status', options: options);
|
||||
return (response.data['is_active'] as int) == 1;
|
||||
} on DioException catch (e) {
|
||||
throw Exception(
|
||||
e.response?.data['message'] ?? 'Failed to update user status.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,4 +116,16 @@ class UsersNotifier extends AsyncNotifier<List<SystemUser>> {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggles is_active for a user. Returns the new active state.
|
||||
Future<bool> toggleStatus(int id) async {
|
||||
try {
|
||||
final newActive =
|
||||
await ref.read(userRepositoryProvider).toggleStatus(id);
|
||||
await refresh();
|
||||
return newActive;
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -120,40 +120,24 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(SystemUser user) async {
|
||||
// Show our new custom delete dialog that collects Reason and Password
|
||||
final result = await showDialog<Map<String, String>>(
|
||||
context: context,
|
||||
builder: (ctx) => _DeleteConfirmationDialog(user: user),
|
||||
);
|
||||
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
Future<void> _toggleStatus(SystemUser user) async {
|
||||
try {
|
||||
// ✅ Note: You will need to update `usersProvider.deleteUser` to accept these new parameters!
|
||||
await ref
|
||||
final newActive = await ref
|
||||
.read(usersProvider.notifier)
|
||||
.deleteUser(
|
||||
user.id,
|
||||
reason: result['reason']!,
|
||||
password: result['password']!,
|
||||
);
|
||||
|
||||
.toggleStatus(user.id);
|
||||
if (!mounted) return;
|
||||
|
||||
// ✅ Replaced SnackBar with Success Dialog
|
||||
showSuccessDialog(
|
||||
context,
|
||||
title: 'User Deleted',
|
||||
message: 'User "${user.username}" was permanently deleted.',
|
||||
title: newActive ? 'User Activated' : 'User Deactivated',
|
||||
message: newActive
|
||||
? '"${user.username}" can now log in to the system.'
|
||||
: '"${user.username}" has been deactivated and cannot log in.',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
||||
// ✅ Replaced SnackBar with Error Dialog
|
||||
showErrorDialog(
|
||||
context,
|
||||
'Deletion Failed',
|
||||
'Status Change Failed',
|
||||
e.toString().replaceAll('Exception: ', ''),
|
||||
);
|
||||
}
|
||||
@@ -293,15 +277,18 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
||||
),
|
||||
if (user.id != currentUser?.id)
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.delete_outline, size: 16),
|
||||
label: const Text('Delete'),
|
||||
icon: Icon(
|
||||
user.isActive ? Icons.block : Icons.check_circle_outline,
|
||||
size: 16,
|
||||
),
|
||||
label: Text(user.isActive ? 'Deactivate' : 'Activate'),
|
||||
onPressed: () {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
_confirmDelete(user);
|
||||
_toggleStatus(user);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
side: const BorderSide(color: Colors.red),
|
||||
foregroundColor: user.isActive ? Colors.red : AppColors.primaryGreen,
|
||||
side: BorderSide(color: user.isActive ? Colors.red : AppColors.primaryGreen),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
@@ -341,6 +328,12 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
||||
'Phone',
|
||||
user.phone?.isNotEmpty == true ? user.phone! : '—',
|
||||
),
|
||||
// Status row
|
||||
const SizedBox(height: AppSizes.p16),
|
||||
_detailRow(
|
||||
'Account Status',
|
||||
user.isActive ? 'Active' : 'Inactive',
|
||||
),
|
||||
const SizedBox(height: AppSizes.p16),
|
||||
_detailRow(
|
||||
'Added By',
|
||||
@@ -727,6 +720,7 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
||||
DataColumn(label: Text('User')),
|
||||
DataColumn(label: Text('Username')),
|
||||
DataColumn(label: Text('Access Role')),
|
||||
DataColumn(label: Text('Status')),
|
||||
DataColumn(label: Text('Contact')),
|
||||
DataColumn(label: SizedBox.shrink()), // Trailing icon
|
||||
],
|
||||
@@ -739,6 +733,7 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
||||
DataCell(Text('')),
|
||||
DataCell(Text('')),
|
||||
DataCell(Text('')),
|
||||
DataCell(Text('')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -784,6 +779,7 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
||||
),
|
||||
),
|
||||
DataCell(_buildRoleChip(user.role)),
|
||||
DataCell(_buildStatusChip(user.isActive)),
|
||||
DataCell(
|
||||
Text(
|
||||
user.email ?? user.phone ?? '—',
|
||||
@@ -935,6 +931,26 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusChip(bool isActive) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? Colors.green.withValues(alpha: 0.1)
|
||||
: Colors.grey.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
isActive ? 'Active' : 'Inactive',
|
||||
style: TextStyle(
|
||||
color: isActive ? Colors.green.shade700 : Colors.grey.shade600,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _roleColor(String role) {
|
||||
if (role == 'admin') return Colors.blue;
|
||||
return AppColors.primaryGreen;
|
||||
@@ -1107,7 +1123,7 @@ class _AddEditUserModalState extends ConsumerState<AddEditUserModal> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'⚠️ Copy and share these credentials now. The temporary password will not be shown again.',
|
||||
'⚠️ Save these credentials now. The temporary password will not be shown again.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.orange,
|
||||
@@ -1115,6 +1131,15 @@ class _AddEditUserModalState extends ConsumerState<AddEditUserModal> {
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'🔒 For best protection, remind the user to change their password after first login.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_credentialRow('Username', username),
|
||||
const SizedBox(height: 10),
|
||||
@@ -1511,21 +1536,38 @@ class _AddEditUserModalState extends ConsumerState<AddEditUserModal> {
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_awesome,
|
||||
const Icon(
|
||||
Icons.info_outline,
|
||||
color: AppColors.primaryGreen,
|
||||
size: 16,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'A secure temporary password will be auto-generated and shown once after creation.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.primaryGreen,
|
||||
fontWeight: FontWeight.w600,
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.primaryGreen,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(
|
||||
text: 'Temporary password will be ',
|
||||
),
|
||||
TextSpan(
|
||||
text: '${_usernameCtrl.text.trim().isEmpty ? '<username>' : _usernameCtrl.text.trim()}123',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const TextSpan(
|
||||
text: '. Remind the user to change it after first login.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1741,146 +1783,6 @@ class _AddEditUserModalState extends ConsumerState<AddEditUserModal> {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NEW: CUSTOM DELETE CONFIRMATION DIALOG
|
||||
// ============================================================
|
||||
class _DeleteConfirmationDialog extends StatefulWidget {
|
||||
final SystemUser user;
|
||||
const _DeleteConfirmationDialog({required this.user});
|
||||
|
||||
@override
|
||||
State<_DeleteConfirmationDialog> createState() =>
|
||||
_DeleteConfirmationDialogState();
|
||||
}
|
||||
|
||||
class _DeleteConfirmationDialogState extends State<_DeleteConfirmationDialog> {
|
||||
final _reasonCtrl = TextEditingController();
|
||||
final _passwordCtrl = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_reasonCtrl.dispose();
|
||||
_passwordCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final reason = _reasonCtrl.text.trim();
|
||||
final password = _passwordCtrl.text;
|
||||
|
||||
if (reason.isEmpty || password.isEmpty) {
|
||||
showErrorDialog(
|
||||
context,
|
||||
'Missing Fields',
|
||||
'You must provide a reason and confirm your password to delete a user.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Return the collected reason and password back to the _confirmDelete handler
|
||||
Navigator.pop(context, {'reason': reason, 'password': password});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppSizes.radiusLarge),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSizes.p24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.delete_forever, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Delete @${widget.user.username}?',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'This action is irreversible. For security auditing, please provide a reason and confirm using your admin password.',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||
),
|
||||
const Divider(height: 28),
|
||||
|
||||
// 1. Reason Field
|
||||
CustomTextField(
|
||||
hintText: 'Reason for deletion',
|
||||
prefixIcon: Icons.warning_amber_rounded,
|
||||
controller: _reasonCtrl,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 2. Admin Password Field
|
||||
CustomTextField(
|
||||
hintText: 'Your Admin Password',
|
||||
prefixIcon: Icons.lock_outline,
|
||||
controller: _passwordCtrl,
|
||||
isPassword: true,
|
||||
obscureText: _obscurePassword,
|
||||
onToggleVisibility: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('CANCEL'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSizes.radiusMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: _submit,
|
||||
child: const Text(
|
||||
'DELETE',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CUSTOM RESET PASSWORD CONFIRMATION DIALOG
|
||||
// ============================================================
|
||||
|
||||
Reference in new issue
Block a user