777 lines
28 KiB
Dart
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();
|
|
}
|
|
} |