summaryrefslogtreecommitdiff
path: root/player-android/lib/screens
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 /player-android/lib/screens
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>
Diffstat (limited to 'player-android/lib/screens')
-rw-r--r--player-android/lib/screens/admin_users_screen.dart619
-rw-r--r--player-android/lib/screens/settings_screen.dart37
2 files changed, 656 insertions, 0 deletions
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),
+ ),
+ ],
],
),
),