fix: support pin protected profiles

This commit is contained in:
edde746
2025-11-04 04:44:22 +01:00
parent fffe342dd1
commit 781aa4640e
4 changed files with 221 additions and 3 deletions
+1 -1
View File
@@ -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;
}
+58
View File
@@ -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<bool> _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;
+4 -2
View File
@@ -179,8 +179,9 @@ class PlexAuthService {
/// Switch to a different user in the home
Future<UserSwitchResponse> 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
+158
View File
@@ -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<PinEntryDialog> createState() => _PinEntryDialogState();
}
class _PinEntryDialogState extends State<PinEntryDialog>
with SingleTickerProviderStateMixin {
final _pinController = TextEditingController();
final _focusNode = FocusNode();
bool _obscureText = true;
late AnimationController _shakeController;
late Animation<double> _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<double>([
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<String?> showPinEntryDialog(
BuildContext context,
String userName, {
String? errorMessage,
}) {
return showDialog<String>(
context: context,
barrierDismissible: false,
builder: (context) =>
PinEntryDialog(userName: userName, errorMessage: errorMessage),
);
}