]> git.proxmox.com Git - flutter/proxmox_login_manager.git/blob - lib/proxmox_login_form.dart
login form: avoid useless intermediate container for logo
[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: Column(
341 mainAxisAlignment: MainAxisAlignment.center,
342 children: [
343 Image.asset(
344 'assets/images/proxmox_logo_symbol_wordmark.png',
345 package: 'proxmox_login_manager',
346 ),
347 ],
348 ),
349 ),
350 ProxmoxLoginForm(
351 originController: _originController,
352 originValidator: (value) {
353 if (value == null || value.isEmpty) {
354 return 'Please enter origin';
355 }
356 try {
357 normalizeUrl(value);
358 return null;
359 } on FormatException catch (_) {
360 return 'Invalid URI';
361 } on Exception catch (e) {
362 return 'Invalid URI: $e';
363 }
364 },
365 usernameController: _usernameController,
366 passwordController: _passwordController,
367 accessDomains: snapshot.data,
368 selectedDomain: _selectedDomain,
369 onSavePasswordChanged: (value) {
370 _savePasswordCB = value;
371 },
372 canSavePassword: _canSavePassword,
373 passwordSaved: widget.password != null,
374 onDomainChanged: (value) {
375 setState(() {
376 _selectedDomain = value;
377 });
378 },
379 onOriginSubmitted: () {
380 final isValid =
381 _formKey.currentState!.validate();
382 setState(() {
383 _submittButtonEnabled = isValid;
384 });
385 if (isValid) {
386 setState(() {
387 _accessDomains = _getAccessDomains();
388 });
389 }
390 },
391 onPasswordSubmitted: _submittButtonEnabled
392 ? () {
393 final isValid =
394 _formKey.currentState!.validate();
395 setState(() {
396 _submittButtonEnabled = isValid;
397 });
398 if (isValid) {
399 _onLoginButtonPressed();
400 }
401 }
402 : null,
403 ),
404 Expanded(
405 child: Align(
406 alignment: Alignment.bottomCenter,
407 child: SizedBox(
408 width: MediaQuery.of(context).size.width,
409 child: TextButton(
410 onPressed: _submittButtonEnabled
411 ? () {
412 final isValid = _formKey
413 .currentState!
414 .validate();
415 setState(() {
416 _submittButtonEnabled =
417 isValid;
418 });
419 if (isValid) {
420 if (snapshot.hasData) {
421 _onLoginButtonPressed();
422 } else {
423 setState(() {
424 _accessDomains =
425 _getAccessDomains();
426 });
427 }
428 }
429 }
430 : null,
431 child: const Text('Continue'),
432 ),
433 ),
434 ),
435 ),
436 ],
437 ),
438 );
439 }),
440 ),
441 ),
442 ),
443 ),
444 if (_progressModel.inProgress > 0)
445 ProxmoxProgressOverlay(message: _progressModel.message),
446 ],
447 ),
448 ),
449 );
450 }
451
452 Future<void> _onLoginButtonPressed(
453 {String ticket = '', String? mRealm}) async {
454 setState(() {
455 _progressModel
456 ..inProgress += 1
457 ..message = 'Authenticating...';
458 });
459
460 try {
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;
467
468 final password = ticket.isNotEmpty ? ticket : enteredPassword;
469 final realm = _selectedDomain?.realm ?? mRealm;
470
471 var client = await proxclient.authenticate(
472 '$username@$realm', password, origin, settings.sslValidation!);
473
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(
480 apiClient: client,
481 ),
482 ));
483
484 if (tfaclient != null) {
485 client = tfaclient;
486 } else {
487 setState(() {
488 _progressModel.inProgress -= 1;
489 });
490 return;
491 }
492 }
493
494 final status = await client.getClusterStatus();
495 final hostname =
496 status.singleWhereOrNull((element) => element.local ?? false)?.name;
497 var loginStorage = await ProxmoxLoginStorage.fromLocalStorage();
498
499 final savePW = enteredPassword != '' &&
500 _savePasswordCB &&
501 enteredPassword != savedPassword;
502 final deletePW = enteredPassword != '' && !savePW && !_savePasswordCB;
503 String? id;
504
505 if (widget.isCreate!) {
506 final newLogin = ProxmoxLoginModel((b) => b
507 ..origin = origin
508 ..username = username
509 ..realm = realm
510 ..productType = ProxmoxProductType.pve
511 ..ticket = client.credentials.ticket
512 ..passwordSaved = savePW
513 ..hostname = hostname);
514
515 loginStorage = loginStorage!.rebuild((b) => b..logins.add(newLogin));
516 id = newLogin.identifier;
517 } else {
518 loginStorage = loginStorage!.rebuild((b) => b
519 ..logins.rebuildWhere(
520 (m) => m == widget.userModel,
521 (b) => b
522 ..ticket = client.credentials.ticket
523 ..passwordSaved =
524 savePW || (deletePW ? false : b.passwordSaved ?? false)
525 ..hostname = hostname));
526 id = widget.userModel!.identifier;
527 }
528
529 if (id != null) {
530 if (savePW) {
531 await savePassword(id, enteredPassword);
532 } else if (deletePW) {
533 await deletePassword(id);
534 }
535 }
536 await loginStorage.saveToDisk();
537
538 if (mounted) {
539 Navigator.of(context).pop(client);
540 }
541 } on proxclient.ProxmoxApiException catch (e) {
542 print(e);
543 if (!mounted) return;
544 if (e.message.contains('No ticket')) {
545 showDialog(
546 context: context,
547 builder: (context) => AlertDialog(
548 title: const Text('Version Error'),
549 content: const Text(
550 'Proxmox VE version not supported, please update your instance to use this app.'),
551 actions: [
552 TextButton(
553 onPressed: () => Navigator.of(context).pop(),
554 child: const Text('Close'),
555 ),
556 ],
557 ),
558 );
559 } else {
560 showDialog(
561 context: context,
562 builder: (context) => ProxmoxApiErrorDialog(
563 exception: e,
564 ),
565 );
566 }
567 } catch (e, trace) {
568 print(e);
569 print(trace);
570 if (mounted) {
571 if (e.runtimeType == HandshakeException) {
572 showDialog(
573 context: context,
574 builder: (context) => const ProxmoxCertificateErrorDialog(),
575 );
576 } else {
577 showDialog(
578 context: context,
579 builder: (context) => ConnectionErrorDialog(exception: e),
580 );
581 }
582 }
583 }
584 setState(() {
585 _progressModel.inProgress -= 1;
586 });
587 }
588
589 Future<List<PveAccessDomainModel?>?> _loadAccessDomains(Uri uri) async {
590 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
591 List<PveAccessDomainModel?>? response;
592 try {
593 response = await proxclient.accessDomains(uri, settings.sslValidation!);
594 } on proxclient.ProxmoxApiException catch (e) {
595 if (mounted) {
596 showDialog(
597 context: context,
598 builder: (context) => ProxmoxApiErrorDialog(
599 exception: e,
600 ),
601 );
602 }
603 } on HandshakeException {
604 if (mounted) {
605 showDialog(
606 context: context,
607 builder: (context) => const ProxmoxCertificateErrorDialog(),
608 );
609 }
610 }
611 return response;
612 }
613
614 Future<List<PveAccessDomainModel?>?> _tryLoadAccessDomains(Uri uri) async {
615 List<PveAccessDomainModel?>? response;
616 try {
617 response = await _loadAccessDomains(uri);
618 } catch (e) {
619 if (mounted) {
620 showDialog(
621 context: context,
622 builder: (context) => ConnectionErrorDialog(
623 exception: e,
624 ),
625 );
626 }
627 }
628 return response;
629 }
630
631 Future<List<PveAccessDomainModel?>?> _getAccessDomains() async {
632 setState(() {
633 _progressModel
634 ..inProgress += 1
635 ..message = 'Connecting...';
636 });
637
638 final canSavePW = await canSavePassword();
639 setState(() {
640 _canSavePassword = canSavePW;
641 });
642
643 var host = _originController.text.trim();
644 var apiBaseUrl = normalizeUrl(host);
645
646 RegExp portRE = RegExp(r":\d{1,5}$");
647
648 List<PveAccessDomainModel?>? response;
649
650 if (portRE.hasMatch(host)) {
651 response = await _tryLoadAccessDomains(apiBaseUrl);
652 } else {
653 // try to guess the port, 8006 first, and then 443
654 apiBaseUrl = apiBaseUrl.replace(port: 8006);
655 try {
656 response = await _loadAccessDomains(apiBaseUrl);
657 if (response != null) {
658 _originController.text = '$host:8006';
659 }
660 } catch (e) {
661 // we were no port given, and we couldn't reach on port 8006, retry with 443
662 apiBaseUrl = apiBaseUrl.replace(port: 443);
663 response = await _tryLoadAccessDomains(apiBaseUrl);
664 if (response != null) {
665 _originController.text = '$host:443';
666 }
667 }
668 }
669
670 response?.sort((a, b) => a!.realm.compareTo(b!.realm));
671
672 final selection = response?.singleWhere(
673 (e) => e!.realm == widget.userModel?.realm,
674 orElse: () => response?.first,
675 );
676
677 setState(() {
678 _progressModel.inProgress -= 1;
679 _selectedDomain = selection;
680 });
681
682 return response;
683 }
684
685 @override
686 void dispose() {
687 _originController.dispose();
688 _usernameController.dispose();
689 _passwordController.dispose();
690 super.dispose();
691 }
692 }
693
694 class ProxmoxProgressOverlay extends StatelessWidget {
695 const ProxmoxProgressOverlay({
696 Key? key,
697 required this.message,
698 }) : super(key: key);
699
700 final String message;
701
702 @override
703 Widget build(BuildContext context) {
704 return Container(
705 decoration: BoxDecoration(color: Colors.black.withOpacity(0.5)),
706 child: Center(
707 child: Column(
708 mainAxisAlignment: MainAxisAlignment.center,
709 children: [
710 Text(
711 message,
712 style: const TextStyle(
713 fontSize: 20,
714 ),
715 ),
716 const Padding(
717 padding: EdgeInsets.only(top: 20.0),
718 child: CircularProgressIndicator(),
719 )
720 ],
721 ),
722 ),
723 );
724 }
725 }
726
727 class ConnectionErrorDialog extends StatelessWidget {
728 final Object exception;
729
730 const ConnectionErrorDialog({
731 Key? key,
732 required this.exception,
733 }) : super(key: key);
734
735 @override
736 Widget build(BuildContext context) {
737 return AlertDialog(
738 title: const Text('Connection error'),
739 content: Text('Could not establish connection: $exception'),
740 actions: [
741 TextButton(
742 onPressed: () => Navigator.of(context).pop(),
743 child: const Text('Close'),
744 ),
745 ],
746 );
747 }
748 }
749
750 class ProxmoxApiErrorDialog extends StatelessWidget {
751 final proxclient.ProxmoxApiException exception;
752
753 const ProxmoxApiErrorDialog({
754 Key? key,
755 required this.exception,
756 }) : super(key: key);
757
758 @override
759 Widget build(BuildContext context) {
760 return AlertDialog(
761 title: const Text('API Error'),
762 content: SingleChildScrollView(
763 child: Text(exception.message),
764 ),
765 actions: [
766 TextButton(
767 onPressed: () => Navigator.of(context).pop(),
768 child: const Text('Close'),
769 ),
770 ],
771 );
772 }
773 }
774
775 class ProxmoxCertificateErrorDialog extends StatelessWidget {
776 const ProxmoxCertificateErrorDialog({
777 Key? key,
778 }) : super(key: key);
779
780 @override
781 Widget build(BuildContext context) {
782 return AlertDialog(
783 title: const Text('Certificate error'),
784 content: SingleChildScrollView(
785 child: Column(
786 crossAxisAlignment: CrossAxisAlignment.start,
787 children: [
788 const Text('Your connection is not private.'),
789 Text(
790 'Note: Consider to disable SSL validation,'
791 ' if you use a self signed, not commonly trusted, certificate.',
792 style: Theme.of(context).textTheme.bodySmall,
793 ),
794 ],
795 ),
796 ),
797 actions: [
798 TextButton(
799 onPressed: () => Navigator.of(context).pop(),
800 child: const Text('Close'),
801 ),
802 TextButton(
803 onPressed: () => Navigator.of(context).pushReplacement(
804 MaterialPageRoute(
805 builder: (context) => const ProxmoxGeneralSettingsForm())),
806 child: const Text('Settings'),
807 )
808 ],
809 );
810 }
811 }
812
813 Uri normalizeUrl(String urlText) {
814 if (urlText.startsWith('https://')) {
815 urlText = urlText.substring('https://'.length);
816 }
817 if (urlText.startsWith('http://')) {
818 throw Exception("HTTP without TLS is not supported");
819 }
820
821 return Uri.https(urlText, '');
822 }