diff --git a/lib/providers/admin_user_provider.dart b/lib/providers/admin_user_provider.dart index eedcc259..8bf7c3d1 100644 --- a/lib/providers/admin_user_provider.dart +++ b/lib/providers/admin_user_provider.dart @@ -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 _invokeAdminFunction( + String action, + Map 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 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 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 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) - ? (data['user'] as Map?) - : null; + final data = await _invokeAdminFunction('get_user', {'userId': userId}); + final user = + (data as Map?)?['user'] as Map?; 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>> 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>() + }); + + final users = (data is Map && data['users'] is List) + ? (data['users'] as List).cast>() : >[]; return users; } diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 26bab73f..e9f17206 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -144,14 +144,17 @@ final appRouterProvider = Provider((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) { diff --git a/lib/screens/admin/user_management_screen.dart b/lib/screens/admin/user_management_screen.dart index 73f643c7..8000d546 100644 --- a/lib/screens/admin/user_management_screen.dart +++ b/lib/screens/admin/user_management_screen.dart @@ -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 { 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 { 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 { 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 { 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 { .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( context: context, @@ -318,6 +331,17 @@ class _UserManagementScreenState extends ConsumerState { ); }, ); + + // 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 { '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 { 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 { 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 { ), ); } 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, diff --git a/supabase/functions/admin_user_management/index.ts b/supabase/functions/admin_user_management/index.ts index 20c52bce..8104e4fa 100644 --- a/supabase/functions/admin_user_management/index.ts +++ b/supabase/functions/admin_user_management/index.ts @@ -46,8 +46,42 @@ serve(async (req) => { }); const { data: authData, error: authError } = await authClient.auth.getUser(); + + // DEBUG: log auth results to help diagnose intermittent 401s from the + // gateway / auth service. Remove these logs before deploying to production. + console.log("admin_user_management: token-snippet", token.slice(0, 16)); + console.log("admin_user_management: authData", JSON.stringify(authData)); + console.log("admin_user_management: authError", JSON.stringify(authError)); + if (authError || !authData?.user) { - return jsonResponse({ error: "Unauthorized" }, 401); + // Extract token header (kid/alg) for debugging without revealing the full + // token. This helps confirm which key the gateway/runtime attempted to + // verify against. + let tokenHeader: unknown = null; + try { + const headerB64 = token.split(".")[0] ?? ""; + tokenHeader = JSON.parse(atob(headerB64)); + } catch (e) { + tokenHeader = { parseError: (e as Error).message }; + } + + return jsonResponse( + { + error: "Unauthorized", + debug: { + authError: authError?.message ?? null, + tokenHeader, + authDataUser: authData?.user + ? { + id: authData.user.id, + email: authData.user.email ?? null, + banned_until: authData.user.banned_until ?? null, + } + : null, + }, + }, + 401, + ); } const adminClient = createClient(supabaseUrl, serviceKey); diff --git a/test/user_management_screen_test.dart b/test/user_management_screen_test.dart new file mode 100644 index 00000000..851704fb --- /dev/null +++ b/test/user_management_screen_test.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'package:tasq/models/office.dart'; +import 'package:tasq/models/profile.dart'; +import 'package:tasq/models/user_office.dart'; +import 'package:tasq/providers/profile_provider.dart'; + +import 'package:tasq/providers/user_offices_provider.dart'; +import 'package:tasq/providers/admin_user_provider.dart'; +import 'package:tasq/providers/tickets_provider.dart'; +import 'package:tasq/screens/admin/user_management_screen.dart'; + +class _FakeAdminController extends AdminUserController { + _FakeAdminController() + : super(SupabaseClient('http://localhost', 'test-key')); + + String? lastSetPasswordUserId; + String? lastSetPasswordValue; + + String? lastSetLockUserId; + bool? lastSetLockedValue; + + @override + Future setPassword({ + required String userId, + required String password, + }) async { + lastSetPasswordUserId = userId; + lastSetPasswordValue = password; + return Future.value(); + } + + @override + Future setLock({required String userId, required bool locked}) async { + lastSetLockUserId = userId; + lastSetLockedValue = locked; + return Future.value(); + } +} + +void main() { + final office = Office(id: 'office-1', name: 'HQ'); + final profile = Profile(id: 'user-1', role: 'admin', fullName: 'Alice Admin'); + + ProviderScope buildApp({required List overrides}) { + return ProviderScope( + overrides: overrides, + child: const MaterialApp(home: UserManagementScreen()), + ); + } + + testWidgets('sanity - basic Text widgets render', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: Text('SANITY'))), + ); + await tester.pump(); + expect(find.text('SANITY'), findsOneWidget); + }); + + testWidgets('Edit dialog pre-fills fields and shows actions', (tester) async { + await tester.pumpWidget( + buildApp( + overrides: [ + currentProfileProvider.overrideWith((ref) => Stream.value(profile)), + profilesProvider.overrideWith((ref) => Stream.value([profile])), + officesProvider.overrideWith((ref) => Stream.value([office])), + userOfficesProvider.overrideWith( + (ref) => Stream.value([ + UserOffice(userId: 'user-1', officeId: 'office-1'), + ]), + ), + ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()), + isAdminProvider.overrideWith((ref) => true), + // Provide an AdminUserStatus so the dialog can show email/status immediately. + adminUserStatusProvider.overrideWithProvider( + FutureProvider.autoDispose.family( + (ref, id) async => AdminUserStatus( + email: 'alice@example.com', + bannedUntil: null, + ), + ), + ), + ], + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + + expect(find.text('Alice Admin'), findsOneWidget); + + // Open edit dialog + await tester.tap(find.text('Alice Admin')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + + // Dialog should show and the Full name TextField should contain the profile name + final alert = find.byType(AlertDialog); + expect(alert, findsOneWidget); + + final editable = find.descendant( + of: alert, + matching: find.byType(EditableText), + ); + expect(editable, findsWidgets); + + // The first EditableText inside the dialog is the Full name field + final fullNameEditable = editable.first; + final et = tester.widget(fullNameEditable); + expect(et.controller.text, equals('Alice Admin')); + + // Role should show selected value + expect( + find.descendant(of: alert, matching: find.text('admin')), + findsWidgets, + ); + + // Reset password and Lock buttons must be visible + expect( + find.descendant( + of: alert, + matching: find.widgetWithText(OutlinedButton, 'Reset password'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: alert, + matching: find.widgetWithText(OutlinedButton, 'Lock'), + ), + findsOneWidget, + ); + }); + + testWidgets('Reset password and lock call admin controller', (tester) async { + final fake = _FakeAdminController(); + + await tester.pumpWidget( + buildApp( + overrides: [ + currentProfileProvider.overrideWith((ref) => Stream.value(profile)), + profilesProvider.overrideWith((ref) => Stream.value([profile])), + officesProvider.overrideWith((ref) => Stream.value([office])), + userOfficesProvider.overrideWith( + (ref) => Stream.value([ + UserOffice(userId: 'user-1', officeId: 'office-1'), + ]), + ), + ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()), + isAdminProvider.overrideWith((ref) => true), + adminUserStatusProvider.overrideWithProvider( + FutureProvider.autoDispose.family( + (ref, id) async => AdminUserStatus( + email: 'alice@example.com', + bannedUntil: null, + ), + ), + ), + adminUserControllerProvider.overrideWithValue(fake), + ], + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + + // Open edit dialog (again) + await tester.tap(find.text('Alice Admin')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + + // Tap Reset password + await tester.tap(find.widgetWithText(OutlinedButton, 'Reset password')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + + // Enter new password in the nested dialog + final pwdField = find.byType(TextFormField).last; + await tester.enterText(pwdField, 'new-pass-123'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + + // Confirm update + await tester.tap(find.text('Update password')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + + expect(fake.lastSetPasswordUserId, equals('user-1')); + expect(fake.lastSetPasswordValue, equals('new-pass-123')); + + // Back in the main dialog - press Lock + await tester.tap(find.widgetWithText(OutlinedButton, 'Lock')); + await tester.pumpAndSettle(); + + expect(fake.lastSetLockUserId, equals('user-1')); + expect(fake.lastSetLockedValue, isTrue); + }); +}