Files
PinePods-nix/PinePods-0.8.2/mobile/lib/ui/auth/pinepods_startup_login.dart
2026-03-03 10:57:43 -05:00

777 lines
28 KiB
Dart

// 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<PinepodsStartupLogin> createState() => _PinepodsStartupLoginState();
}
class _PinepodsStartupLoginState extends State<PinepodsStartupLogin> {
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _mfaController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _showMfaField = false;
bool _isLoadingOidc = false;
String _errorMessage = '';
String? _tempServerUrl;
String? _tempUsername;
int? _tempUserId;
String? _tempMfaSessionToken;
List<OidcProvider> _oidcProviders = [];
bool _hasCheckedOidc = false;
Timer? _oidcCheckTimer;
// List of background images - you can add your own images to assets/images/
final List<String> _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<void> _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<void> _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<void> _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<SettingsBloc>(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<void> _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<SettingsBloc>(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<SettingsBloc>(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();
}
}