added cargo files
This commit is contained in:
777
PinePods-0.8.2/mobile/lib/ui/auth/pinepods_startup_login.dart
Normal file
777
PinePods-0.8.2/mobile/lib/ui/auth/pinepods_startup_login.dart
Normal file
@@ -0,0 +1,777 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user