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