]>
Commit | Line | Data |
---|---|---|
0e468546 TM |
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'; | |
1dfe0c76 | 11 | import 'package:proxmox_login_manager/extension.dart'; |
0e468546 TM |
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; | |
c2ebed05 TM |
30 | final Function onPasswordSubmitted; |
31 | final Function onOriginSubmitted; | |
0e468546 TM |
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, | |
c2ebed05 TM |
42 | this.onPasswordSubmitted, |
43 | this.onOriginSubmitted, | |
0e468546 TM |
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 | ||
0e468546 TM |
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, | |
c2ebed05 | 65 | onFieldSubmitted: (value) => widget.onOriginSubmitted(), |
0e468546 TM |
66 | ); |
67 | } | |
68 | ||
eeba8b50 TM |
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, | |
0e468546 | 80 | ), |
eeba8b50 TM |
81 | TextFormField( |
82 | decoration: InputDecoration( | |
83 | icon: Icon(Icons.person), | |
84 | labelText: 'Username', | |
0e468546 | 85 | ), |
eeba8b50 TM |
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], | |
0e468546 | 130 | ), |
eeba8b50 TM |
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 | ), | |
0e468546 TM |
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; | |
1dfe0c76 | 160 | final String ticket; |
0e468546 TM |
161 | |
162 | const ProxmoxLoginPage({ | |
163 | Key key, | |
164 | this.userModel, | |
165 | this.isCreate, | |
1dfe0c76 | 166 | this.ticket = '', |
0e468546 TM |
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; | |
1dfe0c76 | 181 | |
0e468546 TM |
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; | |
79ed04db | 195 | if (widget.ticket.isNotEmpty && userModel.activeSession) { |
1dfe0c76 TM |
196 | _onLoginButtonPressed(ticket: widget.ticket, mRealm: userModel.realm); |
197 | } | |
0e468546 TM |
198 | } |
199 | } | |
200 | ||
201 | @override | |
202 | Widget build(BuildContext context) { | |
203 | return Theme( | |
204 | data: ThemeData.dark().copyWith(accentColor: Color(0xFFE47225)), | |
5589fc9c TM |
205 | child: Scaffold( |
206 | backgroundColor: Theme.of(context).primaryColor, | |
ab9e420b TM |
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 | ), | |
5589fc9c | 216 | body: Stack( |
0e468546 TM |
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: [ | |
dadc4428 TM |
243 | Image.asset( |
244 | 'assets/images/proxmox_logo_symbol_wordmark.png', | |
245 | package: 'proxmox_login_manager', | |
0e468546 TM |
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 | }, | |
c2ebed05 TM |
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, | |
0e468546 TM |
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 | ||
1dfe0c76 TM |
379 | Future<void> _onLoginButtonPressed( |
380 | {String ticket = '', String mRealm}) async { | |
0e468546 TM |
381 | setState(() { |
382 | _progressModel | |
383 | ..inProgress = true | |
384 | ..message = 'Authenticating...'; | |
385 | }); | |
386 | ||
387 | try { | |
388 | final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage(); | |
3752cb66 TM |
389 | |
390 | //cleaned form fields | |
391 | final origin = Uri.https(_originController.text.trim(), ''); | |
392 | final username = _usernameController.text.trim(); | |
1dfe0c76 TM |
393 | final password = |
394 | ticket.isNotEmpty ? ticket : _passwordController.text.trim(); | |
395 | final realm = _selectedDomain?.realm ?? mRealm; | |
3752cb66 | 396 | |
0e468546 | 397 | var client = await proxclient.authenticate( |
3752cb66 | 398 | '$username@$realm', password, origin, settings.sslValidation); |
0e468546 TM |
399 | |
400 | if (client.credentials.tfa) { | |
401 | client = await Navigator.of(context).push(MaterialPageRoute( | |
402 | builder: (context) => ProxmoxTfaForm( | |
403 | apiClient: client, | |
404 | ), | |
405 | )); | |
79ed04db TM |
406 | |
407 | if (client == null) { | |
408 | setState(() { | |
409 | _progressModel.inProgress = false; | |
410 | }); | |
411 | return; | |
412 | } | |
0e468546 TM |
413 | } |
414 | ||
1dfe0c76 TM |
415 | final status = await client.getClusterStatus(); |
416 | final hostname = | |
417 | status.singleWhere((element) => element.local ?? false).name; | |
0e468546 TM |
418 | var loginStorage = await ProxmoxLoginStorage.fromLocalStorage(); |
419 | ||
420 | if (widget.isCreate) { | |
421 | final newLogin = ProxmoxLoginModel((b) => b | |
422 | ..origin = origin | |
3752cb66 TM |
423 | ..username = username |
424 | ..realm = realm | |
0e468546 | 425 | ..productType = ProxmoxProductType.pve |
1dfe0c76 TM |
426 | ..ticket = client.credentials.ticket |
427 | ..hostname = hostname); | |
3752cb66 | 428 | |
0e468546 TM |
429 | loginStorage = loginStorage.rebuild((b) => b..logins.add(newLogin)); |
430 | } else { | |
431 | loginStorage = loginStorage.rebuild((b) => b | |
1dfe0c76 TM |
432 | ..logins.rebuildWhere( |
433 | (m) => m == widget.userModel, | |
434 | (b) => b | |
435 | ..ticket = client.credentials.ticket | |
436 | ..hostname = hostname)); | |
0e468546 TM |
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 | }); | |
3752cb66 | 470 | var apiBaseUrl = Uri.https(_originController.text.trim(), ''); |
0e468546 TM |
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 | } | |
3752cb66 TM |
514 | |
515 | response?.sort((a, b) => a.realm.compareTo(b.realm)); | |
516 | ||
0e468546 TM |
517 | final selection = response?.singleWhere( |
518 | (e) => e.realm == widget.userModel?.realm, | |
519 | orElse: () => response?.first, | |
520 | ); | |
3752cb66 | 521 | |
0e468546 TM |
522 | setState(() { |
523 | _progressModel.inProgress = false; | |
524 | _selectedDomain = selection; | |
525 | }); | |
3752cb66 | 526 | |
0e468546 TM |
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 | } |