]> git.proxmox.com Git - flutter/proxmox_login_manager.git/blob - lib/proxmox_login_form.dart
5776ea516ea50796bdaa7be0c7bb387fa5dc1be7
[flutter/proxmox_login_manager.git] / lib / proxmox_login_form.dart
1 import 'dart:io';
2 import 'dart:async';
3
4 import 'package:flutter/material.dart';
5 import 'package:collection/src/iterable_extensions.dart';
6 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart'
7 as proxclient;
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';
15
16 class ProxmoxProgressModel {
17 int inProgress = 0;
18 String message = 'Loading...';
19 ProxmoxProgressModel({
20 this.inProgress = 0,
21 this.message = 'Loading...',
22 });
23 }
24
25 // FIXME: copied from pve_flutter_frontend, re-use common set
26 class ProxmoxColors {
27 static const Color orange = Color(0xFFE57000);
28 static const Color supportGrey = Color(0xFFABBABA);
29 static const Color supportBlue = Color(0xFF00617F);
30 }
31
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;
45
46 const ProxmoxLoginForm({
47 Key? key,
48 required this.originController,
49 required this.usernameController,
50 required this.passwordController,
51 required this.accessDomains,
52 required this.originValidator,
53 this.selectedDomain,
54 required this.onDomainChanged,
55 this.onPasswordSubmitted,
56 required this.onOriginSubmitted,
57 this.onSavePasswordChanged,
58 this.canSavePassword,
59 this.passwordSaved,
60 }) : super(key: key);
61
62 @override
63 State<ProxmoxLoginForm> createState() => _ProxmoxLoginFormState();
64 }
65
66 class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
67 bool _obscure = true;
68 bool? _savePwCheckbox;
69 FocusNode? passwordFocusNode;
70
71 @override
72 Widget build(BuildContext context) {
73 if (widget.accessDomains == null) {
74 return TextFormField(
75 decoration: const InputDecoration(
76 icon: Icon(Icons.vpn_lock),
77 labelText: 'Origin',
78 hintText: 'e.g. 192.168.1.2',
79 helperText:
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(),
85 );
86 }
87
88 return AutofillGroup(
89 child: Column(
90 mainAxisAlignment: MainAxisAlignment.center,
91 children: [
92 TextFormField(
93 decoration: const InputDecoration(
94 icon: Icon(Icons.vpn_lock),
95 labelText: 'Origin',
96 ),
97 controller: widget.originController,
98 enabled: false,
99 ),
100 TextFormField(
101 decoration: const InputDecoration(
102 icon: Icon(Icons.person),
103 labelText: 'Username',
104 ),
105 controller: widget.usernameController,
106 validator: (value) {
107 if (value!.isEmpty) {
108 return 'Please enter username';
109 }
110 return null;
111 },
112 autofillHints: const [AutofillHints.username],
113 ),
114 DropdownButtonFormField(
115 decoration: const InputDecoration(icon: Icon(Icons.domain)),
116 items: widget.accessDomains!
117 .map((e) => DropdownMenuItem(
118 value: e,
119 child: ListTile(
120 title: Text(e!.realm),
121 subtitle: Text(e.comment ?? ''),
122 ),
123 ))
124 .toList(),
125 onChanged: widget.onDomainChanged,
126 selectedItemBuilder: (context) =>
127 widget.accessDomains!.map((e) => Text(e!.realm)).toList(),
128 value: widget.selectedDomain,
129 ),
130 Stack(
131 children: [
132 TextFormField(
133 decoration: const InputDecoration(
134 icon: Icon(Icons.lock),
135 labelText: 'Password',
136 ),
137 controller: widget.passwordController,
138 obscureText: _obscure,
139 autocorrect: false,
140 focusNode: passwordFocusNode,
141 validator: (value) {
142 if (value!.isEmpty) {
143 return 'Please enter password';
144 }
145 return null;
146 },
147 onFieldSubmitted: (value) => widget.onPasswordSubmitted!(),
148 autofillHints: const [AutofillHints.password],
149 ),
150 Align(
151 alignment: Alignment.bottomRight,
152 child: IconButton(
153 constraints: BoxConstraints.tight(const Size(58, 58)),
154 iconSize: 24,
155 icon:
156 Icon(_obscure ? Icons.visibility : Icons.visibility_off),
157 onPressed: () => setState(() {
158 _obscure = !_obscure;
159 }),
160 ),
161 )
162 ],
163 ),
164 if (widget.canSavePassword ?? false)
165 CheckboxListTile(
166 title: const Text('Save password'),
167 value: _savePwCheckbox ?? widget.passwordSaved ?? false,
168 onChanged: (value) {
169 if (widget.onSavePasswordChanged != null) {
170 widget.onSavePasswordChanged!(value!);
171 }
172 setState(() {
173 _savePwCheckbox = value!;
174 });
175 },
176 )
177 ],
178 ),
179 );
180 }
181
182 @override
183 void dispose() {
184 passwordFocusNode?.dispose();
185 super.dispose();
186 }
187 }
188
189 class ProxmoxLoginPage extends StatefulWidget {
190 final ProxmoxLoginModel? userModel;
191 final bool? isCreate;
192 final String? ticket;
193 final String? password;
194
195 const ProxmoxLoginPage({
196 Key? key,
197 this.userModel,
198 this.isCreate,
199 this.ticket = '',
200 this.password,
201 }) : super(key: key);
202 @override
203 State<ProxmoxLoginPage> createState() => _ProxmoxLoginPageState();
204 }
205
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;
217
218 @override
219 void initState() {
220 super.initState();
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";
230 }
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);
238 }
239 }
240 }
241
242 @override
243 Widget build(BuildContext context) {
244 return Theme(
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,
252 ),
253 ),
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)) {
262 return null;
263 }
264 if (states.contains(MaterialState.selected)) {
265 return ProxmoxColors.orange;
266 }
267 return null;
268 }),
269 ),
270 radioTheme: RadioThemeData(
271 fillColor: MaterialStateProperty.resolveWith<Color?>(
272 (Set<MaterialState> states) {
273 if (states.contains(MaterialState.disabled)) {
274 return null;
275 }
276 if (states.contains(MaterialState.selected)) {
277 return ProxmoxColors.orange;
278 }
279 return null;
280 }),
281 ),
282 switchTheme: SwitchThemeData(
283 thumbColor: MaterialStateProperty.resolveWith<Color?>(
284 (Set<MaterialState> states) {
285 if (states.contains(MaterialState.disabled)) {
286 return null;
287 }
288 if (states.contains(MaterialState.selected)) {
289 return ProxmoxColors.orange;
290 }
291 return null;
292 }),
293 trackColor: MaterialStateProperty.resolveWith<Color?>(
294 (Set<MaterialState> states) {
295 if (states.contains(MaterialState.disabled)) {
296 return null;
297 }
298 if (states.contains(MaterialState.selected)) {
299 return ProxmoxColors.orange;
300 }
301 return null;
302 }),
303 ),
304 ),
305 child: Scaffold(
306 backgroundColor: ProxmoxColors.supportBlue,
307 extendBodyBehindAppBar: true,
308 appBar: AppBar(
309 elevation: 0.0,
310 backgroundColor: Colors.transparent,
311 leading: IconButton(
312 icon: const Icon(Icons.close),
313 onPressed: () => Navigator.of(context).pop(),
314 ),
315 ),
316 body: Stack(
317 children: [
318 SingleChildScrollView(
319 child: ConstrainedBox(
320 constraints: BoxConstraints.tightFor(
321 height: MediaQuery.of(context).size.height),
322 child: SafeArea(
323 child: Padding(
324 padding: const EdgeInsets.all(8.0),
325 child: FutureBuilder<List<PveAccessDomainModel?>?>(
326 future: _accessDomains,
327 builder: (context, snapshot) {
328 return Form(
329 key: _formKey,
330 onChanged: () {
331 setState(() {
332 _submittButtonEnabled =
333 _formKey.currentState!.validate();
334 });
335 },
336 child: Column(
337 mainAxisAlignment: MainAxisAlignment.center,
338 children: [
339 Expanded(
340 child: Container(
341 child: Column(
342 mainAxisAlignment:
343 MainAxisAlignment.center,
344 children: [
345 Image.asset(
346 'assets/images/proxmox_logo_symbol_wordmark.png',
347 package: 'proxmox_login_manager',
348 ),
349 ],
350 ),
351 ),
352 ),
353 ProxmoxLoginForm(
354 originController: _originController,
355 originValidator: (value) {
356 if (value == null || value.isEmpty) {
357 return 'Please enter origin';
358 }
359 try {
360 normalizeUrl(value);
361 return null;
362 } on FormatException catch (_) {
363 return 'Invalid URI';
364 } on Exception catch (e) {
365 return 'Invalid URI: $e';
366 }
367 },
368 usernameController: _usernameController,
369 passwordController: _passwordController,
370 accessDomains: snapshot.data,
371 selectedDomain: _selectedDomain,
372 onSavePasswordChanged: (value) {
373 _savePasswordCB = value;
374 },
375 canSavePassword: _canSavePassword,
376 passwordSaved: widget.password != null,
377 onDomainChanged: (value) {
378 setState(() {
379 _selectedDomain = value;
380 });
381 },
382 onOriginSubmitted: () {
383 final isValid =
384 _formKey.currentState!.validate();
385 setState(() {
386 _submittButtonEnabled = isValid;
387 });
388 if (isValid) {
389 setState(() {
390 _accessDomains = _getAccessDomains();
391 });
392 }
393 },
394 onPasswordSubmitted: _submittButtonEnabled
395 ? () {
396 final isValid =
397 _formKey.currentState!.validate();
398 setState(() {
399 _submittButtonEnabled = isValid;
400 });
401 if (isValid) {
402 _onLoginButtonPressed();
403 }
404 }
405 : null,
406 ),
407 Expanded(
408 child: Align(
409 alignment: Alignment.bottomCenter,
410 child: SizedBox(
411 width: MediaQuery.of(context).size.width,
412 child: TextButton(
413 onPressed: _submittButtonEnabled
414 ? () {
415 final isValid = _formKey
416 .currentState!
417 .validate();
418 setState(() {
419 _submittButtonEnabled =
420 isValid;
421 });
422 if (isValid) {
423 if (snapshot.hasData) {
424 _onLoginButtonPressed();
425 } else {
426 setState(() {
427 _accessDomains =
428 _getAccessDomains();
429 });
430 }
431 }
432 }
433 : null,
434 child: const Text('Continue'),
435 ),
436 ),
437 ),
438 ),
439 ],
440 ),
441 );
442 }),
443 ),
444 ),
445 ),
446 ),
447 if (_progressModel.inProgress > 0)
448 ProxmoxProgressOverlay(message: _progressModel.message),
449 ],
450 ),
451 ),
452 );
453 }
454
455 Future<void> _onLoginButtonPressed(
456 {String ticket = '', String? mRealm}) async {
457 setState(() {
458 _progressModel
459 ..inProgress += 1
460 ..message = 'Authenticating...';
461 });
462
463 try {
464 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
465 //cleaned form fields
466 final origin = normalizeUrl(_originController.text.trim());
467 final username = _usernameController.text.trim();
468 final String enteredPassword = _passwordController.text.trim();
469 final String? savedPassword = widget.password;
470
471 final password = ticket.isNotEmpty ? ticket : enteredPassword;
472 final realm = _selectedDomain?.realm ?? mRealm;
473
474 var client = await proxclient.authenticate(
475 '$username@$realm', password, origin, settings.sslValidation!);
476
477 if (client.credentials.tfa != null &&
478 client.credentials.tfa!.kinds().isNotEmpty) {
479 if (!mounted) return;
480 ProxmoxApiClient? tfaclient =
481 await Navigator.of(context).push(MaterialPageRoute(
482 builder: (context) => ProxmoxTfaForm(
483 apiClient: client,
484 ),
485 ));
486
487 if (tfaclient != null) {
488 client = tfaclient;
489 } else {
490 setState(() {
491 _progressModel.inProgress -= 1;
492 });
493 return;
494 }
495 }
496
497 final status = await client.getClusterStatus();
498 final hostname =
499 status.singleWhereOrNull((element) => element.local ?? false)?.name;
500 var loginStorage = await ProxmoxLoginStorage.fromLocalStorage();
501
502 final savePW = enteredPassword != '' &&
503 _savePasswordCB &&
504 enteredPassword != savedPassword;
505 final deletePW = enteredPassword != '' && !savePW && !_savePasswordCB;
506 String? id;
507
508 if (widget.isCreate!) {
509 final newLogin = ProxmoxLoginModel((b) => b
510 ..origin = origin
511 ..username = username
512 ..realm = realm
513 ..productType = ProxmoxProductType.pve
514 ..ticket = client.credentials.ticket
515 ..passwordSaved = savePW
516 ..hostname = hostname);
517
518 loginStorage = loginStorage!.rebuild((b) => b..logins.add(newLogin));
519 id = newLogin.identifier;
520 } else {
521 loginStorage = loginStorage!.rebuild((b) => b
522 ..logins.rebuildWhere(
523 (m) => m == widget.userModel,
524 (b) => b
525 ..ticket = client.credentials.ticket
526 ..passwordSaved =
527 savePW || (deletePW ? false : b.passwordSaved ?? false)
528 ..hostname = hostname));
529 id = widget.userModel!.identifier;
530 }
531
532 if (id != null) {
533 if (savePW) {
534 await savePassword(id, enteredPassword);
535 } else if (deletePW) {
536 await deletePassword(id);
537 }
538 }
539 await loginStorage.saveToDisk();
540
541 if (mounted) {
542 Navigator.of(context).pop(client);
543 }
544 } on proxclient.ProxmoxApiException catch (e) {
545 print(e);
546 if (!mounted) return;
547 if (e.message.contains('No ticket')) {
548 showDialog(
549 context: context,
550 builder: (context) => AlertDialog(
551 title: const Text('Version Error'),
552 content: const Text(
553 'Proxmox VE version not supported, please update your instance to use this app.'),
554 actions: [
555 TextButton(
556 onPressed: () => Navigator.of(context).pop(),
557 child: const Text('Close'),
558 ),
559 ],
560 ),
561 );
562 } else {
563 showDialog(
564 context: context,
565 builder: (context) => ProxmoxApiErrorDialog(
566 exception: e,
567 ),
568 );
569 }
570 } catch (e, trace) {
571 print(e);
572 print(trace);
573 if (mounted) {
574 if (e.runtimeType == HandshakeException) {
575 showDialog(
576 context: context,
577 builder: (context) => const ProxmoxCertificateErrorDialog(),
578 );
579 } else {
580 showDialog(
581 context: context,
582 builder: (context) => ConnectionErrorDialog(exception: e),
583 );
584 }
585 }
586 }
587 setState(() {
588 _progressModel.inProgress -= 1;
589 });
590 }
591
592 Future<List<PveAccessDomainModel?>?> _loadAccessDomains(Uri uri) async {
593 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
594 List<PveAccessDomainModel?>? response;
595 try {
596 response = await proxclient.accessDomains(uri, settings.sslValidation!);
597 } on proxclient.ProxmoxApiException catch (e) {
598 if (mounted) {
599 showDialog(
600 context: context,
601 builder: (context) => ProxmoxApiErrorDialog(
602 exception: e,
603 ),
604 );
605 }
606 } on HandshakeException {
607 if (mounted) {
608 showDialog(
609 context: context,
610 builder: (context) => const ProxmoxCertificateErrorDialog(),
611 );
612 }
613 }
614 return response;
615 }
616
617 Future<List<PveAccessDomainModel?>?> _tryLoadAccessDomains(Uri uri) async {
618 List<PveAccessDomainModel?>? response;
619 try {
620 response = await _loadAccessDomains(uri);
621 } catch (e) {
622 if (mounted) {
623 showDialog(
624 context: context,
625 builder: (context) => ConnectionErrorDialog(
626 exception: e,
627 ),
628 );
629 }
630 }
631 return response;
632 }
633
634 Future<List<PveAccessDomainModel?>?> _getAccessDomains() async {
635 setState(() {
636 _progressModel
637 ..inProgress += 1
638 ..message = 'Connecting...';
639 });
640
641 final canSavePW = await canSavePassword();
642 setState(() {
643 _canSavePassword = canSavePW;
644 });
645
646 var host = _originController.text.trim();
647 var apiBaseUrl = normalizeUrl(host);
648
649 RegExp portRE = RegExp(r":\d{1,5}$");
650
651 List<PveAccessDomainModel?>? response;
652
653 if (portRE.hasMatch(host)) {
654 response = await _tryLoadAccessDomains(apiBaseUrl);
655 } else {
656 // try to guess the port, 8006 first, and then 443
657 apiBaseUrl = apiBaseUrl.replace(port: 8006);
658 try {
659 response = await _loadAccessDomains(apiBaseUrl);
660 if (response != null) {
661 _originController.text = '$host:8006';
662 }
663 } catch (e) {
664 // we were no port given, and we couldn't reach on port 8006, retry with 443
665 apiBaseUrl = apiBaseUrl.replace(port: 443);
666 response = await _tryLoadAccessDomains(apiBaseUrl);
667 if (response != null) {
668 _originController.text = '$host:443';
669 }
670 }
671 }
672
673 response?.sort((a, b) => a!.realm.compareTo(b!.realm));
674
675 final selection = response?.singleWhere(
676 (e) => e!.realm == widget.userModel?.realm,
677 orElse: () => response?.first,
678 );
679
680 setState(() {
681 _progressModel.inProgress -= 1;
682 _selectedDomain = selection;
683 });
684
685 return response;
686 }
687
688 @override
689 void dispose() {
690 _originController.dispose();
691 _usernameController.dispose();
692 _passwordController.dispose();
693 super.dispose();
694 }
695 }
696
697 class ProxmoxProgressOverlay extends StatelessWidget {
698 const ProxmoxProgressOverlay({
699 Key? key,
700 required this.message,
701 }) : super(key: key);
702
703 final String message;
704
705 @override
706 Widget build(BuildContext context) {
707 return Container(
708 decoration: BoxDecoration(color: Colors.black.withOpacity(0.5)),
709 child: Center(
710 child: Column(
711 mainAxisAlignment: MainAxisAlignment.center,
712 children: [
713 Text(
714 message,
715 style: const TextStyle(
716 fontSize: 20,
717 ),
718 ),
719 const Padding(
720 padding: EdgeInsets.only(top: 20.0),
721 child: CircularProgressIndicator(),
722 )
723 ],
724 ),
725 ),
726 );
727 }
728 }
729
730 class ConnectionErrorDialog extends StatelessWidget {
731 final Object exception;
732
733 const ConnectionErrorDialog({
734 Key? key,
735 required this.exception,
736 }) : super(key: key);
737
738 @override
739 Widget build(BuildContext context) {
740 return AlertDialog(
741 title: const Text('Connection error'),
742 content: Text('Could not establish connection: $exception'),
743 actions: [
744 TextButton(
745 onPressed: () => Navigator.of(context).pop(),
746 child: const Text('Close'),
747 ),
748 ],
749 );
750 }
751 }
752
753 class ProxmoxApiErrorDialog extends StatelessWidget {
754 final proxclient.ProxmoxApiException exception;
755
756 const ProxmoxApiErrorDialog({
757 Key? key,
758 required this.exception,
759 }) : super(key: key);
760
761 @override
762 Widget build(BuildContext context) {
763 return AlertDialog(
764 title: const Text('API Error'),
765 content: SingleChildScrollView(
766 child: Text(exception.message),
767 ),
768 actions: [
769 TextButton(
770 onPressed: () => Navigator.of(context).pop(),
771 child: const Text('Close'),
772 ),
773 ],
774 );
775 }
776 }
777
778 class ProxmoxCertificateErrorDialog extends StatelessWidget {
779 const ProxmoxCertificateErrorDialog({
780 Key? key,
781 }) : super(key: key);
782
783 @override
784 Widget build(BuildContext context) {
785 return AlertDialog(
786 title: const Text('Certificate error'),
787 content: SingleChildScrollView(
788 child: Column(
789 crossAxisAlignment: CrossAxisAlignment.start,
790 children: [
791 const Text('Your connection is not private.'),
792 Text(
793 'Note: Consider to disable SSL validation,'
794 ' if you use a self signed, not commonly trusted, certificate.',
795 style: Theme.of(context).textTheme.bodySmall,
796 ),
797 ],
798 ),
799 ),
800 actions: [
801 TextButton(
802 onPressed: () => Navigator.of(context).pop(),
803 child: const Text('Close'),
804 ),
805 TextButton(
806 onPressed: () => Navigator.of(context).pushReplacement(
807 MaterialPageRoute(
808 builder: (context) => const ProxmoxGeneralSettingsForm())),
809 child: const Text('Settings'),
810 )
811 ],
812 );
813 }
814 }
815
816 Uri normalizeUrl(String urlText) {
817 if (urlText.startsWith('https://')) {
818 urlText = urlText.substring('https://'.length);
819 }
820 if (urlText.startsWith('http://')) {
821 throw Exception("HTTP without TLS is not supported");
822 }
823
824 return Uri.https(urlText, '');
825 }