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