// lib/ui/auth/pinepods_startup_login.dart import 'package:flutter/material.dart'; import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart'; import 'package:pinepods_mobile/services/pinepods/login_service.dart'; import 'package:pinepods_mobile/services/pinepods/oidc_service.dart'; import 'package:pinepods_mobile/services/auth_notifier.dart'; import 'package:pinepods_mobile/ui/auth/oidc_browser.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'dart:math'; import 'dart:async'; class PinepodsStartupLogin extends StatefulWidget { final VoidCallback? onLoginSuccess; const PinepodsStartupLogin({ Key? key, this.onLoginSuccess, }) : super(key: key); @override State createState() => _PinepodsStartupLoginState(); } class _PinepodsStartupLoginState extends State { final _serverController = TextEditingController(); final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); final _mfaController = TextEditingController(); final _formKey = GlobalKey(); bool _isLoading = false; bool _showMfaField = false; bool _isLoadingOidc = false; String _errorMessage = ''; String? _tempServerUrl; String? _tempUsername; int? _tempUserId; String? _tempMfaSessionToken; List _oidcProviders = []; bool _hasCheckedOidc = false; Timer? _oidcCheckTimer; // List of background images - you can add your own images to assets/images/ final List _backgroundImages = [ 'assets/images/1.webp', 'assets/images/2.webp', 'assets/images/3.webp', 'assets/images/4.webp', 'assets/images/5.webp', 'assets/images/6.webp', 'assets/images/7.webp', 'assets/images/8.webp', 'assets/images/9.webp', ]; late String _selectedBackground; @override void initState() { super.initState(); // Select a random background image final random = Random(); _selectedBackground = _backgroundImages[random.nextInt(_backgroundImages.length)]; // Listen for server URL changes to check OIDC providers _serverController.addListener(_onServerUrlChanged); // Register global login success callback AuthNotifier.setGlobalLoginSuccessCallback(_handleLoginSuccess); } void _onServerUrlChanged() { final serverUrl = _serverController.text.trim(); // Cancel any existing timer _oidcCheckTimer?.cancel(); // Reset OIDC state setState(() { _oidcProviders.clear(); _hasCheckedOidc = false; _isLoadingOidc = false; }); // Only check if URL looks complete and valid if (serverUrl.isNotEmpty && (serverUrl.startsWith('http://') || serverUrl.startsWith('https://')) && _isValidUrl(serverUrl)) { // Debounce the API call - wait 1 second after user stops typing _oidcCheckTimer = Timer(const Duration(seconds: 1), () { _checkOidcProviders(serverUrl); }); } } bool _isValidUrl(String url) { try { final uri = Uri.parse(url); // Check if it has a proper host (not just protocol) return uri.hasScheme && uri.host.isNotEmpty && uri.host.contains('.') && // Must have at least one dot for domain uri.host.length > 3; // Minimum reasonable length } catch (e) { return false; } } Future _checkOidcProviders(String serverUrl) async { // Allow rechecking if server URL changed final currentUrl = _serverController.text.trim(); if (currentUrl != serverUrl) return; // URL changed while we were waiting setState(() { _isLoadingOidc = true; }); try { final providers = await OidcService.getPublicProviders(serverUrl); // Double-check the URL hasn't changed during the API call if (mounted && _serverController.text.trim() == serverUrl) { setState(() { _oidcProviders = providers; _hasCheckedOidc = true; _isLoadingOidc = false; }); } } catch (e) { // Only update state if URL hasn't changed if (mounted && _serverController.text.trim() == serverUrl) { setState(() { _oidcProviders.clear(); _hasCheckedOidc = true; _isLoadingOidc = false; }); } } } // Manual retry when user focuses on other fields (like username) void _retryOidcCheck() { final serverUrl = _serverController.text.trim(); if (serverUrl.isNotEmpty && _isValidUrl(serverUrl) && !_hasCheckedOidc && !_isLoadingOidc) { _checkOidcProviders(serverUrl); } } Future _handleOidcLogin(OidcProvider provider) async { final serverUrl = _serverController.text.trim(); if (serverUrl.isEmpty) { setState(() { _errorMessage = 'Please enter a server URL first'; }); return; } setState(() { _isLoading = true; _errorMessage = ''; }); try { // Generate PKCE and state parameters for security final pkce = OidcService.generatePkce(); final state = OidcService.generateState(); // Build authorization URL for in-app browser final authUrl = await OidcService.buildOidcLoginUrl( provider: provider, serverUrl: serverUrl, state: state, pkce: pkce, ); if (authUrl == null) { setState(() { _errorMessage = 'Failed to prepare OIDC authentication URL'; _isLoading = false; }); return; } setState(() { _isLoading = false; }); // Launch in-app browser if (mounted) { await Navigator.of(context).push( MaterialPageRoute( builder: (context) => OidcBrowser( authUrl: authUrl, serverUrl: serverUrl, onSuccess: (apiKey) async { Navigator.of(context).pop(); // Close the browser await _completeOidcLogin(apiKey, serverUrl); }, onError: (error) { Navigator.of(context).pop(); // Close the browser setState(() { _errorMessage = 'Authentication failed: $error'; }); }, ), ), ); } } catch (e) { setState(() { _errorMessage = 'OIDC login error: ${e.toString()}'; _isLoading = false; }); } } Future _completeOidcLogin(String apiKey, String serverUrl) async { if (!mounted) return; setState(() { _isLoading = true; _errorMessage = ''; }); try { // Verify API key final isValidKey = await PinepodsLoginService.verifyApiKey(serverUrl, apiKey); if (!isValidKey) { throw Exception('API key verification failed'); } // Get user ID final userId = await PinepodsLoginService.getUserId(serverUrl, apiKey); if (userId == null) { throw Exception('Failed to get user ID'); } // Get user details final userDetails = await PinepodsLoginService.getUserDetails(serverUrl, apiKey, userId); if (userDetails == null) { throw Exception('Failed to get user details'); } // Store credentials final settingsBloc = Provider.of(context, listen: false); settingsBloc.setPinepodsServer(serverUrl); settingsBloc.setPinepodsApiKey(apiKey); settingsBloc.setPinepodsUserId(userId); // Set additional user details if available if (userDetails.username != null) { settingsBloc.setPinepodsUsername(userDetails.username!); } if (userDetails.email != null) { settingsBloc.setPinepodsEmail(userDetails.email!); } // Fetch theme from server try { await settingsBloc.fetchThemeFromServer(); } catch (e) { // Theme fetch failure is non-critical } // Notify login success AuthNotifier.notifyLoginSuccess(); // Call the callback if provided if (widget.onLoginSuccess != null) { widget.onLoginSuccess!(); } } catch (e) { if (mounted) { setState(() { _errorMessage = 'Failed to complete login: ${e.toString()}'; _isLoading = false; }); } } } Future _connectToPinepods() async { if (!_formKey.currentState!.validate()) { return; } setState(() { _isLoading = true; _errorMessage = ''; }); try { if (_showMfaField && _tempMfaSessionToken != null) { // Complete MFA login flow final mfaCode = _mfaController.text.trim(); final result = await PinepodsLoginService.completeMfaLogin( serverUrl: _tempServerUrl!, username: _tempUsername!, mfaSessionToken: _tempMfaSessionToken!, mfaCode: mfaCode, ); if (result.isSuccess) { // Save the connection details including user ID final settingsBloc = Provider.of(context, listen: false); settingsBloc.setPinepodsServer(result.serverUrl!); settingsBloc.setPinepodsApiKey(result.apiKey!); settingsBloc.setPinepodsUserId(result.userId!); // Fetch theme from server after successful login await settingsBloc.fetchThemeFromServer(); setState(() { _isLoading = false; }); // Call success callback if (widget.onLoginSuccess != null) { widget.onLoginSuccess!(); } } else { setState(() { _errorMessage = result.errorMessage ?? 'MFA verification failed'; _isLoading = false; }); } } else { // Initial login flow final serverUrl = _serverController.text.trim(); final username = _usernameController.text.trim(); final password = _passwordController.text; final result = await PinepodsLoginService.login( serverUrl, username, password, ); if (result.isSuccess) { // Save the connection details including user ID final settingsBloc = Provider.of(context, listen: false); settingsBloc.setPinepodsServer(result.serverUrl!); settingsBloc.setPinepodsApiKey(result.apiKey!); settingsBloc.setPinepodsUserId(result.userId!); // Fetch theme from server after successful login await settingsBloc.fetchThemeFromServer(); setState(() { _isLoading = false; }); // Call success callback if (widget.onLoginSuccess != null) { widget.onLoginSuccess!(); } } else if (result.requiresMfa) { // Store MFA session info and show MFA field setState(() { _tempServerUrl = result.serverUrl; _tempUsername = result.username; _tempUserId = result.userId; _tempMfaSessionToken = result.mfaSessionToken; _showMfaField = true; _isLoading = false; _errorMessage = 'Please enter your MFA code'; }); } else { setState(() { _errorMessage = result.errorMessage ?? 'Login failed'; _isLoading = false; }); } } } catch (e) { setState(() { _errorMessage = 'Error: ${e.toString()}'; _isLoading = false; }); } } void _resetMfa() { setState(() { _showMfaField = false; _tempServerUrl = null; _tempUsername = null; _tempUserId = null; _tempMfaSessionToken = null; _mfaController.clear(); _errorMessage = ''; }); } /// Parse hex color string to Color object Color _parseColor(String hexColor) { try { final hex = hexColor.replaceAll('#', ''); if (hex.length == 6) { return Color(int.parse('FF$hex', radix: 16)); } else if (hex.length == 8) { return Color(int.parse(hex, radix: 16)); } } catch (e) { // Fallback to default color on parsing error } return Theme.of(context).primaryColor; } @override Widget build(BuildContext context) { return Scaffold( body: Container( decoration: BoxDecoration( image: DecorationImage( image: AssetImage(_selectedBackground), fit: BoxFit.cover, colorFilter: ColorFilter.mode( Colors.black.withOpacity(0.6), BlendMode.darken, ), ), ), child: SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24.0), child: Card( elevation: 8, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: Padding( padding: const EdgeInsets.all(32.0), child: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // App Logo/Title Center( child: Container( width: 80, height: 80, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 8, offset: const Offset(0, 4), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(16), child: Image.asset( 'assets/images/favicon.png', fit: BoxFit.contain, errorBuilder: (context, error, stackTrace) { return Container( decoration: BoxDecoration( color: Theme.of(context).primaryColor, borderRadius: BorderRadius.circular(16), ), child: Icon( Icons.headset, size: 48, color: Colors.white, ), ); }, ), ), ), ), const SizedBox(height: 16), Text( 'Welcome to PinePods', style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, ), textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( 'Connect to your PinePods server to get started', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey[600], ), textAlign: TextAlign.center, ), const SizedBox(height: 32), // Server URL Field TextFormField( controller: _serverController, decoration: InputDecoration( labelText: 'Server URL', hintText: 'https://your-pinepods-server.com', prefixIcon: const Icon(Icons.dns), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), ), validator: (value) { if (value == null || value.isEmpty) { return 'Please enter a server URL'; } if (!value.startsWith('http://') && !value.startsWith('https://')) { return 'URL must start with http:// or https://'; } return null; }, textInputAction: TextInputAction.next, ), const SizedBox(height: 16), // Username Field Focus( onFocusChange: (hasFocus) { if (hasFocus) { // User focused on username field, retry OIDC check if needed _retryOidcCheck(); } }, child: TextFormField( controller: _usernameController, decoration: InputDecoration( labelText: 'Username', prefixIcon: const Icon(Icons.person), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), ), validator: (value) { if (value == null || value.isEmpty) { return 'Please enter your username'; } return null; }, textInputAction: TextInputAction.next, ), ), const SizedBox(height: 16), // Password Field TextFormField( controller: _passwordController, decoration: InputDecoration( labelText: 'Password', prefixIcon: const Icon(Icons.lock), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), ), obscureText: true, validator: (value) { if (value == null || value.isEmpty) { return 'Please enter your password'; } return null; }, textInputAction: _showMfaField ? TextInputAction.next : TextInputAction.done, onFieldSubmitted: (_) { if (!_showMfaField) { _connectToPinepods(); } }, enabled: !_showMfaField, ), // MFA Field (shown when MFA is required) if (_showMfaField) ...[ const SizedBox(height: 16), TextFormField( controller: _mfaController, decoration: InputDecoration( labelText: 'MFA Code', hintText: 'Enter 6-digit code', prefixIcon: const Icon(Icons.security), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), suffixIcon: IconButton( icon: const Icon(Icons.close), onPressed: _resetMfa, tooltip: 'Cancel MFA', ), ), keyboardType: TextInputType.number, maxLength: 6, validator: (value) { if (_showMfaField && (value == null || value.isEmpty)) { return 'Please enter your MFA code'; } if (_showMfaField && value!.length != 6) { return 'MFA code must be 6 digits'; } return null; }, textInputAction: TextInputAction.done, onFieldSubmitted: (_) => _connectToPinepods(), ), ], // Error Message if (_errorMessage.isNotEmpty) ...[ const SizedBox(height: 16), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.red.shade50, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.red.shade200), ), child: Row( children: [ Icon(Icons.error_outline, color: Colors.red.shade700), const SizedBox(width: 8), Expanded( child: Text( _errorMessage, style: TextStyle(color: Colors.red.shade700), ), ), ], ), ), ], const SizedBox(height: 24), // Connect Button ElevatedButton( onPressed: _isLoading ? null : _connectToPinepods, style: ElevatedButton.styleFrom( padding: const EdgeInsets.all(16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: _isLoading ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator( strokeWidth: 2, ), ) : Text( _showMfaField ? 'Verify MFA Code' : 'Connect to PinePods', style: const TextStyle(fontSize: 16), ), ), const SizedBox(height: 16), // OIDC Providers Section if (_oidcProviders.isNotEmpty && !_showMfaField) ...[ // Divider Row( children: [ const Expanded(child: Divider()), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( 'Or continue with', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey[600], ), ), ), const Expanded(child: Divider()), ], ), const SizedBox(height: 16), // OIDC Provider Buttons ..._oidcProviders.map((provider) => Padding( padding: const EdgeInsets.only(bottom: 8), child: SizedBox( width: double.infinity, child: ElevatedButton( onPressed: _isLoading ? null : () => _handleOidcLogin(provider), style: ElevatedButton.styleFrom( backgroundColor: _parseColor(provider.buttonColorHex), foregroundColor: _parseColor(provider.buttonTextColorHex), padding: const EdgeInsets.all(16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (provider.iconSvg != null && provider.iconSvg!.isNotEmpty) Container( width: 20, height: 20, margin: const EdgeInsets.only(right: 8), child: const Icon(Icons.account_circle, size: 20), ), Text( provider.displayText, style: const TextStyle(fontSize: 16), ), ], ), ), ), )), const SizedBox(height: 16), ], // Loading indicator for OIDC discovery if (_isLoadingOidc) ...[ const SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(height: 16), ], // Additional Info Text( 'Don\'t have a PinePods server? Visit pinepods.online to learn more.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey[600], ), textAlign: TextAlign.center, ), ], ), ), ), ), ), ), ), ), ); } /// Handle login success from any source (traditional or OIDC) void _handleLoginSuccess() { if (mounted) { widget.onLoginSuccess?.call(); } } @override void dispose() { _oidcCheckTimer?.cancel(); _serverController.removeListener(_onServerUrlChanged); _serverController.dispose(); _usernameController.dispose(); _passwordController.dispose(); _mfaController.dispose(); // Clear global callback to prevent memory leaks AuthNotifier.clearGlobalLoginSuccessCallback(); super.dispose(); } }