summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--player-android/lib/app_routes.dart6
-rw-r--r--player-android/lib/router.dart8
-rw-r--r--player-android/lib/screens/api_tokens_screen.dart733
-rw-r--r--player-android/lib/screens/settings_screen.dart15
-rw-r--r--player-android/lib/utils/error_mappers.dart28
-rw-r--r--player-android/test/screens/api_tokens_screen_test.dart783
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