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