fix: support pin protected profiles
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user