chore: remove unused profile lock migration and admin_profile_service

This commit is contained in:
2026-02-18 18:13:10 +08:00
parent e3afd51410
commit 04fd874a48
18 changed files with 1471 additions and 220 deletions
+98 -55
View File
@@ -22,11 +22,7 @@ class AdminUserQuery {
/// Full text search query.
final String searchQuery;
AdminUserQuery copyWith({
int? offset,
int? limit,
String? searchQuery,
}) {
AdminUserQuery copyWith({int? offset, int? limit, String? searchQuery}) {
return AdminUserQuery(
offset: offset ?? this.offset,
limit: limit ?? this.limit,
@@ -35,7 +31,9 @@ class AdminUserQuery {
}
}
final adminUserQueryProvider = StateProvider<AdminUserQuery>((ref) => const AdminUserQuery());
final adminUserQueryProvider = StateProvider<AdminUserQuery>(
(ref) => const AdminUserQuery(),
);
final adminUserControllerProvider = Provider<AdminUserController>((ref) {
final client = ref.watch(supabaseClientProvider);
@@ -77,66 +75,111 @@ class AdminUserController {
await _client.from('profiles').update({'role': role}).eq('id', userId);
}
/// Password administration — forwarded to the admin Edge Function.
Future<void> setPassword({
required String userId,
required String password,
}) async {
await _invokeAdminFunction(
action: 'set_password',
payload: {'userId': userId, 'password': password},
);
}
Future<void> setLock({required String userId, required bool locked}) async {
await _invokeAdminFunction(
action: 'set_lock',
payload: {'userId': userId, 'locked': locked},
);
}
Future<AdminUserStatus> fetchStatus(String userId) async {
final data = await _invokeAdminFunction(
action: 'get_user',
payload: {'userId': userId},
);
final user = (data as Map<String, dynamic>)['user'] as Map<String, dynamic>;
final bannedUntilRaw = user['banned_until'] as String?;
final bannedUntilParsed = bannedUntilRaw == null
? null
: DateTime.tryParse(bannedUntilRaw);
return AdminUserStatus(
email: user['email'] as String?,
bannedUntil: bannedUntilParsed == null
? null
: AppTime.toAppTime(bannedUntilParsed),
);
}
Future<dynamic> _invokeAdminFunction({
required String action,
required Map<String, dynamic> payload,
}) async {
final payload = {
'action': 'set_password',
'userId': userId,
'password': password,
};
final accessToken = _client.auth.currentSession?.accessToken;
final response = await _client.functions.invoke(
'admin_user_management',
body: {'action': action, ...payload},
body: payload,
headers: accessToken == null
? null
: {'Authorization': 'Bearer $accessToken'},
);
if (response.status != 200) {
throw Exception(_extractErrorMessage(response.data));
throw Exception(response.data ?? 'Failed to reset password');
}
return response.data;
}
String _extractErrorMessage(dynamic data) {
if (data is Map<String, dynamic>) {
final error = data['error'];
if (error is String && error.trim().isNotEmpty) {
return error;
}
final message = data['message'];
if (message is String && message.trim().isNotEmpty) {
return message;
}
/// 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');
}
return 'Admin request failed.';
}
/// 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 email = user?['email'] as String?;
DateTime? bannedUntil;
final bannedRaw = user?['banned_until'];
if (bannedRaw is String) {
bannedUntil = DateTime.tryParse(bannedRaw);
} else if (bannedRaw is DateTime) {
bannedUntil = bannedRaw;
}
return AdminUserStatus(
email: email,
bannedUntil: bannedUntil == null ? null : AppTime.toAppTime(bannedUntil),
);
}
/// 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',
'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>>()
: <Map<String, dynamic>>[];
return users;
}
}
final adminUserStatusProvider = FutureProvider.family
.autoDispose<AdminUserStatus, String>((ref, userId) {
return ref.watch(adminUserControllerProvider).fetchStatus(userId);
});
final adminUsersProvider =
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) {
final q = ref.watch(adminUserQueryProvider);
final ctrl = ref.watch(adminUserControllerProvider);
return ctrl.listUsers(q);
});
+10
View File
@@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart';
import '../providers/profile_provider.dart';
import '../providers/supabase_provider.dart';
import '../utils/lock_enforcer.dart';
import '../screens/auth/login_screen.dart';
import '../screens/auth/signup_screen.dart';
import '../screens/admin/offices_screen.dart';
@@ -142,6 +144,14 @@ 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));
}
});
notifyListeners();
});
_profileSub = ref.listen(currentProfileProvider, (previous, next) {
+103 -161
View File
@@ -10,6 +10,7 @@ import '../../providers/profile_provider.dart';
import '../../providers/tickets_provider.dart';
import '../../theme/app_surfaces.dart';
import '../../providers/user_offices_provider.dart';
import '../../utils/app_time.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart';
@@ -36,14 +37,8 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
String? _selectedUserId;
String? _selectedRole;
Set<String> _selectedOfficeIds = {};
AdminUserStatus? _selectedStatus;
final Set<String> _selectedOfficeIds = {};
bool _isSaving = false;
bool _isStatusLoading = false;
final Map<String, AdminUserStatus> _statusCache = {};
final Set<String> _statusLoading = {};
final Set<String> _statusErrors = {};
Set<String> _prefetchedUserIds = {};
@override
void dispose() {
@@ -106,8 +101,6 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
final assignments = assignmentsAsync.valueOrNull ?? [];
final messages = messagesAsync.valueOrNull ?? [];
_prefetchStatuses(profiles);
final lastActiveByUser = <String, DateTime>{};
for (final message in messages) {
final senderId = message.senderId;
@@ -168,12 +161,12 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
TasQColumn<Profile>(
header: 'Email',
cellBuilder: (context, profile) {
final status = _statusCache[profile.id];
final hasError = _statusErrors.contains(profile.id);
final email = hasError
? 'Unavailable'
: (status?.email ?? 'Unknown');
return Text(email);
final statusAsync = ref.watch(adminUserStatusProvider(profile.id));
return statusAsync.when(
data: (s) => Text(s.email ?? 'Unknown'),
loading: () => const Text('Loading...'),
error: (_, __) => const Text('Unknown'),
);
},
),
TasQColumn<Profile>(
@@ -190,11 +183,15 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
TasQColumn<Profile>(
header: 'Status',
cellBuilder: (context, profile) {
final status = _statusCache[profile.id];
final hasError = _statusErrors.contains(profile.id);
final isLoading = _statusLoading.contains(profile.id);
final statusLabel = _userStatusLabel(status, hasError, isLoading);
return _StatusBadge(label: statusLabel);
final statusAsync = ref.watch(adminUserStatusProvider(profile.id));
return statusAsync.when(
data: (s) {
final statusLabel = s.isLocked ? 'Locked' : 'Active';
return _StatusBadge(label: statusLabel);
},
loading: () => _StatusBadge(label: 'Loading'),
error: (_, __) => _StatusBadge(label: 'Unknown'),
);
},
),
TasQColumn<Profile>(
@@ -209,13 +206,9 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
_showUserDialog(context, profile, offices, assignments),
mobileTileBuilder: (context, profile, actions) {
final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
final status = _statusCache[profile.id];
final hasError = _statusErrors.contains(profile.id);
final isLoading = _statusLoading.contains(profile.id);
final email = hasError ? 'Unavailable' : (status?.email ?? 'Unknown');
final statusAsync = ref.watch(adminUserStatusProvider(profile.id));
final officesAssigned = officeCountByUser[profile.id] ?? 0;
final lastActive = lastActiveByUser[profile.id];
final statusLabel = _userStatusLabel(status, hasError, isLoading);
return Card(
child: ListTile(
@@ -231,10 +224,19 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
Text('Last active: ${_formatLastActiveLabel(lastActive)}'),
const SizedBox(height: 4),
MonoText('ID ${profile.id}'),
Text('Email: $email'),
statusAsync.when(
data: (s) => Text('Email: ${s.email ?? 'Unknown'}'),
loading: () => const Text('Email: Loading...'),
error: (_, __) => const Text('Email: Unknown'),
),
],
),
trailing: _StatusBadge(label: statusLabel),
trailing: statusAsync.when(
data: (s) =>
_StatusBadge(label: s.isLocked ? 'Locked' : 'Active'),
loading: () => _StatusBadge(label: 'Loading'),
error: (_, __) => _StatusBadge(label: 'Unknown'),
),
onTap: () =>
_showUserDialog(context, profile, offices, assignments),
),
@@ -279,7 +281,6 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
List<Office> offices,
List<UserOffice> assignments,
) async {
await _selectUser(profile);
final currentOfficeIds = assignments
.where((assignment) => assignment.userId == profile.id)
.map((assignment) => assignment.officeId)
@@ -319,44 +320,6 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
);
}
void _ensureStatusLoaded(String userId) {
if (_statusCache.containsKey(userId) || _statusLoading.contains(userId)) {
return;
}
_statusLoading.add(userId);
_statusErrors.remove(userId);
ref
.read(adminUserControllerProvider)
.fetchStatus(userId)
.then((status) {
if (!mounted) return;
setState(() {
_statusCache[userId] = status;
_statusLoading.remove(userId);
});
})
.catchError((_) {
if (!mounted) return;
setState(() {
_statusLoading.remove(userId);
_statusErrors.add(userId);
});
});
}
void _prefetchStatuses(List<Profile> profiles) {
final ids = profiles.map((profile) => profile.id).toSet();
final missing = ids.difference(_prefetchedUserIds);
if (missing.isEmpty) return;
_prefetchedUserIds = ids;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
for (final userId in missing) {
_ensureStatusLoaded(userId);
}
});
}
Widget _buildUserForm(
BuildContext context,
Profile profile,
@@ -383,7 +346,67 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
decoration: const InputDecoration(labelText: 'Role'),
),
const SizedBox(height: 12),
_buildStatusRow(profile),
// Email and lock status are retrieved from auth via Edge Function / admin API.
Consumer(
builder: (context, ref, _) {
final statusAsync = ref.watch(adminUserStatusProvider(profile.id));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
statusAsync.when(
data: (s) => Text(
'Email: ${s.email ?? 'Unknown'}',
style: Theme.of(context).textTheme.bodySmall,
),
loading: () => Text(
'Email: Loading...',
style: Theme.of(context).textTheme.bodySmall,
),
error: (_, __) => Text(
'Email: Unknown',
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 8),
Row(
children: [
OutlinedButton.icon(
onPressed: _isSaving
? null
: () => _showPasswordResetDialog(profile.id),
icon: const Icon(Icons.password),
label: const Text('Reset password'),
),
const SizedBox(width: 12),
statusAsync.when(
data: (s) => OutlinedButton.icon(
onPressed: _isSaving
? null
: () => _toggleLock(profile.id, !s.isLocked),
icon: Icon(s.isLocked ? Icons.lock_open : Icons.lock),
label: Text(s.isLocked ? 'Unlock' : 'Lock'),
),
loading: () => OutlinedButton.icon(
onPressed: null,
icon: const Icon(Icons.lock),
label: const Text('Loading...'),
),
error: (_, __) => OutlinedButton.icon(
onPressed: _isSaving
? null
: () => _toggleLock(profile.id, true),
icon: const Icon(Icons.lock),
label: const Text('Lock'),
),
),
],
),
],
);
},
),
const SizedBox(height: 16),
Text('Offices', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
@@ -445,82 +468,6 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
);
}
Widget _buildStatusRow(Profile profile) {
final email = _selectedStatus?.email;
final isLocked = _selectedStatus?.isLocked ?? false;
final lockLabel = isLocked ? 'Unlock' : 'Lock';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Email: ${email ?? 'Loading...'}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
Row(
children: [
OutlinedButton.icon(
onPressed: _isStatusLoading
? null
: () => _showPasswordResetDialog(profile.id),
icon: const Icon(Icons.password),
label: const Text('Reset password'),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: _isStatusLoading
? null
: () => _toggleLock(profile.id, !isLocked),
icon: Icon(isLocked ? Icons.lock_open : Icons.lock),
label: Text(lockLabel),
),
],
),
],
);
}
Future<void> _selectUser(Profile profile) async {
setState(() {
_selectedUserId = profile.id;
_selectedRole = profile.role;
_fullNameController.text = profile.fullName;
_selectedStatus = null;
_isStatusLoading = true;
});
final assignments = ref.read(userOfficesProvider).valueOrNull ?? [];
final officeIds = assignments
.where((assignment) => assignment.userId == profile.id)
.map((assignment) => assignment.officeId)
.toSet();
setState(() => _selectedOfficeIds = officeIds);
try {
final status = await ref
.read(adminUserControllerProvider)
.fetchStatus(profile.id);
if (mounted) {
setState(() {
_selectedStatus = status;
_statusCache[profile.id] = status;
});
}
} catch (_) {
if (mounted) {
setState(
() =>
_selectedStatus = AdminUserStatus(email: null, bannedUntil: null),
);
}
} finally {
if (mounted) {
setState(() => _isStatusLoading = false);
}
}
}
Future<bool> _saveChanges(
BuildContext context,
Profile profile,
@@ -646,18 +593,24 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
}
Future<void> _toggleLock(String userId, bool locked) async {
setState(() => _isStatusLoading = true);
setState(() => _isSaving = true);
try {
// Use AdminUserController (Edge Function or direct DB) to lock/unlock.
await ref
.read(adminUserControllerProvider)
.setLock(userId: userId, locked: locked);
final status = await ref
.read(adminUserControllerProvider)
.fetchStatus(userId);
// Refresh profile streams so other UI updates observe the change.
ref.invalidate(profilesProvider);
ref.invalidate(currentProfileProvider);
if (!mounted) return;
setState(() => _selectedStatus = status);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locked ? 'User locked.' : 'User unlocked.')),
SnackBar(
content: Text(
locked ? 'User locked (app-level).' : 'User unlocked (app-level).',
),
),
);
} catch (error) {
if (!mounted) return;
@@ -666,23 +619,12 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
).showSnackBar(SnackBar(content: Text('Lock update failed: $error')));
} finally {
if (mounted) {
setState(() => _isStatusLoading = false);
setState(() => _isSaving = false);
}
}
}
}
String _userStatusLabel(
AdminUserStatus? status,
bool hasError,
bool isLoading,
) {
if (isLoading) return 'Loading';
if (hasError) return 'Status error';
if (status == null) return 'Unknown';
return status.isLocked ? 'Locked' : 'Active';
}
String _formatLastActiveLabel(DateTime? value) {
if (value == null) return 'N/A';
final now = AppTime.now();
+23
View File
@@ -0,0 +1,23 @@
import 'package:supabase_flutter/supabase_flutter.dart';
/// Call after sign-in and on app start to enforce app-level profile lock.
/// If the user's `profiles.is_locked` flag is true, this signs out the user.
Future<void> enforceLockForCurrentUser(SupabaseClient supabase) async {
final user = supabase.auth.currentUser;
if (user == null) return;
try {
final record = await supabase
.from('profiles')
.select('is_locked')
.eq('id', user.id)
.maybeSingle();
if (record == null) return;
if (record['is_locked'] == true) {
await supabase.auth.signOut();
}
} catch (_) {
// swallow; enforcement is a best-effort client-side check
}
}