4 import 'package:flutter/material.dart';
5 import 'package:collection/collection.dart';
6 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart'
8 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart';
9 import 'package:proxmox_login_manager/proxmox_general_settings_form.dart';
10 import 'package:proxmox_login_manager/proxmox_general_settings_model.dart';
11 import 'package:proxmox_login_manager/proxmox_login_model.dart';
12 import 'package:proxmox_login_manager/proxmox_tfa_form.dart';
13 import 'package:proxmox_login_manager/extension.dart';
14 import 'package:proxmox_login_manager/proxmox_password_store.dart';
16 class ProxmoxProgressModel {
18 String message = 'Loading...';
19 ProxmoxProgressModel({
21 this.message = 'Loading...',
25 // FIXME: copied from pve_flutter_frontend, re-use common set
27 static const Color orange = Color(0xFFE57000);
28 static const Color supportGrey = Color(0xFFABBABA);
29 static const Color supportBlue = Color(0xFF00617F);
32 class ProxmoxLoginForm extends StatefulWidget {
33 final TextEditingController originController;
34 final FormFieldValidator<String> originValidator;
35 final TextEditingController usernameController;
36 final TextEditingController passwordController;
37 final List<PveAccessDomainModel?>? accessDomains;
38 final PveAccessDomainModel? selectedDomain;
39 final ValueChanged<PveAccessDomainModel?> onDomainChanged;
40 final Function? onPasswordSubmitted;
41 final Function onOriginSubmitted;
42 final Function? onSavePasswordChanged;
43 final bool? canSavePassword;
44 final bool? passwordSaved;
46 const ProxmoxLoginForm({
48 required this.originController,
49 required this.usernameController,
50 required this.passwordController,
51 required this.accessDomains,
52 required this.originValidator,
54 required this.onDomainChanged,
55 this.onPasswordSubmitted,
56 required this.onOriginSubmitted,
57 this.onSavePasswordChanged,
63 State<ProxmoxLoginForm> createState() => _ProxmoxLoginFormState();
66 class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
68 bool? _savePwCheckbox;
69 FocusNode? passwordFocusNode;
72 Widget build(BuildContext context) {
73 if (widget.accessDomains == null) {
75 decoration: const InputDecoration(
76 icon: Icon(Icons.vpn_lock),
78 hintText: 'e.g. 192.168.1.2',
80 'Protocol (https) and default port (8006 or 443) implied'),
81 textInputAction: TextInputAction.next,
82 controller: widget.originController,
83 validator: widget.originValidator,
84 onFieldSubmitted: (value) => widget.onOriginSubmitted(),
90 mainAxisAlignment: MainAxisAlignment.center,
93 decoration: const InputDecoration(
94 icon: Icon(Icons.vpn_lock),
97 controller: widget.originController,
101 decoration: const InputDecoration(
102 icon: Icon(Icons.person),
103 labelText: 'Username',
105 controller: widget.usernameController,
107 if (value!.isEmpty) {
108 return 'Please enter username';
112 autofillHints: const [AutofillHints.username],
114 DropdownButtonFormField(
115 decoration: const InputDecoration(icon: Icon(Icons.domain)),
116 items: widget.accessDomains!
117 .map((e) => DropdownMenuItem(
120 title: Text(e!.realm),
121 subtitle: Text(e.comment ?? ''),
125 onChanged: widget.onDomainChanged,
126 selectedItemBuilder: (context) =>
127 widget.accessDomains!.map((e) => Text(e!.realm)).toList(),
128 value: widget.selectedDomain,
133 decoration: const InputDecoration(
134 icon: Icon(Icons.lock),
135 labelText: 'Password',
137 controller: widget.passwordController,
138 obscureText: _obscure,
140 focusNode: passwordFocusNode,
142 if (value!.isEmpty) {
143 return 'Please enter password';
147 onFieldSubmitted: (value) => widget.onPasswordSubmitted!(),
148 autofillHints: const [AutofillHints.password],
151 alignment: Alignment.bottomRight,
153 constraints: BoxConstraints.tight(const Size(58, 58)),
156 Icon(_obscure ? Icons.visibility : Icons.visibility_off),
157 onPressed: () => setState(() {
158 _obscure = !_obscure;
164 if (widget.canSavePassword ?? false)
166 title: const Text('Save password'),
167 value: _savePwCheckbox ?? widget.passwordSaved ?? false,
169 if (widget.onSavePasswordChanged != null) {
170 widget.onSavePasswordChanged!(value!);
173 _savePwCheckbox = value!;
184 passwordFocusNode?.dispose();
189 class ProxmoxLoginPage extends StatefulWidget {
190 final ProxmoxLoginModel? userModel;
191 final bool? isCreate;
192 final String? ticket;
193 final String? password;
195 const ProxmoxLoginPage({
203 State<ProxmoxLoginPage> createState() => _ProxmoxLoginPageState();
206 class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
207 final _originController = TextEditingController();
208 final _usernameController = TextEditingController();
209 final _passwordController = TextEditingController();
210 Future<List<PveAccessDomainModel?>?>? _accessDomains;
211 PveAccessDomainModel? _selectedDomain;
212 final _formKey = GlobalKey<FormState>();
213 ProxmoxProgressModel _progressModel = ProxmoxProgressModel();
214 bool _submittButtonEnabled = true;
215 bool _canSavePassword = false;
216 bool _savePasswordCB = false;
221 final userModel = widget.userModel;
222 _progressModel = ProxmoxProgressModel();
223 if (!widget.isCreate! && userModel != null) {
224 _originController.text = userModel.origin?.toString() ?? '';
225 // Uri does not append 443 for https, so we do it manually
226 if (userModel.origin != null &&
227 userModel.origin!.scheme == "https" &&
228 userModel.origin!.port == 443) {
229 _originController.text += ":443";
231 _passwordController.text = widget.password ?? '';
232 _accessDomains = _getAccessDomains();
233 _usernameController.text = userModel.username!;
234 _savePasswordCB = widget.password != null;
235 if ((widget.ticket!.isNotEmpty && userModel.activeSession) ||
236 widget.password != null) {
237 _onLoginButtonPressed(ticket: widget.ticket!, mRealm: userModel.realm);
243 Widget build(BuildContext context) {
245 //data: ThemeData.dark().copyWith(accentColor: Color(0xFFE47225)),
246 data: ThemeData.dark().copyWith(
247 textButtonTheme: TextButtonThemeData(
248 style: TextButton.styleFrom(
249 foregroundColor: Colors.white,
250 backgroundColor: ProxmoxColors.orange,
251 disabledBackgroundColor: Colors.grey,
254 colorScheme: const ColorScheme.dark().copyWith(
255 primary: ProxmoxColors.orange,
256 secondary: ProxmoxColors.orange,
257 onSecondary: ProxmoxColors.supportGrey),
258 checkboxTheme: CheckboxThemeData(
259 fillColor: MaterialStateProperty.resolveWith<Color?>(
260 (Set<MaterialState> states) {
261 if (states.contains(MaterialState.disabled)) {
264 if (states.contains(MaterialState.selected)) {
265 return ProxmoxColors.orange;
270 radioTheme: RadioThemeData(
271 fillColor: MaterialStateProperty.resolveWith<Color?>(
272 (Set<MaterialState> states) {
273 if (states.contains(MaterialState.disabled)) {
276 if (states.contains(MaterialState.selected)) {
277 return ProxmoxColors.orange;
282 switchTheme: SwitchThemeData(
283 thumbColor: MaterialStateProperty.resolveWith<Color?>(
284 (Set<MaterialState> states) {
285 if (states.contains(MaterialState.disabled)) {
288 if (states.contains(MaterialState.selected)) {
289 return ProxmoxColors.orange;
293 trackColor: MaterialStateProperty.resolveWith<Color?>(
294 (Set<MaterialState> states) {
295 if (states.contains(MaterialState.disabled)) {
298 if (states.contains(MaterialState.selected)) {
299 return ProxmoxColors.orange;
306 backgroundColor: ProxmoxColors.supportBlue,
307 extendBodyBehindAppBar: true,
310 backgroundColor: Colors.transparent,
312 icon: const Icon(Icons.close),
313 onPressed: () => Navigator.of(context).pop(),
318 SingleChildScrollView(
319 child: ConstrainedBox(
320 constraints: BoxConstraints.tightFor(
321 height: MediaQuery.of(context).size.height),
324 padding: const EdgeInsets.all(8.0),
325 child: FutureBuilder<List<PveAccessDomainModel?>?>(
326 future: _accessDomains,
327 builder: (context, snapshot) {
332 _submittButtonEnabled =
333 _formKey.currentState!.validate();
337 mainAxisAlignment: MainAxisAlignment.center,
341 mainAxisAlignment: MainAxisAlignment.center,
344 'assets/images/proxmox_logo_symbol_wordmark.png',
345 package: 'proxmox_login_manager',
351 originController: _originController,
352 originValidator: (value) {
353 if (value == null || value.isEmpty) {
354 return 'Please enter origin';
359 } on FormatException catch (_) {
360 return 'Invalid URI';
361 } on Exception catch (e) {
362 return 'Invalid URI: $e';
365 usernameController: _usernameController,
366 passwordController: _passwordController,
367 accessDomains: snapshot.data,
368 selectedDomain: _selectedDomain,
369 onSavePasswordChanged: (value) {
370 _savePasswordCB = value;
372 canSavePassword: _canSavePassword,
373 passwordSaved: widget.password != null,
374 onDomainChanged: (value) {
376 _selectedDomain = value;
379 onOriginSubmitted: () {
381 _formKey.currentState!.validate();
383 _submittButtonEnabled = isValid;
387 _accessDomains = _getAccessDomains();
391 onPasswordSubmitted: _submittButtonEnabled
394 _formKey.currentState!.validate();
396 _submittButtonEnabled = isValid;
399 _onLoginButtonPressed();
406 alignment: Alignment.bottomCenter,
408 width: MediaQuery.of(context).size.width,
410 onPressed: _submittButtonEnabled
412 final isValid = _formKey
416 _submittButtonEnabled =
420 if (snapshot.hasData) {
421 _onLoginButtonPressed();
431 child: const Text('Continue'),
444 if (_progressModel.inProgress > 0)
445 ProxmoxProgressOverlay(message: _progressModel.message),
452 Future<void> _onLoginButtonPressed(
453 {String ticket = '', String? mRealm}) async {
457 ..message = 'Authenticating...';
461 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
462 //cleaned form fields
463 final origin = normalizeUrl(_originController.text.trim());
464 final username = _usernameController.text.trim();
465 final String enteredPassword = _passwordController.text.trim();
466 final String? savedPassword = widget.password;
468 final password = ticket.isNotEmpty ? ticket : enteredPassword;
469 final realm = _selectedDomain?.realm ?? mRealm;
471 var client = await proxclient.authenticate(
472 '$username@$realm', password, origin, settings.sslValidation!);
474 if (client.credentials.tfa != null &&
475 client.credentials.tfa!.kinds().isNotEmpty) {
476 if (!mounted) return;
477 ProxmoxApiClient? tfaclient =
478 await Navigator.of(context).push(MaterialPageRoute(
479 builder: (context) => ProxmoxTfaForm(
484 if (tfaclient != null) {
488 _progressModel.inProgress -= 1;
494 final status = await client.getClusterStatus();
496 status.singleWhereOrNull((element) => element.local ?? false)?.name;
497 var loginStorage = await ProxmoxLoginStorage.fromLocalStorage();
499 final savePW = enteredPassword != '' &&
501 enteredPassword != savedPassword;
502 final deletePW = enteredPassword != '' && !savePW && !_savePasswordCB;
505 if (widget.isCreate!) {
506 final newLogin = ProxmoxLoginModel((b) => b
508 ..username = username
510 ..productType = ProxmoxProductType.pve
511 ..ticket = client.credentials.ticket
512 ..passwordSaved = savePW
513 ..hostname = hostname);
515 loginStorage = loginStorage!.rebuild((b) => b..logins.add(newLogin));
516 id = newLogin.identifier;
518 loginStorage = loginStorage!.rebuild((b) => b
519 ..logins.rebuildWhere(
520 (m) => m == widget.userModel,
522 ..ticket = client.credentials.ticket
524 savePW || (deletePW ? false : b.passwordSaved ?? false)
525 ..hostname = hostname));
526 id = widget.userModel!.identifier;
532 await savePassword(id, enteredPassword);
533 } else if (deletePW) {
534 await deletePassword(id);
537 if (!mounted) return;
540 builder: (context) => AlertDialog(
541 title: const Text('Password saving error'),
544 mainAxisSize: MainAxisSize.min,
546 const Text('Could not save or delete password.'),
548 title: const Text('Details'),
549 children: [Text(e.toString())],
555 onPressed: () => Navigator.of(context).pop(),
556 child: const Text('Continue')),
561 await loginStorage.saveToDisk();
564 Navigator.of(context).pop(client);
566 } on proxclient.ProxmoxApiException catch (e) {
568 if (!mounted) return;
569 if (e.message.contains('No ticket')) {
572 builder: (context) => AlertDialog(
573 title: const Text('Version Error'),
575 'Proxmox VE version not supported, please update your instance to use this app.'),
578 onPressed: () => Navigator.of(context).pop(),
579 child: const Text('Close'),
587 builder: (context) => ProxmoxApiErrorDialog(
596 if (e.runtimeType == HandshakeException) {
599 builder: (context) => const ProxmoxCertificateErrorDialog(),
604 builder: (context) => ConnectionErrorDialog(exception: e),
610 _progressModel.inProgress -= 1;
614 Future<List<PveAccessDomainModel?>?> _loadAccessDomains(Uri uri) async {
615 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
616 List<PveAccessDomainModel?>? response;
618 response = await proxclient.accessDomains(uri, settings.sslValidation!);
619 } on proxclient.ProxmoxApiException catch (e) {
623 builder: (context) => ProxmoxApiErrorDialog(
628 } on HandshakeException {
632 builder: (context) => const ProxmoxCertificateErrorDialog(),
639 Future<List<PveAccessDomainModel?>?> _tryLoadAccessDomains(Uri uri) async {
640 List<PveAccessDomainModel?>? response;
642 response = await _loadAccessDomains(uri);
647 builder: (context) => ConnectionErrorDialog(
656 Future<List<PveAccessDomainModel?>?> _getAccessDomains() async {
660 ..message = 'Connecting...';
663 final canSavePW = await canSavePassword();
665 _canSavePassword = canSavePW;
668 var host = _originController.text.trim();
669 var apiBaseUrl = normalizeUrl(host);
671 RegExp portRE = RegExp(r":\d{1,5}$");
673 List<PveAccessDomainModel?>? response;
675 if (portRE.hasMatch(host)) {
676 response = await _tryLoadAccessDomains(apiBaseUrl);
678 // try to guess the port, 8006 first, and then 443
679 apiBaseUrl = apiBaseUrl.replace(port: 8006);
681 response = await _loadAccessDomains(apiBaseUrl);
682 if (response != null) {
683 _originController.text = '$host:8006';
686 // we were no port given, and we couldn't reach on port 8006, retry with 443
687 apiBaseUrl = apiBaseUrl.replace(port: 443);
688 response = await _tryLoadAccessDomains(apiBaseUrl);
689 if (response != null) {
690 _originController.text = '$host:443';
695 response?.sort((a, b) => a!.realm.compareTo(b!.realm));
697 final selection = response?.singleWhere(
698 (e) => e!.realm == widget.userModel?.realm,
699 orElse: () => response?.first,
703 _progressModel.inProgress -= 1;
704 _selectedDomain = selection;
712 _originController.dispose();
713 _usernameController.dispose();
714 _passwordController.dispose();
719 class ProxmoxProgressOverlay extends StatelessWidget {
720 const ProxmoxProgressOverlay({
722 required this.message,
725 final String message;
728 Widget build(BuildContext context) {
730 decoration: BoxDecoration(color: Colors.black.withOpacity(0.5)),
733 mainAxisAlignment: MainAxisAlignment.center,
737 style: const TextStyle(
742 padding: EdgeInsets.only(top: 20.0),
743 child: CircularProgressIndicator(),
752 class ConnectionErrorDialog extends StatelessWidget {
753 final Object exception;
755 const ConnectionErrorDialog({
757 required this.exception,
761 Widget build(BuildContext context) {
763 title: const Text('Connection error'),
764 content: Text('Could not establish connection: $exception'),
767 onPressed: () => Navigator.of(context).pop(),
768 child: const Text('Close'),
775 class ProxmoxApiErrorDialog extends StatelessWidget {
776 final proxclient.ProxmoxApiException exception;
778 const ProxmoxApiErrorDialog({
780 required this.exception,
784 Widget build(BuildContext context) {
786 title: const Text('API Error'),
787 content: SingleChildScrollView(
788 child: Text(exception.message),
792 onPressed: () => Navigator.of(context).pop(),
793 child: const Text('Close'),
800 class ProxmoxCertificateErrorDialog extends StatelessWidget {
801 const ProxmoxCertificateErrorDialog({
806 Widget build(BuildContext context) {
808 title: const Text('Certificate error'),
809 content: SingleChildScrollView(
811 crossAxisAlignment: CrossAxisAlignment.start,
813 const Text('Your connection is not private.'),
815 'Note: Consider to disable SSL validation,'
816 ' if you use a self signed, not commonly trusted, certificate.',
817 style: Theme.of(context).textTheme.bodySmall,
824 onPressed: () => Navigator.of(context).pop(),
825 child: const Text('Close'),
828 onPressed: () => Navigator.of(context).pushReplacement(
830 builder: (context) => const ProxmoxGeneralSettingsForm())),
831 child: const Text('Settings'),
838 Uri normalizeUrl(String urlText) {
839 if (urlText.startsWith('https://')) {
840 urlText = urlText.substring('https://'.length);
842 if (urlText.startsWith('http://')) {
843 throw Exception("HTTP without TLS is not supported");
846 return Uri.https(urlText, '');