diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-22 16:21:11 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-22 16:21:11 +0300 |
| commit | b4a200fa07a5c659661a2a695c6cfe57c70e3ab1 (patch) | |
| tree | 6ba75f6b86fd9916abbeeda58c1e0f47a9832937 /player-android | |
| parent | ee99ef26a45cbcf7595bf77bc315644d59473f30 (diff) | |
Implement AdminUsersScreen with create/delete user and admin gating (db)
- Add AdminUsersScreen with user list, create dialog, delete confirmation,
and optimistic UI (revert on error) for both create and delete operations.
- Add currentUserProvider (FutureProvider) to resolve the logged-in User
object from token storage + listUsers, used for self-delete gating and
Settings admin section visibility.
- Gate Admin section (Manage Users tile) in SettingsScreen behind
currentUserProvider → isAdmin, providing defence-in-depth alongside
server-side 403 enforcement.
- Add adminUserErrorMessage to error_mappers.dart with 400/403/409 handling.
- Add adminUsers route constant (AppRoutes.adminUsers) and GoRouter entry.
- Add 25 tests in admin_users_screen_test.dart and 3 admin-section tests
in settings_screen_test.dart (397 tests total pass).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android')
| -rw-r--r-- | player-android/lib/app_routes.dart | 6 | ||||
| -rw-r--r-- | player-android/lib/providers/current_user_provider.dart | 43 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 9 | ||||
| -rw-r--r-- | player-android/lib/screens/admin_users_screen.dart | 619 | ||||
| -rw-r--r-- | player-android/lib/screens/settings_screen.dart | 37 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 33 | ||||
| -rw-r--r-- | player-android/test/screens/admin_users_screen_test.dart | 704 | ||||
| -rw-r--r-- | player-android/test/screens/settings_screen_test.dart | 75 |
8 files changed, 1520 insertions, 6 deletions
diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart index c1f350e..4d31fbd 100644 --- a/player-android/lib/app_routes.dart +++ b/player-android/lib/app_routes.dart @@ -115,4 +115,10 @@ abstract final class AppRoutes { if (setName == null || setName.isEmpty) return base; return '$base?name=${Uri.encodeComponent(setName)}'; } + + /// Route for the admin user management screen. + /// + /// Only accessible when the authenticated user has admin privileges. + /// Shows a list of all registered users and allows creating/deleting accounts. + static const adminUsers = '/admin/users'; } diff --git a/player-android/lib/providers/current_user_provider.dart b/player-android/lib/providers/current_user_provider.dart new file mode 100644 index 0000000..560feb0 --- /dev/null +++ b/player-android/lib/providers/current_user_provider.dart @@ -0,0 +1,43 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/player_api_client.dart'; +import '../models/models.dart'; +import 'api_client_provider.dart'; + +/// Provides the currently authenticated [User] object. +/// +/// Calls [PlayerApiClient.login] is not used here — instead, the logged-in +/// user is fetched lazily via [listUsers] or derived from the auth context. +/// Since the server does not expose a "GET /api/v1/auth/me" endpoint, we +/// obtain the current user by calling [listUsers] and matching against the +/// stored username token. +/// +/// The provider is autoDispose so it is released when no screen is watching it, +/// and keepAlive is not used — a fresh fetch is acceptable when navigating back. +/// +/// Returns null when the user list cannot be fetched or the username is not +/// found among the registered users (e.g. during a race with logout). +final currentUserProvider = FutureProvider.autoDispose<User?>((ref) async { + // Obtain the stored username from token storage (same source as the settings + // screen's _currentUsernameProvider) to identify which user is logged in. + final storage = ref.watch(tokenStorageProvider); + final username = await storage.readToken(); + if (username == null || username.isEmpty) return null; + + // Fetch the full user list to resolve the user object for the stored username. + // This is the only way to get the User with isAdmin flag since there is no + // dedicated /api/v1/auth/me endpoint. Admin-only screens already require + // admin status so this round-trip is acceptable at navigation time. + final client = ref.watch(apiClientProvider); + try { + final users = await client.listUsers(); + return users.firstWhere( + (u) => u.username == username, + orElse: () => User(id: 0, username: username, isAdmin: false), + ); + } catch (_) { + // If listUsers fails (e.g. non-admin user, network error) fall back to a + // minimal user object with isAdmin=false so gating logic fails safely. + return User(id: 0, username: username, isAdmin: false); + } +}); diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index c838a8b..4c60d8d 100644 --- a/player-android/lib/router.dart +++ b/player-android/lib/router.dart @@ -20,6 +20,7 @@ import 'screens/share_screen.dart'; import 'screens/share_viewer_screen.dart'; import 'screens/my_shares_screen.dart'; import 'screens/notes_editor_screen.dart'; +import 'screens/admin_users_screen.dart'; import 'screens/folder_browser_screen.dart'; import 'screens/video_player_screen.dart'; @@ -226,6 +227,14 @@ final routerProvider = Provider<GoRouter>((ref) { builder: (context, state) => const MySharesScreen(), ), GoRoute( + // Admin user management — lists, creates, and deletes user accounts. + // Only reachable from the Settings screen when the current user is admin. + // The server enforces admin-only access independently, so non-admin users + // who somehow reach this route will receive 403 responses. + path: AppRoutes.adminUsers, + builder: (context, state) => const AdminUsersScreen(), + ), + GoRoute( // Folder browser — shows subfolders and media at the current path // within a set. The ':setId' path segment identifies the set; // the optional 'path' query parameter identifies the current subfolder diff --git a/player-android/lib/screens/admin_users_screen.dart b/player-android/lib/screens/admin_users_screen.dart new file mode 100644 index 0000000..89d1918 --- /dev/null +++ b/player-android/lib/screens/admin_users_screen.dart @@ -0,0 +1,619 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/models.dart'; +import '../providers/api_client_provider.dart'; +import '../providers/current_user_provider.dart'; +import '../utils/error_mappers.dart'; + +/// Admin-only screen for managing registered user accounts. +/// +/// Design notes: +/// - Only accessible to admin users; the Settings screen gates the nav entry +/// on [currentUserProvider] → [User.isAdmin]. +/// - A generation counter prevents stale async loads: if the user triggers +/// a refresh while a previous load is still in flight, the old result is +/// silently discarded when it arrives. +/// - Create and delete use optimistic UI: the list is mutated locally first, +/// then the API call is made. On error the mutation is reverted and a +/// SnackBar reports the problem. +/// - The current user's own row omits the delete action to prevent +/// self-deletion (the server also rejects it with 400, but we hide the +/// button to make the restriction obvious in the UI). +/// - All async continuations guard on [mounted] to prevent setState / context +/// calls after widget disposal. +class AdminUsersScreen extends ConsumerStatefulWidget { + const AdminUsersScreen({super.key}); + + @override + ConsumerState<AdminUsersScreen> createState() => _AdminUsersScreenState(); +} + +class _AdminUsersScreenState extends ConsumerState<AdminUsersScreen> { + // Null while the initial load is in-flight; non-null (possibly empty) after + // the first successful fetch. + List<User>? _users; + + // Non-null when the last load attempt failed. + String? _error; + + // True while a load is in flight (initial or refresh). + bool _isLoading = false; + + // Generation counter: incremented on every load call. Async completions + // compare against the current generation and discard stale results. + int _generation = 0; + + @override + void initState() { + super.initState(); + // Defer until after first frame so provider overrides in tests are applied. + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + // --------------------------------------------------------------------------- + // Data loading + // --------------------------------------------------------------------------- + + /// Fetches the full user list and updates local state. + /// + /// Increments [_generation] so results from a previous in-flight request are + /// silently discarded if they arrive after a newer request has started. + Future<void> _load() async { + if (!mounted) return; + final generation = ++_generation; + + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final users = await ref.read(apiClientProvider).listUsers(); + if (!mounted || generation != _generation) return; + setState(() { + _users = users; + _isLoading = false; + }); + } catch (e) { + if (!mounted || generation != _generation) return; + setState(() { + _error = adminUserErrorMessage(e); + _isLoading = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Create user action + // --------------------------------------------------------------------------- + + /// Opens the create-user dialog and submits if the user confirms. + /// + /// Uses optimistic UI: the new user row is appended to [_users] immediately, + /// then the real API response replaces it (or reverts on error). + Future<void> _showCreateDialog() async { + final result = await showDialog<_CreateUserInput>( + context: context, + builder: (_) => const _CreateUserDialog(), + ); + if (result == null || !mounted) return; + + // Optimistic placeholder: id=0 will be replaced by the real server response. + final placeholder = User( + id: 0, + username: result.username, + isAdmin: result.isAdmin, + ); + setState(() => _users = [...?_users, placeholder]); + + try { + final created = await ref.read(apiClientProvider).createUser( + username: result.username, + password: result.password, + isAdmin: result.isAdmin, + ); + if (!mounted) return; + // Replace the placeholder with the real user returned by the server. + setState(() { + _users = _users!.map((u) => u == placeholder ? created : u).toList(); + }); + } catch (e) { + if (!mounted) return; + // Revert optimistic insertion on error. + setState(() => _users = _users!.where((u) => u != placeholder).toList()); + _showError(adminUserErrorMessage(e)); + } + } + + // --------------------------------------------------------------------------- + // Delete user action + // --------------------------------------------------------------------------- + + /// Shows a confirmation dialog, then deletes [user] if confirmed. + /// + /// Optimistically removes the row first; reverts on error. + Future<void> _deleteUser(User user, int index) async { + final confirmed = await _confirmDelete(user.username); + if (!confirmed || !mounted) return; + + // Optimistic removal. + setState(() => _users!.removeAt(index)); + + try { + await ref.read(apiClientProvider).deleteUser(user.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + key: const Key('admin_users_delete_snackbar'), + content: Text('User "${user.username}" deleted.'), + duration: const Duration(seconds: 3), + ), + ); + } catch (e) { + if (!mounted) return; + // Revert optimistic removal. + setState(() => _users!.insert(index, user)); + _showError(adminUserErrorMessage(e)); + } + } + + /// Shows a [AlertDialog] asking the user to confirm deletion. + /// + /// Returns true only when the user taps the "Delete" button. + Future<bool> _confirmDelete(String username) async { + final result = await showDialog<bool>( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete user'), + content: Text('Delete "$username"? This cannot be undone.'), + actions: [ + TextButton( + key: const Key('admin_users_confirm_cancel'), + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + key: const Key('admin_users_confirm_delete'), + style: TextButton.styleFrom( + foregroundColor: Theme.of(ctx).colorScheme.error, + ), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + return result ?? false; + } + + // --------------------------------------------------------------------------- + // Error display + // --------------------------------------------------------------------------- + + void _showError(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + key: const Key('admin_users_error_snackbar'), + content: Text(message), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + // Read the current user to identify the self-row (disable self-delete). + final currentUserAsync = ref.watch(currentUserProvider); + final currentUserId = currentUserAsync.valueOrNull?.id; + + return Scaffold( + appBar: AppBar( + title: const Text('Manage Users'), + actions: [ + IconButton( + key: const Key('admin_users_refresh'), + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + onPressed: _load, + ), + ], + ), + floatingActionButton: FloatingActionButton( + key: const Key('admin_users_fab'), + tooltip: 'Create user', + onPressed: _showCreateDialog, + child: const Icon(Icons.person_add_outlined), + ), + body: _buildBody(context, currentUserId), + ); + } + + /// Builds the appropriate body widget for the current state. + Widget _buildBody(BuildContext context, int? currentUserId) { + // Show a full-screen spinner while the very first load is in flight. + if (_isLoading && _users == null) { + return const Center( + key: Key('admin_users_loading'), + child: CircularProgressIndicator(), + ); + } + + if (_error != null) { + return _ErrorView(message: _error!, onRetry: _load); + } + + return RefreshIndicator( + onRefresh: _load, + child: _users == null || _users!.isEmpty + ? const _EmptyView() + : _UserList( + users: _users!, + currentUserId: currentUserId, + onDelete: _deleteUser, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Sub-widgets +// --------------------------------------------------------------------------- + +/// Scrollable list of [User] rows. +/// +/// Extracted into its own stateless widget (SRP) so the state class stays +/// focused on data-loading and mutation concerns. +class _UserList extends StatelessWidget { + const _UserList({ + required this.users, + required this.currentUserId, + required this.onDelete, + }); + + final List<User> users; + + /// The authenticated user's own ID; used to disable self-delete. + final int? currentUserId; + + final Future<void> Function(User user, int index) onDelete; + + @override + Widget build(BuildContext context) { + return ListView.separated( + key: const Key('admin_users_list'), + itemCount: users.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, index) { + final user = users[index]; + // Self-delete is both hidden from the UI and rejected by the server; + // hiding it makes the constraint visible without a server round-trip. + final isSelf = user.id == currentUserId && currentUserId != null; + return _UserTile( + user: user, + isSelf: isSelf, + index: index, + onDelete: onDelete, + ); + }, + ); + } +} + +/// A single user row with a role badge and an optional delete action. +/// +/// The delete icon is hidden when [isSelf] is true so users cannot delete +/// their own account from this screen. +class _UserTile extends StatelessWidget { + const _UserTile({ + required this.user, + required this.isSelf, + required this.index, + required this.onDelete, + }); + + final User user; + final bool isSelf; + final int index; + final Future<void> Function(User user, int index) onDelete; + + @override + Widget build(BuildContext context) { + return ListTile( + key: Key('admin_user_tile_${user.id}'), + leading: CircleAvatar( + child: Text( + user.username.isNotEmpty ? user.username[0].toUpperCase() : '?', + ), + ), + title: Text(user.username), + subtitle: isSelf ? const Text('(you)') : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Role badge: visually distinguishes admin accounts from regular users. + _RoleBadge(isAdmin: user.isAdmin), + // Delete button is hidden for the current user's own row. + if (!isSelf) ...[ + const SizedBox(width: 8), + IconButton( + key: Key('admin_user_delete_${user.id}'), + icon: const Icon(Icons.delete_outline), + tooltip: 'Delete user', + color: Theme.of(context).colorScheme.error, + onPressed: () => onDelete(user, index), + ), + ], + ], + ), + ); + } +} + +/// Compact coloured chip that shows "Admin" or "User" depending on [isAdmin]. +/// +/// Kept as a dedicated widget so the badge style is consistent and can be +/// updated in one place without touching [_UserTile]. +class _RoleBadge extends StatelessWidget { + const _RoleBadge({required this.isAdmin}); + + final bool isAdmin; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Chip( + key: Key('role_badge_${isAdmin ? "admin" : "user"}'), + label: Text( + isAdmin ? 'Admin' : 'User', + style: TextStyle( + fontSize: 12, + color: isAdmin ? colorScheme.onPrimaryContainer : colorScheme.onSurface, + ), + ), + backgroundColor: isAdmin + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ); + } +} + +/// Full-screen empty-state shown when the user list is empty. +/// +/// Wrapped in a [ListView] so the parent [RefreshIndicator] can still trigger +/// pull-to-refresh even when no content is present. +class _EmptyView extends StatelessWidget { + const _EmptyView(); + + @override + Widget build(BuildContext context) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.6, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.people_outline, + size: 72, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No users found', + key: const Key('admin_users_empty'), + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ), + ], + ); + } +} + +/// Full-screen error view with a retry button. +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 56, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + message, + key: const Key('admin_users_error'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + key: const Key('admin_users_retry'), + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Create-user dialog +// --------------------------------------------------------------------------- + +/// Input value returned by [_CreateUserDialog] when the user confirms. +class _CreateUserInput { + const _CreateUserInput({ + required this.username, + required this.password, + required this.isAdmin, + }); + + final String username; + final String password; + final bool isAdmin; +} + +/// Dialog for creating a new user account. +/// +/// Validates that username is non-empty and password is at least 8 characters. +/// Validation is inline (shown below the fields) so the user gets immediate +/// feedback without requiring a submit attempt. +/// +/// Uses [StatefulWidget] rather than [ConsumerStatefulWidget] because the +/// dialog itself makes no API calls — the parent handles the network request. +class _CreateUserDialog extends StatefulWidget { + const _CreateUserDialog(); + + @override + State<_CreateUserDialog> createState() => _CreateUserDialogState(); +} + +class _CreateUserDialogState extends State<_CreateUserDialog> { + final _formKey = GlobalKey<FormState>(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _isAdmin = false; + + // Show password as plain text when true (toggle with the visibility icon). + bool _passwordVisible = false; + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + /// Validates the form and pops the dialog with a [_CreateUserInput] if valid. + void _submit() { + if (_formKey.currentState?.validate() != true) return; + Navigator.of(context).pop( + _CreateUserInput( + username: _usernameController.text.trim(), + password: _passwordController.text, + isAdmin: _isAdmin, + ), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + key: const Key('admin_create_user_dialog'), + title: const Text('Create user'), + content: _buildForm(), + actions: _buildActions(context), + ); + } + + /// Builds the form fields: username, password with toggle, and isAdmin checkbox. + Widget _buildForm() { + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + key: const Key('admin_create_username'), + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Username', + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.next, + autocorrect: false, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Username is required.'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + key: const Key('admin_create_password'), + controller: _passwordController, + decoration: InputDecoration( + labelText: 'Password', + border: const OutlineInputBorder(), + // Toggle visibility icon so the admin can verify the typed password. + suffixIcon: IconButton( + icon: Icon( + _passwordVisible + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + tooltip: _passwordVisible ? 'Hide password' : 'Show password', + onPressed: () => + setState(() => _passwordVisible = !_passwordVisible), + ), + ), + obscureText: !_passwordVisible, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Password is required.'; + } + if (value.length < 8) { + return 'Password must be at least 8 characters.'; + } + return null; + }, + ), + const SizedBox(height: 8), + CheckboxListTile( + key: const Key('admin_create_is_admin'), + title: const Text('Administrator'), + subtitle: const Text('Can manage users and settings'), + value: _isAdmin, + contentPadding: EdgeInsets.zero, + onChanged: (value) => setState(() => _isAdmin = value ?? false), + ), + ], + ), + ); + } + + /// Cancel and Submit action buttons for the dialog. + List<Widget> _buildActions(BuildContext context) { + return [ + TextButton( + key: const Key('admin_create_cancel'), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + key: const Key('admin_create_submit'), + onPressed: _submit, + child: const Text('Create'), + ), + ]; + } +} diff --git a/player-android/lib/screens/settings_screen.dart b/player-android/lib/screens/settings_screen.dart index 53e171a..1260273 100644 --- a/player-android/lib/screens/settings_screen.dart +++ b/player-android/lib/screens/settings_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../app_routes.dart'; import '../providers/api_client_provider.dart'; import '../providers/auth_state_provider.dart'; +import '../providers/current_user_provider.dart'; import '../providers/settings_provider.dart'; import '../providers/theme_provider.dart'; @@ -20,6 +21,10 @@ import '../providers/theme_provider.dart'; /// triggers go_router's redirect callback (via [refreshListenable]) and /// navigates to /login automatically. An explicit [context.go] acts as a /// safety net in case the redirect has not fired yet. +/// - The Admin section (Manage Users entry) is shown only when +/// [currentUserProvider] resolves to a user with [User.isAdmin] == true. +/// Non-admin users never see the tile; the server also enforces this via +/// 403 on the API endpoints, so the gating is defence-in-depth in the UI. /// - All async continuations guard on [mounted] to prevent setState/context /// calls after widget disposal. class SettingsScreen extends ConsumerStatefulWidget { @@ -100,6 +105,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { // Watch settings to seed the URL field on first load. final settingsAsync = ref.watch(settingsProvider); + // Watch the current user to conditionally show the Admin section. + // currentUserProvider is autoDispose and resolves to null for non-admins. + final isAdmin = ref.watch(currentUserProvider).valueOrNull?.isAdmin ?? false; + // Seed the URL text field exactly once, after settings have loaded. // Doing this in build (rather than initState) ensures we have the loaded // value; [_urlInitialised] prevents clobbering an in-progress edit. @@ -247,6 +256,34 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { trailing: const Icon(Icons.chevron_right), onTap: () => context.go(AppRoutes.shares), ), + + // ---------------------------------------------------------------- + // Admin section: only visible to admin users. + // Non-admin users are gated out here; the server enforces this + // independently via 403 responses, making this defence-in-depth. + // ---------------------------------------------------------------- + if (isAdmin) ...[ + const SizedBox(height: 32), + const Divider(), + const SizedBox(height: 24), + + Text( + 'Administration', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + + // Manage Users tile — navigates to /admin/users. + ListTile( + key: const Key('settings_manage_users'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.manage_accounts_outlined), + title: const Text('Manage Users'), + subtitle: const Text('Create and delete user accounts'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.go(AppRoutes.adminUsers), + ), + ], ], ), ), diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart index b7c0fe3..773b11c 100644 --- a/player-android/lib/utils/error_mappers.dart +++ b/player-android/lib/utils/error_mappers.dart @@ -301,6 +301,39 @@ String episodeListErrorMessage(Object error) { return 'Unexpected error. Please try again.'; } +/// Maps any thrown object from [PlayerApiClient.listUsers], +/// [PlayerApiClient.createUser], or [PlayerApiClient.deleteUser] to a UI string. +/// +/// Adds human-readable messages for the common failure modes: +/// - 400: the request body is invalid (e.g. password too short, empty fields). +/// - 403: the caller is not an admin. +/// - 409: a user with the same username already exists. +/// +/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve +/// independently of the other mappers. +String adminUserErrorMessage(Object error) { + if (error is DioException) { + if (error.response?.statusCode == 400) { + // Prefer the server's specific message (e.g. "password too short") over + // a generic fallback because 400 covers several distinct validation cases. + final body = error.response?.data; + if (body is Map<String, dynamic>) { + final msg = body['message'] as String? ?? body['error'] as String?; + if (msg != null && msg.isNotEmpty) return msg; + } + return 'Invalid request. Check the username and password and try again.'; + } + if (error.response?.statusCode == 403) { + return 'You do not have permission to manage users.'; + } + if (error.response?.statusCode == 409) { + return 'A user with that username already exists.'; + } + return dioConnectionErrorMessage(error); + } + return 'Unexpected error. Please try again.'; +} + /// Maps any thrown object from [PlayerApiClient.toggleEpisodeComplete] to a /// UI string. /// diff --git a/player-android/test/screens/admin_users_screen_test.dart b/player-android/test/screens/admin_users_screen_test.dart new file mode 100644 index 0000000..17be47e --- /dev/null +++ b/player-android/test/screens/admin_users_screen_test.dart @@ -0,0 +1,704 @@ +// Widget tests for AdminUsersScreen (admin_users_screen.dart). +// +// Tests cover: +// 1. Loading state: spinner shown while listUsers is in flight. +// 2. Renders user list after a successful load. +// 3. Self-row: delete button is hidden for the current user's own row. +// 4. Non-self row: delete button is visible for other users. +// 5. Create dialog: opens on FAB tap and submits a new user. +// 6. Create dialog: cancel closes without calling createUser. +// 7. Create dialog: validation — empty username and short password are rejected. +// 8. Create optimistic UI: placeholder row appears immediately, replaced on success. +// 9. Create optimistic UI: placeholder reverted and error SnackBar shown on failure. +// 10. Delete: confirmation dialog appears; cancel leaves the row; confirm removes it. +// 11. Delete optimistic UI: row removed immediately, reinserted on API error. +// 12. Empty state: shown when listUsers returns []. +// 13. Error state: shown when listUsers throws. +// 14. Retry button re-calls listUsers after an error. +// 15. adminUserErrorMessage unit tests (400, 403, 409, connection, generic). +// +// Riverpod providers are overridden with fakes so tests run without a real +// server or OS keychain. +// +// Run with: flutter test test/screens/admin_users_screen_test.dart + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:player_android/api/dio_client.dart'; +import 'package:player_android/api/player_api_client.dart'; +import 'package:player_android/models/models.dart'; +import 'package:player_android/providers/api_client_provider.dart'; +import 'package:player_android/providers/current_user_provider.dart'; +import 'package:player_android/screens/admin_users_screen.dart'; +import 'package:player_android/utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory [TokenStorage] that returns a fixed username without hitting +/// the OS keychain. +class _FakeTokenStorage implements TokenStorage { + _FakeTokenStorage([this._token = 'alice']); + final String? _token; + + @override + Future<String?> readToken() async => _token; + + @override + Future<void> writeToken(String token) async {} + + @override + Future<void> deleteToken() async {} +} + +/// Controllable [PlayerApiClient] stub for [AdminUsersScreen] tests. +/// +/// [listUsers], [createUser], and [deleteUser] are the primary subjects. +/// All other methods remain [UnimplementedError] — the screen calls only these. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.local'))); + + // ---- listUsers ---- + + /// When non-null, [listUsers] returns this list. + List<User>? usersResult; + + /// When non-null, [listUsers] throws this instead of returning. + Object? users |
