Fixed user management

This commit is contained in:
Marc Rejohn Castillano 2026-02-18 20:43:11 +08:00
parent af6cfe76b4
commit 8c1bb7646e
5 changed files with 360 additions and 77 deletions

View File

@ -75,63 +75,66 @@ class AdminUserController {
await _client.from('profiles').update({'role': role}).eq('id', userId); 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({ Future<void> setPassword({
required String userId, required String userId,
required String password, required String password,
}) async { }) async {
final payload = { if (password.length < 8) {
'action': 'set_password', throw Exception('Password must be at least 8 characters');
}
await _invokeAdminFunction('set_password', {
'userId': userId, 'userId': userId,
'password': password, '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 { Future<void> setLock({required String userId, required bool locked}) async {
final payload = {'action': 'set_lock', 'userId': userId, 'locked': locked}; await _invokeAdminFunction('set_lock', {
final accessToken = _client.auth.currentSession?.accessToken; 'userId': userId,
final response = await _client.functions.invoke( 'locked': locked,
'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');
}
} }
/// Fetch user email + banned state from the admin Edge Function (auth.user).
Future<AdminUserStatus> fetchStatus(String userId) async { Future<AdminUserStatus> fetchStatus(String userId) async {
final payload = {'action': 'get_user', 'userId': userId}; final data = await _invokeAdminFunction('get_user', {'userId': userId});
final accessToken = _client.auth.currentSession?.accessToken; final user =
final response = await _client.functions.invoke( (data as Map<String, dynamic>?)?['user'] as Map<String, dynamic>?;
'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?; final email = user?['email'] as String?;
DateTime? bannedUntil; DateTime? bannedUntil;
final bannedRaw = user?['banned_until']; 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 { Future<List<Map<String, dynamic>>> listUsers(AdminUserQuery q) async {
final payload = { final data = await _invokeAdminFunction('list_users', {
'action': 'list_users',
'offset': q.offset, 'offset': q.offset,
'limit': q.limit, 'limit': q.limit,
'searchQuery': q.searchQuery, 'searchQuery': q.searchQuery,
}; });
final accessToken = _client.auth.currentSession?.accessToken;
final response = await _client.functions.invoke( final users = (data is Map && data['users'] is List)
'admin_user_management', ? (data['users'] as List).cast<Map<String, dynamic>>()
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>>[]; : <Map<String, dynamic>>[];
return users; return users;
} }

View File

@ -144,14 +144,17 @@ final appRouterProvider = Provider<GoRouter>((ref) {
class RouterNotifier extends ChangeNotifier { class RouterNotifier extends ChangeNotifier {
RouterNotifier(this.ref) { RouterNotifier(this.ref) {
_authSub = ref.listen(authStateChangesProvider, (previous, next) { _authSub = ref.listen(authStateChangesProvider, (previous, next) {
// Enforce app-level profile lock when a session becomes available. // Enforce auth-level ban when a session becomes available.
next.whenData((authState) { next.maybeWhen(
data: (authState) {
final session = authState.session; final session = authState.session;
if (session != null) { if (session != null) {
// Fire-and-forget enforcement (best-effort client-side sign-out) // Fire-and-forget enforcement (best-effort client-side sign-out)
enforceLockForCurrentUser(ref.read(supabaseClientProvider)); enforceLockForCurrentUser(ref.read(supabaseClientProvider));
} }
}); },
orElse: () {},
);
notifyListeners(); notifyListeners();
}); });
_profileSub = ref.listen(currentProfileProvider, (previous, next) { _profileSub = ref.listen(currentProfileProvider, (previous, next) {

View File

@ -6,6 +6,7 @@ import '../../models/profile.dart';
import '../../models/ticket_message.dart'; import '../../models/ticket_message.dart';
import '../../models/user_office.dart'; import '../../models/user_office.dart';
import '../../providers/admin_user_provider.dart'; import '../../providers/admin_user_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/profile_provider.dart'; import '../../providers/profile_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
@ -165,7 +166,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
return statusAsync.when( return statusAsync.when(
data: (s) => Text(s.email ?? 'Unknown'), data: (s) => Text(s.email ?? 'Unknown'),
loading: () => const Text('Loading...'), 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); return _StatusBadge(label: statusLabel);
}, },
loading: () => _StatusBadge(label: 'Loading'), 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( statusAsync.when(
data: (s) => Text('Email: ${s.email ?? 'Unknown'}'), data: (s) => Text('Email: ${s.email ?? 'Unknown'}'),
loading: () => const Text('Email: Loading...'), 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) => data: (s) =>
_StatusBadge(label: s.isLocked ? 'Locked' : 'Active'), _StatusBadge(label: s.isLocked ? 'Locked' : 'Active'),
loading: () => _StatusBadge(label: 'Loading'), loading: () => _StatusBadge(label: 'Loading'),
error: (_, __) => _StatusBadge(label: 'Unknown'), error: (error, stack) => _StatusBadge(label: 'Unknown'),
), ),
onTap: () => onTap: () =>
_showUserDialog(context, profile, offices, assignments), _showUserDialog(context, profile, offices, assignments),
@ -286,6 +287,18 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
.map((assignment) => assignment.officeId) .map((assignment) => assignment.officeId)
.toSet(); .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; if (!context.mounted) return;
await showDialog<void>( await showDialog<void>(
context: context, 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( Widget _buildUserForm(
@ -363,7 +387,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
'Email: Loading...', 'Email: Loading...',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
error: (_, __) => Text( error: (error, stack) => Text(
'Email: Unknown', 'Email: Unknown',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
@ -392,7 +416,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
icon: const Icon(Icons.lock), icon: const Icon(Icons.lock),
label: const Text('Loading...'), label: const Text('Loading...'),
), ),
error: (_, __) => OutlinedButton.icon( error: (error, stack) => OutlinedButton.icon(
onPressed: _isSaving onPressed: _isSaving
? null ? null
: () => _toggleLock(profile.id, true), : () => _toggleLock(profile.id, true),
@ -578,6 +602,22 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
const SnackBar(content: Text('Password updated.')), const SnackBar(content: Text('Password updated.')),
); );
} catch (error) { } 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; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Reset failed: $error')), SnackBar(content: Text('Reset failed: $error')),
@ -613,6 +653,20 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
), ),
); );
} catch (error) { } 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; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,

View File

@ -46,8 +46,42 @@ serve(async (req) => {
}); });
const { data: authData, error: authError } = const { data: authData, error: authError } =
await authClient.auth.getUser(); 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) { 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); const adminClient = createClient(supabaseUrl, serviceKey);

View File

@ -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<void> setPassword({
required String userId,
required String password,
}) async {
lastSetPasswordUserId = userId;
lastSetPasswordValue = password;
return Future.value();
}
@override
Future<void> 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<Override> 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<AdminUserStatus, String>(
(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<EditableText>(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<AdminUserStatus, String>(
(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);
});
}