diff options
| -rw-r--r-- | player-android/lib/app_routes.dart | 6 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 8 | ||||
| -rw-r--r-- | player-android/lib/screens/api_tokens_screen.dart | 733 | ||||
| -rw-r--r-- | player-android/lib/screens/settings_screen.dart | 15 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 28 | ||||
| -rw-r--r-- | player-android/test/screens/api_tokens_screen_test.dart | 783 |
6 files changed, 1573 insertions, 0 deletions
diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart index c07559a..1975818 100644 --- a/player-android/lib/app_routes.dart +++ b/player-android/lib/app_routes.dart @@ -137,4 +137,10 @@ abstract final class AppRoutes { /// /// Lists soft-deleted media items and allows restore or hard-delete. static const adminTrash = '/admin/trash'; + + /// Route for the API token management screen. + /// + /// Allows any authenticated user to list, create, and revoke their own + /// Bearer API tokens. Available in the Account section of Settings. + static const apiTokens = '/settings/api-tokens'; } diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index 7cf1823..2949ec7 100644 --- a/player-android/lib/router.dart +++ b/player-android/lib/router.dart @@ -24,6 +24,7 @@ import 'screens/admin_permissions_screen.dart'; import 'screens/admin_rescan_screen.dart'; import 'screens/admin_trash_screen.dart'; import 'screens/admin_users_screen.dart'; +import 'screens/api_tokens_screen.dart'; import 'screens/folder_browser_screen.dart'; import 'screens/video_player_screen.dart'; @@ -260,6 +261,13 @@ final routerProvider = Provider<GoRouter>((ref) { builder: (context, state) => const AdminTrashScreen(), ), GoRoute( + // API token management — lists, creates, and revokes Bearer API tokens + // for the authenticated user. Available to all authenticated users + // (not admin-only) via the Account section of Settings. + path: AppRoutes.apiTokens, + builder: (context, state) => const ApiTokensScreen(), + ), + 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/api_tokens_screen.dart b/player-android/lib/screens/api_tokens_screen.dart new file mode 100644 index 0000000..df57cf6 --- /dev/null +++ b/player-android/lib/screens/api_tokens_screen.dart @@ -0,0 +1,733 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/api_client_provider.dart'; +import '../utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// Data model +// --------------------------------------------------------------------------- + +/// Lightweight view-model for an API token row. +/// +/// Uses raw map fields from the server rather than a dedicated model class +/// because API tokens have no corresponding model in models.dart, and adding +/// one solely for this screen would violate YAGNI. The map is unwrapped once +/// here and never propagated further into the UI (Law of Demeter). +class _TokenRow { + const _TokenRow({ + required this.id, + required this.name, + required this.createdAt, + this.expiresAt, + }); + + final int id; + final String name; + + /// ISO-8601 creation timestamp from the server response. + final String createdAt; + + /// ISO-8601 expiry timestamp, or null for a non-expiring token. + final String? expiresAt; + + /// Parses a raw JSON map from `listAPITokens` / `createAPIToken` into a row. + /// + /// The `createAPIToken` response carries `token` (plaintext) but not + /// `created_at`; callers should pass a fallback timestamp in that case. + factory _TokenRow.fromMap(Map<String, dynamic> map, {String? createdAtFallback}) { + return _TokenRow( + id: (map['id'] as num).toInt(), + name: (map['name'] as String?) ?? '', + createdAt: (map['created_at'] as String?) ?? createdAtFallback ?? '', + expiresAt: map['expires_at'] as String?, + ); + } + + /// Returns a display-friendly date string (YYYY-MM-DD) from an ISO-8601 + /// timestamp, or an empty string if the value is absent or malformed. + static String _shortDate(String? iso) { + if (iso == null || iso.isEmpty) return ''; + // Truncate after the date portion; ignore timezone offsets for display. + return iso.length >= 10 ? iso.substring(0, 10) : iso; + } + + String get createdAtDisplay => _shortDate(createdAt); + String get expiresAtDisplay => expiresAt != null ? _shortDate(expiresAt) : 'No expiry'; +} + +// --------------------------------------------------------------------------- +// Main screen +// --------------------------------------------------------------------------- + +/// Screen for managing the authenticated user's API tokens. +/// +/// Design notes: +/// - Any authenticated user can manage their own tokens; this screen is in +/// the Account section of Settings, not the Admin section. +/// - Create uses optimistic UI with index-based slot tracking: the placeholder +/// is inserted at [_tokens!.length] before the API call; on success the +/// real row replaces that slot; on error the slot is removed. +/// - Revoke uses identity-based optimistic removal: +/// `_tokens!.removeWhere((t) => t.id == token.id)` first, then reverted +/// with `[..._tokens!, token]` on error (consistent with MySharesScreen). +/// - The plaintext token from `createAPIToken` is shown exactly once in a +/// dialog with a copy button; after the user taps Done it is discarded. +/// - All async continuations guard on [mounted] to prevent setState / context +/// calls after widget disposal. +class ApiTokensScreen extends ConsumerStatefulWidget { + const ApiTokensScreen({super.key}); + + @override + ConsumerState<ApiTokensScreen> createState() => _ApiTokensScreenState(); +} + +class _ApiTokensScreenState extends ConsumerState<ApiTokensScreen> { + // Null while the initial load is in flight; non-null after first success. + List<_TokenRow>? _tokens; + + // Non-null when the last load attempt failed. + String? _error; + + // True while a load (initial or refresh) is in flight. + bool _isLoading = false; + + // Generation counter: stale async completions are silently discarded. + 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 token list and refreshes local state. + /// + /// Increments [_generation] so in-flight results from a previous call are + /// silently dropped if a newer call starts first (stale-result guard). + Future<void> _load() async { + if (!mounted) return; + final generation = ++_generation; + + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final rawList = await ref.read(apiClientProvider).listAPITokens(); + if (!mounted || generation != _generation) return; + setState(() { + _tokens = rawList.map((m) => _TokenRow.fromMap(m)).toList(); + _isLoading = false; + }); + } catch (e) { + if (!mounted || generation != _generation) return; + setState(() { + _error = apiTokenErrorMessage(e); + _isLoading = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Create token action + // --------------------------------------------------------------------------- + + /// Opens the create dialog; on confirm submits the request and shows the + /// plaintext token once. + Future<void> _showCreateDialog() async { + final result = await showDialog<_CreateTokenInput>( + context: context, + builder: (_) => const _CreateTokenDialog(), + ); + if (result == null || !mounted) return; + + await _submitCreateToken(result); + } + + /// Submits the create request with optimistic placeholder insertion. + /// + /// Index-based slot tracking ensures the placeholder is replaced (or removed + /// on error) at the exact position it was inserted, even if a concurrent + /// refresh or mutation changes the list length while the request is in flight. + Future<void> _submitCreateToken(_CreateTokenInput input) async { + // Capture the slot index before inserting the placeholder so both the + // success and error paths operate on the same position. + final placeholderIdx = _tokens?.length ?? 0; + final placeholder = _TokenRow( + id: 0, + name: input.name, + createdAt: '', + ); + setState(() => _tokens = [...?_tokens, placeholder]); + + try { + final raw = await ref.read(apiClientProvider).createAPIToken( + name: input.name, + expiresInDays: input.expiresInDays, + ); + if (!mounted) return; + + final created = _TokenRow.fromMap( + raw, + createdAtFallback: DateTime.now().toUtc().toIso8601String(), + ); + // Replace the placeholder slot with the real row from the server. + // Bounds-check guards against a concurrent refresh that shrank the list. + setState(() { + if (placeholderIdx < (_tokens?.length ?? 0)) { + _tokens![placeholderIdx] = created; + } + }); + + // Show the plaintext token exactly once; the user must copy it before + // tapping Done because it will never be returned by the API again. + final plaintext = raw['token'] as String? ?? ''; + if (mounted && plaintext.isNotEmpty) { + await _showPlaintextDialog(plaintext); + } + } catch (e) { + if (!mounted) return; + // Revert optimistic insertion by removing the placeholder slot. + setState(() { + if (placeholderIdx < (_tokens?.length ?? 0)) { + _tokens!.removeAt(placeholderIdx); + } + }); + _showError(apiTokenErrorMessage(e)); + } + } + + // --------------------------------------------------------------------------- + // Plaintext token display + // --------------------------------------------------------------------------- + + /// Shows the one-time plaintext token with a copy button. + /// + /// Called immediately after a successful create so the user can copy the + /// token before it disappears. The dialog blocks navigation until dismissed. + Future<void> _showPlaintextDialog(String plaintext) async { + await showDialog<void>( + context: context, + barrierDismissible: false, // force explicit Done to acknowledge loss + builder: (ctx) => _PlaintextTokenDialog(plaintext: plaintext), + ); + } + + // --------------------------------------------------------------------------- + // Revoke token action + // --------------------------------------------------------------------------- + + /// Shows a confirmation dialog, then revokes the token if confirmed. + /// + /// Identity-based optimistic removal: the token row is removed from the list + /// immediately, then the API call is made. On error the row is appended back + /// (consistent with MySharesScreen and the task spec). + Future<void> _revokeToken(_TokenRow token) async { + final confirmed = await _confirmRevoke(token.name); + if (!confirmed || !mounted) return; + + // Optimistic removal by identity so the UI responds instantly. + setState(() => _tokens!.removeWhere((t) => t.id == token.id)); + + try { + await ref.read(apiClientProvider).revokeAPIToken(token.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + key: const Key('api_tokens_revoke_snackbar'), + content: Text('Token "${token.name}" revoked.'), + duration: const Duration(seconds: 3), + ), + ); + } catch (e) { + if (!mounted) return; + // Re-append the token to restore the list after the failed revoke. + // Append rather than re-insert at original index to avoid position jitter + // from concurrent mutations (mirrors MySharesScreen and admin_users). + setState(() => _tokens = [...?_tokens, token]); + _showError(apiTokenErrorMessage(e)); + } + } + + /// Shows a confirmation [AlertDialog] before revoking [tokenName]. + /// + /// Returns true only when the user taps "Revoke". + Future<bool> _confirmRevoke(String tokenName) async { + final result = await showDialog<bool>( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Revoke token'), + content: Text('Revoke "$tokenName"? It will stop working immediately.'), + actions: [ + TextButton( + key: const Key('api_tokens_confirm_cancel'), + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + key: const Key('api_tokens_confirm_revoke'), + style: TextButton.styleFrom( + foregroundColor: Theme.of(ctx).colorScheme.error, + ), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Revoke'), + ), + ], + ), + ); + return result ?? false; + } + + // --------------------------------------------------------------------------- + // Error display + // --------------------------------------------------------------------------- + + void _showError(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + key: const Key('api_tokens_error_snackbar'), + content: Text(message), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('API Tokens'), + actions: [ + IconButton( + key: const Key('api_tokens_refresh'), + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + onPressed: _load, + ), + ], + ), + floatingActionButton: FloatingActionButton( + key: const Key('api_tokens_fab'), + tooltip: 'Create token', + onPressed: _showCreateDialog, + child: const Icon(Icons.add), + ), + body: _buildBody(context), + ); + } + + /// Builds the appropriate body widget for the current data/error/loading state. + Widget _buildBody(BuildContext context) { + if (_isLoading && _tokens == null) { + return const Center( + key: Key('api_tokens_loading'), + child: CircularProgressIndicator(), + ); + } + + if (_error != null) { + return _ErrorView(message: _error!, onRetry: _load); + } + + return RefreshIndicator( + onRefresh: _load, + child: _tokens == null || _tokens!.isEmpty + ? const _EmptyView() + : _TokenList(tokens: _tokens!, onRevoke: _revokeToken), + ); + } +} + +// --------------------------------------------------------------------------- +// Token list +// --------------------------------------------------------------------------- + +/// Scrollable list of [_TokenRow] tiles. +/// +/// Extracted as a separate stateless widget (SRP) so the state class stays +/// focused on data-loading and mutation concerns. +class _TokenList extends StatelessWidget { + const _TokenList({required this.tokens, required this.onRevoke}); + + final List<_TokenRow> tokens; + final Future<void> Function(_TokenRow token) onRevoke; + + @override + Widget build(BuildContext context) { + return ListView.separated( + key: const Key('api_tokens_list'), + itemCount: tokens.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, index) { + final token = tokens[index]; + return _TokenTile(token: token, onRevoke: onRevoke); + }, + ); + } +} + +/// A single token row showing name, created date, expiry, and a revoke button. +class _TokenTile extends StatelessWidget { + const _TokenTile({required this.token, required this.onRevoke}); + + final _TokenRow token; + final Future<void> Function(_TokenRow token) onRevoke; + + @override + Widget build(BuildContext context) { + // Use id=0 key for placeholders (optimistic rows not yet confirmed by server). + return ListTile( + key: Key('api_token_tile_${token.id}'), + leading: const Icon(Icons.key_outlined), + title: Text(token.name), + subtitle: _buildSubtitle(context), + trailing: IconButton( + key: Key('api_token_revoke_${token.id}'), + icon: const Icon(Icons.delete_outline), + tooltip: 'Revoke token', + color: Theme.of(context).colorScheme.error, + // Disable the revoke button on optimistic placeholder rows (id == 0) + // to prevent double-revoke while the create request is still in flight. + onPressed: token.id == 0 ? null : () => onRevoke(token), + ), + ); + } + + /// Builds the subtitle with created date and expiry information. + Widget _buildSubtitle(BuildContext context) { + final created = token.createdAtDisplay; + final expiry = token.expiresAtDisplay; + final parts = <String>[ + if (created.isNotEmpty) 'Created: $created', + 'Expires: $expiry', + ]; + return Text(parts.join(' · ')); + } +} + +// --------------------------------------------------------------------------- +// Empty and error views +// --------------------------------------------------------------------------- + +/// Full-screen empty-state shown when no tokens exist. +/// +/// Wrapped in a scrollable so the parent [RefreshIndicator] works even without +/// content present (mirrors the pattern used in AdminUsersScreen). +class _EmptyView extends StatelessWidget { + const _EmptyView(); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: SizedBox( + height: constraints.maxHeight, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.key_off_outlined, + size: 72, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No API tokens', + key: const Key('api_tokens_empty'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Tap + to create one', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 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('api_tokens_error'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + key: const Key('api_tokens_retry'), + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Plaintext token dialog +// --------------------------------------------------------------------------- + +/// Dialog that shows the one-time plaintext token value with a clipboard copy +/// button and a Done button. +/// +/// [barrierDismissible] is set to false at the call-site so the user must +/// explicitly acknowledge that the token will not be shown again. +class _PlaintextTokenDialog extends StatelessWidget { + const _PlaintextTokenDialog({required this.plaintext}); + + final String plaintext; + + @override + Widget build(BuildContext context) { + return AlertDialog( + key: const Key('api_tokens_plaintext_dialog'), + title: const Text('Your new token'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Copy this token now — it will not be shown again.', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + // Display the token in a selectable text widget so the user can also + // long-press to select and copy via the OS text menu. + SelectableText( + plaintext, + key: const Key('api_tokens_plaintext_value'), + style: const TextStyle(fontFamily: 'monospace'), + ), + ], + ), + actions: [ + TextButton.icon( + key: const Key('api_tokens_plaintext_copy'), + icon: const Icon(Icons.copy_outlined), + label: const Text('Copy'), + onPressed: () async { + await Clipboard.setData(ClipboardData(text: plaintext)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + key: Key('api_tokens_copy_snackbar'), + content: Text('Token copied to clipboard.'), + duration: Duration(seconds: 2), + ), + ); + } + }, + ), + FilledButton( + key: const Key('api_tokens_plaintext_done'), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Done'), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Create token dialog +// --------------------------------------------------------------------------- + +/// Input value returned by [_CreateTokenDialog] when the user confirms. +class _CreateTokenInput { + const _CreateTokenInput({required this.name, this.expiresInDays}); + + final String name; + + /// Null means the token never expires. + final int? expiresInDays; +} + +/// Dialog for creating a new API token. +/// +/// Collects a required token name and an optional expiry date. +/// Validation is inline so the user gets immediate feedback without a round-trip. +/// +/// Uses [StatefulWidget] because the dialog makes no API calls itself — the +/// parent [_ApiTokensScreenState] handles the network request (SRP). +class _CreateTokenDialog extends StatefulWidget { + const _CreateTokenDialog(); + + @override + State<_CreateTokenDialog> createState() => _CreateTokenDialogState(); +} + +class _CreateTokenDialogState extends State<_CreateTokenDialog> { + final _formKey = GlobalKey<FormState>(); + final _nameController = TextEditingController(); + + // The selected expiry date, or null for a non-expiring token. + DateTime? _expiryDate; + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + /// Validates the form and pops with a [_CreateTokenInput] on success. + void _submit() { + if (_formKey.currentState?.validate() != true) return; + + // Calculate the number of days from today to the chosen expiry date so the + // server can compute the absolute expiry timestamp using its own clock. + // Using ceiling division ensures the token does not expire before the + // selected date in any timezone. + int? expiresInDays; + if (_expiryDate != null) { + final now = DateTime.now(); + final diff = _expiryDate!.difference(DateTime(now.year, now.month, now.day)); + expiresInDays = diff.inDays.clamp(1, 36500); // 1 day to 100 years + } + + Navigator.of(context).pop( + _CreateTokenInput( + name: _nameController.text.trim(), + expiresInDays: expiresInDays, + ), + ); + } + + /// Opens the date picker and stores the selected date in [_expiryDate]. + Future<void> _pickExpiryDate() async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: now.add(const Duration(days: 30)), + firstDate: now.add(const Duration(days: 1)), // must be in the future + lastDate: now.add(const Duration(days: 36500)), + ); + if (picked != null) { + setState(() => _expiryDate = picked); + } + } + + /// Builds the token name [TextFormField] with non-empty validation. + Widget _buildNameField() { + return TextFormField( + key: const Key('api_tokens_create_name'), + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Token name', + border: OutlineInputBorder(), + hintText: 'e.g. android-client', + ), + textInputAction: TextInputAction.done, + autocorrect: false, + onFieldSubmitted: (_) => _submit(), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Token name is required.'; + } + return null; + }, + ); + } + + /// Builds the expiry date picker row. + /// + /// Tapping the row opens a date picker; tapping the clear icon resets the + /// date to null (no expiry). + Widget _buildExpiryRow() { + final expiryText = _expiryDate != null + ? '${_expiryDate!.year.toString().padLeft(4, '0')}' + '-${_expiryDate!.month.toString().padLeft(2, '0')}' + '-${_expiryDate!.day.toString().padLeft(2, '0')}' + : 'No expiry (optional)'; + + return ListTile( + key: const Key('api_tokens_expiry_tile'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.calendar_today_outlined), + title: const Text('Expiry date'), + subtitle: Text(expiryText), + trailing: _expiryDate != null + ? IconButton( + key: const Key('api_tokens_expiry_clear'), + icon: const Icon(Icons.clear), + tooltip: 'Remove expiry', + onPressed: () => setState(() => _expiryDate = null), + ) + : null, + onTap: _pickExpiryDate, + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + key: const Key('api_tokens_create_dialog'), + title: const Text('Create API token'), + content: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildNameField(), + const SizedBox(height: 8), + _buildExpiryRow(), + ], + ), + ), + actions: [ + TextButton( + key: const Key('api_tokens_create_cancel'), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + key: const Key('api_tokens_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 2fc034c..bea9d13 100644 --- a/player-android/lib/screens/settings_screen.dart +++ b/player-android/lib/screens/settings_screen.dart @@ -165,6 +165,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { ), const SizedBox(height: 24), + // API Tokens tile — navigates to /settings/api-tokens. + // Available to all authenticated users (not admin-only) so they + // can manage their own Bearer tokens for external integrations. + ListTile( + key: const Key('settings_api_tokens'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.key_outlined), + title: const Text('API Tokens'), + subtitle: const Text('Create and revoke Bearer API tokens'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.go(AppRoutes.apiTokens), + ), + + const SizedBox(height: 24), + // Logout button: shows a spinner while the token is being deleted. _isLoggingOut ? const Center(child: CircularProgressIndicator()) diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart index 6e6f118..23b0b51 100644 --- a/player-android/lib/utils/error_mappers.dart +++ b/player-android/lib/utils/error_mappers.dart @@ -419,6 +419,34 @@ String adminTrashErrorMessage(Object error) { return 'Unexpected error. Please try again.'; } +/// Maps any thrown object from [PlayerApiClient.listAPITokens], +/// [PlayerApiClient.createAPIToken], or [PlayerApiClient.revokeAPIToken] to a +/// human-readable UI string. +/// +/// Adds messages for the common failure modes: +/// - 400: the request body is invalid (e.g. empty name, non-positive expiry). +/// Delegates to [dioErrorMessage] to surface the server's JSON body message. +/// - 404: the token no longer exists (may have been revoked already). +/// +/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve +/// independently of the other mappers. +String apiTokenErrorMessage(Object error) { + if (error is DioException) { + if (error.response?.statusCode == 404) { + return 'Token not found. It may have already been revoked.'; + } + if (error.response?.statusCode == 400) { + // Prefer the server's JSON body message (e.g. "name required") over a + // generic fallback so the user knows exactly what to fix. + final serverMsg = dioErrorMessage(error); + if (!serverMsg.startsWith('Server error')) return serverMsg; + return 'Invalid request. Check the token name and expiry and try again.'; + } + return dioConnectionErrorMessage(error); + } + return 'Unexpected error. Please try again.'; +} + /// Maps any thrown object from [PlayerApiClient.downloadEpisode] to a UI string. /// /// Adds human-readable messages for the failure modes specific to triggering a diff --git a/player-android/test/screens/api_tokens_screen_test.dart b/player-android/test/screens/api_tokens_screen_test.dart new file mode 100644 index 0000000..8d61ebc --- /dev/null +++ b/player-android/test/screens/api_tokens_screen_test.dart @@ -0,0 +1,783 @@ +// Widget tests for ApiTokensScreen (api_tokens_screen.dart). +// +// Tests cover: +// 1. Loading state: spinner shown while listAPITokens is in flight. +// 2. Renders token list after a successful load (name, dates, expiry). +// 3. "No expiry" shown when expires_at is absent. +// 4. Revoke: confirmation cancel leaves the row intact. +// 5. Revoke: confirmation confirm removes the row (optimistic). +// 6. Revoke optimistic UI: row re-appended and error SnackBar shown on failure. +// 7. Create dialog: opens on FAB tap. +// 8. Create dialog: cancel closes without calling createAPIToken. +// 9. Create dialog: validation — empty name is rejected. +// 10. Create dialog: submits and shows the plaintext token dialog. +// 11. Plaintext dialog: copy button writes token to clipboard. +// 12. Plaintext dialog: Done dismisses the dialog. +// 13. Create optimistic UI: placeholder visible while in flight, replaced on success. +// 14. Create optimistic UI: placeholder reverted and error SnackBar shown on failure. +// 15. Empty state: shown when listAPITokens returns []. +// 16. Error state: shown when listAPITokens throws. +// 17. Retry button re-calls listAPITokens after an error. +// 18. apiTokenErrorMessage unit tests (400, 404, connection, generic). +// +// Riverpod providers are overridden with fakes so tests run without a real +// server or OS keychain. +// +// Run with: flutter test test/screens/api_tokens_screen_test.dart + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/providers/api_client_provider.dart'; +import 'package:player_android/screens/api_tokens_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 { + const _FakeTokenStorage(); + + @override + Future<String?> readToken() async => 'test-token'; + + @override + Future<void> writeToken(String token) async {} + + @override + Future<void> deleteToken() async {} +} + +/// Controllable [PlayerApiClient] stub for [ApiTokensScreen] tests. +/// +/// [listAPITokens], [createAPIToken], and [revokeAPIToken] are the primary +/// subjects. All other methods remain [UnimplementedError]. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() + : super(dio: Dio(BaseOptions(baseUrl: 'http://test.local'))); + + // ---- listAPITokens ---- + + /// When non-null, [listAPITokens] returns this list. + List<Map<String, dynamic>>? tokensResult; + + /// When non-null, [listAPITokens] throws this instead of returning. + Object? tokensError; + + /// Number of times [listAPITokens] has been called. + int listTokensCallCount = 0; + + @override + Future<List<Map<String, dynamic>>> listAPITokens() async { + listTokensCallCount++; + if (tokensError != null) throw tokensError!; + return tokensResult!; + } + + // ---- createAPIToken ---- + + /// When non-null, [createAPIToken] returns this map. + Map<String, dynamic>? createResult; + + /// When non-null, [createAPIToken] throws this instead of returning. + Object? createError; + + /// Captures the last name passed to [createAPIToken]. + String? createdName; + + /// Captures the last expiresInDays passed to [createAPIToken]. + int? createdExpiresInDays; + + @override + Future<Map<String, dynamic>> createAPIToken({ + required String name, + int? expiresInDays, + }) async { + createdName = name; + createdExpiresInDays = expiresInDays; + if (createError != null) throw createError!; + return createResult!; + } + + // ---- revokeAPIToken ---- + + /// When non-null, [revokeAPIToken] throws this instead of returning. + Object? revokeError; + + /// The ID passed |
