Fixed user management
This commit is contained in:
@@ -75,63 +75,66 @@ class AdminUserController {
|
||||
await _client.from('profiles').update({'role': role}).eq('id', userId);
|
||||
}
|
||||
|
||||
/// Password administration — forwarded to the admin Edge Function.
|
||||
// Centralized helper that calls the admin Edge Function and surfaces
|
||||
// a clear error for 401/bad_jwt so the UI can react (sign out/reauth).
|
||||
Future<dynamic> _invokeAdminFunction(
|
||||
String action,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
final accessToken = _client.auth.currentSession?.accessToken;
|
||||
final response = await _client.functions.invoke(
|
||||
'admin_user_management',
|
||||
body: {'action': action, ...payload},
|
||||
headers: accessToken == null
|
||||
? null
|
||||
: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
|
||||
if (response.status == 401) {
|
||||
// If the gateway rejects the JWT, proactively clear the local session
|
||||
// so the app can re-authenticate and obtain a valid Supabase token.
|
||||
try {
|
||||
await _client.auth.signOut();
|
||||
} catch (_) {
|
||||
// ignore sign-out errors
|
||||
}
|
||||
throw Exception(
|
||||
'Unauthorized: invalid or expired session token (bad_jwt)',
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status != 200) {
|
||||
throw Exception(response.data ?? 'Admin request failed');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
Future<void> setPassword({
|
||||
required String userId,
|
||||
required String password,
|
||||
}) async {
|
||||
final payload = {
|
||||
'action': 'set_password',
|
||||
if (password.length < 8) {
|
||||
throw Exception('Password must be at least 8 characters');
|
||||
}
|
||||
|
||||
await _invokeAdminFunction('set_password', {
|
||||
'userId': userId,
|
||||
'password': password,
|
||||
};
|
||||
final accessToken = _client.auth.currentSession?.accessToken;
|
||||
final response = await _client.functions.invoke(
|
||||
'admin_user_management',
|
||||
body: payload,
|
||||
headers: accessToken == null
|
||||
? null
|
||||
: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
if (response.status != 200) {
|
||||
throw Exception(response.data ?? 'Failed to reset password');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Set/unset a user's ban/lock via the admin Edge Function (preferred).
|
||||
Future<void> setLock({required String userId, required bool locked}) async {
|
||||
final payload = {'action': 'set_lock', 'userId': userId, 'locked': locked};
|
||||
final accessToken = _client.auth.currentSession?.accessToken;
|
||||
final response = await _client.functions.invoke(
|
||||
'admin_user_management',
|
||||
body: payload,
|
||||
headers: accessToken == null
|
||||
? null
|
||||
: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
if (response.status != 200) {
|
||||
throw Exception(response.data ?? 'Failed to update lock state');
|
||||
}
|
||||
await _invokeAdminFunction('set_lock', {
|
||||
'userId': userId,
|
||||
'locked': locked,
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch user email + banned state from the admin Edge Function (auth.user).
|
||||
Future<AdminUserStatus> fetchStatus(String userId) async {
|
||||
final payload = {'action': 'get_user', 'userId': userId};
|
||||
final accessToken = _client.auth.currentSession?.accessToken;
|
||||
final response = await _client.functions.invoke(
|
||||
'admin_user_management',
|
||||
body: payload,
|
||||
headers: accessToken == null
|
||||
? null
|
||||
: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
if (response.status != 200) {
|
||||
return AdminUserStatus(email: null, bannedUntil: null);
|
||||
}
|
||||
final data = response.data;
|
||||
final user = (data is Map<String, dynamic>)
|
||||
? (data['user'] as Map<String, dynamic>?)
|
||||
: null;
|
||||
final data = await _invokeAdminFunction('get_user', {'userId': userId});
|
||||
final user =
|
||||
(data as Map<String, dynamic>?)?['user'] as Map<String, dynamic>?;
|
||||
final email = user?['email'] as String?;
|
||||
DateTime? bannedUntil;
|
||||
final bannedRaw = user?['banned_until'];
|
||||
@@ -146,27 +149,15 @@ class AdminUserController {
|
||||
);
|
||||
}
|
||||
|
||||
/// Server-side paginated listing via Edge Function (returns auth + profile light view).
|
||||
Future<List<Map<String, dynamic>>> listUsers(AdminUserQuery q) async {
|
||||
final payload = {
|
||||
'action': 'list_users',
|
||||
final data = await _invokeAdminFunction('list_users', {
|
||||
'offset': q.offset,
|
||||
'limit': q.limit,
|
||||
'searchQuery': q.searchQuery,
|
||||
};
|
||||
final accessToken = _client.auth.currentSession?.accessToken;
|
||||
final response = await _client.functions.invoke(
|
||||
'admin_user_management',
|
||||
body: payload,
|
||||
headers: accessToken == null
|
||||
? null
|
||||
: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
if (response.status != 200) {
|
||||
throw Exception(response.data ?? 'Failed to list users');
|
||||
}
|
||||
final users = (response.data is Map && response.data['users'] is List)
|
||||
? (response.data['users'] as List).cast<Map<String, dynamic>>()
|
||||
});
|
||||
|
||||
final users = (data is Map && data['users'] is List)
|
||||
? (data['users'] as List).cast<Map<String, dynamic>>()
|
||||
: <Map<String, dynamic>>[];
|
||||
return users;
|
||||
}
|
||||
|
||||
@@ -144,14 +144,17 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
class RouterNotifier extends ChangeNotifier {
|
||||
RouterNotifier(this.ref) {
|
||||
_authSub = ref.listen(authStateChangesProvider, (previous, next) {
|
||||
// Enforce app-level profile lock when a session becomes available.
|
||||
next.whenData((authState) {
|
||||
final session = authState.session;
|
||||
if (session != null) {
|
||||
// Fire-and-forget enforcement (best-effort client-side sign-out)
|
||||
enforceLockForCurrentUser(ref.read(supabaseClientProvider));
|
||||
}
|
||||
});
|
||||
// Enforce auth-level ban when a session becomes available.
|
||||
next.maybeWhen(
|
||||
data: (authState) {
|
||||
final session = authState.session;
|
||||
if (session != null) {
|
||||
// Fire-and-forget enforcement (best-effort client-side sign-out)
|
||||
enforceLockForCurrentUser(ref.read(supabaseClientProvider));
|
||||
}
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
notifyListeners();
|
||||
});
|
||||
_profileSub = ref.listen(currentProfileProvider, (previous, next) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../models/profile.dart';
|
||||
import '../../models/ticket_message.dart';
|
||||
import '../../models/user_office.dart';
|
||||
import '../../providers/admin_user_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
@@ -165,7 +166,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
return statusAsync.when(
|
||||
data: (s) => Text(s.email ?? 'Unknown'),
|
||||
loading: () => const Text('Loading...'),
|
||||
error: (_, __) => const Text('Unknown'),
|
||||
error: (error, stack) => const Text('Unknown'),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -190,7 +191,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
return _StatusBadge(label: statusLabel);
|
||||
},
|
||||
loading: () => _StatusBadge(label: 'Loading'),
|
||||
error: (_, __) => _StatusBadge(label: 'Unknown'),
|
||||
error: (error, stack) => _StatusBadge(label: 'Unknown'),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -227,7 +228,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
statusAsync.when(
|
||||
data: (s) => Text('Email: ${s.email ?? 'Unknown'}'),
|
||||
loading: () => const Text('Email: Loading...'),
|
||||
error: (_, __) => const Text('Email: Unknown'),
|
||||
error: (error, stack) => const Text('Email: Unknown'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -235,7 +236,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
data: (s) =>
|
||||
_StatusBadge(label: s.isLocked ? 'Locked' : 'Active'),
|
||||
loading: () => _StatusBadge(label: 'Loading'),
|
||||
error: (_, __) => _StatusBadge(label: 'Unknown'),
|
||||
error: (error, stack) => _StatusBadge(label: 'Unknown'),
|
||||
),
|
||||
onTap: () =>
|
||||
_showUserDialog(context, profile, offices, assignments),
|
||||
@@ -286,6 +287,18 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
.map((assignment) => assignment.officeId)
|
||||
.toSet();
|
||||
|
||||
// Populate dialog-backed state so form fields reflect the selected user.
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_selectedUserId = profile.id;
|
||||
_selectedRole = profile.role;
|
||||
_fullNameController.text = profile.fullName;
|
||||
_selectedOfficeIds
|
||||
..clear()
|
||||
..addAll(currentOfficeIds);
|
||||
});
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
@@ -318,6 +331,17 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Clear the temporary selection state after the dialog is closed so the
|
||||
// next dialog starts from a clean slate.
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_selectedUserId = null;
|
||||
_selectedRole = null;
|
||||
_selectedOfficeIds.clear();
|
||||
_fullNameController.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildUserForm(
|
||||
@@ -363,7 +387,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
'Email: Loading...',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
error: (_, __) => Text(
|
||||
error: (error, stack) => Text(
|
||||
'Email: Unknown',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
@@ -392,7 +416,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
icon: const Icon(Icons.lock),
|
||||
label: const Text('Loading...'),
|
||||
),
|
||||
error: (_, __) => OutlinedButton.icon(
|
||||
error: (error, stack) => OutlinedButton.icon(
|
||||
onPressed: _isSaving
|
||||
? null
|
||||
: () => _toggleLock(profile.id, true),
|
||||
@@ -578,6 +602,22 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
const SnackBar(content: Text('Password updated.')),
|
||||
);
|
||||
} catch (error) {
|
||||
final msg = error.toString();
|
||||
if (msg.contains('Unauthorized') ||
|
||||
msg.contains('bad_jwt') ||
|
||||
msg.contains('expired')) {
|
||||
await ref.read(authControllerProvider).signOut();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Session expired — please sign in again.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Reset failed: $error')),
|
||||
@@ -613,6 +653,20 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
final msg = error.toString();
|
||||
if (msg.contains('Unauthorized') ||
|
||||
msg.contains('bad_jwt') ||
|
||||
msg.contains('expired')) {
|
||||
await ref.read(authControllerProvider).signOut();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Session expired — please sign in again.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
|
||||
Reference in New Issue
Block a user