]> git.proxmox.com Git - flutter/proxmox_login_manager.git/blob - lib/proxmox_login_form.dart
add onsubmitted handler for origin & password field
[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 body: Stack(
206 children: [
207 SingleChildScrollView(
208 child: ConstrainedBox(
209 constraints: BoxConstraints.tightFor(
210 height: MediaQuery.of(context).size.height),
211 child: Padding(
212 padding: const EdgeInsets.all(8.0),
213 child: FutureBuilder<List<PveAccessDomainModel>>(
214 future: _accessDomains,
215 builder: (context, snapshot) {
216 return Form(
217 key: _formKey,
218 onChanged: () {
219 setState(() {
220 _submittButtonEnabled =
221 _formKey.currentState.validate();
222 });
223 },
224 child: Column(
225 mainAxisAlignment: MainAxisAlignment.center,
226 children: [
227 Expanded(
228 child: Container(
229 child: Column(
230 mainAxisAlignment: MainAxisAlignment.center,
231 children: [
232 Text(
233 'PROXMOX',
234 style: TextStyle(
235 fontFamily: 'Proxmox',
236 fontSize: 36,
237 ),
238 ),
239 Text(
240 'Open Source',
241 style: TextStyle(
242 fontSize: 18,
243 ),
244 ),
245 ],
246 ),
247 ),
248 ),
249 ProxmoxLoginForm(
250 originController: _originController,
251 originValidator: (value) {
252 if (value.isEmpty) {
253 return 'Please enter origin';
254 }
255 if (value.startsWith('https://') ||
256 value.startsWith('http://')) {
257 return 'Do not prefix with scheme';
258 }
259 try {
260 Uri.https(value, '');
261 return null;
262 } on FormatException catch (_) {
263 return 'Invalid URI';
264 }
265 },
266 usernameController: _usernameController,
267 passwordController: _passwordController,
268 accessDomains: snapshot.data,
269 selectedDomain: _selectedDomain,
270 onDomainChanged: (value) {
271 setState(() {
272 _selectedDomain = value;
273 });
274 },
275 onOriginSubmitted: _submittButtonEnabled
276 ? () {
277 final isValid =
278 _formKey.currentState.validate();
279 setState(() {
280 _submittButtonEnabled = isValid;
281 });
282 if (isValid) {
283 setState(() {
284 _accessDomains =
285 _getAccessDomains();
286 });
287 }
288 }
289 : null,
290 onPasswordSubmitted: _submittButtonEnabled
291 ? () {
292 final isValid =
293 _formKey.currentState.validate();
294 setState(() {
295 _submittButtonEnabled = isValid;
296 });
297 if (isValid) {
298 _onLoginButtonPressed();
299 }
300 }
301 : null,
302 ),
303 if (snapshot.hasData)
304 Expanded(
305 child: Align(
306 alignment: Alignment.bottomCenter,
307 child: Container(
308 width: MediaQuery.of(context).size.width,
309 child: FlatButton(
310 onPressed: _submittButtonEnabled
311 ? () {
312 final isValid = _formKey
313 .currentState
314 .validate();
315 setState(() {
316 _submittButtonEnabled =
317 isValid;
318 });
319 if (isValid) {
320 _onLoginButtonPressed();
321 }
322 }
323 : null,
324 color: Color(0xFFE47225),
325 disabledColor: Colors.grey,
326 child: Text('Continue'),
327 ),
328 ),
329 ),
330 ),
331 if (!snapshot.hasData)
332 Expanded(
333 child: Align(
334 alignment: Alignment.bottomCenter,
335 child: Container(
336 width: MediaQuery.of(context).size.width,
337 child: FlatButton(
338 onPressed: _submittButtonEnabled
339 ? () {
340 final isValid = _formKey
341 .currentState
342 .validate();
343 setState(() {
344 _submittButtonEnabled =
345 isValid;
346 });
347 if (isValid) {
348 setState(() {
349 _accessDomains =
350 _getAccessDomains();
351 });
352 }
353 }
354 : null,
355 color: Color(0xFFE47225),
356 child: Text('Continue'),
357 disabledColor: Colors.grey,
358 ),
359 ),
360 ),
361 ),
362 ],
363 ),
364 );
365 }),
366 ),
367 ),
368 ),
369 if (_progressModel.inProgress)
370 ProxmoxProgressOverlay(message: _progressModel.message),
371 ],
372 ),
373 ),
374 );
375 }
376
377 Future<void> _onLoginButtonPressed() async {
378 setState(() {
379 _progressModel
380 ..inProgress = true
381 ..message = 'Authenticating...';
382 });
383
384 try {
385 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
386
387 //cleaned form fields
388 final origin = Uri.https(_originController.text.trim(), '');
389 final username = _usernameController.text.trim();
390 final password = _passwordController.text.trim();
391 final realm = _selectedDomain.realm;
392
393 var client = await proxclient.authenticate(
394 '$username@$realm', password, origin, settings.sslValidation);
395
396 if (client.credentials.tfa) {
397 client = await Navigator.of(context).push(MaterialPageRoute(
398 builder: (context) => ProxmoxTfaForm(
399 apiClient: client,
400 ),
401 ));
402 }
403
404 var loginStorage = await ProxmoxLoginStorage.fromLocalStorage();
405
406 if (widget.isCreate) {
407 final newLogin = ProxmoxLoginModel((b) => b
408 ..origin = origin
409 ..username = username
410 ..realm = realm
411 ..productType = ProxmoxProductType.pve
412 ..ticket = client.credentials.ticket);
413
414 loginStorage = loginStorage.rebuild((b) => b..logins.add(newLogin));
415 } else {
416 loginStorage = loginStorage.rebuild((b) => b
417 ..logins.remove(widget.userModel)
418 ..logins.add(widget.userModel
419 .rebuild((b) => b..ticket = client.credentials.ticket)));
420 }
421 await loginStorage.saveToDisk();
422
423 Navigator.of(context).pop(client);
424 } on proxclient.ProxmoxApiException catch (e) {
425 print(e);
426 showDialog(
427 context: context,
428 builder: (context) => ProxmoxApiErrorDialog(
429 exception: e,
430 ),
431 );
432 } catch (e, trace) {
433 print(e);
434 print(trace);
435 if (e.runtimeType == HandshakeException) {
436 showDialog(
437 context: context,
438 builder: (context) => ProxmoxCertificateErrorDialog(),
439 );
440 }
441 }
442 setState(() {
443 _progressModel.inProgress = false;
444 });
445 }
446
447 Future<List<PveAccessDomainModel>> _getAccessDomains() async {
448 setState(() {
449 _progressModel
450 ..inProgress = true
451 ..message = 'Connection test...';
452 });
453 var apiBaseUrl = Uri.https(_originController.text.trim(), '');
454
455 if (!apiBaseUrl.hasPort) {
456 _originController.text += ':8006';
457 apiBaseUrl = apiBaseUrl.replace(port: 8006);
458 }
459
460 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
461 List<PveAccessDomainModel> response;
462 try {
463 response =
464 await proxclient.accessDomains(apiBaseUrl, settings.sslValidation);
465 } on proxclient.ProxmoxApiException catch (e) {
466 print(e);
467 showDialog(
468 context: context,
469 builder: (context) => ProxmoxApiErrorDialog(
470 exception: e,
471 ),
472 );
473 } catch (e, trace) {
474 print(e);
475 print(trace);
476 if (e.runtimeType == HandshakeException) {
477 showDialog(
478 context: context,
479 builder: (context) => ProxmoxCertificateErrorDialog(),
480 );
481 } else {
482 showDialog(
483 context: context,
484 builder: (context) => AlertDialog(
485 title: Text('Connection error'),
486 content: Text('Could not establish connection.'),
487 actions: [
488 FlatButton(
489 onPressed: () => Navigator.of(context).pop(),
490 child: Text('Close'),
491 ),
492 ],
493 ),
494 );
495 }
496 }
497
498 response?.sort((a, b) => a.realm.compareTo(b.realm));
499
500 final selection = response?.singleWhere(
501 (e) => e.realm == widget.userModel?.realm,
502 orElse: () => response?.first,
503 );
504
505 setState(() {
506 _progressModel.inProgress = false;
507 _selectedDomain = selection;
508 });
509
510 return response;
511 }
512
513 @override
514 void dispose() {
515 _originController.dispose();
516 _usernameController.dispose();
517 _passwordController.dispose();
518 super.dispose();
519 }
520 }
521
522 class ProxmoxProgressOverlay extends StatelessWidget {
523 const ProxmoxProgressOverlay({
524 Key key,
525 @required this.message,
526 }) : super(key: key);
527
528 final String message;
529
530 @override
531 Widget build(BuildContext context) {
532 return Container(
533 decoration: new BoxDecoration(color: Colors.black.withOpacity(0.5)),
534 child: Center(
535 child: Column(
536 mainAxisAlignment: MainAxisAlignment.center,
537 children: [
538 Text(
539 message,
540 style: TextStyle(
541 color: Theme.of(context).accentColor,
542 fontSize: 20,
543 ),
544 ),
545 Padding(
546 padding: const EdgeInsets.only(top: 20.0),
547 child: CircularProgressIndicator(),
548 )
549 ],
550 ),
551 ),
552 );
553 }
554 }
555
556 class ProxmoxApiErrorDialog extends StatelessWidget {
557 final proxclient.ProxmoxApiException exception;
558
559 const ProxmoxApiErrorDialog({
560 Key key,
561 @required this.exception,
562 }) : super(key: key);
563
564 @override
565 Widget build(BuildContext context) {
566 return AlertDialog(
567 title: Text('API Error'),
568 content: SingleChildScrollView(
569 child: Text(exception.message),
570 ),
571 actions: [
572 FlatButton(
573 onPressed: () => Navigator.of(context).pop(),
574 child: Text('Close'),
575 ),
576 ],
577 );
578 }
579 }
580
581 class ProxmoxCertificateErrorDialog extends StatelessWidget {
582 const ProxmoxCertificateErrorDialog({
583 Key key,
584 }) : super(key: key);
585
586 @override
587 Widget build(BuildContext context) {
588 return AlertDialog(
589 title: Text('Certificate error'),
590 content: SingleChildScrollView(
591 child: Column(
592 crossAxisAlignment: CrossAxisAlignment.start,
593 children: [
594 Text('Your connection is not private.'),
595 Text(
596 'Note: Consider to disable SSL validation,'
597 ' if you use a self signed, not commonly trusted, certificate.',
598 style: Theme.of(context).textTheme.caption,
599 ),
600 ],
601 ),
602 ),
603 actions: [
604 FlatButton(
605 onPressed: () => Navigator.of(context).pop(),
606 child: Text('Close'),
607 ),
608 FlatButton(
609 onPressed: () => Navigator.of(context).pushReplacement(
610 MaterialPageRoute(
611 builder: (context) => ProxmoxGeneralSettingsForm())),
612 child: Text('Settings'),
613 )
614 ],
615 );
616 }
617 }