summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-22 16:21:11 +0300
committerPaul Buetow <paul@buetow.org>2026-05-22 16:21:11 +0300
commitb4a200fa07a5c659661a2a695c6cfe57c70e3ab1 (patch)
tree6ba75f6b86fd9916abbeeda58c1e0f47a9832937
parentee99ef26a45cbcf7595bf77bc315644d59473f30 (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>
-rw-r--r--player-android/lib/app_routes.dart6
-rw-r--r--player-android/lib/providers/current_user_provider.dart43
-rw-r--r--player-android/lib/router.dart9
-rw-r--r--player-android/lib/screens/admin_users_screen.dart619
-rw-r--r--player-android/lib/screens/settings_screen.dart37
-rw-r--r--player-android/lib/utils/error_mappers.dart33
-rw-r--r--player-android/test/screens/admin_users_screen_test.dart704
-rw-r--r--player-android/test/screens/settings_screen_test.dart75
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? usersError;
+
+ /// Number of times [listUsers] has been called.
+ int listUsersCallCount = 0;
+
+ @override
+ Future<List<User>> listUsers() async {
+ listUsersCallCount++;
+ if (usersError != null) throw usersError!;
+ return usersResult!;
+ }
+