From 781aa4640eb7f25e274f49b618ec6549cf09c830 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 4 Nov 2025 04:44:22 +0100 Subject: [PATCH] fix: support pin protected profiles --- lib/models/plex_home_user.dart | 2 +- lib/providers/user_profile_provider.dart | 58 +++++++++ lib/services/plex_auth_service.dart | 6 +- lib/widgets/pin_entry_dialog.dart | 158 +++++++++++++++++++++++ 4 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 lib/widgets/pin_entry_dialog.dart diff --git a/lib/models/plex_home_user.dart b/lib/models/plex_home_user.dart index ba48ae37..0a422ba7 100644 --- a/lib/models/plex_home_user.dart +++ b/lib/models/plex_home_user.dart @@ -70,5 +70,5 @@ class PlexHomeUser { bool get isAdminUser => admin; bool get isRestrictedUser => restricted; bool get isGuestUser => guest; - bool get requiresPassword => hasPassword; + bool get requiresPassword => protected; } diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart index 5aaff1c6..03ddca58 100644 --- a/lib/providers/user_profile_provider.dart +++ b/lib/providers/user_profile_provider.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import '../models/plex_home.dart'; import '../models/plex_home_user.dart'; @@ -6,6 +7,7 @@ import '../services/plex_auth_service.dart'; import '../services/storage_service.dart'; import '../utils/app_logger.dart'; import '../utils/provider_extensions.dart'; +import '../widgets/pin_entry_dialog.dart'; import 'plex_client_provider.dart'; class UserProfileProvider extends ChangeNotifier { @@ -254,15 +256,41 @@ class UserProfileProvider extends ChangeNotifier { _setLoading(true); _clearError(); + return await _attemptUserSwitch(user, context, clientProvider, null); + } + + Future _attemptUserSwitch( + PlexHomeUser user, + BuildContext? context, + PlexClientProvider? clientProvider, + String? errorMessage, + ) async { try { final currentToken = _storageService!.getPlexToken(); if (currentToken == null) { throw Exception('No Plex.tv authentication token available'); } + // Check if user requires PIN + String? pin; + if (user.requiresPassword && context != null && context.mounted) { + pin = await showPinEntryDialog( + context, + user.displayName, + errorMessage: errorMessage, + ); + + // User cancelled the PIN dialog + if (pin == null) { + _setLoading(false); + return false; + } + } + final switchResponse = await _authService!.switchToUser( user.uuid, currentToken, + pin: pin, ); // switchResponse.authToken is the new user's Plex.tv token @@ -348,6 +376,36 @@ class UserProfileProvider extends ChangeNotifier { appLogger.i('Successfully switched to user: ${user.displayName}'); return true; } catch (e) { + // Check if it's a PIN validation error + if (e is DioException && e.response?.statusCode == 403) { + final errors = e.response?.data['errors'] as List?; + if (errors != null && errors.isNotEmpty) { + final errorCode = errors[0]['code'] as int?; + final errorMessage = errors[0]['message'] as String?; + + // Error code 1041 means invalid PIN + if (errorCode == 1041) { + appLogger.w('Invalid PIN for user: ${user.displayName}'); + _clearError(); // Clear any previous error state + + // Retry with error message if context is still available + if (context != null && context.mounted) { + return await _attemptUserSwitch( + user, + context, + clientProvider, + errorMessage ?? 'Incorrect PIN. Please try again.', + ); + } + + // If context not available, return false without showing error + appLogger.d('Cannot retry PIN entry - context not available'); + return false; + } + } + } + + // Only show error for non-PIN validation errors _setError('Failed to switch user: $e'); appLogger.e('Failed to switch to user: ${user.displayName}', error: e); return false; diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index a25129e5..3bde3830 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -179,8 +179,9 @@ class PlexAuthService { /// Switch to a different user in the home Future switchToUser( String userUUID, - String currentToken, - ) async { + String currentToken, { + String? pin, + }) async { final queryParams = { 'includeSubscriptions': '1', 'includeProviders': '1', @@ -193,6 +194,7 @@ class PlexAuthService { 'X-Plex-Platform-Version': '3.8.1', 'X-Plex-Token': currentToken, 'X-Plex-Language': 'en', + if (pin != null) 'pin': pin, }; final queryString = queryParams.entries diff --git a/lib/widgets/pin_entry_dialog.dart b/lib/widgets/pin_entry_dialog.dart new file mode 100644 index 00000000..86fad92a --- /dev/null +++ b/lib/widgets/pin_entry_dialog.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Dialog for entering a PIN to access a protected profile +class PinEntryDialog extends StatefulWidget { + final String userName; + final String? errorMessage; + + const PinEntryDialog({super.key, required this.userName, this.errorMessage}); + + @override + State createState() => _PinEntryDialogState(); +} + +class _PinEntryDialogState extends State + with SingleTickerProviderStateMixin { + final _pinController = TextEditingController(); + final _focusNode = FocusNode(); + bool _obscureText = true; + late AnimationController _shakeController; + late Animation _shakeAnimation; + + @override + void initState() { + super.initState(); + + // Setup shake animation + _shakeController = AnimationController( + duration: const Duration(milliseconds: 600), + vsync: this, + ); + + // Create a shake effect that oscillates + _shakeAnimation = + TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0.0, end: 10.0), weight: 1), + TweenSequenceItem(tween: Tween(begin: 10.0, end: -10.0), weight: 1), + TweenSequenceItem(tween: Tween(begin: -10.0, end: 10.0), weight: 1), + TweenSequenceItem(tween: Tween(begin: 10.0, end: -10.0), weight: 1), + TweenSequenceItem(tween: Tween(begin: -10.0, end: 0.0), weight: 1), + ]).animate( + CurvedAnimation(parent: _shakeController, curve: Curves.easeInOut), + ); + + // Auto-focus the PIN field when dialog opens + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + + // If there's an error message, trigger shake and clear field + if (widget.errorMessage != null) { + _pinController.clear(); + _shakeController.forward(from: 0); + } + }); + } + + @override + void dispose() { + _pinController.dispose(); + _focusNode.dispose(); + _shakeController.dispose(); + super.dispose(); + } + + void _submit() { + final pin = _pinController.text.trim(); + if (pin.isEmpty) { + return; + } + Navigator.of(context).pop(pin); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return AnimatedBuilder( + animation: _shakeAnimation, + builder: (context, child) { + return Transform.translate( + offset: Offset(_shakeAnimation.value, 0), + child: child, + ); + }, + child: AlertDialog( + title: Row( + children: [ + Icon( + Icons.lock_outline, + size: 24, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text(widget.userName, overflow: TextOverflow.ellipsis), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _pinController, + focusNode: _focusNode, + obscureText: _obscureText, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + ], + decoration: InputDecoration( + hintText: 'Enter PIN', + border: const OutlineInputBorder(), + errorText: widget.errorMessage, + errorMaxLines: 2, + suffixIcon: IconButton( + icon: Icon( + _obscureText ? Icons.visibility_off : Icons.visibility, + size: 20, + ), + onPressed: () { + setState(() { + _obscureText = !_obscureText; + }); + }, + tooltip: _obscureText ? 'Show PIN' : 'Hide PIN', + ), + ), + onSubmitted: (_) => _submit(), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(null), + child: const Text('Cancel'), + ), + FilledButton(onPressed: _submit, child: const Text('Submit')), + ], + ), + ); + } +} + +/// Shows the PIN entry dialog and returns the entered PIN, or null if cancelled +Future showPinEntryDialog( + BuildContext context, + String userName, { + String? errorMessage, +}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => + PinEntryDialog(userName: userName, errorMessage: errorMessage), + ); +}