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