]> git.proxmox.com Git - flutter/proxmox_login_manager.git/blob - lib/proxmox_login_form.dart
c275143949ab9bcae9df3afd7b0cf2828868b029
[flutter/proxmox_login_manager.git] / lib / proxmox_login_form.dart
1 import 'dart:io';
2 import 'dart:async';
3
4 import 'package:biometric_storage/biometric_storage.dart';
5 import 'package:flutter/material.dart';
6 import 'package:collection/src/iterable_extensions.dart';
7 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart'
8 as proxclient;
9 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart';
10 import 'package:proxmox_login_manager/proxmox_general_settings_form.dart';
11 import 'package:proxmox_login_manager/proxmox_general_settings_model.dart';
12 import 'package:proxmox_login_manager/proxmox_login_model.dart';
13 import 'package:proxmox_login_manager/proxmox_tfa_form.dart';
14 import 'package:proxmox_login_manager/extension.dart';
15 import 'package:proxmox_login_manager/proxmox_password_store.dart';
16
17 class ProxmoxProgressModel {
18 int inProgress = 0;
19 String message = 'Loading...';
20 ProxmoxProgressModel({
21 this.inProgress = 0,
22 this.message = 'Loading...',
23 });
24 }
25
26 // FIXME: copied from pve_flutter_frontend, re-use common set
27 class ProxmoxColors {
28 static final Color orange = Color(0xFFE57000);
29 static final Color supportGrey = Color(0xFFABBABA);
30 static final Color supportBlue = Color(0xFF00617F);
31 }
32
33 class ProxmoxLoginForm extends StatefulWidget {
34 final TextEditingController originController;
35 final FormFieldValidator<String> originValidator;
36 final TextEditingController usernameController;
37 final TextEditingController passwordController;
38 final List<PveAccessDomainModel?>? accessDomains;
39 final PveAccessDomainModel? selectedDomain;
40 final ValueChanged<PveAccessDomainModel?> onDomainChanged;
41 final Function? onPasswordSubmitted;
42 final Function? onOriginSubmitted;
43 final Function? onSavePasswordChanged;
44 final bool? canSavePassword;
45 final bool? passwordSaved;
46
47 const ProxmoxLoginForm({
48 Key? key,
49 required this.originController,
50 required this.usernameController,
51 required this.passwordController,
52 required this.accessDomains,
53 required this.originValidator,
54 this.selectedDomain,
55 required this.onDomainChanged,
56 this.onPasswordSubmitted,
57 this.onOriginSubmitted,
58 this.onSavePasswordChanged,
59 this.canSavePassword,
60 this.passwordSaved,
61 }) : super(key: key);
62
63 @override
64 _ProxmoxLoginFormState createState() => _ProxmoxLoginFormState();
65 }
66
67 class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
68 bool _obscure = true;
69 bool? _savePwCheckbox;
70 FocusNode? passwordFocusNode;
71
72 @override
73 Widget build(BuildContext context) {
74 if (widget.accessDomains == null) {
75 return TextFormField(
76 decoration: InputDecoration(
77 icon: Icon(Icons.vpn_lock),
78 labelText: 'Origin',
79 hintText: 'e.g. 192.168.1.2',
80 helperText: 'Protocol (https) and default port (8006) implied'),
81 controller: widget.originController,
82 validator: widget.originValidator,
83 onFieldSubmitted: (value) => widget.onOriginSubmitted!(),
84 );
85 }
86
87 return AutofillGroup(
88 child: Column(
89 mainAxisAlignment: MainAxisAlignment.center,
90 children: [
91 TextFormField(
92 decoration: InputDecoration(
93 icon: Icon(Icons.vpn_lock),
94 labelText: 'Origin',
95 ),
96 controller: widget.originController,
97 enabled: false,
98 ),
99 TextFormField(
100 decoration: InputDecoration(
101 icon: Icon(Icons.person),
102 labelText: 'Username',
103 ),
104 controller: widget.usernameController,
105 validator: (value) {
106 if (value!.isEmpty) {
107 return 'Please enter username';
108 }
109 return null;
110 },
111 autofillHints: [AutofillHints.username],
112 ),
113 DropdownButtonFormField(
114 decoration: InputDecoration(icon: Icon(Icons.domain)),
115 items: widget.accessDomains!
116 .map((e) => DropdownMenuItem(
117 child: ListTile(
118 title: Text(e!.realm),
119 subtitle: Text(e.comment ?? ''),
120 ),
121 value: e,
122 ))
123 .toList(),
124 onChanged: widget.onDomainChanged,
125 selectedItemBuilder: (context) =>
126 widget.accessDomains!.map((e) => Text(e!.realm)).toList(),
127 value: widget.selectedDomain,
128 ),
129 Stack(
130 children: [
131 TextFormField(
132 decoration: InputDecoration(
133 icon: Icon(Icons.lock),
134 labelText: 'Password',
135 ),
136 controller: widget.passwordController,
137 obscureText: _obscure,
138 autocorrect: false,
139 focusNode: passwordFocusNode,
140 validator: (value) {
141 if (value!.isEmpty) {
142 return 'Please enter password';
143 }
144 return null;
145 },
146 onFieldSubmitted: (value) => widget.onPasswordSubmitted!(),
147 autofillHints: [AutofillHints.password],
148 ),
149 Align(
150 alignment: Alignment.bottomRight,
151 child: IconButton(
152 constraints: BoxConstraints.tight(Size(58, 58)),
153 iconSize: 24,
154 icon:
155 Icon(_obscure ? Icons.visibility : Icons.visibility_off),
156 onPressed: () => setState(() {
157 _obscure = !_obscure;
158 }),
159 ),
160 )
161 ],
162 ),
163 if (widget.canSavePassword ?? false)
164 CheckboxListTile(
165 title: const Text('Save password'),
166 value: _savePwCheckbox ?? widget.passwordSaved ?? false,
167 onChanged: (value) {
168 if (widget.onSavePasswordChanged != null) {
169 widget.onSavePasswordChanged!(value!);
170 }
171 setState(() {
172 _savePwCheckbox = value!;
173 });
174 },
175 )
176 ],
177 ),
178 );
179 }
180
181 @override
182 void dispose() {
183 passwordFocusNode?.dispose();
184 super.dispose();
185 }
186 }
187
188 class ProxmoxLoginPage extends StatefulWidget {
189 final ProxmoxLoginModel? userModel;
190 final bool? isCreate;
191 final String? ticket;
192 final String? password;
193
194 const ProxmoxLoginPage({
195 Key? key,
196 this.userModel,
197 this.isCreate,
198 this.ticket = '',
199 this.password,
200 }) : super(key: key);
201 @override
202 _ProxmoxLoginPageState createState() => _ProxmoxLoginPageState();
203 }
204
205 class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
206 final _originController = TextEditingController();
207 final _usernameController = TextEditingController();
208 final _passwordController = TextEditingController();
209 Future<List<PveAccessDomainModel?>?>? _accessDomains;
210 PveAccessDomainModel? _selectedDomain;
211 final _formKey = GlobalKey<FormState>();
212 ProxmoxProgressModel _progressModel = ProxmoxProgressModel();
213 bool _submittButtonEnabled = true;
214 bool _canSavePassword = false;
215 bool _savePasswordCB = false;
216
217 @override
218 void initState() {
219 super.initState();
220 final userModel = widget.userModel;
221 _progressModel = ProxmoxProgressModel();
222 if (!widget.isCreate! && userModel != null) {
223 _originController.text = userModel.origin?.toString() ?? '';
224 _passwordController.text = widget.password ?? '';
225 _accessDomains = _getAccessDomains();
226 _usernameController.text = userModel.username!;
227 _savePasswordCB = widget.password != null;
228 if ((widget.ticket!.isNotEmpty && userModel.activeSession) ||
229 widget.password != null) {
230 _onLoginButtonPressed(ticket: widget.ticket!, mRealm: userModel.realm);
231 }
232 }
233 }
234
235 @override
236 Widget build(BuildContext context) {
237 return Theme(
238 //data: ThemeData.dark().copyWith(accentColor: Color(0xFFE47225)),
239 data: ThemeData.dark().copyWith(
240 textButtonTheme: TextButtonThemeData(
241 style: TextButton.styleFrom(
242 foregroundColor: Colors.white,
243 backgroundColor: ProxmoxColors.orange,
244 disabledBackgroundColor: Colors.grey,
245 ),
246 ),
247 toggleableActiveColor: ProxmoxColors.orange,
248 colorScheme: ColorScheme.dark().copyWith(
249 primary: ProxmoxColors.orange,
250 secondary: ProxmoxColors.orange,
251 onSecondary: ProxmoxColors.supportGrey),
252 ),
253 child: Scaffold(
254 backgroundColor: ProxmoxColors.supportBlue,
255 extendBodyBehindAppBar: true,
256 appBar: AppBar(
257 elevation: 0.0,
258 backgroundColor: Colors.transparent,
259 leading: IconButton(
260 icon: Icon(Icons.close),
261 onPressed: () => Navigator.of(context).pop(),
262 ),
263 ),
264 body: Stack(
265 children: [
266 SingleChildScrollView(
267 child: ConstrainedBox(
268 constraints: BoxConstraints.tightFor(
269 height: MediaQuery.of(context).size.height),
270 child: SafeArea(
271 child: Padding(
272 padding: const EdgeInsets.all(8.0),
273 child: FutureBuilder<List<PveAccessDomainModel?>?>(
274 future: _accessDomains,
275 builder: (context, snapshot) {
276 return Form(
277 key: _formKey,
278 onChanged: () {
279 setState(() {
280 _submittButtonEnabled =
281 _formKey.currentState!.validate();
282 });
283 },
284 child: Column(
285 mainAxisAlignment: MainAxisAlignment.center,
286 children: [
287 Expanded(
288 child: Container(
289 child: Column(
290 mainAxisAlignment:
291 MainAxisAlignment.center,
292 children: [
293 Image.asset(
294 'assets/images/proxmox_logo_symbol_wordmark.png',
295 package: 'proxmox_login_manager',
296 ),
297 ],
298 ),
299 ),
300 ),
301 ProxmoxLoginForm(
302 originController: _originController,
303 originValidator: (value) {
304 if (value == null || value.isEmpty) {
305 return 'Please enter origin';
306 }
307 try {
308 normalizeUrl(value);
309 return null;
310 } on FormatException catch (_) {
311 return 'Invalid URI';
312 } on Exception catch (e) {
313 return 'Invalid URI: $e';
314 }
315 },
316 usernameController: _usernameController,
317 passwordController: _passwordController,
318 accessDomains: snapshot.data,
319 selectedDomain: _selectedDomain,
320 onSavePasswordChanged: (value) {
321 _savePasswordCB = value;
322 },
323 canSavePassword: _canSavePassword,
324 passwordSaved: widget.password != null,
325 onDomainChanged: (value) {
326 setState(() {
327 _selectedDomain = value;
328 });
329 },
330 onOriginSubmitted: _submittButtonEnabled
331 ? () {
332 final isValid =
333 _formKey.currentState!.validate();
334 setState(() {
335 _submittButtonEnabled = isValid;
336 });
337 if (isValid) {
338 setState(() {
339 _accessDomains =
340 _getAccessDomains();
341 });
342 }
343 }
344 : null,
345 onPasswordSubmitted: _submittButtonEnabled
346 ? () {
347 final isValid =
348 _formKey.currentState!.validate();
349 setState(() {
350 _submittButtonEnabled = isValid;
351 });
352 if (isValid) {
353 _onLoginButtonPressed();
354 }
355 }
356 : null,
357 ),
358 Expanded(
359 child: Align(
360 alignment: Alignment.bottomCenter,
361 child: Container(
362 width: MediaQuery.of(context).size.width,
363 child: TextButton(
364 onPressed: _submittButtonEnabled
365 ? () {
366 final isValid = _formKey
367 .currentState!
368 .validate();
369 setState(() {
370 _submittButtonEnabled =
371 isValid;
372 });
373 if (isValid) {
374 if (snapshot.hasData) {
375 _onLoginButtonPressed();
376 } else {
377 setState(() {
378 _accessDomains =
379 _getAccessDomains();
380 });
381 }
382 }
383 }
384 : null,
385 child: Text('Continue'),
386 ),
387 ),
388 ),
389 ),
390 ],
391 ),
392 );
393 }),
394 ),
395 ),
396 ),
397 ),
398 if (_progressModel.inProgress > 0)
399 ProxmoxProgressOverlay(message: _progressModel.message),
400 ],
401 ),
402 ),
403 );
404 }
405
406 Future<void> _onLoginButtonPressed(
407 {String ticket = '', String? mRealm}) async {
408 setState(() {
409 _progressModel
410 ..inProgress += 1
411 ..message = 'Authenticating...';
412 });
413
414 try {
415 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
416 //cleaned form fields
417 final origin = normalizeUrl(_originController.text.trim());
418 final username = _usernameController.text.trim();
419 final String enteredPassword = _passwordController.text.trim();
420 final String? savedPassword = widget.password;
421
422 final password = ticket.isNotEmpty ? ticket : enteredPassword;
423 final realm = _selectedDomain?.realm ?? mRealm;
424
425 var client = await proxclient.authenticate(
426 '$username@$realm', password, origin, settings.sslValidation!);
427
428 if (client.credentials.tfa) {
429 client = await Navigator.of(context).push(MaterialPageRoute(
430 builder: (context) => ProxmoxTfaForm(
431 apiClient: client,
432 ),
433 ));
434 }
435
436 final status = await client.getClusterStatus();
437 final hostname =
438 status.singleWhereOrNull((element) => element.local ?? false)?.name;
439 var loginStorage = await ProxmoxLoginStorage.fromLocalStorage();
440
441 final savePW = enteredPassword != '' &&
442 _savePasswordCB &&
443 enteredPassword != savedPassword;
444 final deletePW = enteredPassword != '' && !savePW && !_savePasswordCB;
445 String? id;
446
447 if (widget.isCreate!) {
448 final newLogin = ProxmoxLoginModel((b) => b
449 ..origin = origin
450 ..username = username
451 ..realm = realm
452 ..productType = ProxmoxProductType.pve
453 ..ticket = client.credentials.ticket
454 ..passwordSaved = savePW
455 ..hostname = hostname);
456
457 loginStorage = loginStorage!.rebuild((b) => b..logins.add(newLogin));
458 id = newLogin.identifier;
459 } else {
460 loginStorage = loginStorage!.rebuild((b) => b
461 ..logins.rebuildWhere(
462 (m) => m == widget.userModel,
463 (b) => b
464 ..ticket = client.credentials.ticket
465 ..passwordSaved =
466 savePW || (deletePW ? false : b.passwordSaved ?? false)
467 ..hostname = hostname));
468 id = widget.userModel!.identifier;
469 }
470
471 if (id != null) {
472 if (savePW) {
473 await savePassword(id, enteredPassword);
474 } else if (deletePW) {
475 await deletePassword(id);
476 }
477 }
478 await loginStorage.saveToDisk();
479
480 Navigator.of(context).pop(client);
481 } on proxclient.ProxmoxApiException catch (e) {
482 print(e);
483 if (e.message.contains('No ticket')) {
484 showDialog(
485 context: context,
486 builder: (context) => AlertDialog(
487 title: Text('Version Error'),
488 content: Text(
489 'Proxmox VE version not supported, please update your instance to use this app.'),
490 actions: [
491 TextButton(
492 onPressed: () => Navigator.of(context).pop(),
493 child: Text('Close'),
494 ),
495 ],
496 ),
497 );
498 } else {
499 showDialog(
500 context: context,
501 builder: (context) => ProxmoxApiErrorDialog(
502 exception: e,
503 ),
504 );
505 }
506 } catch (e, trace) {
507 print(e);
508 print(trace);
509 if (e.runtimeType == HandshakeException) {
510 showDialog(
511 context: context,
512 builder: (context) => ProxmoxCertificateErrorDialog(),
513 );
514 }
515 }
516 setState(() {
517 _progressModel.inProgress -= 1;
518 });
519 }
520
521 Future<List<PveAccessDomainModel?>?> _getAccessDomains() async {
522 setState(() {
523 _progressModel
524 ..inProgress += 1
525 ..message = 'Connection test...';
526 });
527
528 final canSavePW = await BiometricStorage().canAuthenticate();
529 setState(() {
530 _canSavePassword = canSavePW == CanAuthenticateResponse.success;
531 });
532
533 var host = _originController.text.trim();
534 var apiBaseUrl = normalizeUrl(host);
535
536 RegExp portRE = new RegExp(r":\d{1,5}$");
537
538 if (!portRE.hasMatch(host)) {
539 _originController.text += ':8006';
540 apiBaseUrl = apiBaseUrl.replace(port: 8006);
541 }
542
543 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
544 List<PveAccessDomainModel?>? response;
545 try {
546 response =
547 await proxclient.accessDomains(apiBaseUrl, settings.sslValidation!);
548 } on proxclient.ProxmoxApiException catch (e) {
549 showDialog(
550 context: context,
551 builder: (context) => ProxmoxApiErrorDialog(
552 exception: e,
553 ),
554 );
555 } catch (e, trace) {
556 print(e);
557 print(trace);
558 if (e.runtimeType == HandshakeException) {
559 showDialog(
560 context: context,
561 builder: (context) => ProxmoxCertificateErrorDialog(),
562 );
563 } else {
564 showDialog(
565 context: context,
566 builder: (context) => AlertDialog(
567 title: Text('Connection error'),
568 content: Text('Could not establish connection.'),
569 actions: [
570 TextButton(
571 onPressed: () => Navigator.of(context).pop(),
572 child: Text('Close'),
573 ),
574 ],
575 ),
576 );
577 }
578 }
579
580 response?.sort((a, b) => a!.realm.compareTo(b!.realm));
581
582 final selection = response?.singleWhere(
583 (e) => e!.realm == widget.userModel?.realm,
584 orElse: () => response?.first,
585 );
586
587 setState(() {
588 _progressModel.inProgress -= 1;
589 _selectedDomain = selection;
590 });
591
592 return response;
593 }
594
595 @override
596 void dispose() {
597 _originController.dispose();
598 _usernameController.dispose();
599 _passwordController.dispose();
600 super.dispose();
601 }
602 }
603
604 class ProxmoxProgressOverlay extends StatelessWidget {
605 const ProxmoxProgressOverlay({
606 Key? key,
607 required this.message,
608 }) : super(key: key);
609
610 final String message;
611
612 @override
613 Widget build(BuildContext context) {
614 return Container(
615 decoration: new BoxDecoration(color: Colors.black.withOpacity(0.5)),
616 child: Center(
617 child: Column(
618 mainAxisAlignment: MainAxisAlignment.center,
619 children: [
620 Text(
621 message,
622 style: TextStyle(
623 fontSize: 20,
624 ),
625 ),
626 Padding(
627 padding: const EdgeInsets.only(top: 20.0),
628 child: CircularProgressIndicator(),
629 )
630 ],
631 ),
632 ),
633 );
634 }
635 }
636
637 class ProxmoxApiErrorDialog extends StatelessWidget {
638 final proxclient.ProxmoxApiException exception;
639
640 const ProxmoxApiErrorDialog({
641 Key? key,
642 required this.exception,
643 }) : super(key: key);
644
645 @override
646 Widget build(BuildContext context) {
647 return AlertDialog(
648 title: Text('API Error'),
649 content: SingleChildScrollView(
650 child: Text(exception.message),
651 ),
652 actions: [
653 TextButton(
654 onPressed: () => Navigator.of(context).pop(),
655 child: Text('Close'),
656 ),
657 ],
658 );
659 }
660 }
661
662 class ProxmoxCertificateErrorDialog extends StatelessWidget {
663 const ProxmoxCertificateErrorDialog({
664 Key? key,
665 }) : super(key: key);
666
667 @override
668 Widget build(BuildContext context) {
669 return AlertDialog(
670 title: Text('Certificate error'),
671 content: SingleChildScrollView(
672 child: Column(
673 crossAxisAlignment: CrossAxisAlignment.start,
674 children: [
675 Text('Your connection is not private.'),
676 Text(
677 'Note: Consider to disable SSL validation,'
678 ' if you use a self signed, not commonly trusted, certificate.',
679 style: Theme.of(context).textTheme.caption,
680 ),
681 ],
682 ),
683 ),
684 actions: [
685 TextButton(
686 onPressed: () => Navigator.of(context).pop(),
687 child: Text('Close'),
688 ),
689 TextButton(
690 onPressed: () => Navigator.of(context).pushReplacement(
691 MaterialPageRoute(
692 builder: (context) => ProxmoxGeneralSettingsForm())),
693 child: Text('Settings'),
694 )
695 ],
696 );
697 }
698 }
699
700 Uri normalizeUrl(String urlText) {
701 if (urlText.startsWith('https://')) {
702 urlText = urlText.substring('https://'.length);
703 }
704 if (urlText.startsWith('http://')) {
705 throw new Exception("HTTP without TLS is not supported");
706 }
707
708 return Uri.https(urlText, '');
709 }